clixad 0.0.1-beta.30 → 0.0.1-beta.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,10 @@ cover it, for example `task ≈ 560–3.1k · free` or `task ≈ 7.5k–47k · n
15
15
  estimate from the size of the conversation and how tasks usually run; what is charged is what the
16
16
  model actually used.
17
17
 
18
+ The same account also works in the browser, at <https://clixad.onrender.com/chat>: sign in with the
19
+ GitHub account you use here. A model, a reasoning setting, your name and instructions set there or on
20
+ its account page hold here too (from 0.0.1-beta.31), and `/model` and `/effort` here hold there.
21
+
18
22
  `/effort` sets how long the model thinks before it answers, saved separately for each model.
19
23
  Thinking is billed as output tokens like the answer, so a higher setting costs more credits per turn
20
24
  and a lower one costs less. `auto` leaves the model at its own default, and a model with nothing to
package/dist/clixad.mjs CHANGED
@@ -324,6 +324,13 @@ function usd(v) {
324
324
  function packsFromClause(cheapestUsd) {
325
325
  return typeof cheapestUsd === "number" && cheapestUsd > 0 ? `, from ${usd(cheapestUsd)}` : "";
326
326
  }
327
+ function parseSpendable(raw) {
328
+ if (!raw || typeof raw !== "object") return void 0;
329
+ const r = raw;
330
+ const ok = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
331
+ if (!ok(r.budget) || !ok(r.premium) || !ok(r.budget_after_reset)) return void 0;
332
+ return { budget: r.budget, premium: r.premium, budgetAfterReset: r.budget_after_reset };
333
+ }
327
334
  function usableStreak(streak) {
328
335
  if (typeof streak !== "object" || streak === null) return void 0;
329
336
  const s = streak;
@@ -432,6 +439,22 @@ function extraFormatIds(raw) {
432
439
  }
433
440
  return out;
434
441
  }
442
+ function parseAccountSettings(body) {
443
+ if (!body || typeof body !== "object") return null;
444
+ const b = body;
445
+ const efforts = {};
446
+ if (b.efforts && typeof b.efforts === "object" && !Array.isArray(b.efforts)) {
447
+ for (const [k, v] of Object.entries(b.efforts)) if (typeof v === "string") efforts[k] = v;
448
+ }
449
+ const str = (v) => typeof v === "string" && v.trim() ? v : null;
450
+ return {
451
+ model: str(b.model),
452
+ efforts,
453
+ name: str(b.name),
454
+ instructions: str(b.instructions),
455
+ sponsorLine: typeof b.sponsor_line === "boolean" ? b.sponsor_line : null
456
+ };
457
+ }
435
458
  function imagePart(image) {
436
459
  return { type: "image_url", image_url: { url: `data:${image.mimeType};base64,${image.data}` } };
437
460
  }
@@ -465,7 +488,11 @@ function asSignupClosed(status, data) {
465
488
  const d = data;
466
489
  return d && d.error === "signup_closed" && typeof d.message === "string" ? { error: d.error, message: d.message, waitlist_url: d.waitlist_url ?? "https://clixad.io" } : void 0;
467
490
  }
468
- function premiumLockedError(message, model, body) {
491
+ function spendableAt(raw, balance) {
492
+ const figures = parseSpendable(raw);
493
+ return figures && typeof balance === "number" && Number.isFinite(balance) ? { figures, balance } : void 0;
494
+ }
495
+ function premiumLockedError(message, model, body, spendable) {
469
496
  const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
470
497
  return new PremiumLockedError(
471
498
  message,
@@ -475,7 +502,8 @@ function premiumLockedError(message, model, body) {
475
502
  typeof body?.unlock_usd === "number" && body.unlock_usd > 0 ? body.unlock_usd : null,
476
503
  num(body?.grant_per_ad),
477
504
  freeAlternatives(body?.free_models),
478
- usableStreak(body?.streak)
505
+ usableStreak(body?.streak),
506
+ spendable
479
507
  );
480
508
  }
481
509
  function shortfallHeadline(err, model) {
@@ -500,9 +528,23 @@ function freeAlternatives(list) {
500
528
  }
501
529
  async function refusal402(res, model) {
502
530
  const body = await res.json();
503
- if (body.error === "premium_locked") return premiumLockedError(body.message ?? "", model, body.premium);
531
+ if (body.error === "premium_locked") {
532
+ return premiumLockedError(body.message ?? "", model, body.premium, spendableAt(body.spendable, body.balance));
533
+ }
504
534
  return paywallError(body);
505
535
  }
536
+ async function refusal429(res) {
537
+ const text = await res.text().catch(() => "");
538
+ try {
539
+ const body = JSON.parse(text);
540
+ if (typeof body.error === "string" && LIMIT_REFUSALS.has(body.error) && typeof body.message === "string") {
541
+ return new LimitRefusalError(body.message, body.error, spendableAt(body.spendable, body.balance));
542
+ }
543
+ } catch {
544
+ }
545
+ const detail = safeDetail(text);
546
+ return new Error(detail ? `chat failed: 429 \u2014 ${detail}` : "chat failed: 429");
547
+ }
506
548
  function neverConnected(code) {
507
549
  return code !== void 0 && NEVER_CONNECTED.has(code);
508
550
  }
@@ -627,7 +669,7 @@ async function* parseSSE(body) {
627
669
  }
628
670
  }
629
671
  }
630
- var CHAT_ENCODED_CONTENT_TYPE, LEDGER_REASONS, SignupClosedError, PaywallError, PremiumLockedError, GatewayUnreachableError, NEVER_CONNECTED, AuthError, GatewayClient;
672
+ var CHAT_ENCODED_CONTENT_TYPE, LEDGER_REASONS, SignupClosedError, PaywallError, PremiumLockedError, LimitRefusalError, LIMIT_REFUSALS, GatewayUnreachableError, NEVER_CONNECTED, AuthError, GatewayClient;
631
673
  var init_client = __esm({
632
674
  "src/client.ts"() {
633
675
  "use strict";
@@ -680,7 +722,7 @@ var init_client = __esm({
680
722
  cheapestPackUsd;
681
723
  };
682
724
  PremiumLockedError = class extends Error {
683
- constructor(message, model, neededCredits, premiumCredits, unlockUsd, grantPerAd, freeModels, streak) {
725
+ constructor(message, model, neededCredits, premiumCredits, unlockUsd, grantPerAd, freeModels, streak, spendable) {
684
726
  super(message);
685
727
  this.model = model;
686
728
  this.neededCredits = neededCredits;
@@ -689,6 +731,7 @@ var init_client = __esm({
689
731
  this.grantPerAd = grantPerAd;
690
732
  this.freeModels = freeModels;
691
733
  this.streak = streak;
734
+ this.spendable = spendable;
692
735
  this.name = "PremiumLockedError";
693
736
  }
694
737
  model;
@@ -698,7 +741,19 @@ var init_client = __esm({
698
741
  grantPerAd;
699
742
  freeModels;
700
743
  streak;
744
+ spendable;
701
745
  };
746
+ LimitRefusalError = class extends Error {
747
+ constructor(message, type, spendable) {
748
+ super(message);
749
+ this.type = type;
750
+ this.spendable = spendable;
751
+ this.name = "LimitRefusalError";
752
+ }
753
+ type;
754
+ spendable;
755
+ };
756
+ LIMIT_REFUSALS = /* @__PURE__ */ new Set(["paid_user_limit_reached", "paid_budget_spent", "paid_budget_unavailable"]);
702
757
  GatewayUnreachableError = class extends Error {
703
758
  constructor(host, code) {
704
759
  const where = `${host}${code ? ` (${code})` : ""}`;
@@ -840,6 +895,42 @@ var init_client = __esm({
840
895
  * is "say nothing about a streak". A missed grant is claimed by the next
841
896
  * command; a session that died over one would be a real loss.
842
897
  */
898
+ /**
899
+ * The account's preferences (gateway 0039), kept with the account so they
900
+ * hold in the browser's chat and here. Null when they cannot be read, and on
901
+ * a gateway too old to have them (404): a preference is never a reason for
902
+ * the REPL not to open.
903
+ */
904
+ async getSettings(signal) {
905
+ try {
906
+ const res = await gatewayFetch(`${this.config.gatewayUrl}/v1/account/settings`, {
907
+ headers: this.headers(),
908
+ ...signal ? { signal } : {}
909
+ });
910
+ if (!res.ok) return null;
911
+ return parseAccountSettings(await res.json());
912
+ } catch {
913
+ return null;
914
+ }
915
+ }
916
+ /**
917
+ * Change the preferences a request names. Best-effort and never throws: a
918
+ * model picked here is saved locally whatever the gateway says, and a
919
+ * failed sync costs only that the browser's chat does not follow.
920
+ */
921
+ async saveSettings(change) {
922
+ try {
923
+ const res = await gatewayFetch(`${this.config.gatewayUrl}/v1/account/settings`, {
924
+ method: "PUT",
925
+ headers: { ...this.headers(), "Content-Type": "application/json" },
926
+ body: JSON.stringify(change)
927
+ });
928
+ if (!res.ok) return null;
929
+ return parseAccountSettings(await res.json());
930
+ } catch {
931
+ return null;
932
+ }
933
+ }
843
934
  async claimStreak(signal) {
844
935
  try {
845
936
  const res = await gatewayFetch(`${this.config.gatewayUrl}/v1/streak/claim`, {
@@ -1361,16 +1452,19 @@ var init_client = __esm({
1361
1452
  );
1362
1453
  if (res.status === 401) throw await authFailure(res, "chat");
1363
1454
  if (res.status === 402) throw await refusal402(res, model);
1455
+ if (res.status === 429) throw await refusal429(res);
1364
1456
  if (!res.ok) throw new Error(await httpFailure(res, "chat"));
1365
1457
  const data = await res.json();
1366
1458
  const choice = data.choices[0];
1367
1459
  if (!choice) throw new Error("chat: no choices in response");
1460
+ const spendable = parseSpendable(data.clixad.spendable);
1368
1461
  return {
1369
1462
  message: choice.message,
1370
1463
  finishReason: choice.finish_reason,
1371
1464
  creditsCharged: data.clixad.credits_charged,
1372
1465
  balance: data.clixad.balance,
1373
- ...freeMeta(data.clixad)
1466
+ ...freeMeta(data.clixad),
1467
+ ...spendable ? { spendable } : {}
1374
1468
  };
1375
1469
  }
1376
1470
  /**
@@ -1399,6 +1493,8 @@ var init_client = __esm({
1399
1493
  let free = {};
1400
1494
  let balance = 0;
1401
1495
  let streamError;
1496
+ let streamErrorType;
1497
+ let spendable;
1402
1498
  let premiumRefusal;
1403
1499
  const calls = /* @__PURE__ */ new Map();
1404
1500
  const reasoning = [];
@@ -1421,24 +1517,36 @@ var init_client = __esm({
1421
1517
  }
1422
1518
  mergeReasoningDetails(reasoning, choice?.delta?.reasoning_details);
1423
1519
  if (choice?.finish_reason) finishReason = choice.finish_reason;
1424
- if (json.error) streamError = json.error.message || "the model stream failed";
1520
+ if (json.error) {
1521
+ streamError = json.error.message || "the model stream failed";
1522
+ streamErrorType = json.error.type;
1523
+ }
1425
1524
  if (json.error?.type === "premium_locked") {
1426
- premiumRefusal = premiumLockedError(streamError ?? "", model, json.clixad?.premium);
1525
+ premiumRefusal = premiumLockedError(
1526
+ streamError ?? "",
1527
+ model,
1528
+ json.clixad?.premium,
1529
+ spendableAt(json.clixad?.spendable, json.clixad?.balance)
1530
+ );
1427
1531
  }
1428
1532
  if (json.clixad) {
1429
1533
  creditsCharged = json.clixad.credits_charged;
1430
1534
  balance = json.clixad.balance;
1431
1535
  free = freeMeta(json.clixad);
1536
+ spendable = parseSpendable(json.clixad.spendable);
1432
1537
  }
1433
1538
  }
1434
1539
  if (premiumRefusal) throw premiumRefusal;
1540
+ if (streamError && streamErrorType && LIMIT_REFUSALS.has(streamErrorType)) {
1541
+ throw new LimitRefusalError(streamError, streamErrorType, spendable ? { figures: spendable, balance } : void 0);
1542
+ }
1435
1543
  if (streamError) throw new Error(streamError);
1436
1544
  const message = { role: "assistant", content: content || null };
1437
1545
  if (calls.size > 0) {
1438
1546
  message.tool_calls = [...calls.entries()].sort(([a], [b]) => a - b).map(([, c2]) => c2);
1439
1547
  if (reasoning.length) message.reasoning_details = reasoning;
1440
1548
  }
1441
- return { message, finishReason, creditsCharged, balance, ...free };
1549
+ return { message, finishReason, creditsCharged, balance, ...free, ...spendable ? { spendable } : {} };
1442
1550
  }
1443
1551
  };
1444
1552
  }
@@ -1638,6 +1746,46 @@ var init_config = __esm({
1638
1746
  }
1639
1747
  });
1640
1748
 
1749
+ // src/preferences.ts
1750
+ async function syncPreferences(client, config, settings) {
1751
+ if (!settings) return { name: null, instructions: null, sponsorLine: null };
1752
+ const upload = {};
1753
+ if (settings.model && settings.model !== config.model) {
1754
+ config.model = settings.model;
1755
+ saveConfig(config);
1756
+ } else if (!settings.model && config.model && config.model !== FALLBACK_MODEL) {
1757
+ upload.model = config.model;
1758
+ }
1759
+ const local = config.effort ?? {};
1760
+ for (const [model, level] of Object.entries(settings.efforts)) {
1761
+ if (local[model] !== level) saveEffort(config, model, level);
1762
+ }
1763
+ const localOnly = {};
1764
+ for (const [model, level] of Object.entries(local)) {
1765
+ if (!(model in settings.efforts)) localOnly[model] = level;
1766
+ }
1767
+ if (Object.keys(localOnly).length) upload.efforts = localOnly;
1768
+ if (Object.keys(upload).length) await client.saveSettings(upload).catch(() => null);
1769
+ return { name: settings.name, instructions: settings.instructions, sponsorLine: settings.sponsorLine };
1770
+ }
1771
+ function preferencesInstruction(prefs) {
1772
+ if (!prefs) return "";
1773
+ const parts = [];
1774
+ if (prefs.name) parts.push(`The person's name is ${prefs.name}.`);
1775
+ if (prefs.instructions) parts.push(`What the person wants you to keep in mind in every answer:
1776
+ ${prefs.instructions}`);
1777
+ return parts.join("\n\n");
1778
+ }
1779
+ function sponsorPreference(config, prefs) {
1780
+ return prefs?.sponsorLine === false ? false : config.sponsor;
1781
+ }
1782
+ var init_preferences = __esm({
1783
+ "src/preferences.ts"() {
1784
+ "use strict";
1785
+ init_config();
1786
+ }
1787
+ });
1788
+
1641
1789
  // src/browser.ts
