clixad 0.0.1-beta.14 → 0.0.1-beta.16

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 (3) hide show
  1. package/README.md +4 -0
  2. package/dist/clixad.mjs +620 -108
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -19,6 +19,10 @@ npm install -g clixad
19
19
 
20
20
  Requires Node.js >= 20.
21
21
 
22
+ ## Community
23
+
24
+ Discord: <https://discord.gg/pwxPM3QyF> — bug reports and feature requests are read there.
25
+
22
26
  ## What it does
23
27
 
24
28
  It reads, searches and edits files and runs shell commands in the directory you start it in, asking
package/dist/clixad.mjs CHANGED
@@ -101,6 +101,52 @@ function grantRangeLabel(range) {
101
101
  const n = (v) => v.toLocaleString("en-US");
102
102
  return low === high ? `${n(low)} credits on completion` : `${n(low)}\u2013${n(high)} credits on completion`;
103
103
  }
104
+ function streakLabel(streak) {
105
+ if (streak.length <= 0 || streak.day <= 0) return null;
106
+ const n = (x) => x.toLocaleString("en-US");
107
+ const where = `day ${streak.day} of ${streak.length}`;
108
+ if (!streak.claimed) return `daily streak: ${where} \xB7 today's already counted`;
109
+ const tomorrow = streak.schedule[Math.min(streak.day, streak.length - 1)];
110
+ const next = tomorrow === void 0 ? "" : ` \xB7 tomorrow ${n(tomorrow)}`;
111
+ return `daily streak: ${where} \xB7 +${n(streak.credits)} credits${next}`;
112
+ }
113
+ function referLines(info) {
114
+ const n = (x) => x.toLocaleString("en-US");
115
+ const lines = [
116
+ `your code: ${info.code}`,
117
+ `share it: clixad login --invite ${info.code}`,
118
+ `you both get ${n(info.inviterCredits)} credits once they complete their first offer`
119
+ ];
120
+ if (info.invited === 0) {
121
+ lines.push("nobody has signed up with it yet");
122
+ return lines;
123
+ }
124
+ const waiting = info.invited - info.credited;
125
+ lines.push(
126
+ `${n(info.invited)} signed up \xB7 ${n(info.credited)} completed an offer \xB7 ${n(info.creditsEarned)} credits earned`
127
+ );
128
+ if (waiting > 0) {
129
+ lines.push(`${n(waiting)} haven't completed an offer yet \u2014 that pays nothing until they do`);
130
+ }
131
+ if (info.remaining === 0 && info.maxCredited > 0) {
132
+ lines.push(`you've been paid for the maximum of ${n(info.maxCredited)} referrals`);
133
+ }
134
+ return lines;
135
+ }
136
+ function redeemMessage(result) {
137
+ switch (result.status) {
138
+ case "redeemed":
139
+ return { ok: true, text: `+${result.credited.toLocaleString("en-US")} credits` };
140
+ case "used":
141
+ return { ok: false, text: "that code has already been used" };
142
+ case "expired":
143
+ return { ok: false, text: "that code has expired" };
144
+ case "wrong_account":
145
+ return { ok: false, text: "that code belongs to a different account \u2014 check who you're signed in as" };
146
+ default:
147
+ return { ok: false, text: "that isn't a code we recognise \u2014 check it for typos" };
148
+ }
149
+ }
104
150
  function ledgerReasonLabel(reason) {
105
151
  return LEDGER_REASONS[reason] ?? reason.replace(/_/g, " ");
106
152
  }
