charness 3.4.0 → 3.5.0

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 (146) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +73 -5
  3. package/dist/adapters/claudeCliAdapter.js +2 -2
  4. package/dist/adapters/claudeCliAdapter.js.map +1 -1
  5. package/dist/adapters/claudeConfigDir.d.ts +18 -0
  6. package/dist/adapters/claudeConfigDir.d.ts.map +1 -1
  7. package/dist/adapters/claudeJsonStreamAdapter.d.ts +8 -1
  8. package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
  9. package/dist/adapters/claudeJsonStreamAdapter.js +24 -6
  10. package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
  11. package/dist/adapters/createAdapter.d.ts +2 -1
  12. package/dist/adapters/createAdapter.d.ts.map +1 -1
  13. package/dist/adapters/createAdapter.js +16 -0
  14. package/dist/adapters/createAdapter.js.map +1 -1
  15. package/dist/agentLogin/agentLogin.d.ts +18 -1
  16. package/dist/agentLogin/agentLogin.d.ts.map +1 -1
  17. package/dist/agentLogin/agentLogin.js +59 -18
  18. package/dist/agentLogin/agentLogin.js.map +1 -1
  19. package/dist/apiErrorRetry.d.ts +3 -1
  20. package/dist/apiErrorRetry.d.ts.map +1 -1
  21. package/dist/apiErrorRetry.js +11 -1
  22. package/dist/apiErrorRetry.js.map +1 -1
  23. package/dist/bot.d.ts.map +1 -1
  24. package/dist/bot.js +176 -23
  25. package/dist/bot.js.map +1 -1
  26. package/dist/cli/claudeProfiles.d.ts +1 -1
  27. package/dist/cli/claudeProfiles.d.ts.map +1 -1
  28. package/dist/cli/claudeProfiles.js +11 -6
  29. package/dist/cli/claudeProfiles.js.map +1 -1
  30. package/dist/connectors/telegram/commands/claudeAccounts.d.ts +6 -2
  31. package/dist/connectors/telegram/commands/claudeAccounts.d.ts.map +1 -1
  32. package/dist/connectors/telegram/commands/claudeAccounts.js +127 -28
  33. package/dist/connectors/telegram/commands/claudeAccounts.js.map +1 -1
  34. package/dist/connectors/telegram/commands/fallbackChain.d.ts.map +1 -1
  35. package/dist/connectors/telegram/commands/fallbackChain.js +6 -2
  36. package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -1
  37. package/dist/i18n/de.d.ts.map +1 -1
  38. package/dist/i18n/de.js +28 -1
  39. package/dist/i18n/de.js.map +1 -1
  40. package/dist/i18n/en.d.ts.map +1 -1
  41. package/dist/i18n/en.js +28 -1
  42. package/dist/i18n/en.js.map +1 -1
  43. package/dist/i18n/es.d.ts.map +1 -1
  44. package/dist/i18n/es.js +28 -1
  45. package/dist/i18n/es.js.map +1 -1
  46. package/dist/i18n/fr.d.ts.map +1 -1
  47. package/dist/i18n/fr.js +28 -1
  48. package/dist/i18n/fr.js.map +1 -1
  49. package/dist/i18n/hi.d.ts.map +1 -1
  50. package/dist/i18n/hi.js +28 -1
  51. package/dist/i18n/hi.js.map +1 -1
  52. package/dist/i18n/ja.d.ts.map +1 -1
  53. package/dist/i18n/ja.js +28 -1
  54. package/dist/i18n/ja.js.map +1 -1
  55. package/dist/i18n/ka.d.ts.map +1 -1
  56. package/dist/i18n/ka.js +28 -1
  57. package/dist/i18n/ka.js.map +1 -1
  58. package/dist/i18n/pt.d.ts.map +1 -1
  59. package/dist/i18n/pt.js +28 -1
  60. package/dist/i18n/pt.js.map +1 -1
  61. package/dist/i18n/ru.d.ts.map +1 -1
  62. package/dist/i18n/ru.js +28 -1
  63. package/dist/i18n/ru.js.map +1 -1
  64. package/dist/i18n/uk.d.ts.map +1 -1
  65. package/dist/i18n/uk.js +28 -1
  66. package/dist/i18n/uk.js.map +1 -1
  67. package/dist/i18n/uz.d.ts.map +1 -1
  68. package/dist/i18n/uz.js +28 -1
  69. package/dist/i18n/uz.js.map +1 -1
  70. package/dist/i18n/zh.d.ts.map +1 -1
  71. package/dist/i18n/zh.js +28 -1
  72. package/dist/i18n/zh.js.map +1 -1
  73. package/dist/scheduler/mcpSurface.d.ts.map +1 -1
  74. package/dist/scheduler/mcpSurface.js +4 -2
  75. package/dist/scheduler/mcpSurface.js.map +1 -1
  76. package/dist/state.d.ts +31 -0
  77. package/dist/state.d.ts.map +1 -1
  78. package/dist/state.js +36 -0
  79. package/dist/state.js.map +1 -1
  80. package/dist/types.d.ts +6 -0
  81. package/dist/types.d.ts.map +1 -1
  82. package/dist/utils/anthropicApiKeyCheck.d.ts +19 -0
  83. package/dist/utils/anthropicApiKeyCheck.d.ts.map +1 -0
  84. package/dist/utils/anthropicApiKeyCheck.js +41 -0
  85. package/dist/utils/anthropicApiKeyCheck.js.map +1 -0
  86. package/dist/utils/claudeAccountPickerPlan.d.ts +6 -1
  87. package/dist/utils/claudeAccountPickerPlan.d.ts.map +1 -1
  88. package/dist/utils/claudeAccountPickerPlan.js +5 -1
  89. package/dist/utils/claudeAccountPickerPlan.js.map +1 -1
  90. package/dist/utils/claudeApiKeyHelper.d.ts +19 -0
  91. package/dist/utils/claudeApiKeyHelper.d.ts.map +1 -0
  92. package/dist/utils/claudeApiKeyHelper.js +32 -0
  93. package/dist/utils/claudeApiKeyHelper.js.map +1 -0
  94. package/dist/utils/claudeApiSpend.d.ts +73 -0
  95. package/dist/utils/claudeApiSpend.d.ts.map +1 -0
  96. package/dist/utils/claudeApiSpend.js +82 -0
  97. package/dist/utils/claudeApiSpend.js.map +1 -0
  98. package/dist/utils/claudeCompactHook.d.ts +11 -6
  99. package/dist/utils/claudeCompactHook.d.ts.map +1 -1
  100. package/dist/utils/claudeCompactHook.js +28 -11
  101. package/dist/utils/claudeCompactHook.js.map +1 -1
  102. package/dist/utils/claudeLimitSwitch.d.ts +2 -1
  103. package/dist/utils/claudeLimitSwitch.d.ts.map +1 -1
  104. package/dist/utils/claudeLimitSwitch.js +3 -2
  105. package/dist/utils/claudeLimitSwitch.js.map +1 -1
  106. package/dist/utils/claudeProfileUsageLines.d.ts +18 -3
  107. package/dist/utils/claudeProfileUsageLines.d.ts.map +1 -1
  108. package/dist/utils/claudeProfileUsageLines.js +23 -2
  109. package/dist/utils/claudeProfileUsageLines.js.map +1 -1
  110. package/dist/utils/claudeProfiles.d.ts +57 -2
  111. package/dist/utils/claudeProfiles.d.ts.map +1 -1
  112. package/dist/utils/claudeProfiles.js +96 -3
  113. package/dist/utils/claudeProfiles.js.map +1 -1
  114. package/dist/utils/claudeStreamJson.d.ts +4 -1
  115. package/dist/utils/claudeStreamJson.d.ts.map +1 -1
  116. package/dist/utils/claudeStreamJson.js +6 -0
  117. package/dist/utils/claudeStreamJson.js.map +1 -1
  118. package/dist/utils/claudeTranscriptContent.d.ts +8 -0
  119. package/dist/utils/claudeTranscriptContent.d.ts.map +1 -1
  120. package/dist/utils/claudeTranscriptContent.js +44 -0
  121. package/dist/utils/claudeTranscriptContent.js.map +1 -1
  122. package/dist/utils/claudeUsage.d.ts +22 -6
  123. package/dist/utils/claudeUsage.d.ts.map +1 -1
  124. package/dist/utils/claudeUsage.js +12 -5
  125. package/dist/utils/claudeUsage.js.map +1 -1
  126. package/dist/utils/claudeUsageRefresher.d.ts +6 -0
  127. package/dist/utils/claudeUsageRefresher.d.ts.map +1 -1
  128. package/dist/utils/claudeUsageRefresher.js +2 -2
  129. package/dist/utils/claudeUsageRefresher.js.map +1 -1
  130. package/dist/utils/fallbackChain.d.ts +15 -8
  131. package/dist/utils/fallbackChain.d.ts.map +1 -1
  132. package/dist/utils/fallbackChain.js +30 -13
  133. package/dist/utils/fallbackChain.js.map +1 -1
  134. package/dist/utils/limitsBoardPlan.d.ts +4 -2
  135. package/dist/utils/limitsBoardPlan.d.ts.map +1 -1
  136. package/dist/utils/limitsBoardPlan.js +12 -5
  137. package/dist/utils/limitsBoardPlan.js.map +1 -1
  138. package/dist/utils/recvPreviewRedaction.d.ts +7 -2
  139. package/dist/utils/recvPreviewRedaction.d.ts.map +1 -1
  140. package/dist/utils/recvPreviewRedaction.js +10 -4
  141. package/dist/utils/recvPreviewRedaction.js.map +1 -1
  142. package/dist/utils/threadStatusReport.d.ts +12 -1
  143. package/dist/utils/threadStatusReport.d.ts.map +1 -1
  144. package/dist/utils/threadStatusReport.js +13 -4
  145. package/dist/utils/threadStatusReport.js.map +1 -1
  146. package/package.json +1 -1