1642
1790
  import { spawn } from "node:child_process";
1643
1791
  function openBrowser(url) {
@@ -4368,7 +4516,8 @@ ${NO_SEARCH_INSTRUCTION}`,
4368
4516
  free: true,
4369
4517
  creditsWaived: turn.creditsWaived ?? 0,
4370
4518
  freeCreditsLeft: turn.freeCreditsLeft ?? 0
4371
- } : {}
4519
+ } : {},
4520
+ ...turn.spendable ? { spendable: turn.spendable } : {}
4372
4521
  });
4373
4522
  }
4374
4523
  let retriedEmpty = false;
@@ -4940,12 +5089,12 @@ var init_kimi = __esm({
4940
5089
  CONTEXT_TOKENS = {
4941
5090
  "gpt-5-nano": 4e5,
4942
5091
  "gemini-2.5-flash-lite": 1048576,
5092
+ "claude-haiku-5.5": 1e6,
4943
5093
  "deepseek-v4-flash": 1048576,
4944
5094
  "gemini-3.5-flash-lite": 1048576,
4945
5095
  "kimi-k2.5": 262144,
4946
5096
  "gemini-3.8-flash": 1048576,
4947
5097
  "kimi-k2.7-code": 262144,
4948
- "claude-haiku-4.5": 2e5,
4949
5098
  "claude-sonnet-5.5": 1e6,
4950
5099
  "gpt-6-sol": 105e4,
4951
5100
  "kimi-k3": 1048576,
@@ -4955,14 +5104,17 @@ var init_kimi = __esm({
4955
5104
  // packages/pricing), so an install still configured with one compacts
4956
5105
  // against the window of the model that actually answers.
4957
5106
  "gemini-3.1-flash-lite": 1048576,
4958
- "kimi-k2": 262144,
5107
+ // Served by Claude Haiku 5.5 since 2026-10-12, when Kimi K2.5 left the free set.
5108
+ "kimi-k2": 1e6,
4959
5109
  "gemini-3.6-flash": 1048576,
4960
5110
  "gpt-5.2": 105e4,
4961
5111
  "claude-opus-5": 1e6,
4962
5112
  "gpt-5.5": 105e4,
4963
5113
  "gpt-5.2-pro": 105e4,
4964
5114
  // Retired on 2026-09-29 for Claude Sonnet 5.5, same window.
4965
- "claude-sonnet-5": 1e6
5115
+ "claude-sonnet-5": 1e6,
5116
+ // Retired on 2026-10-12 for Claude Haiku 5.5.
5117
+ "claude-haiku-4.5": 1e6
4966
5118
  };
4967
5119
  DEFAULT_CONTEXT_TOKENS = 256e3;
4968
5120
  INSTALL_HELP = `Kimi CLI is not installed (or not on PATH).
@@ -5401,6 +5553,7 @@ ${summary}` },
5401
5553
  summary,
5402
5554
  creditsCharged: turn.creditsCharged,
5403
5555
  balance: turn.balance,
5556
+ ...turn.spendable ? { spendable: turn.spendable } : {},
5404
5557
  before,
5405
5558
  after: conversationTokens(compacted)
5406
5559
  };
@@ -6090,12 +6243,22 @@ function headRows(text, cols, max) {
6090
6243
  if (rows.length <= max) return text;
6091
6244
  return rows.slice(0, max).join("\n");
6092
6245
  }
6093
- function errorNotice(message, rows = ERROR_NOTICE_ROWS) {
6246
+ function errorNotice(message, rows = ERROR_NOTICE_ROWS, indent = "") {
6094
6247
  const clean = message.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)?/g, " ").replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, " ").replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, " ");
6095
6248
  const lines = clean.split("\n");
6096
- const kept = lines.slice(0, rows).map((l) => l.length > ERROR_NOTICE_COLS ? `${l.slice(0, ERROR_NOTICE_COLS - 1)}\u2026` : l);
6097
- if (lines.length > rows) kept.push(` \u2026 +${lines.length - rows} more lines`);
6098
- return kept.join("\n");
6249
+ const kept = [];
6250
+ let left = ERROR_NOTICE_CHARS;
6251
+ for (const line2 of lines.slice(0, rows)) {
6252
+ if (line2.length > left) {
6253
+ kept.push(`${line2.slice(0, Math.max(0, left - 1))}\u2026`);
6254
+ break;
6255
+ }
6256
+ kept.push(line2);
6257
+ left -= line2.length;
6258
+ }
6259
+ if (lines.length > kept.length) kept.push(`\u2026 +${lines.length - kept.length} more lines`);
6260
+ return kept.join(`
6261
+ ${indent}`);
6099
6262
  }
6100
6263
  function entryHeight(entry, cols) {
6101
6264
  const margin = entry.kind === "banner" ? 0 : 1;
@@ -6143,7 +6306,7 @@ function EntryView({ entry }) {
6143
6306
  ) });
6144
6307
  }
6145
6308
  }
6146
- var MANGO3, MANGO_BRIGHT, SLATE, AD_TEXT, AD_MARK, MODE_STYLE, WRAP_LIKE_INK, DIALOG_CHROME_COLS, ERROR_NOTICE_ROWS, ERROR_NOTICE_COLS;
6309
+ var MANGO3, MANGO_BRIGHT, SLATE, AD_TEXT, AD_MARK, MODE_STYLE, WRAP_LIKE_INK, DIALOG_CHROME_COLS, ERROR_NOTICE_ROWS, ERROR_NOTICE_CHARS;
6147
6310
  var init_views = __esm({
6148
6311
  "src/tui/views.tsx"() {
6149
6312
  "use strict";
@@ -6162,7 +6325,7 @@ var init_views = __esm({
6162
6325
  WRAP_LIKE_INK = { trim: false, hard: true };
6163
6326
  DIALOG_CHROME_COLS = 4;
6164
6327
  ERROR_NOTICE_ROWS = 12;
6165
- ERROR_NOTICE_COLS = 200;
6328
+ ERROR_NOTICE_CHARS = 2400;
6166
6329
  }
6167
6330
  });
6168
6331
 
@@ -6187,6 +6350,9 @@ function buyConfirmLines(pack) {
6187
6350
  `Terms: ${TERMS_URL}`
6188
6351
  ].join("\n");
6189
6352
  }
6353
+ function utcDay(t = Date.now()) {
6354
+ return new Date(t).toISOString().slice(0, 10);
6355
+ }
6190
6356
  function useTerminalSize() {
6191
6357
  const { stdout } = useStdout();
6192
6358
  const snapshot2 = useRef({
@@ -6224,9 +6390,11 @@ function App({
6224
6390
  sponsorServe,
6225
6391
  sponsorFormats,
6226
6392
  sponsorDirect,
6227
- initHint
6393
+ initHint,
6394
+ preferences
6228
6395
  }) {
6229
6396
  const { exit } = useApp();
6397
+ const preferencesText = preferencesInstruction(preferences);
6230
6398
  const { stdout, write: writeToStdout } = useStdout();
6231
6399
  const idRef = useRef(1);
6232
6400
  const { rows, cols } = useTerminalSize();
@@ -6270,7 +6438,8 @@ function App({
6270
6438
  const switchModel = useCallback((id) => {
6271
6439
  modelRef.current = id;
6272
6440
  setModel2(id);
6273
- }, []);
6441
+ void client.saveSettings?.({ model: id });
6442
+ }, [client]);
6274
6443
  const [hist, setHist] = useState(() => loadHistory());
6275
6444
  const [histIdx, setHistIdx] = useState(-1);
6276
6445
  const [sel, setSel] = useState(0);
@@ -6307,6 +6476,7 @@ function App({
6307
6476
  const runStartedAtRef = useRef(Date.now());
6308
6477
  const contextRef = useRef(collectContext(root));
6309
6478
  const catalogRef = useRef([]);
6479
+ const [, setCatalogArrived] = useState(0);
6310
6480
  const searchRef = useRef(false);
6311
6481
  const outputsRef = useRef([]);
6312
6482
  const expandedRef = useRef(/* @__PURE__ */ new Set());
@@ -6328,6 +6498,52 @@ function App({
6328
6498
  const refreshPremium = useCallback(() => {
6329
6499
  void client.wallet(void 0, { premium: true }).then((w) => setPremium(w.premium)).catch(() => void 0);
6330
6500
  }, [client, setPremium]);
6501
+ const initialSpendable = useMemo(() => {
6502
+ const figures = parseSpendable(wallet?.spendable);
6503
+ return figures && wallet ? { figures, balance: wallet.balance, day: utcDay() } : void 0;
6504
+ }, []);
6505
+ const [spendable, setSpendableState] = useState(initialSpendable);
6506
+ const spendableRef = useRef(initialSpendable);
6507
+ const takeSpendable = useCallback(
6508
+ (figures, balance2) => {
6509
+ const next = { figures, balance: balance2, day: utcDay() };
6510
+ spendableRef.current = next;
6511
+ setSpendableState(next);
6512
+ setBalance(balance2);
6513
+ const p = premiumRef.current;
6514
+ if (p) setPremium({ ...p, credits: figures.premium });
6515
+ },
6516
+ [setPremium]
6517
+ );
6518
+ const balanceSeqRef = useRef(0);
6519
+ const spendableInFlightRef = useRef(false);
6520
+ const spendableAskedRef = useRef(null);
6521
+ const refreshSpendable = useCallback(() => {
6522
+ if (spendableInFlightRef.current) return;
6523
+ spendableInFlightRef.current = true;
6524
+ const seq = balanceSeqRef.current;
6525
+ void client.wallet(void 0, { premium: true }).then((w) => {
6526
+ if (balanceSeqRef.current !== seq) return;
6527
+ const figures = parseSpendable(w.spendable);
6528
+ if (figures) takeSpendable(figures, w.balance);
6529
+ else setBalance(w.balance);
6530
+ if (w.premium) setPremium(w.premium);
6531
+ }).catch(() => void 0).finally(() => {
6532
+ spendableInFlightRef.current = false;
6533
+ });
6534
+ }, [client, setPremium, takeSpendable]);
6535
+ const [dayTick, setDayTick] = useState(0);
6536
+ useEffect(() => {
6537
+ const now = Date.now();
6538
+ const next = Date.UTC(
6539
+ new Date(now).getUTCFullYear(),
6540
+ new Date(now).getUTCMonth(),
6541
+ new Date(now).getUTCDate() + 1
6542
+ );
6543
+ const timer = setTimeout(() => setDayTick((t) => t + 1), next - now + 1e3);
6544
+ timer.unref?.();
6545
+ return () => clearTimeout(timer);
6546
+ }, [dayTick]);
6331
6547
  const mcpRef = useRef(McpHub.empty());
6332
6548
  const todosRef = useRef(new TodoList());
6333
6549
  const lastTurnRef = useRef(null);
@@ -6482,6 +6698,7 @@ function App({
6482
6698
  }
6483
6699
  client.models().then((m) => {
6484
6700
  catalogRef.current = m;
6701
+ setCatalogArrived((n) => n + 1);
6485
6702
  searchRef.current = client.capabilities?.web_search === true;
6486
6703
  const successor = client.retiredModels[modelRef.current];
6487
6704
  if (successor) {
@@ -6544,7 +6761,7 @@ function App({
6544
6761
  if (!sponsorEnabled({
6545
6762
  isTTY: stdout.isTTY,
6546
6763
  env: process.env,
6547
- configEnabled: config.sponsor
6764
+ configEnabled: sponsorPreference(config, preferences)
6548
6765
  })) {
6549
6766
  setSponsor(null);
6550
6767
  setAdSlots({});
@@ -6746,8 +6963,11 @@ function App({
6746
6963
  push({ kind: "notice", text: ` showing the model: ${event.labels.join(", ")}` });
6747
6964
  return;
6748
6965
  case "usage":
6966
+ balanceSeqRef.current++;
6749
6967
  setBalance(event.balance);
6750
- if (event.creditsCharged > 0 && premiumRef.current) {
6968
+ if (event.spendable) {
6969
+ takeSpendable(event.spendable, event.balance);
6970
+ } else if (event.creditsCharged > 0 && premiumRef.current) {
6751
6971
  const p = premiumRef.current;
6752
6972
  setPremium({
6753
6973
  ...p,
@@ -6765,8 +6985,18 @@ function App({
6765
6985
  return;
6766
6986
  }
6767
6987
  },
6768
- [cols, flushDelta, push, setPremium]
6988
+ [cols, flushDelta, push, setPremium, takeSpendable]
6769
6989
  );
6990
+ useEffect(() => {
6991
+ if (balance === void 0) return;
6992
+ const s = spendableRef.current;
6993
+ const day = utcDay();
6994
+ if (s && s.balance === balance && s.day === day) return;
6995
+ const key = `${balance}|${day}`;
6996
+ if (spendableAskedRef.current === key) return;
6997
+ spendableAskedRef.current = key;
6998
+ refreshSpendable();
6999
+ }, [balance, spendable, dayTick, refreshSpendable]);
6770
7000
  const runTurn2 = useCallback(
6771
7001
  async (task, images = []) => {
6772
7002
  pendingFreeRef.current = [];
@@ -6789,7 +7019,9 @@ function App({
6789
7019
  if (res) {
6790
7020
  history = res.messages.slice(1);
6791
7021
  setMessages(history);
7022
+ balanceSeqRef.current++;
6792
7023
  setBalance(res.balance);
7024
+ if (res.spendable) takeSpendable(res.spendable, res.balance);
6793
7025
  setSpent((s) => s + res.creditsCharged);
6794
7026
  push({
6795
7027
  kind: "notice",
@@ -6817,7 +7049,11 @@ function App({
6817
7049
 
6818
7050
  ${modeInstruction(permRef.current.mode)}` + (searchRef.current ? "" : `
6819
7051
 
6820
- ${NO_SEARCH_INSTRUCTION}`),
7052
+ ${NO_SEARCH_INSTRUCTION}`) + // The account's name and instructions (gateway 0039), set in the
7053
+ // browser or on the account page, said on every turn.
7054
+ (preferencesText ? `
7055
+
7056
+ ${preferencesText}` : ""),
6821
7057
  permit,
6822
7058
  approvePlan,
6823
7059
  askUser: askQuestion,
@@ -6907,6 +7143,10 @@ ${NO_SEARCH_INSTRUCTION}`),
6907
7143
  }