@@ -195,6 +241,10 @@ var init_client = __esm({
195
241
  CHAT_ENCODED_CONTENT_TYPE = "application/vnd.clixad.chat+gzip";
196
242
  LEDGER_REASONS = {
197
243
  signup_bonus: "signup bonus",
244
+ login_streak: "daily streak",
245
+ feedback_bonus: "thanks for the feedback",
246
+ referral_bonus: "referral bonus",
247
+ redeem_code: "code redeemed",
198
248
  ad_reward: "offer reward",
199
249
  ad_screenout_bonus: "screenout bonus (didn't qualify)",
200
250
  ad_sponsor_impression: "sponsored line shown",
@@ -323,6 +373,138 @@ var init_client = __esm({
323
373
  return void 0;
324
374
  }
325
375
  }
376
+ /**
377
+ * Claim today's login-streak grant, or find out that today is already claimed.
378
+ *
379
+ * Safe to call from anywhere and as often as you like: the gateway keys it on
380
+ * `(account, UTC day)`, so the second call of the day credits nothing and
381
+ * answers with the same day number. That is what lets every surface which
382
+ * might be somebody's first contact today — a login, the REPL opening,
383
+ * `wallet` — claim without coordinating.
384
+ *
385
+ * **Never throws.** Like `sponsorConfig`, every caller is decorating something
386
+ * that already works: a gateway too old to have the route, a cold start, a
387
+ * dropped connection and an expired token all mean the same thing here, which
388
+ * is "say nothing about a streak". A missed grant is claimed by the next
389
+ * command; a session that died over one would be a real loss.
390
+ */
391
+ async claimStreak(signal) {
392
+ try {
393
+ const res = await fetch(`${this.config.gatewayUrl}/v1/streak/claim`, {
394
+ method: "POST",
395
+ headers: this.headers(),
396
+ ...signal ? { signal } : {}
397
+ });
398
+ if (!res.ok) return null;
399
+ const body = await res.json();
400
+ if (typeof body.day !== "number" || typeof body.credits !== "number") return null;
401
+ return {
402
+ day: body.day,
403
+ credits: body.credits,
404
+ claimed: body.claimed === true,
405
+ balance: typeof body.balance === "number" ? body.balance : 0,
406
+ schedule: Array.isArray(body.schedule) ? body.schedule : [],
407
+ length: typeof body.length === "number" ? body.length : 0
408
+ };
409
+ } catch {
410
+ return null;
411
+ }
412
+ }
413
+ /**
414
+ * Send one rating, and answer what it was paid.
415
+ *
416
+ * **Never throws**, for `claimStreak`'s reason and one of its own: this is
417
+ * called from a prompt that has already interrupted somebody, and the worst
418
+ * possible outcome is that answering it takes the session down. A gateway too
419
+ * old for the route, a cold start and an expired token all mean the same thing
420
+ * — the rating is lost, which costs the user nothing and us one data point.
421
+ *
422
+ * `null` for "nothing to say"; an outcome with `credited: 0` for "recorded,
423
+ * unpaid", which is what the daily ceiling looks like from here and is worth
424
+ * telling the user apart from a grant.
425
+ */
426
+ async sendFeedback(report, signal) {
427
+ try {
428
+ const res = await fetch(`${this.config.gatewayUrl}/v1/feedback`, {
429
+ method: "POST",
430
+ headers: this.headers(),
431
+ ...signal ? { signal } : {},
432
+ body: JSON.stringify({
433
+ session_id: report.session,
434
+ turn: report.turn,
435
+ rating: report.rating,
436
+ model: report.model,
437
+ prompt: report.prompt,
438
+ response: report.response,
439
+ ...report.steps === void 0 ? {} : { steps: report.steps }
440
+ })
441
+ });
442
+ if (!res.ok) return null;
443
+ const body = await res.json();
444
+ return {
445
+ recorded: body.recorded === true,
446
+ credited: typeof body.credited === "number" ? body.credited : 0,
447
+ balance: typeof body.balance === "number" ? body.balance : 0
448
+ };
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+ /**
454
+ * This account's referral code and standing, minting the code if it has none.
455
+ *
456
+ * **This one throws**, unlike `claimStreak` and `sendFeedback`. Those two
457
+ * decorate something that already works and a failure costs a line of chrome;
458
+ * this *is* the command — `clixad refer` with nothing to print is a command
459
+ * that did not run, and saying so beats printing an empty summary.
460
+ */
461
+ async referral(signal) {
462
+ const res = await fetch(`${this.config.gatewayUrl}/v1/referrals`, {
463
+ headers: this.headers(),
464
+ ...signal ? { signal } : {}
465
+ });
466
+ if (res.status === 401) throw await authFailure(res, "referrals");
467
+ if (!res.ok) throw new Error(await httpFailure(res, "referrals"));
468
+ const body = await res.json();
469
+ const num = (value) => typeof value === "number" ? value : 0;
470
+ if (typeof body.code !== "string" || !body.code) {
471
+ throw new Error("the gateway did not return a referral code");
472
+ }
473
+ return {
474
+ code: body.code,
475
+ invited: num(body.invited),
476
+ credited: num(body.credited),
477
+ creditsEarned: num(body.credits_earned),
478
+ remaining: num(body.remaining),
479
+ maxCredited: num(body.max_credited),
480
+ inviterCredits: num(body.inviter_credits),
481
+ inviteeCredits: num(body.invitee_credits)
482
+ };
483
+ }
484
+ /**
485
+ * Spend a redeem code.
486
+ *
487
+ * **Throws**, like `referral` and unlike `claimStreak`: this is the command
488
+ * itself, and somebody who typed a code and got silence has been told nothing.
489
+ * A refusal is not a throw though — an expired or already-spent code answers
490
+ * 200 with a status, because that is an answer rather than a failure.
491
+ */
492
+ async redeem(code, signal) {
493
+ const res = await fetch(`${this.config.gatewayUrl}/v1/redeem`, {
494
+ method: "POST",
495
+ headers: this.headers(),
496
+ ...signal ? { signal } : {},
497
+ body: JSON.stringify({ code })
498
+ });
499
+ if (res.status === 401) throw await authFailure(res, "redeem");
500
+ if (!res.ok) throw new Error(await httpFailure(res, "redeem"));
501
+ const body = await res.json();
502
+ return {
503
+ status: body.status ?? "unknown",
504
+ credited: typeof body.credited === "number" ? body.credited : 0,
505
+ balance: typeof body.balance === "number" ? body.balance : 0
506
+ };
507
+ }
326
508
  async creditPacks() {
327
509
  const res = await fetch(`${this.config.gatewayUrl}/v1/billing/packs`);
328
510
  if (!res.ok) throw new Error(`packs failed: ${res.status}`);
@@ -604,7 +786,12 @@ var init_client = __esm({
604
786
  const calls = /* @__PURE__ */ new Map();
605
787
  for await (const evt of parseSSE(res.body)) {
606
788
  if (evt === "[DONE]") break;
607
- const json = JSON.parse(evt);
789
+ let json;
790
+ try {
791
+ json = JSON.parse(evt);
792
+ } catch {
793
+ continue;
794
+ }
608
795
  const choice = json.choices?.[0];
609
796
  const delta = choice?.delta?.content;
610
797
  if (delta) {
@@ -1239,12 +1426,13 @@ function installExitHook(registry) {
1239
1426
  });
1240
1427
  }
1241
1428
  }
1242
- var MAX_SHELL_BUFFER, MAX_SHELLS, ShellError, ShellRegistry, shells, hookInstalled;
1429
+ var MAX_SHELL_BUFFER, MAX_SHELLS, MAX_FINISHED_SHELLS, ShellError, ShellRegistry, shells, hookInstalled;
1243
1430
  var init_shells = __esm({
1244
1431
  "src/shells.ts"() {
1245
1432
  "use strict";
1246
1433
  MAX_SHELL_BUFFER = 2e5;
1247
1434
  MAX_SHELLS = 8;
1435
+ MAX_FINISHED_SHELLS = 8;
1248
1436
  ShellError = class extends Error {
1249
1437
  };
1250
1438
  ShellRegistry = class {
@@ -1252,21 +1440,27 @@ var init_shells = __esm({
1252
1440
  seq = 0;
1253
1441
  /** Start a command in the background and return it before it has finished. */
1254
1442
  start(root, command, now = Date.now()) {
1443
+ this.pruneFinished();
1255
1444
  const live = [...this.entries.values()].filter((e) => e.status === "running");
1256
1445
  if (live.length >= MAX_SHELLS) {
1257
1446
  throw new ShellError(
1258
1447
  `${MAX_SHELLS} background commands are already running (${live.map((e) => e.id).join(", ")}) \u2014 stop one with kill_shell before starting another`
1259
1448
  );
1260
1449
  }
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
- });
1450
+ let child;
1451
+ try {
1452
+ child = spawn2(command, {
1453
+ cwd: root,
1454
+ shell: true,
1455
+ windowsHide: true,
1456
+ detached: process.platform !== "win32",
1457
+ // Nothing types at a background command, and leaving stdin inherited
1458
+ // lets it steal the keystrokes meant for the REPL.
1459
+ stdio: ["ignore", "pipe", "pipe"]
1460
+ });
1461
+ } catch (err) {
1462
+ throw new ShellError(`could not start that command: ${err.message}`);
1463
+ }
1270
1464
  const entry = {
1271
1465
  id: `bash_${++this.seq}`,
1272
1466
  command,
@@ -1339,10 +1533,23 @@ var init_shells = __esm({
1339
1533
  list(now = Date.now()) {
1340
1534
  return [...this.entries.values()].map((e) => snapshot(e, now));
1341
1535
  }
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);
1536
+ /**
1537
+ * Forget all but the most recent `MAX_FINISHED_SHELLS` finished commands.
1538
+ *
1539
+ * Run when a new one starts, which is the only moment the registry grows.
1540
+ * Running commands are never touched — those are `killAll`'s and
1541
+ * `kill`'s — and the Map's insertion order is the start order, so "oldest"
1542
+ * needs no timestamp.
1543
+ *
1544
+ * This replaces a `forget(id)` that nothing ever called: an explicit
1545
+ * single-entry drop is only useful to a caller who knows a shell is finished
1546
+ * with, and neither the tools nor the REPL is in a position to know that.
1547
+ */
1548
+ pruneFinished() {
1549
+ const finished = [...this.entries.values()].filter((e) => e.status !== "running");
1550
+ for (const entry of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_SHELLS))) {
1551
+ this.entries.delete(entry.id);
1552
+ }
1346
1553
  }
1347
1554
  /** Stop everything. Called on exit; safe to call more than once. */
1348
1555
  killAll() {
@@ -2189,6 +2396,64 @@ function passesThrough(err) {
2189
2396
  if (err instanceof PaywallError) return true;
2190
2397
  return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
2191
2398
  }
2399
+ function fsCall(what, run) {
2400
+ try {
2401
+ return run();
2402
+ } catch (err) {
2403
+ if (passesThrough(err)) throw err;
2404
+ const code = err.code ?? "";
2405
+ throw new ToolError(`${what}: ${FS_REASON[code] ?? err.message}`);
2406
+ }
2407
+ }
2408
+ function countArg(raw, fallback) {
2409
+ const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : Number.NaN;
2410
+ return Number.isFinite(n) ? Math.max(1, Math.floor(n)) : fallback;
2411
+ }
2412
+ function requireString(value, tool, field) {
2413
+ const text = readString(value);
2414
+ if (text === void 0) {
2415
+ throw new ToolError(`${tool}: ${field} must be a string, got ${describeValue(value)}`);
2416
+ }
2417
+ return text;
2418
+ }
2419
+ function optionalString(value, tool, field) {
2420
+ if (value === void 0 || value === null) return void 0;
2421
+ return requireString(value, tool, field);
2422
+ }
2423
+ function readString(value) {
2424
+ if (typeof value === "string") return value;
2425
+ return typeof value === "number" && Number.isFinite(value) ? String(value) : void 0;
2426
+ }
2427
+ function describeValue(value) {
2428
+ if (value === null) return "null";
2429
+ if (value === void 0) return "nothing";
2430
+ if (Array.isArray(value)) return "an array";
2431
+ if (typeof value === "number") return "an unusable number";
2432
+ return typeof value === "object" ? "an object" : `a ${typeof value}`;
2433
+ }
2434
+ function entryKind(entry, abs) {
2435
+ if (entry.isDirectory()) return "dir";
2436
+ if (entry.isFile()) return "file";
2437
+ if (!entry.isSymbolicLink()) return "other";
2438
+ try {
2439
+ const stat = statSync(abs);
2440
+ return stat.isDirectory() ? "dir" : stat.isFile() ? "file" : "other";
2441
+ } catch {
2442
+ return "other";
2443
+ }
2444
+ }
2445
+ function isFilePath(abs) {
2446
+ try {
2447
+ return statSync(abs).isFile();
2448
+ } catch {
2449
+ return false;
2450
+ }
2451
+ }
2452
+ function searchBase(ctx, path) {
2453
+ const abs = safeResolve(ctx.root, path ?? ".");
2454
+ if (!existsSync2(abs)) throw new ToolError(`not found: ${path ?? "."}`);
2455
+ return abs;
2456
+ }
2192
2457
  function commandSignature(command) {
2193
2458
  return `run:${command.replace(/\s+/g, " ").trim()}`;
2194
2459
  }
@@ -2299,13 +2564,19 @@ async function runMcpTool(ctx, name, args) {
2299
2564
  }
2300
2565
  function execute(ctx, command, timeout) {
2301
2566
  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
- });
2567
+ let child;
2568
+ try {
2569
+ child = spawn4(command, {
2570
+ cwd: ctx.root,
2571
+ shell: true,
2572
+ windowsHide: true,
2573
+ // Own process group on POSIX so killTree can take the children with it.
2574
+ detached: process.platform !== "win32"
2575
+ });
2576
+ } catch (err) {
2577
+ return resolvePromise(`command failed to start: ${err.message}
2578
+ (no output)`);
2579
+ }
2309
2580
  let out = "";
2310
2581
  let total = 0;
2311
2582
  let timedOut = false;
@@ -2352,6 +2623,10 @@ function listWorkspaceFiles(root, limit = 5e3) {
2352
2623
  return files.sort();
2353
2624
  }
2354
2625
  function walk(dir, root, visit) {
2626
+ if (isFilePath(dir)) {
2627
+ visit(dir);
2628
+ return;
2629
+ }
2355
2630
  let seen = 0;
2356
2631
  const stack = [dir];
2357
2632
  while (stack.length > 0) {
@@ -2364,12 +2639,13 @@ function walk(dir, root, visit) {
2364
2639
  }
2365
2640
  for (const entry of entries) {
2366
2641
  const abs = join3(current, entry.name);
2367
- if (entry.isDirectory()) {
2368
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
2642
+ const kind = entryKind(entry, abs);
2643
+ if (kind === "dir") {
2644
+ if (entry.isSymbolicLink() || SKIP_DIRS.has(entry.name)) continue;
2369
2645
  stack.push(abs);
2370
2646
  continue;
2371
2647
  }
2372
- if (!entry.isFile()) continue;
2648
+ if (kind !== "file") continue;
2373
2649
  if (++seen > MAX_WALK_FILES) return;
2374
2650
  if (!visit(abs)) return;
2375
2651
  }
@@ -2377,8 +2653,9 @@ function walk(dir, root, visit) {
2377
2653
  void root;
2378
2654
  }
2379
2655
  function globToRegExp(pattern) {
2380
- const p = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
2656
+ const p = requireString(pattern, "glob", "pattern").replace(/\\/g, "/").replace(/^\.\//, "");
2381
2657
  let re = "";
2658
+ let depth = 0;
2382
2659
  for (let i = 0; i < p.length; i++) {
2383
2660
  const ch = p[i];
2384
2661
  if (ch === "*") {
@@ -2394,14 +2671,23 @@ function globToRegExp(pattern) {
2394
2671
  re += "[^/]*";
2395
2672
  }
2396
2673
  } 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;
2674
+ else if (ch === "{") {
2675
+ re += "(?:";
2676
+ depth++;
2677
+ } else if (ch === "}" && depth > 0) {
2678
+ re += ")";
2679
+ depth--;
2680
+ } else if (ch === "," && depth > 0) re += "|";
2681
+ else if (".+^$()[]|\\{}".includes(ch)) re += "\\" + ch;
2401
2682
  else re += ch;
2402
2683
  }
2684
+ re += ")".repeat(depth);
2403
2685
  const anchored = p.includes("/") ? `^${re}$` : `^(?:.*/)?${re}$`;
2404
- return new RegExp(anchored);
2686
+ try {
2687
+ return new RegExp(anchored);
2688
+ } catch (err) {
2689
+ throw new ToolError(`not a usable glob pattern: ${pattern} \u2014 ${err.message}`);
2690
+ }
2405
2691
  }
2406
2692
  function toolSchemaFor(mode, mcpSchemas = [], available = {}) {
2407
2693
  const usable = (t) => t.function.name !== "web_search" || available.webSearch === true;
@@ -2416,7 +2702,7 @@ function subagentToolSchema(available = {}) {
2416
2702
  (t) => SUBAGENT_TOOLS.includes(t.function.name) && (t.function.name !== "web_search" || available.webSearch === true)
2417
2703
  );
2418
2704
  }
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;
2705
+ 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
2706
  var init_tools = __esm({
2421
2707
  "src/tools.ts"() {
2422
2708
  "use strict";
@@ -2436,6 +2722,19 @@ var init_tools = __esm({
2436
2722
  this.notice = notice;
2437
2723
  }
2438
2724
  };
2725
+ FS_REASON = {
2726
+ ENOTDIR: "it is a file, not a directory \u2014 use read_file",
2727
+ EISDIR: "it is a directory, not a file \u2014 use list_dir, or edit a file inside it",
2728
+ ENOENT: "it does not exist (a symlink pointing at nothing looks like this too)",
2729
+ EACCES: "permission denied",
2730
+ EPERM: "permission denied",
2731
+ ELOOP: "the path goes through a symlink loop",
2732
+ ENAMETOOLONG: "the path is too long",
2733
+ EMFILE: "this process has too many files open \u2014 try again",
2734
+ ENFILE: "the system has too many files open \u2014 try again",
2735
+ ENOSPC: "the disk is full",
2736
+ EROFS: "the filesystem is read-only"
2737
+ };
2439
2738
  MAX_FILE_BYTES = 4e5;
2440
2739
  MAX_READ_LINES = 2e3;
2441
2740
  MAX_READ_LINE_CHARS = 2e3;
@@ -2450,16 +2749,37 @@ var init_tools = __esm({
2450
2749
  MAX_ASK_OPTIONS = 6;
2451
2750
  BACKGROUND_SETTLE_MS = 500;
2452
2751
  SKIP_DIRS = /* @__PURE__ */ new Set([
2752
+ // Version control.
2453
2753
  ".git",
2754
+ ".hg",
2755
+ ".svn",
2756
+ // Dependencies and their caches.
2454
2757
  "node_modules",
2758
+ ".yarn",
2759
+ ".pnpm-store",
2760
+ ".venv",
2761
+ ".tox",
2762
+ // Build output.
2455
2763
  "dist",
2456
2764
  "build",
2457
2765
  "coverage",
2458
2766
  ".next",
2767
+ ".nuxt",
2768
+ ".svelte-kit",
2769
+ ".output",
2770
+ ".angular",
2771
+ ".dart_tool",
2772
+ // Tool caches.
2459
2773
  ".turbo",
2774
+ ".nx",
2460
2775
  ".cache",
2776
+ ".parcel-cache",
2777
+ ".gradle",
2461
2778
  "__pycache__",
2462
- ".venv"
2779
+ ".mypy_cache",
2780
+ ".pytest_cache",
2781
+ ".ruff_cache",
2782
+ ".terraform"
2463
2783
  ]);
2464
2784
  TOOLS = {
2465
2785
  /**
@@ -2478,11 +2798,14 @@ var init_tools = __esm({
2478
2798
  * is told precisely what went wrong instead of guessing.
2479
2799
  */
2480
2800
  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);
2801
+ const path = requireString(args.path, "read_file", "path");
2802
+ const abs = safeResolve(ctx.root, path);
2803
+ if (!existsSync2(abs)) throw new ToolError(`not found: ${path}`);
2485
2804
  const name = rel(ctx.root, abs);
2805
+ if (fsCall(`cannot read ${name}`, () => statSync(abs)).isDirectory()) {
2806
+ throw new ToolError(`${path} is a directory \u2014 use list_dir`);
2807
+ }
2808
+ const bytes = fsCall(`cannot read ${name}`, () => readFileSync3(abs));
2486
2809
  const image = imageType(bytes);
2487
2810
  if (image) return readImage(ctx, bytes, name, image);
2488
2811
  if (looksLikePdf(bytes)) {
@@ -2503,11 +2826,11 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2503
2826
  const raw = bytes.toString("utf8");
2504
2827
  const all = raw.split("\n");
2505
2828
  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));
2829
+ const first = countArg(args.offset, 1);
2830
+ const want = countArg(args.limit, MAX_READ_LINES);
2508
2831
  const last = Math.min(total, first + want - 1);
2509
2832
  if (first > total) {
2510
- return `${args.path} has ${total} lines; offset ${first} is past the end`;
2833
+ return `${path} has ${total} lines; offset ${first} is past the end`;
2511
2834
  }
2512
2835
  const body = all.slice(first - 1, last).map((line2, i) => {
2513
2836
  const n = String(first + i).padStart(5, " ");
@@ -2523,27 +2846,35 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2523
2846
  return body + note;
2524
2847
  },
2525
2848
  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
- });
2849
+ const path = optionalString(args.path, "list_dir", "path") ?? ".";
2850
+ const abs = safeResolve(ctx.root, path);
2851
+ if (!existsSync2(abs)) throw new ToolError(`not found: ${path}`);
2852
+ const name = rel(ctx.root, abs);
2853
+ if (!fsCall(`cannot list ${name}`, () => statSync(abs)).isDirectory()) {
2854
+ throw new ToolError(`${path} is a file \u2014 use read_file`);
2855
+ }
2856
+ const entries = fsCall(`cannot list ${name}`, () => readdirSync(abs, { withFileTypes: true })).map(
2857
+ (entry) => {
2858
+ const isDir = entryKind(entry, resolve(abs, entry.name)) === "dir";
2859
+ return { name: isDir ? `${entry.name}/` : entry.name, isDir };
2860
+ }
2861
+ );
2532
2862
  entries.sort((a, b) => Number(b.isDir) - Number(a.isDir) || a.name.localeCompare(b.name));
2533
2863
  return entries.map((e) => e.name).join("\n") || "(empty)";
2534
2864
  },
2535
2865
  /** Find files by glob, e.g. `src/**\/*.ts`. Cheap orientation before reading. */
2536
2866
  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);
2867
+ const pattern = optionalString(args.pattern, "glob", "pattern");
2868
+ if (!pattern) throw new ToolError("glob needs a pattern");
2869
+ const base = searchBase(ctx, optionalString(args.path, "glob", "path"));
2870
+ const re = globToRegExp(pattern);
2540
2871
  const hits = [];
2541
2872
  walk(base, ctx.root, (abs) => {
2542
2873
  const r = rel(ctx.root, abs);
2543
2874
  if (re.test(r)) hits.push(r);
2544
2875
  return hits.length < MAX_GLOB_RESULTS;
2545
2876
  });
2546
- if (hits.length === 0) return `no files match ${args.pattern}`;
2877
+ if (hits.length === 0) return `no files match ${pattern}`;
2547
2878
  hits.sort();
2548
2879
  const more = hits.length >= MAX_GLOB_RESULTS ? `
2549
2880
  \u2026 [capped at ${MAX_GLOB_RESULTS}]` : "";
@@ -2551,15 +2882,17 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2551
2882
  },
2552
2883
  /** Search file contents with a regular expression; returns path:line: text. */
2553
2884
  grep(ctx, args) {
2554
- if (!args.pattern) throw new ToolError("grep needs a pattern");
2885
+ const pattern = optionalString(args.pattern, "grep", "pattern");
2886
+ if (!pattern) throw new ToolError("grep needs a pattern");
2555
2887
  let re;
2556
2888
  try {
2557
- re = new RegExp(args.pattern);
2889
+ re = new RegExp(pattern);
2558
2890
  } catch (err) {
2559
2891
  throw new ToolError(`invalid regular expression: ${err.message}`);
2560
2892
  }
2561
- const base = safeResolve(ctx.root, args.path ?? ".");
2562
- const filter = args.glob ? globToRegExp(args.glob) : void 0;
2893
+ const base = searchBase(ctx, optionalString(args.path, "grep", "path"));
2894
+ const globArg = optionalString(args.glob, "grep", "glob");
2895
+ const filter = globArg ? globToRegExp(globArg) : void 0;
2563
2896
  const out = [];
2564
2897
  walk(base, ctx.root, (abs) => {
2565
2898
  const r = rel(ctx.root, abs);
@@ -2580,24 +2913,28 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2580
2913
  }
2581
2914
  return true;
2582
2915
  });
2583
- if (out.length === 0) return `no matches for ${args.pattern}`;
2916
+ if (out.length === 0) return `no matches for ${pattern}`;
2584
2917
  const more = out.length >= MAX_GREP_MATCHES ? `
2585
2918
  \u2026 [capped at ${MAX_GREP_MATCHES} matches]` : "";
2586
2919
  return out.join("\n") + more;
2587
2920
  },
2588
2921
  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)";
2922
+ const abs = safeResolve(ctx.root, requireString(args.path, "write_file", "path"));
2923
+ const content = requireString(args.content, "write_file", "content");
2924
+ const name = rel(ctx.root, abs);
2925
+ const before = existsSync2(abs) ? fsCall(`cannot write ${name}`, () => readFileSync3(abs, "utf8")) : "";
2926
+ const preview = renderDiff(before, content) || "(no change)";
2592
2927
  await gate(ctx, {
2593
2928
  tool: "write_file",
2594
- signature: `write:${rel(ctx.root, abs)}`,
2595
- summary: `${before ? "overwrite" : "create"} ${rel(ctx.root, abs)}`,
2929
+ signature: `write:${name}`,
2930
+ summary: `${before ? "overwrite" : "create"} ${name}`,
2596
2931
  preview
2597
2932
  });
2598
- mkdirSync2(dirname2(abs), { recursive: true });
2599
- writeFileSync2(abs, args.content, "utf8");
2600
- return `wrote ${args.content.length} bytes to ${rel(ctx.root, abs)}`;
2933
+ fsCall(`cannot write ${name}`, () => {
2934
+ mkdirSync2(dirname2(abs), { recursive: true });
2935
+ writeFileSync2(abs, content, "utf8");
2936
+ });
2937
+ return `wrote ${content.length} bytes to ${name}`;
2601
2938
  },
2602
2939
  /**
2603
2940
  * Replace an exact snippet in a file. Preferred over write_file: it keeps the
@@ -2605,41 +2942,42 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2605
2942
  * An ambiguous match is an error rather than a guess.
2606
2943
  */
2607
2944
  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);
2945
+ const path = requireString(args.path, "edit_file", "path");
2946
+ const abs = safeResolve(ctx.root, path);
2947
+ if (!existsSync2(abs)) throw new ToolError(`not found: ${path}`);
2948
+ const oldString = requireString(args.old_string, "edit_file", "old_string");
2949
+ const newString = requireString(args.new_string, "edit_file", "new_string");
2950
+ if (oldString === newString) throw new ToolError("old_string and new_string are identical");
2951
+ const name = rel(ctx.root, abs);
2952
+ const before = fsCall(`cannot edit ${name}`, () => readFileSync3(abs, "utf8"));
2953
+ const count = occurrences(before, oldString);
2616
2954
  if (count === 0) {
2617
2955
  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`
2956
+ `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
2957
  );
2620
2958
  }
2621
2959
  if (count > 1 && !args.replace_all) {
2622
2960
  throw new ToolError(
2623
- `old_string appears ${count} times in ${args.path} \u2014 add more surrounding context, or pass replace_all: true`
2961
+ `old_string appears ${count} times in ${path} \u2014 add more surrounding context, or pass replace_all: true`
2624
2962
  );
2625
2963
  }
2626
- const after = before.split(args.old_string).join(args.new_string);
2964
+ const after = before.split(oldString).join(newString);
2627
2965
  const preview = renderDiff(before, after) || "(no change)";
2628
2966
  await gate(ctx, {
2629
2967
  tool: "edit_file",
2630
- signature: `write:${rel(ctx.root, abs)}`,
2631
- summary: `edit ${rel(ctx.root, abs)}`,
2968
+ signature: `write:${name}`,
2969
+ summary: `edit ${name}`,
2632
2970
  preview
2633
2971
  });
2634
- writeFileSync2(abs, after, "utf8");
2635
- return `edited ${rel(ctx.root, abs)} (${count > 1 ? `${count} occurrences` : "1 occurrence"})`;
2972
+ fsCall(`cannot edit ${name}`, () => writeFileSync2(abs, after, "utf8"));
2973
+ return `edited ${name} (${count > 1 ? `${count} occurrences` : "1 occurrence"})`;
2636
2974
  },
2637
2975
  /**
2638
2976
  * Run a shell command in the workspace. Streams output to `ctx.onOutput` as it
2639
2977
  * arrives and dies with `ctx.signal`, so a runaway build can be interrupted.
2640
2978
  */
2641
2979
  async run_command(ctx, args) {
2642
- const command = (args.command ?? "").trim();
2980
+ const command = (optionalString(args.command, "run_command", "command") ?? "").trim();
2643
2981
  if (!command) throw new ToolError("run_command needs a command");
2644
2982
  const background = args.background === true;
2645
2983
  await gate(ctx, {
@@ -2651,7 +2989,7 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
2651
2989
  summary: `${background ? "run in the background" : "run"} ${command.length > 60 ? command.slice(0, 59) + "\u2026" : command}`,
2652
2990
  preview: command
2653
2991
  });
2654
- if (!background) return execute(ctx, command, args.timeout ?? DEFAULT_COMMAND_TIMEOUT);
2992
+ if (!background) return execute(ctx, command, countArg(args.timeout, DEFAULT_COMMAND_TIMEOUT));
2655
2993
  const registry = ctx.shells ?? shells;
2656
2994
  let started;
2657
2995
  try {
@@ -2671,7 +3009,7 @@ ${first.output.trim().slice(0, 4e3)}` : "");
2671
3009
  /** Whatever a background command has printed since it was last read. */
2672
3010
  read_output(ctx, args) {
2673
3011
  const registry = ctx.shells ?? shells;
2674
- const id = (args.shell_id ?? "").trim();
3012
+ const id = (optionalString(args.shell_id, "read_output", "shell_id") ?? "").trim();
2675
3013
  if (!id) {
2676
3014
  const running = registry.list();
2677
3015
  throw new ToolError(
@@ -2680,7 +3018,8 @@ ${first.output.trim().slice(0, 4e3)}` : "");
2680
3018
  }
2681
3019
  let read;
2682
3020
  try {
2683
- read = registry.read(id, args.filter ? { filter: args.filter } : {});
3021
+ const filter = optionalString(args.filter, "read_output", "filter");
3022
+ read = registry.read(id, filter ? { filter } : {});
2684
3023
  } catch (err) {
2685
3024
  asToolError(err);
2686
3025
  }
@@ -2696,7 +3035,7 @@ ${body || "(nothing new since the last read)"}`;
2696
3035
  },
2697
3036
  async kill_shell(ctx, args) {
2698
3037
  const registry = ctx.shells ?? shells;
2699
- const id = (args.shell_id ?? "").trim();
3038
+ const id = (optionalString(args.shell_id, "kill_shell", "shell_id") ?? "").trim();
2700
3039
  if (!id) throw new ToolError("kill_shell needs a shell_id");
2701
3040
  await gate(ctx, {
2702
3041
  tool: "kill_shell",
@@ -2719,14 +3058,14 @@ ${body || "(nothing new since the last read)"}`;
2719
3058
  * a context that is billed by the token.
2720
3059
  */
2721
3060
  async web_search(ctx, args) {
2722
- const query = (args.query ?? "").trim();
3061
+ const query = (optionalString(args.query, "web_search", "query") ?? "").trim();
2723
3062
  if (!query) throw new ToolError("web_search needs a query");
2724
3063
  if (!ctx.webSearch) {
2725
3064
  throw new ToolError(
2726
3065
  "web search is not available in this run \u2014 use web_fetch on a URL you already know instead"
2727
3066
  );
2728
3067
  }
2729
- const count = Math.max(1, Math.min(MAX_SEARCH_RESULTS, Math.floor(args.count ?? DEFAULT_SEARCH_RESULTS)));
3068
+ const count = Math.min(MAX_SEARCH_RESULTS, countArg(args.count, DEFAULT_SEARCH_RESULTS));
2730
3069
  let results;
2731
3070
  try {
2732
3071
  results = await ctx.webSearch(query, { count, ...ctx.signal ? { signal: ctx.signal } : {} });
@@ -2754,9 +3093,9 @@ ${body || "(nothing new since the last read)"}`;
2754
3093
  async web_fetch(ctx, args) {
2755
3094
  let page;
2756
3095
  try {
2757
- page = await fetchPage(args.url ?? "", {
3096
+ page = await fetchPage(optionalString(args.url, "web_fetch", "url") ?? "", {
2758
3097
  ...ctx.signal ? { signal: ctx.signal } : {},
2759
- ...typeof args.max_chars === "number" ? { maxChars: Math.max(1e3, Math.floor(args.max_chars)) } : {}
3098
+ ...args.max_chars === void 0 ? {} : { maxChars: Math.max(1e3, countArg(args.max_chars, MAX_FILE_BYTES)) }
2760
3099
  });
2761
3100
  } catch (err) {
2762
3101
  networkToolError(err);
@@ -2783,8 +3122,8 @@ ${page.text}`;
2783
3122
  * the credits it saves do not pay for that.
2784
3123
  */
2785
3124
  async task(ctx, args) {
2786
- const prompt = (args.prompt ?? "").trim();
2787
- const description = (args.description ?? "").trim() || "subagent";
3125
+ const prompt = (optionalString(args.prompt, "task", "prompt") ?? "").trim();
3126
+ const description = (optionalString(args.description, "task", "description") ?? "").trim() || "subagent";
2788
3127
  if (!prompt) throw new ToolError("task needs a prompt describing what to find out");
2789
3128
  if (!ctx.spawn) {
2790
3129
  throw new ToolError(
@@ -2821,7 +3160,7 @@ ${page.text}`;
2821
3160
  * be told something that was not on the list.
2822
3161
  */
2823
3162
  async ask_user(ctx, args) {
2824
- const question = (args.question ?? "").trim();
3163
+ const question = (optionalString(args.question, "ask_user", "question") ?? "").trim();
2825
3164
  if (!question) throw new ToolError("ask_user needs a question");
2826
3165
  if (!ctx.askUser) {
2827
3166
  throw new ToolError(
@@ -2846,7 +3185,7 @@ ${page.text}`;
2846
3185
  * able to grant itself write access.
2847
3186
  */
2848
3187
  async exit_plan_mode(ctx, args) {
2849
- const plan = (args.plan ?? "").trim();
3188
+ const plan = (optionalString(args.plan, "exit_plan_mode", "plan") ?? "").trim();
2850
3189
  if (!plan) throw new ToolError("exit_plan_mode needs a plan");
2851
3190
  if (!ctx.approvePlan) {
2852
3191
  throw new ToolError(
@@ -3337,8 +3676,6 @@ var init_agent = __esm({
3337
3676
  "use strict";
3338
3677
  init_tools();
3339
3678
  init_mcp();
3340
- init_shells();
3341
- init_web();
3342
3679
  init_client();
3343
3680
  SUBAGENT_MAX_STEPS = 12;
3344
3681
  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.";
@@ -4297,6 +4634,15 @@ var init_commands = __esm({
4297
4634
  // forms, surveys and app trials, and describing the top-up as a video costs
4298
4635
  // the user the one expectation that makes a screenout make sense.
4299
4636
  { name: "earn", desc: "open the offer wall to earn credits" },
4637
+ // Deliberately *no* `args`, for /login's reason: an arg hint makes enter
4638
+ // complete the command instead of running it, and printing the code is the
4639
+ // whole command.
4640
+ { name: "refer", desc: "your invite code, and what it has earned" },
4641
+ // This one *does* carry an `args` hint, unlike /refer and /login: the code is
4642
+ // the whole command, and `/redeem` on its own has nothing to do — so enter
4643
+ // completing to "/redeem " is the helpful outcome rather than the surprising
4644
+ // one.
4645
+ { name: "redeem", args: "<code>", desc: "spend a code you were given" },
4300
4646
  // Reachable from inside the REPL because that is where the 401 is printed.
4301
4647
  // "Run `clixad login` first" is not a runnable instruction at this prompt —
4302
4648
  // anything without a leading slash is a prompt, so it went to the model and
@@ -4771,7 +5117,7 @@ function useTerminalSize() {
4771
5117
  }, [stdout]);
4772
5118
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
4773
5119
  }
4774
- function App({ client, config, wallet, session, initialTask, sponsorServe }) {
5120
+ function App({ client, config, wallet, streak, session, initialTask, sponsorServe }) {
4775
5121
  const { exit } = useApp();
4776
5122
  const { stdout, write: writeToStdout } = useStdout();
4777
5123
  const idRef = useRef(1);
@@ -4831,7 +5177,9 @@ function App({ client, config, wallet, session, initialTask, sponsorServe }) {
4831
5177
  const busyAbortRef = useRef(null);
4832
5178
  const filesRef = useRef(null);
4833
5179
  const ctrlCRef = useRef(0);
4834
- const sessionRef = useRef(session ?? { id: newSessionId(), started: (/* @__PURE__ */ new Date()).toISOString() });
5180
+ const sessionRef = useRef(
5181
+ session ?? { id: newSessionId(), started: (/* @__PURE__ */ new Date()).toISOString() }
5182
+ );
4835
5183
  const runStartedAtRef = useRef(Date.now());
4836
5184
  const contextRef = useRef(collectContext(root));
4837
5185
  const catalogRef = useRef([]);
@@ -4844,6 +5192,8 @@ function App({ client, config, wallet, session, initialTask, sponsorServe }) {
4844
5192
  const pendingTaskRef = useRef(null);
4845
5193
  const mcpRef = useRef(McpHub.empty());
4846
5194
  const todosRef = useRef(new TodoList());
5195
+ const lastTurnRef = useRef(null);
5196
+ const turnCountRef = useRef(config.turns ?? 0);
4847
5197
  const deltaBufRef = useRef("");
4848
5198
  const deltaTimerRef = useRef(null);
4849
5199
  const sponsorRef = useRef(sponsorSource());
@@ -4923,6 +5273,8 @@ function App({ client, config, wallet, session, initialTask, sponsorServe }) {
4923
5273
  client.onNotice = (text2) => push({ kind: "notice", tone: "warn", text: text2 });
4924
5274
  const files = contextRef.current.files;
4925
5275
  if (files.length) push({ kind: "notice", text: ` context: ${files.join(", ")}` });
5276
+ const streakLine = streak && streak.claimed ? streakLabel(streak) : null;
5277
+ if (streakLine) push({ kind: "notice", tone: "good", text: ` ${streakLine}` });
4926
5278
  if (session?.messages.length) {
4927
5279
  push({ kind: "notice", text: ` resumed session ${session.id} (${session.messages.length} messages)` });
4928
5280
  }
@@ -5185,16 +5537,14 @@ ${NO_SEARCH_INSTRUCTION}`),
5185
5537
  });
5186
5538
  const next = result.messages.slice(1);
5187
5539
  setMessages(next);
5540
+ sessionRef.current.title ??= (task.split("\n")[0] || task).slice(0, 80);
5188
5541
  saveSession({
5189
5542
  id: sessionRef.current.id,
5190
5543
  started: sessionRef.current.started,
5191
5544
  updated: (/* @__PURE__ */ new Date()).toISOString(),
5192
5545
  cwd: root,
5193
5546
  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),
5547
+ title: sessionRef.current.title,
5198
5548
  messages: next
5199
5549
  });
5200
5550
  if (result.stopped === "aborted") {
@@ -5209,6 +5559,16 @@ ${NO_SEARCH_INSTRUCTION}`),
5209
5559
  text: result.content,
5210
5560
  meta: `[${result.creditsCharged} credits \xB7 balance ${result.balance.toLocaleString("en-US")}]`
5211
5561
  });
5562
+ lastTurnRef.current = {
5563
+ turn: turnCountRef.current,
5564
+ model,
5565
+ prompt: task,
5566
+ response: result.content,
5567
+ steps: result.steps
5568
+ };
5569
+ turnCountRef.current += 1;
5570
+ config.turns = turnCountRef.current;
5571
+ saveConfig(config);
5212
5572
  }
5213
5573
  } catch (err) {
5214
5574
  if (err instanceof PaywallError) {
@@ -5248,8 +5608,7 @@ ${NO_SEARCH_INSTRUCTION}`),
5248
5608
  nextSponsor,
5249
5609
  permit,
5250
5610
  push,
5251
- root,
5252
- session?.title
5611
+ root
5253
5612
  ]
5254
5613
  );
5255
5614
  const stopCurrent = useCallback(() => {
@@ -5304,6 +5663,10 @@ ${NO_SEARCH_INSTRUCTION}`),
5304
5663
  tone: "good",
5305
5664
  text: ` signed in as ${account.login ?? account.email ?? "you"} \xB7 balance ${account.balance.toLocaleString("en-US")} credits` + (account.created ? " (signup bonus)" : "")
5306
5665
  });
5666
+ const claimed = await client.claimStreak(signal);
5667
+ const line2 = claimed?.claimed ? streakLabel(claimed) : null;
5668
+ if (claimed?.claimed) setBalance(claimed.balance);
5669
+ if (line2) push({ kind: "notice", tone: "good", text: ` ${line2}` });
5307
5670
  return;
5308
5671
  }
5309
5672
  case "closed":
@@ -5395,6 +5758,7 @@ ${NO_SEARCH_INSTRUCTION}`),
5395
5758
  expandedRef.current = /* @__PURE__ */ new Set();
5396
5759
  heightCacheRef.current = /* @__PURE__ */ new Map();
5397
5760
  todosRef.current = new TodoList();
5761
+ lastTurnRef.current = null;
5398
5762
  sessionRef.current = { id: newSessionId(), started: (/* @__PURE__ */ new Date()).toISOString() };
5399
5763
  writeToStdout("\x1B[2J\x1B[3J\x1B[H");
5400
5764
  push({ kind: "notice", text: " cleared \u2014 new session, empty context" });
@@ -5481,16 +5845,19 @@ ${NO_SEARCH_INSTRUCTION}`),
5481
5845
  return;
5482
5846
  case "wallet":
5483
5847
  await runBusy(LOADING, async (signal) => {
5848
+ const streakNow = await client.claimStreak(signal);
5484
5849
  const w = await client.wallet(signal);
5485
5850
  setBalance(w.balance);
5486
5851
  const reversals = w.ledger.filter((e) => e.reason === "ad_reversal");
5487
5852
  const reversed = reversals.reduce((sum, e) => sum + Math.abs(e.delta), 0);
5488
5853
  const screenouts = w.screenouts_today ?? 0;
5489
5854
  const screenoutCredits = w.screenout_credits_today ?? 0;
5855
+ const streakLine = streakNow && streakLabel(streakNow);
5490
5856
  push({
5491
5857
  kind: "notice",
5492
5858
  text: ` balance ${w.balance.toLocaleString("en-US")} credits \xB7 ${w.ads_today} offers today \xB7 $${w.earned_usd_today.toFixed(2)}/$${w.max_reward_usd_per_day.toFixed(2)} earned` + (screenouts ? `
5493
- plus ${screenoutCredits.toLocaleString("en-US")} credits from ${screenouts} screenout bonus${screenouts === 1 ? "" : "es"} \u2014 attempts that didn't qualify, which CPX pays a little for anyway.` : "") + (reversals.length ? `
5859
+ plus ${screenoutCredits.toLocaleString("en-US")} credits from ${screenouts} screenout bonus${screenouts === 1 ? "" : "es"} \u2014 attempts that didn't qualify, which CPX pays a little for anyway.` : "") + (streakLine ? `
5860
+ ${streakLine}` : "") + (reversals.length ? `
5494
5861
  recently: ${reversed.toLocaleString("en-US")} credits from ${reversals.length} offer${reversals.length === 1 ? "" : "s"} were reversed by the provider. Run \`clixad wallet\` for the full ledger.` : "")
5495
5862
  });
5496
5863
  });
@@ -5515,7 +5882,7 @@ ${NO_SEARCH_INSTRUCTION}`),
5515
5882
  return;
5516
5883
  }
5517
5884
  setMessages(found.messages);
5518
- sessionRef.current = { id: found.id, started: found.started };
5885
+ sessionRef.current = { id: found.id, started: found.started, title: found.title };
5519
5886
  push({
5520
5887
  kind: "notice",
5521
5888
  tone: "good",
@@ -5598,6 +5965,32 @@ ${NO_SEARCH_INSTRUCTION}`),
5598
5965
  case "earn":
5599
5966
  await runAdWall();
5600
5967
  return;
5968
+ // Through `runBusy` like every other gateway call: the free instance can
5969
+ // cold-start for the better part of a minute, and the box says "esc to
5970
+ // stop" whenever it is locked.
5971
+ case "refer":
5972
+ await runBusy(LOADING, async (signal) => {
5973
+ const info = await client.referral(signal);
5974
+ push({ kind: "notice", text: referLines(info).map((line3) => ` ${line3}`).join("\n") });
5975
+ });
5976
+ return;
5977
+ case "redeem": {
5978
+ if (!arg) {
5979
+ push({ kind: "notice", tone: "warn", text: " usage: /redeem <code>" });
5980
+ return;
5981
+ }
5982
+ await runBusy(LOADING, async (signal) => {
5983
+ const result = await client.redeem(arg, signal);
5984
+ const { ok, text: text2 } = redeemMessage(result);
5985
+ if (ok) setBalance(result.balance);
5986
+ push({
5987
+ kind: "notice",
5988
+ tone: ok ? "good" : "warn",
5989
+ text: ok ? ` ${text2} \xB7 balance ${result.balance.toLocaleString("en-US")}` : ` ${text2}`
5990
+ });
5991
+ });
5992
+ return;
5993
+ }
5601
5994
  case "login":
5602
5995
  await runLogin(arg);
5603
5996
  return;
@@ -5665,14 +6058,58 @@ ${output}` }
5665
6058
  },
5666
6059
  [push, root, runCommand, runShell, runTurn2]
5667
6060
  );
6061
+ const askFeedback = useCallback(async () => {
6062
+ const turn = lastTurnRef.current;
6063
+ if (!turn) return;
6064
+ lastTurnRef.current = null;
6065
+ const amount = wallet?.rewards?.feedback_credits;
6066
+ const pay = amount ? `Both answers pay ${amount.toLocaleString("en-US")} credits` : "Both answers pay the same";
6067
+ const choice = await new Promise((resolve2) => {
6068
+ setPickerSel(0);
6069
+ setPicker({
6070
+ title: "Was that last answer any good?",
6071
+ subtitle: `${pay} \xB7 we keep that exchange to test against \xB7 esc skips`,
6072
+ items: [
6073
+ { value: "up", label: "yes \u2014 that worked", hint: "" },
6074
+ { value: "down", label: "no \u2014 that missed", hint: "" },
6075
+ { value: "skip", label: "skip", hint: "nothing is sent, nothing is stored" }
6076
+ ],
6077
+ onPick: (item) => resolve2(item.value),
6078
+ // Esc and "skip" are the same outcome and must stay that way: a skip
6079
+ // that cost something is a prompt people stop reading.
6080
+ onCancel: () => resolve2(null)
6081
+ });
6082
+ });
6083
+ if (choice !== "up" && choice !== "down") return;
6084
+ await runBusy(SENDING_FEEDBACK, async (signal) => {
6085
+ const outcome = await client.sendFeedback(
6086
+ { ...turn, session: sessionRef.current.id, rating: choice },
6087
+ signal
6088
+ );
6089
+ if (!outcome) return;
6090
+ if (outcome.credited > 0) setBalance(outcome.balance);
6091
+ push({
6092
+ kind: "notice",
6093
+ tone: "good",
6094
+ text: outcome.credited > 0 ? ` thanks \u2014 +${outcome.credited.toLocaleString("en-US")} credits` : (
6095
+ // Recorded and unpaid: the daily ceiling, or a turn already rated.
6096
+ // Worth saying, because the offer of credits was made a moment ago.
6097
+ " thanks \u2014 noted (today's feedback credits are already in)"
6098
+ )
6099
+ });
6100
+ });
6101
+ }, [client, push, runBusy, wallet]);
5668
6102
  const runSerially = useCallback(
5669
6103
  async (line2) => {
5670
6104
  await dispatchLine(line2);
5671
6105
  for (let next = dequeue(); next !== void 0; next = dequeue()) {
5672
6106
  await dispatchLine(next);
5673
6107
  }
6108
+ if (lastTurnRef.current && turnCountRef.current % FEEDBACK_TURN_INTERVAL === 0) {
6109
+ await askFeedback();
6110
+ }
5674
6111
  },
5675
- [dequeue, dispatchLine]
6112
+ [askFeedback, dequeue, dispatchLine]
5676
6113
  );
5677
6114
  const submit = useCallback(
5678
6115
  async (raw) => {
@@ -5722,9 +6159,27 @@ ${output}` }
5722
6159
  setEditor((state) => insert(state, text2));
5723
6160
  setHistIdx(-1);
5724
6161
  });
6162
+ const dismissModals = useCallback(() => {
6163
+ if (plan) {
6164
+ setPlan(null);
6165
+ plan.resolve("keepPlanning");
6166
+ }
6167
+ if (ask2) {
6168
+ setAsk(null);
6169
+ ask2.resolve("deny");
6170
+ }
6171
+ if (picker) {
6172
+ const cancel = picker.onCancel;
6173
+ setPicker(null);
6174
+ cancel?.();
6175
+ }
6176
+ }, [ask2, picker, plan]);
5725
6177
  useInput((ch, key) => {
5726
6178
  if (key.ctrl && ch === "c") {
5727
- if (busy) return stopCurrent();
6179
+ if (busy) {
6180
+ dismissModals();
6181
+ return stopCurrent();
6182
+ }
5728
6183
  if (!isEmpty(editor)) return setEditor(EMPTY);
5729
6184
  if (Date.now() - ctrlCRef.current < 2e3) return exit();
5730
6185
  ctrlCRef.current = Date.now();
@@ -6154,7 +6609,7 @@ function tailLines(text, max) {
6154
6609
  const lines = text.split("\n");
6155
6610
  return lines.length <= max ? text : lines.slice(-max).join("\n");
6156
6611
  }
6157
- var WORKING, WAITING_FOR_REWARD, LOADING, COMPACTING, SIGNING_IN, RUNNING, NOT_SIGNED_IN, LIVE_OUTPUT_LINES, COMMITTED_OUTPUT_LINES, DELTA_FLUSH_MS, MAX_INPUT_ROWS, MAX_PICKER_ROWS, MAX_QUEUE_ROWS, QUEUE_MARKER, PICKER_CURRENT, MIN_PICKER_LABEL_COLS, PICKER_CHROME, PICKER_CHROME_COMPACT, ASK_CHROME, PLAN_CHROME, ASK_HINT, PLAN_HINT, EXPAND_MAX_LINES, FALLBACK_ROWS, FALLBACK_COLS, CONTEXT_NOTICE_AT, KEEP_ALWAYS;
6612
+ var WORKING, WAITING_FOR_REWARD, LOADING, COMPACTING, SIGNING_IN, RUNNING, SENDING_FEEDBACK, FEEDBACK_TURN_INTERVAL, NOT_SIGNED_IN, LIVE_OUTPUT_LINES, COMMITTED_OUTPUT_LINES, DELTA_FLUSH_MS, MAX_INPUT_ROWS, MAX_PICKER_ROWS, MAX_QUEUE_ROWS, QUEUE_MARKER, PICKER_CURRENT, MIN_PICKER_LABEL_COLS, PICKER_CHROME, PICKER_CHROME_COMPACT, ASK_CHROME, PLAN_CHROME, ASK_HINT, PLAN_HINT, EXPAND_MAX_LINES, FALLBACK_ROWS, FALLBACK_COLS, CONTEXT_NOTICE_AT, KEEP_ALWAYS;
6158
6613
  var init_app = __esm({
6159
6614
  "src/tui/app.tsx"() {
6160
6615
  "use strict";
@@ -6191,6 +6646,8 @@ var init_app = __esm({
6191
6646
  COMPACTING = "compacting\u2026";
6192
6647
  SIGNING_IN = "signing in\u2026";
6193
6648
  RUNNING = "running\u2026";
6649
+ SENDING_FEEDBACK = "sending\u2026";
6650
+ FEEDBACK_TURN_INTERVAL = 50;
6194
6651
  NOT_SIGNED_IN = " Not signed in, or the stored token isn't valid for this gateway.\n Run /login to sign in.";
6195
6652
  LIVE_OUTPUT_LINES = 5;
6196
6653
  COMMITTED_OUTPUT_LINES = 4;
@@ -6313,6 +6770,10 @@ async function main() {
6313
6770
  return showWallet(client);
6314
6771
  case "earn":
6315
6772
  return earn(client, config);
6773
+ case "refer":
6774
+ return refer(client, config);
6775
+ case "redeem":
6776
+ return redeem(client, config, rest[0]);
6316
6777
  case "buy":
6317
6778
  return buyCmd(client, config, rest[0]);
6318
6779
  case "ask":
@@ -6374,6 +6835,7 @@ async function login(client, config, args) {
6374
6835
  \u2713 logged in as ${who}`) + suffix);
6375
6836
  const bonus = account.created ? c.dim(account.signupBonus === void 0 ? " (signup bonus)" : ` (signup bonus ${credits(account.signupBonus)})`) : "";
6376
6837
  console.log(` balance: ${c.bold(credits(account.balance))} credits${bonus}`);
6838
+ await showStreak(client);
6377
6839
  console.log(c.dim(` token stored in ${configPath()}`));
6378
6840
  return;
6379
6841
  }
@@ -6419,6 +6881,7 @@ function logout(config) {
6419
6881
  delete config.token;
6420
6882
  delete config.userId;
6421
6883
  delete config.email;
6884
+ delete config.login;
6422
6885
  saveConfig(config);
6423
6886
  console.log(c.green("\u2713 logged out"));
6424
6887
  }
@@ -6457,9 +6920,12 @@ async function setModel(client, config, model) {
6457
6920
  console.log(c.green(`\u2713 model set to ${model}`));
6458
6921
  }
6459
6922
  async function showWallet(client) {
6923
+ const streak = await client.claimStreak();
6460
6924
  const w = await client.wallet();
6461
6925
  console.log(`balance: ${c.bold(w.balance.toLocaleString("en-US"))} credits`);
6462
6926
  console.log(`${earnedToday(w)} \xB7 offers pay ${grantRangeLabel(w.grant_per_ad_range)}`);
6927
+ const line2 = streak && streakLabel(streak);
6928
+ if (line2) console.log(streak.claimed ? c.green(line2) : c.dim(line2));
6463
6929
  if (w.ledger.length) {
6464
6930
  console.log(c.dim("recent:"));
6465
6931
  const label = (e) => ledgerReasonLabel(e.reason);
@@ -6506,6 +6972,40 @@ async function earn(client, config) {
6506
6972
  );
6507
6973
  console.log(c.yellow(" pays nothing and is normal. Start another, or check `clixad wallet` later."));
6508
6974
  }
6975
+ async function refer(client, config) {
6976
+ if (!config.token) return console.log(c.yellow("Not logged in. Run `clixad login` first."));
6977
+ let info;
6978
+ try {
6979
+ info = await client.referral();
6980
+ } catch (err) {
6981
+ console.error(c.red(err.message));
6982
+ process.exitCode = 1;
6983
+ return;
6984
+ }
6985
+ const [code, share, deal, ...rest] = referLines(info);
6986
+ console.log(`
6987
+ ${c.bold(code)}`);
6988
+ console.log(` ${c.cyan(share)}`);
6989
+ console.log(c.dim(` ${deal}`));
6990
+ for (const line2 of rest) console.log(c.dim(` ${line2}`));
6991
+ console.log("");
6992
+ }
6993
+ async function redeem(client, config, code) {
6994
+ if (!config.token) return console.log(c.yellow("Not logged in. Run `clixad login` first."));
6995
+ if (!code) return console.error(c.red("usage: clixad redeem <code>"));
6996
+ let result;
6997
+ try {
6998
+ result = await client.redeem(code);
6999
+ } catch (err) {
7000
+ console.error(c.red(err.message));
7001
+ process.exitCode = 1;
7002
+ return;
7003
+ }
7004
+ const { ok, text } = redeemMessage(result);
7005
+ if (!ok) return console.log(c.yellow(` ${text}`));
7006
+ console.log(c.green(`
7007
+ \u2713 ${text}`) + ` \xB7 balance ${c.bold(credits(result.balance))}`);
7008
+ }
6509
7009
  async function buyCmd(client, config, pack) {
6510
7010
  if (!config.token) return console.log(c.yellow("Not logged in. Run `clixad login` first."));
6511
7011
  const { enabled, packs } = await client.creditPacks();
@@ -6672,10 +7172,11 @@ async function repl(client, config, opts = {}) {
6672
7172
  session = loadSession(opts.resume);
6673
7173
  if (!session) return console.log(c.red(`no such session: ${opts.resume}`));
6674
7174
  }
7175
+ const streak = await client.claimStreak();
6675
7176
  const wallet = await safeWallet(client);
6676
7177
  clearScreen();
6677
7178
  const { startTui: startTui2 } = await Promise.resolve().then(() => (init_tui(), tui_exports));
6678
- await startTui2({ client, config, wallet, session, initialTask: opts.task });
7179
+ await startTui2({ client, config, wallet, streak, session, initialTask: opts.task });
6679
7180
  }
6680
7181
  async function runTurn(client, config, messages) {
6681
7182
  try {
@@ -6702,6 +7203,13 @@ async function runTurn(client, config, messages) {
6702
7203
  return void 0;
6703
7204
  }
6704
7205
  }
7206
+ async function showStreak(client) {
7207
+ const streak = await client.claimStreak();
7208
+ const line2 = streak && streakLabel(streak);
7209
+ if (!line2) return streak;
7210
+ console.log(streak.claimed ? c.green(` ${line2}`) : c.dim(` ${line2}`));
7211
+ return streak;
7212
+ }
6705
7213
  async function safeWallet(client) {
6706
7214
  try {
6707
7215
  return await client.wallet();
@@ -6719,6 +7227,8 @@ function printHelp() {
6719
7227
  ${c.cyan("model")} <id> set default model
6720
7228
  ${c.cyan("wallet")} balance, ads today, ledger
6721
7229
  ${c.cyan("earn")} open the ad wall to earn credits
7230
+ ${c.cyan("refer")} your invite code, and what it has earned
7231
+ ${c.cyan("redeem")} <code> spend a code you were given
6722
7232
  ${c.cyan("buy")} [pack] list credit packs / buy one via Stripe
6723
7233
  ${c.cyan("ask")} "<prompt>" one-shot completion (no tools)
6724
7234
  ${c.cyan("agent")} "<task>" open the REPL with a task already running
@@ -6728,7 +7238,9 @@ function printHelp() {
6728
7238
  ${c.cyan("--resume")} [id] list saved sessions, or resume one
6729
7239
  ${c.cyan("--version")} print the version and exit
6730
7240
  (no command) interactive coding REPL ${c.dim("(signs you in if needed)")}
6731
- "<task>" same as ${c.cyan("agent")} \u2014 anything that is not a command is a task`);
7241
+ "<task>" same as ${c.cyan("agent")} \u2014 anything that is not a command is a task
7242
+
7243
+ ${c.dim("Discord: https://discord.gg/pwxPM3QyF \u2014 bugs and feature requests are read there")}`);
6732
7244
  }
6733
7245
  main().catch((err) => {
6734
7246
  console.error(c.red(err.message));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clixad",
3
- "version": "0.0.1-beta.14",
3
+ "version": "0.0.1-beta.16",
4
4
  "description": "Free AI coding agent in your terminal, funded by rewarded ads.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",