package/dist/bot.js CHANGED
@@ -215,6 +215,8 @@ const claudeUsageRefresher_1 = require("./utils/claudeUsageRefresher");
215
215
  const openCodeUsage_1 = require("./utils/openCodeUsage");
216
216
  const usageRefreshBudget_1 = require("./utils/usageRefreshBudget");
217
217
  const claudeLimitSwitch_1 = require("./utils/claudeLimitSwitch");
218
+ const anthropicApiKeyCheck_1 = require("./utils/anthropicApiKeyCheck");
219
+ const claudeApiKeyHelper_1 = require("./utils/claudeApiKeyHelper");
218
220
  const fallbackSwitchNotice_1 = require("./utils/fallbackSwitchNotice");
219
221
  const adapterSwitchSessionIds_1 = require("./utils/adapterSwitchSessionIds");
220
222
  const fallbackChain_1 = require("./utils/fallbackChain");
@@ -226,6 +228,8 @@ const operatorSwitchPlan_1 = require("./utils/operatorSwitchPlan");
226
228
  const backendSwitchPlan_1 = require("./utils/backendSwitchPlan");
227
229
  const selfUpdate_1 = require("./connectors/telegram/commands/selfUpdate");
228
230
  const stt_1 = require("./connectors/telegram/commands/stt");