6908
7144
  }
6909
7145
  } catch (err) {
7146
+ if ((err instanceof LimitRefusalError || err instanceof PremiumLockedError) && err.spendable) {
7147
+ balanceSeqRef.current++;
7148
+ takeSpendable(err.spendable.figures, err.spendable.balance);
7149
+ }
6910
7150
  if (err instanceof PremiumLockedError) {
6911
7151
  pendingTaskRef.current = { task, images };
6912
7152
  pendingPremiumRef.current = err;
@@ -6956,7 +7196,7 @@ ${NO_SEARCH_INSTRUCTION}`),
6956
7196
  } else if (err instanceof AuthError) {
6957
7197
  push({ kind: "notice", tone: "error", text: NOT_SIGNED_IN });
6958
7198
  } else {
6959
- push({ kind: "notice", tone: "error", text: ` error: ${errorNotice(err.message)}` });
7199
+ push({ kind: "notice", tone: "error", text: ` error: ${errorNotice(err.message, void 0, " ")}` });
6960
7200
  }
6961
7201
  } finally {
6962
7202
  abortRef.current = null;
@@ -6981,7 +7221,8 @@ ${NO_SEARCH_INSTRUCTION}`),
6981
7221
  nextSponsor,
6982
7222
  permit,
6983
7223
  push,
6984
- root
7224
+ root,
7225
+ takeSpendable
6985
7226
  ]
6986
7227
  );
6987
7228
  const stopCurrent = useCallback(() => {
@@ -7000,7 +7241,7 @@ ${NO_SEARCH_INSTRUCTION}`),
7000
7241
  } catch (err) {
7001
7242
  if (ac.signal.aborted) push({ kind: "notice", tone: "warn", text: " (stopped)" });
7002
7243
  else if (err instanceof AuthError) push({ kind: "notice", tone: "error", text: NOT_SIGNED_IN });
7003
- else push({ kind: "notice", tone: "error", text: ` ${errorNotice(err.message)}` });
7244
+ else push({ kind: "notice", tone: "error", text: ` ${errorNotice(err.message, void 0, " ")}` });
7004
7245
  } finally {
7005
7246
  busyAbortRef.current = null;
7006
7247
  setBusyLabel(WORKING);
@@ -7442,6 +7683,7 @@ ${NO_SEARCH_INSTRUCTION}`),
7442
7683
  const defaultWords = fallback ? effortLabel(fallback) : "its default";
7443
7684
  const apply2 = (choice, note = "") => {
7444
7685
  saveEffort(config, model, choice);
7686
+ void client.saveSettings?.({ efforts: { [model]: choice ?? "auto" } });
7445
7687
  setEffortBy({ ...config.effort ?? {} });
7446
7688
  const now = choice ? effortLabel(choice) : `auto (${defaultWords})`;
7447
7689
  push({ kind: "notice", text: ` ${name}: ${now}${note}` });
@@ -7492,8 +7734,13 @@ ${NO_SEARCH_INSTRUCTION}`),
7492
7734
  case "wallet":
7493
7735
  await runBusy(LOADING, async (signal) => {
7494
7736
  const streakNow = await client.claimStreak(signal);
7495
- const w = await client.wallet(signal);
7496
- setBalance(w.balance);
7737
+ const w = await client.wallet(signal, { premium: true });
7738
+ const figures = parseSpendable(w.spendable);
7739
+ balanceSeqRef.current++;
7740
+ if (figures) takeSpendable(figures, w.balance);
7741
+ else setBalance(w.balance);
7742
+ if (w.premium) setPremium(w.premium);
7743
+ const usableNow = figures ? walletUsableLines(w.balance, figures) : [];
7497
7744
  const reversals = w.ledger.filter((e) => e.reason === "ad_reversal");
7498
7745
  const reversed = reversals.reduce((sum, e) => sum + Math.abs(e.delta), 0);
7499
7746
  const screenouts = w.screenouts_today ?? 0;
@@ -7501,7 +7748,8 @@ ${NO_SEARCH_INSTRUCTION}`),
7501
7748
  const streakLine = streakNow && streakLabel(streakNow);
7502
7749
  push({
7503
7750
  kind: "notice",
7504
- 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 ? `
7751
+ 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` + usableNow.map((l) => `
7752
+ ${l}`).join("") + (screenouts ? `
7505
7753
  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 ? `
7506
7754
  ${streakLine}` : "") + (reversals.length ? `
7507
7755
  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.` : "")
@@ -8156,14 +8404,25 @@ ${dropped.map((l) => ` \xB7 ${l}`).join("\n")}`
8156
8404
  const typed = toText(editor);
8157
8405
  let estimate;
8158
8406
  const info = catalogRef.current.find((m) => m.id === model);
8407
+ const usable = (() => {
8408
+ const s = spendable;
8409
+ if (!s || balance === void 0 || s.balance !== balance || s.day !== utcDay() || !info?.access) return void 0;
8410
+ const credits2 = Math.max(0, Math.min(balance, info.access === "premium" ? s.figures.premium : s.figures.budget));
8411
+ return {
8412
+ kind: info.access,
8413
+ credits: credits2,
8414
+ locked: Math.max(0, balance - credits2),
8415
+ afterReset: Math.max(credits2, Math.min(balance, s.figures.budgetAfterReset))
8416
+ };
8417
+ })();
8159
8418
  const effortNow = info ? resolveEffort(info, effortBy[model]) : effortBy[model];
8160
8419
  const shape = client.taskShape;
8161
8420
  if (!busy && info && shape && balance !== void 0 && !effortRaised(info, resolveEffort(info, effortBy[model])) && typed.trim() !== "" && !/^[/!]/.test(typed.trimStart())) {
8162
8421
  const range = estimateTask(info, conversationUsed + schemaTokens + estimateTokens(typed), shape);
8163
8422
  const onFree = Boolean(info.free && freeTier?.models.includes(model));
8164
- const spendable = info.access === "premium" && premium && !premium.unlocked ? Math.min(balance, premium.credits) : balance;
8423
+ const canSpend = usable ? usable.credits : info.access === "premium" && premium && !premium.unlocked ? Math.min(balance, premium.credits) : balance;
8165
8424
  const verdict = taskVerdict(range, {
8166
- balance: spendable,
8425
+ balance: canSpend,
8167
8426
  ...onFree ? { freeLeft: freeTier.credits_left } : {}
8168
8427
  });
8169
8428
  estimate = estimateClause(range, verdict, !onFree && (freeTier?.credits_left ?? 0) > 0);
@@ -8175,6 +8434,7 @@ ${dropped.map((l) => ` \xB7 ${l}`).join("\n")}`
8175
8434
  model,
8176
8435
  ...effortNow ? { effort: effortNow } : {},
8177
8436
  balance,
8437
+ ...usable ? { usable } : {},
8178
8438
  spent,
8179
8439
  contextPct,
8180
8440
  minutes: (Date.now() - runStartedAtRef.current) / 6e4,
@@ -8360,15 +8620,41 @@ function turnMeta(result) {
8360
8620
  }
8361
8621
  return `[free \xB7 ${n(result.creditsWaived)} credits waived${left}]`;
8362
8622
  }
8623
+ function lockedPhrasings(u) {
8624
+ if (!u || u.locked <= 0) return [];
8625
+ const n = (v) => v.toLocaleString("en-US");
8626
+ const locked = `${n(u.locked)} bonus locked`;
8627
+ if (u.kind === "premium") return [`${locked} on premium`, locked];
8628
+ const back = u.afterReset - u.credits;
8629
+ return back >= u.locked ? [`${locked} until 00:00 UTC`, locked] : back > 0 ? [`${locked}, ${n(back)} usable from 00:00 UTC`, locked] : [locked];
8630
+ }
8631
+ function walletUsableLines(balance, f) {
8632
+ const n = (v) => Math.max(0, v).toLocaleString("en-US");
8633
+ const budget = Math.min(balance, f.budget);
8634
+ const premium = Math.min(balance, f.premium);
8635
+ if (budget >= balance && premium >= balance) return [];
8636
+ const lines = [`usable now: ${n(budget)} credits on budget models, ${n(premium)} on premium models`];
8637
+ const locked = balance - budget;
8638
+ if (locked > 0) {
8639
+ const back = Math.min(balance, Math.max(budget, f.budgetAfterReset)) - budget;
8640
+ lines.push(
8641
+ back >= locked ? `${n(locked)} bonus credits are over today's limit and become usable at 00:00 UTC` : back > 0 ? `${n(locked)} bonus credits are over today's limit: ${n(back)} become usable at 00:00 UTC, the rest on later days` : `${n(locked)} bonus credits are over today's limit`
8642
+ );
8643
+ }
8644
+ return lines;
8645
+ }
8363
8646
  function statusLine(o, cols) {
8364
8647
  const phrasings = o.estimate === void 0 ? [] : typeof o.estimate === "string" ? [o.estimate] : [...o.estimate];
8648
+ const locked = lockedPhrasings(o.usable);
8365
8649
  for (const estimate of [...phrasings, void 0]) {
8366
- const line2 = statusRow(o, estimate, cols);
8367
- if (estimate === void 0 || visibleLength(line2) <= cols) return fitRow(line2, cols);
8650
+ for (const held of [...locked, void 0]) {
8651
+ const line2 = statusRow(o, estimate, held, cols);
8652
+ if (visibleLength(line2) <= cols) return line2;
8653
+ }
8368
8654
  }
8369
- return fitRow(statusRow(o, void 0, cols), cols);
8655
+ return fitRow(statusRow(o, void 0, void 0, cols), cols);
8370
8656
  }
8371
- function statusRow(o, estimate, cols) {
8657
+ function statusRow(o, estimate, locked, cols) {
8372
8658
  const burn = o.spent > 0 && o.minutes >= 1 ? `${Math.round(o.spent / o.minutes).toLocaleString("en-US")} cr/min` : void 0;
8373
8659
  const clauses = [
8374
8660
  { text: o.model, rank: KEEP_ALWAYS },
@@ -8377,9 +8663,15 @@ function statusRow(o, estimate, cols) {
8377
8663
  {
8378
8664
  // "? cr" rather than "0 cr": unknown is not empty, and an empty-looking
8379
8665
  // wallet is the one misreading on this row that frightens somebody.
8380
- text: o.balance === void 0 ? "? cr" : `${o.balance.toLocaleString("en-US")} cr`,
8666
+ // With part of the balance held back, what can be spent takes its place:
8667
+ // "33,396 cr" over a wallet the gateway refused to spend from was how
8668
+ // this row came to be read as a fault (2026-10-12).
8669
+ text: o.balance === void 0 ? "? cr" : o.usable && o.usable.locked > 0 ? `${o.usable.credits.toLocaleString("en-US")} cr usable` : `${o.balance.toLocaleString("en-US")} cr`,
8381
8670
  rank: KEEP_ALWAYS
8382
8671
  },
8672
+ // Never dropped inside a row: `statusLine` tries its shorter phrasings,
8673
+ // then none, as it does for the preview.
8674
+ { text: locked, rank: 5 },
8383
8675
  // Above the context warning, below the balance. It is the second number
8384
8676
  // deciding whether the next turn costs anything, and unlike the spend it is
8385
8677
  // *news* — it only exists while a free model is selected and it counts down.
@@ -8459,6 +8751,7 @@ var init_app = __esm({
8459
8751
  init_todos();
8460
8752
  init_shells();
8461
8753
  init_config();
8754
+ init_preferences();
8462
8755
  init_effort();
8463
8756
  init_login();
8464
8757
  init_banner();
@@ -8568,6 +8861,7 @@ var init_tui = __esm({
8568
8861
  // src/main.ts
8569
8862
  init_client();
8570
8863
  init_config();
8864
+ init_preferences();
8571
8865
  init_login();
8572
8866
  init_free_tier();
8573
8867
  init_agent();
@@ -9076,7 +9370,8 @@ async function repl(client, config, opts = {}) {
9076
9370
  session = loadSession(opts.resume);
9077
9371
  if (!session) return console.log(c.red(`no such session: ${opts.resume}`));
9078
9372
  }
9079
- const { streak, wallet } = await startupReads(client, config.gatewayUrl);
9373
+ const { streak, wallet, settings } = await startupReads(client, config.gatewayUrl);
9374
+ const preferences = await syncPreferences(client, config, settings);
9080
9375
  const cwd = process.cwd();
9081
9376
  const initHint = shouldSuggestInit(cwd) ? { returning: hasSessionIn(cwd) } : void 0;
9082
9377
  clearScreen();
@@ -9088,6 +9383,7 @@ async function repl(client, config, opts = {}) {
9088
9383
  streak,
9089
9384
  session,
9090
9385
  newAccount,
9386
+ preferences,
9091
9387
  initialTask: opts.task,
9092
9388
  ...opts.notices?.length ? { notices: opts.notices } : {},
9093
9389
  ...initHint ? { initHint } : {}
@@ -9166,10 +9462,15 @@ async function startupReads(client, gatewayUrl) {
9166
9462
  )
9167
9463
  ];
9168
9464
  try {
9169
- const [streak, wallet] = await Promise.all([client.claimStreak(), safeWallet(client, { premium: true })]);
9465
+ const [streak, wallet, settings] = await Promise.all([
9466
+ client.claimStreak(),
9467
+ safeWallet(client, { premium: true }),
9468
+ client.getSettings()
9469
+ ]);
9170
9470
  const balance = streak?.claimed ? streak.balance : wallet?.balance;
9171
9471
  return {
9172
9472
  streak,
9473
+ settings,
9173
9474
  wallet: wallet && balance !== void 0 ? { ...wallet, balance } : wallet
9174
9475
  };
9175
9476
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clixad",
3
- "version": "0.0.1-beta.30",
3
+ "version": "0.0.1-beta.32",
4
4
  "description": "Free AI coding agent in your terminal, funded by rewarded ads.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",