231
+ const claudeApiSpend_1 = require("./utils/claudeApiSpend");
232
+ const claudeProfileUsageLines_1 = require("./utils/claudeProfileUsageLines");
229
233
  const resolveBinary_1 = require("./utils/resolveBinary");
230
234
  // ═══════════════════════════════════════════════════════════════════════════════
231
235
  // ENV parsing & fatal validation
@@ -877,6 +881,12 @@ const limitResumeNoticePinnedMessageId = new Map();
877
881
  * Cleared by {@link cancelApiRetry} (the user took over / the session went away).
878
882
  */
879
883
  const autoContinueOffNoticedThreads = new Set();
884
+ /**
885
+ * @description Threads that already got the «API credits used up» notice for the CURRENT episode
886
+ * ({@link handleBalanceExhausted}): that flow arms no retry either, so — like the OFF notice above — a repeated frame
887
+ * of the same error needs its own guard. Cleared by {@link cancelApiRetry} (the user took over / the session went away).
888
+ */
889
+ const balanceExhaustedNoticedThreads = new Set();
880
890
  /**
881
891
  * The OPEN «switch» offer per thread (`utils/fallbackChain.ts`): the limit episode the buttons on the notice
882
892
  * were posted for, the rung the topic was on and the candidate list the buttons index into. A tap is honoured
@@ -1142,6 +1152,11 @@ const wedgeRecoveriesInFlight = new Set();
1142
1152
  */
1143
1153
  function handleApiError(key, cls, options = {}) {
1144
1154
  const k = (0, sessionKey_1.keyToString)(key);
1155
+ // A used-up API credit balance: no wait refills it — the account is marked, and the chain or the operator takes over.
1156
+ if (cls.kind === 'usageLimit' && cls.isBalanceExhausted === true) {
1157
+ void handleBalanceExhausted(key, cls);
1158
+ return;
1159
+ }
1145
1160
  // An unconfirmed usage limit is probed first; it reaches the wait below only once confirmed.
1146
1161
  if (cls.kind === 'usageLimit' && cls.isUnconfirmed === true && handleUnconfirmedLimit(key))
1147
1162
  return;
@@ -1236,7 +1251,7 @@ function armApiRetryTimer(key, arming) {
1236
1251
  // bottom (debounced; no-op when no question is pending).
1237
1252
  onThreadActivityWhileQuestionPending(key);
1238
1253
  void state
1239
- .setApiRetry(key, { kind: arming.kind, attempt: arming.attempt, fireAt: arming.fireAt, ...(arming.isLimitProbe ? { isLimitProbe: true } : {}) })
1254
+ .setApiRetry(key, { kind: arming.kind, attempt: arming.attempt, fireAt: arming.fireAt, ...(arming.isLimitProbe ? { isLimitProbe: true } : {}), ...(arming.resumeOn ? { resumeOn: arming.resumeOn } : {}) })
1240
1255
  .catch(e => console.error('[apiRetry] persist failed:', e));
1241
1256
  clearSavedFiredLimitProbe(key);
1242
1257
  const timer = setTimeout(() => {
@@ -1252,6 +1267,7 @@ function armApiRetryTimer(key, arming) {
1252
1267
  fireAt: arming.fireAt,
1253
1268
  limitWait: arming.limitWait,
1254
1269
  isLimitProbe: arming.isLimitProbe,
1270
+ resumeOn: arming.resumeOn,
1255
1271
  });
1256
1272
  }
1257
1273
  /**
@@ -1443,15 +1459,90 @@ async function announceUsageLimitOrSwitch(key, cls, episodeId, initialWait, atte
1443
1459
  }
1444
1460
  }
1445
1461
  const texts = getLimitWaitTexts(key, wait, attempt, resumeOn);
1446
- const keyboard = plan.action === 'offer'
1447
- ? telegraf_1.Markup.inlineKeyboard(plan.candidates.map((entry, index) => [
1448
- telegraf_1.Markup.button.callback((0, i18n_1.t)('fallback.switchButton', { label: formatFallbackEntry(entry) }), (0, fallbackChain_1.buildFallbackSwitchCallbackData)(episodeId, index)),
1449
- ]))
1450
- : undefined;
1462
+ const keyboard = plan.action === 'offer' ? buildFallbackSwitchKeyboard(episodeId, plan.candidates) : undefined;
1451
1463
  const { isPlainNoticePosted } = await announceLimitWait(key, wait, texts, isPlainNoticeDue, keyboard);
1452
1464
  if (keyboard && !isPlainNoticePosted)
1453
1465
  await replyToThread(key, (0, i18n_1.t)('fallback.switchOffer'), keyboard);
1454
1466
  }
1467
+ /** The «🔁 Switch to …» buttons of a limit episode's notice, one per candidate (best first; the index is into that episode's list). */
1468
+ function buildFallbackSwitchKeyboard(episodeId, candidates) {
1469
+ return telegraf_1.Markup.inlineKeyboard(candidates.map((entry, index) => [
1470
+ telegraf_1.Markup.button.callback((0, i18n_1.t)('fallback.switchButton', { label: formatFallbackEntry(entry) }), (0, fallbackChain_1.buildFallbackSwitchCallbackData)(episodeId, index)),
1471
+ ]));
1472
+ }
1473
+ /** Whose API credits a balance notice names: a Claude account `«console»`, an OpenCode `provider/model`. */
1474
+ function formatBalanceOwner(from) {
1475
+ if (from === null)
1476
+ return (0, i18n_1.t)('fallback.backendClaude');
1477
+ return from.backend === 'claude' ? `«${from.profile}»` : (0, fallbackChain_1.buildOpenCodeModelLabel)(from);
1478
+ }
1479
+ /**
1480
+ * @description A used-up API credit balance on `key` («Credit balance is too low», `isBalanceExhausted`): nothing resets
1481
+ * it, so no wait for a reset is armed and no hourly retry runs. The Claude account is marked out of credits
1482
+ * (`creditExhaustedAt`, until the operator picks it or signs it in again, or a billed turn on it goes through), the parked
1483
+ * episode is settled (a restart does not run this again), and the fallback chain decides as for a limit, with that
1484
+ * account out: switching ON moves the topic at once; OFF puts «switch to …» buttons on the notice. With no rung free
1485
+ * now, the topic waits only for a rung that comes back at a KNOWN time (the earliest reset, and only with «wait for
1486
+ * reset» on) and resumes there; otherwise the notice is all, and an open request is answered and no longer woken. A
1487
+ * repeat of the same episode (another frame, another scrape of the line, a scheduled run into the same balance) says
1488
+ * nothing in the topic again; a request opened since — a scheduled run's — is still answered and no longer woken, unless
1489
+ * a wait for another rung's reset is armed.
1490
+ */
1491
+ async function handleBalanceExhausted(key, cls) {
1492
+ const k = (0, sessionKey_1.keyToString)(key);
1493
+ markLimitEpisodeSettled(key);
1494
+ const position = getTopicFallbackPosition(key);
1495
+ if (position?.backend === 'claude')
1496
+ state.setClaudeProfileCreditExhausted(position.profile, new Date().toISOString());
1497
+ const notice = `${(0, i18n_1.t)('apiRetry.balanceExhaustedNotice', { name: formatBalanceOwner(position) })}${getBalanceSpentLine(position)}`;
1498
+ const texts = { plainNotice: notice, answerBody: (0, i18n_1.t)('requests.limit.answerBalanceExhaustedNotice') };
1499
+ const stopped = { kind: 'autoResumeOff' };
1500
+ if (balanceExhaustedNoticedThreads.has(k)) {
1501
+ // Like the «wait for reset» OFF repeat: without it the wake-up engine re-prods that request into the same refused
1502
+ // balance and ends in an alert.
1503
+ if (getArmedApiRetry(key) === null)
1504
+ await announceLimitWait(key, stopped, texts, false);
1505
+ return;
1506
+ }
1507
+ balanceExhaustedNoticedThreads.add(k);
1508
+ const episodeId = Date.now();
1509
+ let plan = { action: 'none', from: position, earliest: null };
1510
+ try {
1511
+ plan = await planFallbackSwitch(key, cls, episodeId);
1512
+ }
1513
+ catch (e) {
1514
+ console.warn(`[fallback] ${k}: the switch plan after a used-up balance failed:`, e instanceof Error ? e.message : e);
1515
+ }
1516
+ if (plan.action === 'auto') {
1517
+ const to = plan.to;
1518
+ await replyToThread(key, notice);
1519
+ const outcome = await switchTopicToFallbackEntry(key, plan.from, to, { isAnnounced: true }).catch((e) => {
1520
+ console.warn(`[fallback] ${k}: the auto-switch to ${formatFallbackEntry(to)} failed:`, e instanceof Error ? e.message : e);
1521
+ return { isSwitched: false };
1522
+ });
1523
+ // The notice is already in the topic; an open request still hears that the work stopped.
1524
+ if (!outcome.isSwitched)
1525
+ await announceLimitWait(key, stopped, texts, false);
1526
+ return;
1527
+ }
1528
+ if (plan.action === 'offer') {
1529
+ const keyboard = buildFallbackSwitchKeyboard(episodeId, plan.candidates);
1530
+ const { isPlainNoticePosted } = await announceLimitWait(key, stopped, texts, true, keyboard);
1531
+ if (!isPlainNoticePosted)
1532
+ await replyToThread(key, (0, i18n_1.t)('fallback.switchOffer'), keyboard);
1533
+ return;
1534
+ }
1535
+ if (plan.earliest && state.checkIsAutoContinueOnLimitEnabled(key)) {
1536
+ const fireAt = plan.earliest.untilMs + apiErrorRetry_1.resetBufferMs;
1537
+ const wait = { kind: 'afterReset', resetAt: plan.earliest.untilMs, fireAt };
1538
+ clearLimitResumeNotice(key);
1539
+ armApiRetryTimer(key, { kind: 'usageLimit', attempt: 1, fireAt, delayMs: Math.max(fireAt - Date.now(), 0), limitWait: wait, resumeOn: plan.earliest.entry });
1540
+ const waitTexts = getLimitWaitTexts(key, wait, 1, plan.earliest.entry);
1541
+ await announceLimitWait(key, wait, { plainNotice: `${notice}\n\n${waitTexts.plainNotice}`, answerBody: waitTexts.answerBody }, true);
1542
+ return;
1543
+ }
1544
+ await announceLimitWait(key, stopped, texts, true);
1545
+ }
1455
1546
  /**
1456
1547
  * @description Re-aim the ARMED usage-limit wait of `key` at the rung that comes back first: the timer moves to
1457
1548
  * that rung's reset (+ the usual buffer), and the persisted retry record carries the rung (`resumeOn`), so the
@@ -1550,6 +1641,7 @@ function cancelApiRetry(key) {
1550
1641
  // error — so neither can re-notify per message.
1551
1642
  clearLimitResumeNotice(key);
1552
1643
  autoContinueOffNoticedThreads.delete(k);
1644
+ balanceExhaustedNoticedThreads.delete(k);
1553
1645
  fallbackSwitchOffers.delete(k);
1554
1646
  // NOTE: intentionally does NOT touch the logged-out notice. `cancelApiRetry`
1555
1647
  // fires on EVERY inbound message (user takeover); clearing the auth notice here
@@ -1741,6 +1833,8 @@ function clearLimitResumeNotice(key) {
1741
1833
  * sign-in driver built here), so the driver reaches it through this late-bound slot.
1742
1834
  */
1743
1835
  let claudeProfileAuthStatusSink = null;
1836
+ /** Where an API key pasted into an API account's sign-in goes: the `/account` module too, through the same late-bound idiom. */
1837
+ let claudeProfileApiKeySink = null;
1744
1838
  // Out-of-band sign-in drivers (`/login`, `/connect` OAuth): `agentLogin/README.md`.
1745
1839
  const { cancelPendingLogin, checkIsAuthLoginAwaitingCode, cancelClaudeAuthLogin, startClaudeAuthLogin, submitClaudeAuthLoginCode, checkIsOpenCodeOAuthAwaitingReply, cancelOpenCodeOAuthLogin, startOpenCodeOAuthLogin, submitOpenCodeOAuthReply, checkIsAnyLoginInProgress, } = (0, agentLogin_1.createAgentLogin)({
1746
1840
  replyToThread,
@@ -1748,6 +1842,7 @@ const { cancelPendingLogin, checkIsAuthLoginAwaitingCode, cancelClaudeAuthLogin,
1748
1842
  clearAuthNotice,
1749
1843
  execFileAsync,
1750
1844
  onClaudeProfileAuthStatus: (key, profile, status) => claudeProfileAuthStatusSink?.(key, profile, status) ?? Promise.resolve(),
1845
+ onClaudeProfileApiKey: (key, profile, apiKey) => claudeProfileApiKeySink?.(key, profile, apiKey) ?? Promise.resolve((0, i18n_1.t)('account.apiKeyNoDirectory', { name: profile.name })),
1751
1846
  });
1752
1847
  /**
1753
1848
  * @description Buffers prompts typed while an agent session is still booting so
@@ -5773,14 +5868,19 @@ function resolveThreadClaudeConfigDir(key) {
5773
5868
  }
5774
5869
  return dir;
5775
5870
  }
5776
- /** `claude auth status --json` under `configDir` (`null` = `~/.claude`), the subscription login only; `null` when the probe fails. */
5871
+ /**
5872
+ * `claude auth status --json` under `configDir` (`null` = `~/.claude`) as its sessions see it: never through an
5873
+ * `ANTHROPIC_API_KEY` of the bot's own, with the `apiKeyHelper` of a profile that holds a pasted API key; `null` when
5874
+ * the probe fails.
5875
+ */
5777
5876
  async function readClaudeAuthStatusUnder(configDir) {
5778
5877
  const env = { ...process.env };
5779
5878
  delete env.ANTHROPIC_API_KEY;
5780
5879
  if (configDir !== null)
5781
5880
  env[claudeProfiles_1.claudeConfigDirVariableName] = configDir;
5782
5881
  try {
5783
- const { stdout } = await execFileAsync((0, resolveBinary_1.resolveClaudeBinary)(), ['auth', 'status', '--json'], { timeout: claudeAuthStatusProbeTimeoutMs, env });
5882
+ const args = [...(0, claudeApiKeyHelper_1.buildClaudeAuthProbeSettingsArgs)(configDir), 'auth', 'status', '--json'];
5883
+ const { stdout } = await execFileAsync((0, resolveBinary_1.resolveClaudeBinary)(), args, { timeout: claudeAuthStatusProbeTimeoutMs, env });
5784
5884
  return (0, claudeProfiles_1.parseClaudeAuthStatus)(stdout);
5785
5885
  }
5786
5886
  catch (e) {
@@ -5843,9 +5943,12 @@ const openCodeUsagePoller = (0, openCodeUsage_1.createOpenCodeUsagePoller)({
5843
5943
  */
5844
5944
  const claudeUsageRefresher = (0, claudeUsageRefresher_1.createClaudeUsageRefresher)({
5845
5945
  listProfiles: () => [
5846
- { name: claudeProfiles_1.defaultClaudeProfileName, configDir: null, loggedIn: state.getClaudeProfileInfo(claudeProfiles_1.defaultClaudeProfileName)?.loggedIn },
5946
+ {
5947
+ name: claudeProfiles_1.defaultClaudeProfileName, configDir: null,
5948
+ loggedIn: state.getClaudeProfileInfo(claudeProfiles_1.defaultClaudeProfileName)?.loggedIn, billing: state.getClaudeProfileInfo(claudeProfiles_1.defaultClaudeProfileName)?.billing,
5949
+ },
5847
5950
  ...Object.entries(state.getClaudeProfileRecords()).map(([name, record]) => ({
5848
- name, configDir: (0, claudeProfiles_1.resolveClaudeProfileDir)((0, state_1.resolveDataDir)(), name), loggedIn: record.loggedIn,
5951
+ name, configDir: (0, claudeProfiles_1.resolveClaudeProfileDir)((0, state_1.resolveDataDir)(), name), loggedIn: record.loggedIn, billing: record.billing,
5849
5952
  })),
5850
5953
  ],
5851
5954
  getSnapshot: (name) => state.getClaudeProfileInfo(name)?.usage,
@@ -5881,6 +5984,27 @@ function onClaudeRateLimitEvent(configDir, event) {
5881
5984
  state.setClaudeProfileLimitedUntil(name, null);
5882
5985
  }
5883
5986
  }
5987
+ /**
5988
+ * A json-stream turn's cost (`utils/claudeApiSpend.ts`): the topic's conversation baseline moves on for EVERY account
5989
+ * (a later API turn of the same conversation must count only itself), and the turn's spend adds to the account's only
5990
+ * when it is billed to API credits. A billed turn that went through ends a used-up-credits mark on whatever account it
5991
+ * ran on: the balance is back.
5992
+ */
5993
+ function onClaudeTurnCost(key, configDir, report) {
5994
+ const turnCostUsd = (0, claudeApiSpend_1.computeClaudeTurnCostUsd)({ previous: state.getClaudeCostBaseline(key), ...report });
5995
+ state.setClaudeCostBaseline(key, { sessionId: report.sessionId, totalCostUsd: report.totalCostUsd });
5996
+ const name = resolveClaudeProfileNameForDir(configDir);
5997
+ if (name === null)
5998
+ return;
5999
+ const info = state.getClaudeProfileInfo(name);
6000
+ const outcome = (0, claudeApiSpend_1.getClaudeTurnCostOutcome)({
6001
+ billing: info?.billing ?? null, isCreditExhausted: info?.creditExhaustedAt !== undefined, isError: report.isError, turnCostUsd,
6002
+ });
6003
+ if (outcome.isCreditMarkCleared)
6004
+ state.setClaudeProfileCreditExhausted(name, null);
6005
+ if (outcome.spendUsd !== null)
6006
+ state.setClaudeProfileApiSpend(name, (0, claudeApiSpend_1.addClaudeApiSpend)(info?.apiSpend, outcome.spendUsd, new Date().toISOString()));
6007
+ }
5884
6008
  /** The model a topic's Claude session runs (or last ran), for the usage ranking's choice of windows. */
5885
6009
  function getThreadClaudeModel(key) {
5886
6010
  return (0, createAdapter_1.getThreadAdapter)(key).getCurrentModel?.(key) ?? state.getAgent(key)?.model ?? null;
@@ -5888,7 +6012,10 @@ function getThreadClaudeModel(key) {
5888
6012
  /** What `/status` names of a Claude profile: its last probed sign-in and email, and its limit mark (none until probed). */
5889
6013
  function readStatusAccountSource(name) {
5890
6014
  const info = state.getClaudeProfileInfo(name);
5891
- return { name, email: info?.email ?? null, loggedIn: info?.loggedIn, limitedUntil: info?.limitedUntil ?? null };
6015
+ return {
6016
+ name, email: info?.email ?? null, loggedIn: info?.loggedIn, limitedUntil: info?.limitedUntil ?? null,
6017
+ billing: info?.billing ?? null, creditExhaustedAt: info?.creditExhaustedAt ?? null, ...(info?.apiSpend ? { apiSpend: info.apiSpend } : {}),
6018
+ };
5892
6019
  }
5893
6020
  /** Whether the topic's agent is a Claude backend (a usage limit there is a limit of its account profile). */
5894
6021
  function checkIsClaudeProfileBackend(key) {
@@ -5987,13 +6114,15 @@ async function planFallbackSwitch(key, cls, episodeId) {
5987
6114
  const nowMs = Date.now();
5988
6115
  const resetAtMs = cls.kind === 'usageLimit' ? cls.resetAt : undefined;
5989
6116
  if (from.backend === 'claude') {
5990
- state.setClaudeProfileLimitedUntil(from.profile, (0, claudeLimitSwitch_1.resolveClaudeLimitedUntil)({
5991
- resetAtMs,
5992
- usage: state.getClaudeProfileInfo(from.profile)?.usage,
5993
- existingLimitedUntil: state.getClaudeProfileInfo(from.profile)?.limitedUntil ?? null,
5994
- nowMs,
5995
- fallbackMs: apiErrorRetry_1.usageLimitDefaultMs,
5996
- }));
6117
+ // A used-up balance has no reset: its own flow marks the account (`creditExhaustedAt`) instead.
6118
+ if (cls.isBalanceExhausted !== true)
6119
+ state.setClaudeProfileLimitedUntil(from.profile, (0, claudeLimitSwitch_1.resolveClaudeLimitedUntil)({
6120
+ resetAtMs,
6121
+ usage: state.getClaudeProfileInfo(from.profile)?.usage,
6122
+ existingLimitedUntil: state.getClaudeProfileInfo(from.profile)?.limitedUntil ?? null,
6123
+ nowMs,
6124
+ fallbackMs: apiErrorRetry_1.usageLimitDefaultMs,
6125
+ }));
5997
6126
  }
5998
6127
  else {
5999
6128
  const label = (0, fallbackChain_1.buildOpenCodeModelLabel)(from);
@@ -6023,8 +6152,11 @@ async function planFallbackSwitch(key, cls, episodeId) {
6023
6152
  let earliest = null;
6024
6153
  if (plan.action === 'none') {
6025
6154
  const explicit = state.getFallbackChain();
6155
+ // Without a chain the implicit one is the signed-in SUBSCRIPTIONS: an account billed to API credits never ends a wait by itself.
6026
6156
  const rungs = explicit.length > 0 ? explicit : from.backend === 'claude'
6027
- ? detection.claudeProfiles.filter((profile) => profile.loggedIn !== false).map((profile) => ({ backend: 'claude', profile: profile.name, model: null }))
6157
+ ? detection.claudeProfiles
6158
+ .filter((profile) => profile.loggedIn !== false && profile.billing !== 'api')
6159
+ .map((profile) => ({ backend: 'claude', profile: profile.name, model: null }))
6028
6160
  : [];
6029
6161
  const found = (0, fallbackChain_1.pickEarliestResetRung)({ chain: rungs, current: from, detection, nowMs: Date.now() });
6030
6162
  if (found && !found.isCurrent)
@@ -6045,9 +6177,23 @@ function getFallbackResetTime(from) {
6045
6177
  return null;
6046
6178
  return (0, localClock_1.formatLocalClockWithDateIfNotToday)(untilMs, Date.now());
6047
6179
  }
6048
- /** The «switched» notice (`utils/fallbackSwitchNotice.ts`), with the reset of the exhausted rung when known. */
6180
+ /**
6181
+ * The «switched» notice (`utils/fallbackSwitchNotice.ts`), with the reset of the exhausted rung when known — and, onto
6182
+ * an account billed to API credits, the line that it costs money per request and what it has spent so far.
6183
+ */
6049
6184
  function getFallbackSwitchedNotice(from, to, runningModel) {
6050
- return (0, fallbackSwitchNotice_1.buildFallbackSwitchedNotice)({ from, to, resetTime: getFallbackResetTime(from), runningModel });
6185
+ const notice = (0, fallbackSwitchNotice_1.buildFallbackSwitchedNotice)({ from, to, resetTime: getFallbackResetTime(from), runningModel });
6186
+ const info = to.backend === 'claude' ? state.getClaudeProfileInfo(to.profile) : undefined;
6187
+ if (to.backend !== 'claude' || info?.billing !== 'api')
6188
+ return notice;
6189
+ return `${notice}\n${(0, i18n_1.t)('fallback.switchedToApi', { name: to.profile, spent: (0, claudeApiSpend_1.formatUsd)(info.apiSpend?.totalUsd ?? 0) })}`;
6190
+ }
6191
+ /** The spend line a «credits used up» notice ends with (what the account spent through the bot); `''` before any spend. */
6192
+ function getBalanceSpentLine(from) {
6193
+ const spend = from?.backend === 'claude' ? state.getClaudeProfileInfo(from.profile)?.apiSpend : undefined;
6194
+ if (!spend)
6195
+ return '';
6196
+ return `\n${(0, i18n_1.t)('apiRetry.balanceSpent', (0, claudeProfileUsageLines_1.formatClaudeApiSpendWords)(spend, getCurrentTimezone(), Date.now()))}`;
6051
6197
  }
6052
6198
  /**
6053
6199
  * @description The model the topic's Claude session runs ACROSS a move onto `to`, for deciding whether the move
@@ -6314,6 +6460,10 @@ function buildBackendSwitchTargetOfRung(to) {
6314
6460
  * The outcome tells the screen what to say; the question and the limit move's notice are already posted.
6315
6461
  */
6316
6462
  async function switchTopicByOperator(key, to) {
6463
+ // The operator's own pick of an account whose API credits were used up is the act that ends that mark (topped up, or
6464
+ // trying anyway): a failing request marks it again.
6465
+ if (to.backend === 'claude')
6466
+ state.setClaudeProfileCreditExhausted(to.profile, null);
6317
6467
  const currentFamily = getTopicBackendFamily(key);
6318
6468
  const from = getTopicFallbackPosition(key);
6319
6469
  const isUsageLimitWaitArmed = getArmedApiRetry(key)?.kind === 'usageLimit';
@@ -6418,7 +6568,7 @@ const { registerLimitsBoardCommands, registerLimitsBoardCallbacks } = (0, limits
6418
6568
  runAutoSwitchCommand,
6419
6569
  getArmedLimitRetryFireAt,
6420
6570
  });
6421
- const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAwaitingProfileName, cancelProfileNameCapture, submitProfileName, pickClaudeAccount, onClaudeProfileAuthStatus: recordClaudeProfileAuthStatus, } = (0, claudeAccounts_1.createClaudeAccounts)({
6571
+ const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAwaitingProfileName, cancelProfileNameCapture, submitProfileName, pickClaudeAccount, onClaudeProfileAuthStatus: recordClaudeProfileAuthStatus, onClaudeProfileApiKey: storeClaudeProfileApiKey, } = (0, claudeAccounts_1.createClaudeAccounts)({
6422
6572
  ...botCore,
6423
6573
  getDataDir: state_1.resolveDataDir,
6424
6574
  getInstanceTimezone: getCurrentTimezone,
@@ -6434,8 +6584,10 @@ const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAw
6434
6584
  disconnectOpenCodeProvider: applyProviderDisconnect,
6435
6585
  startOpenCodeConnect: showConnectMethodPicker,
6436
6586
  applyAutoSwitch,
6587
+ verifyAnthropicApiKey: (apiKey) => (0, anthropicApiKeyCheck_1.verifyAnthropicApiKey)(apiKey),
6437
6588
  });
6438
6589
  claudeProfileAuthStatusSink = recordClaudeProfileAuthStatus;
6590
+ claudeProfileApiKeySink = storeClaudeProfileApiKey;
6439
6591
  const { registerSttCommands, registerSttCallbacks, startSttAutoInstall, resumeSttInstallAtBoot, getAwaitedSttStep, checkIsAnySttInputAwaited, submitSttInput, cancelSttInput, } = (0, stt_1.createStt)({
6440
6592
  ...botCore,
6441
6593
  getConfigPath: () => (0, sttSettings_1.getSttConfigPath)(),
@@ -7147,7 +7299,7 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7147
7299
  // `/account` «Add» asked for the new profile's name → this message is it (a bad name keeps the prompt
7148
7300
  // armed and says so; a slash command above already disarmed it).
7149
7301
  if (checkIsAwaitingProfileName(key) && !text.startsWith('/')) {
7150
- await submitProfileName(key, text);
7302
+ await submitProfileName(key, text, ctx.message.message_id);
7151
7303
  return;
7152
7304
  }
7153
7305
  // `/topic` asked for the new topic's name, or for the name of a folder to create → this message is the answer (a bad
@@ -11336,6 +11488,7 @@ async function startBot() {
11336
11488
  // Every Claude spawn from here on reads the conversation's account profile (`/account`).
11337
11489
  (0, createAdapter_1.registerClaudeConfigDirReader)(resolveThreadClaudeConfigDir);
11338
11490
  (0, createAdapter_1.registerClaudeRateLimitSink)(onClaudeRateLimitEvent);
11491
+ (0, createAdapter_1.registerClaudeTurnCostSink)(onClaudeTurnCost);
11339
11492
  // The usage cache: a first poll now (off the boot path), then the timer.
11340
11493
  claudeUsageRefresher.start();
11341
11494
  void claudeUsageRefresher.refreshAll();