charness 3.0.0 → 3.1.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 (227) hide show
  1. package/README.md +188 -16
  2. package/dist/adapters/claudeCliAdapter.d.ts +15 -0
  3. package/dist/adapters/claudeCliAdapter.d.ts.map +1 -1
  4. package/dist/adapters/claudeCliAdapter.js +36 -1
  5. package/dist/adapters/claudeCliAdapter.js.map +1 -1
  6. package/dist/adapters/claudeConfigDir.d.ts +40 -0
  7. package/dist/adapters/claudeConfigDir.d.ts.map +1 -0
  8. package/dist/adapters/claudeConfigDir.js +3 -0
  9. package/dist/adapters/claudeConfigDir.js.map +1 -0
  10. package/dist/adapters/claudeJsonStreamAdapter.d.ts +32 -0
  11. package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
  12. package/dist/adapters/claudeJsonStreamAdapter.js +89 -8
  13. package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
  14. package/dist/adapters/claudePlatformFlags.d.ts +4 -4
  15. package/dist/adapters/claudePlatformFlags.js +4 -4
  16. package/dist/adapters/createAdapter.d.ts +8 -0
  17. package/dist/adapters/createAdapter.d.ts.map +1 -1
  18. package/dist/adapters/createAdapter.js +37 -0
  19. package/dist/adapters/createAdapter.js.map +1 -1
  20. package/dist/adapters/openCodeAdapter.d.ts +23 -0
  21. package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
  22. package/dist/adapters/openCodeAdapter.js +84 -1
  23. package/dist/adapters/openCodeAdapter.js.map +1 -1
  24. package/dist/agentLogin/agentLogin.d.ts +15 -2
  25. package/dist/agentLogin/agentLogin.d.ts.map +1 -1
  26. package/dist/agentLogin/agentLogin.js +53 -16
  27. package/dist/agentLogin/agentLogin.js.map +1 -1
  28. package/dist/apiErrorRetry.d.ts +8 -3
  29. package/dist/apiErrorRetry.d.ts.map +1 -1
  30. package/dist/apiErrorRetry.js +36 -5
  31. package/dist/apiErrorRetry.js.map +1 -1
  32. package/dist/apiRetryKick.d.ts +9 -0
  33. package/dist/apiRetryKick.d.ts.map +1 -1
  34. package/dist/apiRetryKick.js +16 -0
  35. package/dist/apiRetryKick.js.map +1 -1
  36. package/dist/bot.d.ts.map +1 -1
  37. package/dist/bot.js +639 -24
  38. package/dist/bot.js.map +1 -1
  39. package/dist/cli/claudeProfiles.d.ts +42 -0
  40. package/dist/cli/claudeProfiles.d.ts.map +1 -0
  41. package/dist/cli/claudeProfiles.js +106 -0
  42. package/dist/cli/claudeProfiles.js.map +1 -0
  43. package/dist/cli.js +10 -0
  44. package/dist/cli.js.map +1 -1
  45. package/dist/connectors/jira/adf.d.ts +2 -0
  46. package/dist/connectors/jira/adf.d.ts.map +1 -1
  47. package/dist/connectors/jira/adf.js +5 -0
  48. package/dist/connectors/jira/adf.js.map +1 -1
  49. package/dist/connectors/jira/answerSink.d.ts +7 -0
  50. package/dist/connectors/jira/answerSink.d.ts.map +1 -1
  51. package/dist/connectors/jira/answerSink.js +24 -10
  52. package/dist/connectors/jira/answerSink.js.map +1 -1
  53. package/dist/connectors/jira/attachmentTool.d.ts +7 -0
  54. package/dist/connectors/jira/attachmentTool.d.ts.map +1 -1
  55. package/dist/connectors/jira/attachmentTool.js +16 -0
  56. package/dist/connectors/jira/attachmentTool.js.map +1 -1
  57. package/dist/connectors/jira/client.d.ts +32 -0
  58. package/dist/connectors/jira/client.d.ts.map +1 -1
  59. package/dist/connectors/jira/client.js +43 -5
  60. package/dist/connectors/jira/client.js.map +1 -1
  61. package/dist/connectors/jira/config.d.ts +3 -16
  62. package/dist/connectors/jira/config.d.ts.map +1 -1
  63. package/dist/connectors/jira/config.js +5 -32
  64. package/dist/connectors/jira/config.js.map +1 -1
  65. package/dist/connectors/jira/connector.d.ts +3 -0
  66. package/dist/connectors/jira/connector.d.ts.map +1 -1
  67. package/dist/connectors/jira/connector.js +70 -15
  68. package/dist/connectors/jira/connector.js.map +1 -1
  69. package/dist/connectors/jira/contextLedger.d.ts +1 -0
  70. package/dist/connectors/jira/contextLedger.d.ts.map +1 -1
  71. package/dist/connectors/jira/contextLedger.js +2 -0
  72. package/dist/connectors/jira/contextLedger.js.map +1 -1
  73. package/dist/connectors/jira/inbound.d.ts +15 -6
  74. package/dist/connectors/jira/inbound.d.ts.map +1 -1
  75. package/dist/connectors/jira/inbound.js +37 -22
  76. package/dist/connectors/jira/inbound.js.map +1 -1
  77. package/dist/connectors/jira/issueBlocks.d.ts +26 -3
  78. package/dist/connectors/jira/issueBlocks.d.ts.map +1 -1
  79. package/dist/connectors/jira/issueBlocks.js +104 -29
  80. package/dist/connectors/jira/issueBlocks.js.map +1 -1
  81. package/dist/connectors/jira/issueContext.d.ts +11 -5
  82. package/dist/connectors/jira/issueContext.d.ts.map +1 -1
  83. package/dist/connectors/jira/issueContext.js +23 -6
  84. package/dist/connectors/jira/issueContext.js.map +1 -1
  85. package/dist/connectors/jira/issueDelta.d.ts +3 -0
  86. package/dist/connectors/jira/issueDelta.d.ts.map +1 -1
  87. package/dist/connectors/jira/issueDelta.js +15 -6
  88. package/dist/connectors/jira/issueDelta.js.map +1 -1
  89. package/dist/connectors/jira/mediaPlaceholders.d.ts +2 -0
  90. package/dist/connectors/jira/mediaPlaceholders.d.ts.map +1 -1
  91. package/dist/connectors/jira/mediaPlaceholders.js +16 -3
  92. package/dist/connectors/jira/mediaPlaceholders.js.map +1 -1
  93. package/dist/connectors/jira/prompt.d.ts +2 -0
  94. package/dist/connectors/jira/prompt.d.ts.map +1 -1
  95. package/dist/connectors/jira/prompt.js +4 -1
  96. package/dist/connectors/jira/prompt.js.map +1 -1
  97. package/dist/connectors/jira/sessionKeyCodec.d.ts +2 -0
  98. package/dist/connectors/jira/sessionKeyCodec.d.ts.map +1 -1
  99. package/dist/connectors/jira/sessionKeyCodec.js +5 -0
  100. package/dist/connectors/jira/sessionKeyCodec.js.map +1 -1
  101. package/dist/connectors/jira/trigger.d.ts +4 -0
  102. package/dist/connectors/jira/trigger.d.ts.map +1 -1
  103. package/dist/connectors/jira/trigger.js +9 -1
  104. package/dist/connectors/jira/trigger.js.map +1 -1
  105. package/dist/connectors/jira/triggerLog.d.ts +4 -2
  106. package/dist/connectors/jira/triggerLog.d.ts.map +1 -1
  107. package/dist/connectors/jira/triggerLog.js +1 -1
  108. package/dist/connectors/jira/triggerLog.js.map +1 -1
  109. package/dist/connectors/jira/trustedGroup.d.ts +54 -0
  110. package/dist/connectors/jira/trustedGroup.d.ts.map +1 -0
  111. package/dist/connectors/jira/trustedGroup.js +98 -0
  112. package/dist/connectors/jira/trustedGroup.js.map +1 -0
  113. package/dist/connectors/telegram/commands/autoContinueLimits.d.ts +3 -0
  114. package/dist/connectors/telegram/commands/autoContinueLimits.d.ts.map +1 -1
  115. package/dist/connectors/telegram/commands/autoContinueLimits.js +15 -45
  116. package/dist/connectors/telegram/commands/autoContinueLimits.js.map +1 -1
  117. package/dist/connectors/telegram/commands/claudeAccounts.d.ts +58 -0
  118. package/dist/connectors/telegram/commands/claudeAccounts.d.ts.map +1 -0
  119. package/dist/connectors/telegram/commands/claudeAccounts.js +591 -0
  120. package/dist/connectors/telegram/commands/claudeAccounts.js.map +1 -0
  121. package/dist/connectors/telegram/commands/fallbackChain.d.ts +49 -0
  122. package/dist/connectors/telegram/commands/fallbackChain.d.ts.map +1 -0
  123. package/dist/connectors/telegram/commands/fallbackChain.js +624 -0
  124. package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -0
  125. package/dist/connectors/telegram/commands/modelProviders.d.ts +23 -1
  126. package/dist/connectors/telegram/commands/modelProviders.d.ts.map +1 -1
  127. package/dist/connectors/telegram/commands/modelProviders.js +131 -6
  128. package/dist/connectors/telegram/commands/modelProviders.js.map +1 -1
  129. package/dist/i18n/de.d.ts.map +1 -1
  130. package/dist/i18n/de.js +137 -9
  131. package/dist/i18n/de.js.map +1 -1
  132. package/dist/i18n/en.d.ts.map +1 -1
  133. package/dist/i18n/en.js +137 -9
  134. package/dist/i18n/en.js.map +1 -1
  135. package/dist/i18n/es.d.ts.map +1 -1
  136. package/dist/i18n/es.js +137 -9
  137. package/dist/i18n/es.js.map +1 -1
  138. package/dist/i18n/fr.d.ts.map +1 -1
  139. package/dist/i18n/fr.js +137 -9
  140. package/dist/i18n/fr.js.map +1 -1
  141. package/dist/i18n/hi.d.ts.map +1 -1
  142. package/dist/i18n/hi.js +137 -9
  143. package/dist/i18n/hi.js.map +1 -1
  144. package/dist/i18n/ja.d.ts.map +1 -1
  145. package/dist/i18n/ja.js +137 -9
  146. package/dist/i18n/ja.js.map +1 -1
  147. package/dist/i18n/ka.d.ts.map +1 -1
  148. package/dist/i18n/ka.js +137 -9
  149. package/dist/i18n/ka.js.map +1 -1
  150. package/dist/i18n/pt.d.ts.map +1 -1
  151. package/dist/i18n/pt.js +137 -9
  152. package/dist/i18n/pt.js.map +1 -1
  153. package/dist/i18n/ru.d.ts.map +1 -1
  154. package/dist/i18n/ru.js +137 -9
  155. package/dist/i18n/ru.js.map +1 -1
  156. package/dist/i18n/uk.d.ts.map +1 -1
  157. package/dist/i18n/uk.js +137 -9
  158. package/dist/i18n/uk.js.map +1 -1
  159. package/dist/i18n/uz.d.ts.map +1 -1
  160. package/dist/i18n/uz.js +137 -9
  161. package/dist/i18n/uz.js.map +1 -1
  162. package/dist/i18n/zh.d.ts.map +1 -1
  163. package/dist/i18n/zh.js +137 -9
  164. package/dist/i18n/zh.js.map +1 -1
  165. package/dist/scheduler/mcpSurface.d.ts +8 -0
  166. package/dist/scheduler/mcpSurface.d.ts.map +1 -1
  167. package/dist/scheduler/mcpSurface.js +42 -2
  168. package/dist/scheduler/mcpSurface.js.map +1 -1
  169. package/dist/state.d.ts +172 -1
  170. package/dist/state.d.ts.map +1 -1
  171. package/dist/state.js +298 -0
  172. package/dist/state.js.map +1 -1
  173. package/dist/types.d.ts +7 -0
  174. package/dist/types.d.ts.map +1 -1
  175. package/dist/utils/autoContinueOnLimit.d.ts +7 -0
  176. package/dist/utils/autoContinueOnLimit.d.ts.map +1 -1
  177. package/dist/utils/autoContinueOnLimit.js +10 -0
  178. package/dist/utils/autoContinueOnLimit.js.map +1 -1
  179. package/dist/utils/claudeAccountPickerPlan.d.ts +85 -0
  180. package/dist/utils/claudeAccountPickerPlan.d.ts.map +1 -0
  181. package/dist/utils/claudeAccountPickerPlan.js +123 -0
  182. package/dist/utils/claudeAccountPickerPlan.js.map +1 -0
  183. package/dist/utils/claudeLimitSwitch.d.ts +40 -0
  184. package/dist/utils/claudeLimitSwitch.d.ts.map +1 -0
  185. package/dist/utils/claudeLimitSwitch.js +54 -0
  186. package/dist/utils/claudeLimitSwitch.js.map +1 -0
  187. package/dist/utils/claudeProfileUsageLines.d.ts +15 -0
  188. package/dist/utils/claudeProfileUsageLines.d.ts.map +1 -0
  189. package/dist/utils/claudeProfileUsageLines.js +42 -0
  190. package/dist/utils/claudeProfileUsageLines.js.map +1 -0
  191. package/dist/utils/claudeProfiles.d.ts +117 -0
  192. package/dist/utils/claudeProfiles.d.ts.map +1 -0
  193. package/dist/utils/claudeProfiles.js +285 -0
  194. package/dist/utils/claudeProfiles.js.map +1 -0
  195. package/dist/utils/claudeStreamJson.d.ts +13 -1
  196. package/dist/utils/claudeStreamJson.d.ts.map +1 -1
  197. package/dist/utils/claudeStreamJson.js +13 -0
  198. package/dist/utils/claudeStreamJson.js.map +1 -1
  199. package/dist/utils/claudeUsage.d.ts +209 -0
  200. package/dist/utils/claudeUsage.d.ts.map +1 -0
  201. package/dist/utils/claudeUsage.js +413 -0
  202. package/dist/utils/claudeUsage.js.map +1 -0
  203. package/dist/utils/claudeUsageRefresher.d.ts +44 -0
  204. package/dist/utils/claudeUsageRefresher.d.ts.map +1 -0
  205. package/dist/utils/claudeUsageRefresher.js +95 -0
  206. package/dist/utils/claudeUsageRefresher.js.map +1 -0
  207. package/dist/utils/fallbackBridge.d.ts +32 -0
  208. package/dist/utils/fallbackBridge.d.ts.map +1 -0
  209. package/dist/utils/fallbackBridge.js +62 -0
  210. package/dist/utils/fallbackBridge.js.map +1 -0
  211. package/dist/utils/fallbackChain.d.ts +299 -0
  212. package/dist/utils/fallbackChain.d.ts.map +1 -0
  213. package/dist/utils/fallbackChain.js +542 -0
  214. package/dist/utils/fallbackChain.js.map +1 -0
  215. package/dist/utils/jsonStreamHost.d.ts +14 -3
  216. package/dist/utils/jsonStreamHost.d.ts.map +1 -1
  217. package/dist/utils/jsonStreamHost.js +26 -5
  218. package/dist/utils/jsonStreamHost.js.map +1 -1
  219. package/dist/utils/modelRecentPlan.d.ts +56 -0
  220. package/dist/utils/modelRecentPlan.d.ts.map +1 -0
  221. package/dist/utils/modelRecentPlan.js +107 -0
  222. package/dist/utils/modelRecentPlan.js.map +1 -0
  223. package/dist/utils/threadStatusReport.d.ts +25 -1
  224. package/dist/utils/threadStatusReport.d.ts.map +1 -1
  225. package/dist/utils/threadStatusReport.js +24 -1
  226. package/dist/utils/threadStatusReport.js.map +1 -1
  227. package/package.json +1 -1
package/dist/bot.js CHANGED
@@ -66,6 +66,7 @@ require("./connectors/jira/sessionKeyCodec");
66
66
  const agentTrigger_1 = require("./agentTrigger");
67
67
  const sessionPick_1 = require("./sessionPick");
68
68
  const claudeCliAdapter_1 = require("./adapters/claudeCliAdapter");
69
+ const openCodeAdapter_1 = require("./adapters/openCodeAdapter");
69
70
  const terminalAdapter_1 = require("./adapters/terminalAdapter");
70
71
  const openCodeQuestionFlow_1 = require("./openCodeQuestionFlow");
71
72
  const pendingQuestionRepost_1 = require("./pendingQuestionRepost");
@@ -192,6 +193,15 @@ const reminders_1 = require("./connectors/telegram/commands/reminders");
192
193
  const modelProviders_1 = require("./connectors/telegram/commands/modelProviders");
193
194
  const compaction_1 = require("./connectors/telegram/commands/compaction");
194
195
  const autoContinueLimits_1 = require("./connectors/telegram/commands/autoContinueLimits");
196
+ const claudeAccounts_1 = require("./connectors/telegram/commands/claudeAccounts");
197
+ const claudeProfiles_1 = require("./utils/claudeProfiles");
198
+ const claudeUsage_1 = require("./utils/claudeUsage");
199
+ const claudeUsageRefresher_1 = require("./utils/claudeUsageRefresher");
200
+ const claudeLimitSwitch_1 = require("./utils/claudeLimitSwitch");
201
+ const fallbackChain_1 = require("./utils/fallbackChain");
202
+ const fallbackBridge_1 = require("./utils/fallbackBridge");
203
+ const fallbackChain_2 = require("./connectors/telegram/commands/fallbackChain");
204
+ const resolveBinary_1 = require("./utils/resolveBinary");
195
205
  // ═══════════════════════════════════════════════════════════════════════════════
196
206
  // ENV parsing & fatal validation
197
207
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -828,6 +838,12 @@ const limitResumeNoticePinnedMessageId = new Map();
828
838
  * Cleared by {@link cancelApiRetry} (the user took over / the session went away).
829
839
  */
830
840
  const autoContinueOffNoticedThreads = new Set();
841
+ /**
842
+ * The OPEN «switch» offer per thread (`utils/fallbackChain.ts`): the limit episode the buttons on the notice
843
+ * were posted for, the rung the topic was on and the candidate list the buttons index into. A tap is honoured
844
+ * only while the thread's open offer is the button's episode; retired with the episode in {@link cancelApiRetry}.
845
+ */
846
+ const fallbackSwitchOffers = new Map();
831
847
  const threadModelLists = new Map();
832
848
  const awaitingModelSelection = new Set();
833
849
  const statusCoalescers = new Map();
@@ -1089,7 +1105,7 @@ function handleApiError(key, cls) {
1089
1105
  // Each repeat is offered to the open request: the dedup is per request, so one
1090
1106
  // opened after the first notice still hears about the limit (and is stopped).
1091
1107
  const wait = { kind: 'autoResumeOff' };
1092
- void announceLimitWait(key, wait, getLimitWaitTexts(key, wait, 0), isFirstOffNotice);
1108
+ void announceUsageLimitOrSwitch(key, cls, Date.now(), wait, 0, isFirstOffNotice);
1093
1109
  return;
1094
1110
  }
1095
1111
  const entry = apiRetryTimers.get(k);
@@ -1135,7 +1151,7 @@ function handleApiError(key, cls) {
1135
1151
  limitWait = cls.resetAt !== undefined
1136
1152
  ? { kind: 'afterReset', resetAt: cls.resetAt, fireAt: action.fireAt }
1137
1153
  : { kind: 'nextAttempt', fireAt: action.fireAt };
1138
- void announceLimitWait(key, limitWait, getLimitWaitTexts(key, limitWait, action.attempt), true);
1154
+ void announceUsageLimitOrSwitch(key, cls, action.fireAt, limitWait, action.attempt, true);
1139
1155
  }
1140
1156
  else {
1141
1157
  void replyToThread(key, (0, i18n_1.t)('apiRetry.transientNotice', {
@@ -1242,6 +1258,13 @@ const apiRetryKickDeps = {
1242
1258
  trackContinuation: async (key, options) => {
1243
1259
  await requestWakeUpEngine?.trackContinuationTurn(key, options);
1244
1260
  },
1261
+ resumeOnRung: async (key, rung) => {
1262
+ const from = getTopicFallbackPosition(key);
1263
+ if (from !== null && (0, fallbackChain_1.checkIsSameFallbackAccount)(from, rung))
1264
+ return false; // already there
1265
+ const outcome = await switchTopicToFallbackEntry(key, from, rung, { isAnnounced: true });
1266
+ return outcome.isSwitched;
1267
+ },
1245
1268
  clearSavedRetry: clearSavedApiRetry,
1246
1269
  };
1247
1270
  /**
@@ -1251,7 +1274,7 @@ const apiRetryKickDeps = {
1251
1274
  * when it is due (`isPlainNoticeDue`) and no answer said it — no open request,
1252
1275
  * or the answer could not be delivered.
1253
1276
  */
1254
- async function announceLimitWait(key, wait, texts, isPlainNoticeDue) {
1277
+ async function announceLimitWait(key, wait, texts, isPlainNoticeDue, plainNoticeExtra) {
1255
1278
  let outcome = 'notAnswered';
1256
1279
  if (requestLimitWaitAnswerDeps) {
1257
1280
  try {
@@ -1261,8 +1284,79 @@ async function announceLimitWait(key, wait, texts, isPlainNoticeDue) {
1261
1284
  console.warn('[requests] limit answer failed:', e instanceof Error ? e.message : e);
1262
1285
  }
1263
1286
  }
1264
- if (outcome === 'notAnswered' && isPlainNoticeDue)
1265
- await replyToThread(key, texts.plainNotice);
1287
+ if (outcome !== 'notAnswered' || !isPlainNoticeDue)
1288
+ return { isPlainNoticePosted: false };
1289
+ await replyToThread(key, texts.plainNotice, plainNoticeExtra);
1290
+ return { isPlainNoticePosted: true };
1291
+ }
1292
+ /**
1293
+ * @description A usage limit on a topic, with the fallback switch in front of the wait (`utils/fallbackChain.ts`):
1294
+ * when a rung of the chain (or, without a chain, another signed-in Claude profile with room) can take the work
1295
+ * over, auto-switch ON moves the topic there at once and the wait is never announced (the work continues — one
1296
+ * line says so); OFF puts «switch to …» buttons on the wait notice (on a line of their own when an open request's
1297
+ * answer replaced the notice). No rung available, or a backend without accounts: the wait is announced as before.
1298
+ */
1299
+ async function announceUsageLimitOrSwitch(key, cls, episodeId, initialWait, attempt, isPlainNoticeDue) {
1300
+ let wait = initialWait;
1301
+ let plan = { action: 'none', from: null, earliest: null };
1302
+ try {
1303
+ plan = await planFallbackSwitch(key, cls, episodeId);
1304
+ }
1305
+ catch (e) {
1306
+ console.warn(`[fallback] ${(0, sessionKey_1.keyToString)(key)}: the limit switch plan failed:`, e instanceof Error ? e.message : e);
1307
+ }
1308
+ if (plan.action === 'auto') {
1309
+ const to = plan.to;
1310
+ const outcome = await switchTopicToFallbackEntry(key, plan.from, to, { isAnnounced: true }).catch((e) => {
1311
+ console.warn(`[fallback] ${(0, sessionKey_1.keyToString)(key)}: the auto-switch to ${formatFallbackEntry(to)} failed:`, e instanceof Error ? e.message : e);
1312
+ return { isSwitched: false };
1313
+ });
1314
+ if (outcome.isSwitched)
1315
+ return;
1316
+ }
1317
+ // Every rung is limited and another one comes back before this one: the wait (armed for this rung's reset)
1318
+ // is re-targeted to that rung — the resume moves the topic there first.
1319
+ let resumeOn;
1320
+ if (plan.earliest && wait.kind !== 'autoResumeOff') {
1321
+ const retargeted = retargetLimitWait(key, episodeId, plan.earliest);
1322
+ if (retargeted) {
1323
+ wait = retargeted;
1324
+ resumeOn = plan.earliest.entry;
1325
+ }
1326
+ }
1327
+ const texts = getLimitWaitTexts(key, wait, attempt, resumeOn);
1328
+ const keyboard = plan.action === 'offer'
1329
+ ? telegraf_1.Markup.inlineKeyboard(plan.candidates.map((entry, index) => [
1330
+ telegraf_1.Markup.button.callback((0, i18n_1.t)('fallback.switchButton', { label: formatFallbackEntry(entry) }), (0, fallbackChain_1.buildFallbackSwitchCallbackData)(episodeId, index)),
1331
+ ]))
1332
+ : undefined;
1333
+ const { isPlainNoticePosted } = await announceLimitWait(key, wait, texts, isPlainNoticeDue, keyboard);
1334
+ if (keyboard && !isPlainNoticePosted)
1335
+ await replyToThread(key, (0, i18n_1.t)('fallback.switchOffer'), keyboard);
1336
+ }
1337
+ /**
1338
+ * @description Re-aim the ARMED usage-limit wait of `key` at the rung that comes back first: the timer moves to
1339
+ * that rung's reset (+ the usual buffer), and the persisted retry record carries the rung (`resumeOn`), so the
1340
+ * resume — also after a restart — moves the topic there before the continue nudge. `null` (nothing changed)
1341
+ * when the wait the episode armed is gone or was replaced meanwhile.
1342
+ */
1343
+ function retargetLimitWait(key, episodeId, earliest) {
1344
+ const k = (0, sessionKey_1.keyToString)(key);
1345
+ const entry = apiRetryTimers.get(k);
1346
+ if (!entry || entry.timer === null || entry.fireAt !== episodeId || entry.kind !== 'usageLimit')
1347
+ return null;
1348
+ const fireAt = earliest.untilMs + apiErrorRetry_1.resetBufferMs;
1349
+ clearTimeout(entry.timer);
1350
+ entry.timer = setTimeout(() => { void fireApiRetry(key); }, Math.min(Math.max(fireAt - Date.now(), 0), engine_1.maxTimeoutMs));
1351
+ entry.timer.unref?.();
1352
+ entry.fireAt = fireAt;
1353
+ entry.resumeOn = earliest.entry;
1354
+ const wait = { kind: 'afterReset', resetAt: earliest.untilMs, fireAt };
1355
+ entry.limitWait = wait;
1356
+ void state
1357
+ .setApiRetry(key, { kind: 'usageLimit', attempt: entry.attempt, fireAt, resumeOn: earliest.entry })
1358
+ .catch((e) => console.error('[apiRetry] persist failed:', e));
1359
+ return wait;
1266
1360
  }
1267
1361
  /**
1268
1362
  * @description A request opened during a usage-limit wait hears about it too
@@ -1280,7 +1374,7 @@ function announceLimitWaitToNewRequest(key) {
1280
1374
  });
1281
1375
  if (!wait)
1282
1376
  return;
1283
- void withThreadLocale(key, () => announceLimitWait(key, wait, getLimitWaitTexts(key, wait, armedLimit?.attempt ?? 0), false));
1377
+ void withThreadLocale(key, () => announceLimitWait(key, wait, getLimitWaitTexts(key, wait, armedLimit?.attempt ?? 0, armedLimit?.resumeOn), false));
1284
1378
  }
1285
1379
  /**
1286
1380
  * @description Both texts of a limit wait, from ONE branch on how it ends, in the
@@ -1288,7 +1382,7 @@ function announceLimitWaitToNewRequest(key) {
1288
1382
  * `/auto_continue_limits` pointer names a Telegram command — elsewhere it would
1289
1383
  * be noise; the "auto-resume is off" text carries its own.
1290
1384
  */
1291
- function getLimitWaitTexts(key, wait, attempt) {
1385
+ function getLimitWaitTexts(key, wait, attempt, resumeOn) {
1292
1386
  if (wait.kind === 'autoResumeOff') {
1293
1387
  const text = (0, i18n_1.t)('autoContinueLimits.limitReachedDisabled');
1294
1388
  // The topic text names a Telegram command; another surface's requester gets the neutral wording (Jira plan J6).
@@ -1298,12 +1392,15 @@ function getLimitWaitTexts(key, wait, attempt) {
1298
1392
  // R31: a tracker's requester may sit in another zone — the time names the instance's.
1299
1393
  const zoneSuffix = (0, sessionKeyCodec_1.checkIsTelegramKey)(key) ? '' : ` ${getCurrentTimezone()}`;
1300
1394
  const time = `${(0, localClock_1.formatLocalClockWithDateIfNotToday)(wait.kind === 'afterReset' ? wait.resetAt : wait.fireAt, nowMs)}${zoneSuffix}`;
1301
- const [noticeText, answerText] = wait.kind === 'afterReset'
1302
- ? [(0, i18n_1.t)('apiRetry.usageLimitResetNotice', { time }), (0, i18n_1.t)('requests.limit.answerAfterResetNotice', { time })]
1303
- : [
1304
- (0, i18n_1.t)('apiRetry.usageLimitDelayNotice', { minutes: Math.round((wait.fireAt - nowMs) / apiRetryMsPerMinute), attempt }),
1305
- (0, i18n_1.t)('requests.limit.answerNextAttemptNotice', { time }),
1306
- ];
1395
+ const model = resumeOn ? formatFallbackEntry(resumeOn) : '';
1396
+ const [noticeText, answerText] = wait.kind === 'afterReset' && resumeOn
1397
+ ? [(0, i18n_1.t)('apiRetry.usageLimitResetOnRungNotice', { time, model }), (0, i18n_1.t)('requests.limit.answerAfterResetOnRungNotice', { time, model })]
1398
+ : wait.kind === 'afterReset'
1399
+ ? [(0, i18n_1.t)('apiRetry.usageLimitResetNotice', { time }), (0, i18n_1.t)('requests.limit.answerAfterResetNotice', { time })]
1400
+ : [
1401
+ (0, i18n_1.t)('apiRetry.usageLimitDelayNotice', { minutes: Math.round((wait.fireAt - nowMs) / apiRetryMsPerMinute), attempt }),
1402
+ (0, i18n_1.t)('requests.limit.answerNextAttemptNotice', { time }),
1403
+ ];
1307
1404
  const addHint = (text) => ((0, sessionKeyCodec_1.checkIsTelegramKey)(key) ? (0, autoContinueLimits_1.appendAutoContinueLimitsHint)(text) : text);
1308
1405
  return { plainNotice: addHint(noticeText), answerBody: addHint(answerText) };
1309
1406
  }
@@ -1331,6 +1428,7 @@ function cancelApiRetry(key) {
1331
1428
  // error — so neither can re-notify per message.
1332
1429
  clearLimitResumeNotice(key);
1333
1430
  autoContinueOffNoticedThreads.delete(k);
1431
+ fallbackSwitchOffers.delete(k);
1334
1432
  // NOTE: intentionally does NOT touch the logged-out notice. `cancelApiRetry`
1335
1433
  // fires on EVERY inbound message (user takeover); clearing the auth notice here
1336
1434
  // would drop the one-notice guard so the user's next prompt (into the STILL
@@ -1516,12 +1614,18 @@ function clearLimitResumeNotice(key) {
1516
1614
  if (pinnedId !== pinnedNoticePendingSentinel)
1517
1615
  void unpinMessageQuiet(key, pinnedId);
1518
1616
  }
1617
+ /**
1618
+ * Where a profile sign-in's final auth status goes: the `/account` module, built further down (it needs the
1619
+ * sign-in driver built here), so the driver reaches it through this late-bound slot.
1620
+ */
1621
+ let claudeProfileAuthStatusSink = null;
1519
1622
  // Out-of-band sign-in drivers (`/login`, `/connect` OAuth): `agentLogin/README.md`.
1520
1623
  const { cancelPendingLogin, checkIsAuthLoginAwaitingCode, cancelClaudeAuthLogin, startClaudeAuthLogin, submitClaudeAuthLoginCode, checkIsOpenCodeOAuthAwaitingReply, cancelOpenCodeOAuthLogin, startOpenCodeOAuthLogin, submitOpenCodeOAuthReply, } = (0, agentLogin_1.createAgentLogin)({
1521
1624
  replyToThread,
1522
1625
  deleteThreadMessage,
1523
1626
  clearAuthNotice,
1524
1627
  execFileAsync,
1628
+ onClaudeProfileAuthStatus: (key, profile, status) => claudeProfileAuthStatusSink?.(key, profile, status) ?? Promise.resolve(),
1525
1629
  });
1526
1630
  /**
1527
1631
  * @description Buffers prompts typed while an agent session is still booting so
@@ -3436,6 +3540,26 @@ async function switchThreadAdapter(key, newName) {
3436
3540
  next.claudeSessionId = agent.claudeSessionId;
3437
3541
  if (newName === adapterNames_1.openCodeAdapterName && agent.opencodeSessionId)
3438
3542
  next.opencodeSessionId = agent.opencodeSessionId;
3543
+ // The id of the family the thread LEAVES is parked, and the parked id of the family it RETURNS to is
3544
+ // restored (`state.parkedSessions`): a cross-backend fallback switch — or a plain /claude ↔ /opencode — can
3545
+ // come back to the conversation it left (L-D4: a sleeping conversation is woken, `/new` is the fresh start).
3546
+ // The reattach loop never reads the parked slot, so the audit S12 bug (both ids on the live row) stays fixed.
3547
+ const parked = state.getParkedSession(key);
3548
+ const parkedAt = new Date().toISOString();
3549
+ if (prevName && (0, createAdapter_1.checkIsClaudeBackend)(prevName) && !(0, createAdapter_1.checkIsClaudeBackend)(newName) && agent.claudeSessionId) {
3550
+ await state.setParkedSession(key, { adapterName: prevName, claudeSessionId: agent.claudeSessionId, parkedAt });
3551
+ }
3552
+ else if (prevName === adapterNames_1.openCodeAdapterName && newName !== adapterNames_1.openCodeAdapterName && agent.opencodeSessionId) {
3553
+ await state.setParkedSession(key, { adapterName: prevName, opencodeSessionId: agent.opencodeSessionId, parkedAt });
3554
+ }
3555
+ else if (parked && (0, createAdapter_1.checkIsClaudeBackend)(newName) && !next.claudeSessionId && parked.claudeSessionId) {
3556
+ next.claudeSessionId = parked.claudeSessionId;
3557
+ await state.setParkedSession(key, null);
3558
+ }
3559
+ else if (parked && newName === adapterNames_1.openCodeAdapterName && !next.opencodeSessionId && parked.opencodeSessionId) {
3560
+ next.opencodeSessionId = parked.opencodeSessionId;
3561
+ await state.setParkedSession(key, null);
3562
+ }
3439
3563
  // Overwrite by removing the row first; setAgent then writes only the
3440
3564
  // fields we kept.
3441
3565
  await state.removeAgent(key);
@@ -4375,11 +4499,20 @@ command('status', async (_ctx, key) => {
4375
4499
  });
4376
4500
  }
4377
4501
  const timezone = getCurrentTimezone();
4502
+ const nowMs = Date.now();
4503
+ // A Claude topic runs on an account PROFILE: the live process's, which a deferred switch keeps apart from the
4504
+ // topic's pick. An OpenCode topic gets no row — its `provider/model` label already names the provider.
4505
+ let account = null;
4506
+ if (adapterName && checkIsClaudeProfileBackend(key)) {
4507
+ const live = resolveLiveClaudeProfileName(key);
4508
+ const picked = state.resolveClaudeProfileName(key);
4509
+ account = { current: readStatusAccountSource(live), pending: picked === live ? null : readStatusAccountSource(picked), nowMs };
4510
+ }
4378
4511
  await replyToThread(key, (0, threadStatusReport_1.getThreadStatusReport)({
4379
- agentLine, subdir, isActive, isSleeping: !isActive && getResumableSessionId(key) !== null, workDir, model, effort, startedAt, runtime,
4512
+ agentLine, subdir, isActive, isSleeping: !isActive && getResumableSessionId(key) !== null, workDir, model, effort, startedAt, runtime, account,
4380
4513
  view: (0, displayModes_1.formatTopicView)(state.getDisplayPrefs(key).view),
4381
4514
  timezone,
4382
- timezoneNow: (0, timezone_1.formatZoneNow)(timezone, Date.now()),
4515
+ timezoneNow: (0, timezone_1.formatZoneNow)(timezone, nowMs),
4383
4516
  }));
4384
4517
  });
4385
4518
  /** The `/language` argument: the first token, lower-cased (locale codes are case-insensitive). */
@@ -5329,7 +5462,7 @@ async function handleAgentStart(key, adapterName, args) {
5329
5462
  function handleStartCommand(parsed, key, adapterName) {
5330
5463
  return handleAgentStart(key, adapterName, parsed.argsText || undefined);
5331
5464
  }
5332
- const { handleProviderConnectKey, applyNumberedModelPick, registerProviderCommands, registerModelCommands, registerModelCallbacks, } = (0, modelProviders_1.createModelProviders)({
5465
+ const { showConnectMethodPicker, applyProviderDisconnect, handleProviderConnectKey, applyNumberedModelPick, registerProviderCommands, registerModelCommands, registerModelCallbacks, } = (0, modelProviders_1.createModelProviders)({
5333
5466
  ...botCore,
5334
5467
  awaitingModelSelection,
5335
5468
  awaitingSessionSelection,
@@ -5458,13 +5591,460 @@ command('rename_session', async (_ctx, key, parsed) => {
5458
5591
  await replyToThread(key, (0, i18n_1.t)('rename_session.success', { title }));
5459
5592
  await updatePinnedStatus(key).catch(() => { });
5460
5593
  });
5461
- const { consumeAutoContinueLimitsPicker, registerAutoContinueLimitsCommands, registerAutoContinueLimitsCallbacks, } = (0, autoContinueLimits_1.createAutoContinueLimits)({
5594
+ const { applyAutoContinueLimits, consumeAutoContinueLimitsPicker, registerAutoContinueLimitsCommands, registerAutoContinueLimitsCallbacks, } = (0, autoContinueLimits_1.createAutoContinueLimits)({
5462
5595
  ...botCore,
5596
+ openLimitsView: (key) => openLimitsView(key),
5463
5597
  getArmedLimitRetryFireAt,
5464
5598
  cancelApiRetry,
5465
5599
  getRequestWakeUpEngine: () => requestWakeUpEngine,
5466
5600
  });
5467
5601
  registerAutoContinueLimitsCommands();
5602
+ // ── Claude account profiles (`/account`): `utils/claudeProfiles.ts`, `commands/claudeAccounts.ts` ──
5603
+ /**
5604
+ * @description The Claude config directory a conversation's NEXT process starts with: its resolved account
5605
+ * profile's directory, `null` for the default profile. Registered as the adapters' reader at boot. A profile
5606
+ * whose directory vanished (a hand-cleaned `DATA_DIR`) runs on the default rather than failing the spawn.
5607
+ */
5608
+ function resolveThreadClaudeConfigDir(key) {
5609
+ const name = state.resolveClaudeProfileName(key);
5610
+ const dir = (0, claudeProfiles_1.resolveClaudeProfileDir)((0, state_1.resolveDataDir)(), name);
5611
+ if (dir !== null && !fs.existsSync(dir)) {
5612
+ console.warn(`[account] ${(0, sessionKey_1.keyToString)(key)}: the directory of profile ${name} is missing; its process runs on the default profile`);
5613
+ return null;
5614
+ }
5615
+ return dir;
5616
+ }
5617
+ /** `claude auth status --json` under `configDir` (`null` = `~/.claude`), the subscription login only; `null` when the probe fails. */
5618
+ async function readClaudeAuthStatusUnder(configDir) {
5619
+ const env = { ...process.env };
5620
+ delete env.ANTHROPIC_API_KEY;
5621
+ if (configDir !== null)
5622
+ env[claudeProfiles_1.claudeConfigDirVariableName] = configDir;
5623
+ try {
5624
+ const { stdout } = await execFileAsync((0, resolveBinary_1.resolveClaudeBinary)(), ['auth', 'status', '--json'], { timeout: claudeAuthStatusProbeTimeoutMs, env });
5625
+ return (0, claudeProfiles_1.parseClaudeAuthStatus)(stdout);
5626
+ }
5627
+ catch (e) {
5628
+ // A logged-out directory makes the CLI exit non-zero WITH its JSON on stdout: that is the answer.
5629
+ return (0, claudeProfiles_1.parseClaudeAuthStatus)((0, claudeProfiles_1.readProcessErrorStdout)(e));
5630
+ }
5631
+ }
5632
+ const claudeAuthStatusProbeTimeoutMs = 5000;
5633
+ /**
5634
+ * @description Bring ONE conversation's live Claude session onto the profile it resolves to now. json-stream
5635
+ * re-spawns the process with `--resume` when idle and defers to turn end when it works (the adapter owns
5636
+ * that); the tmux TUI is reopened under the new environment when idle (`resumeSession` kills and recreates the
5637
+ * pane, same conversation) and left alone when busy — its next start or resume applies the pick. Any other
5638
+ * backend has no Claude process to move.
5639
+ */
5640
+ async function applyClaudeProfileToThread(key, options = {}) {
5641
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
5642
+ if (adapter instanceof claudeJsonStreamAdapter_1.ClaudeJsonStreamAdapter)
5643
+ return adapter.applyClaudeConfigDir(key, options);
5644
+ if (!(adapter instanceof claudeCliAdapter_1.ClaudeCliAdapter))
5645
+ return 'inactive';
5646
+ const current = adapter.getSessionClaudeConfigDir(key);
5647
+ if (current === undefined)
5648
+ return 'inactive';
5649
+ if (current === resolveThreadClaudeConfigDir(key))
5650
+ return 'unchanged';
5651
+ // After a usage limit the turn is dead: the pane is reopened even though it looks busy.
5652
+ if (!options.isAfterLimit && adapter.checkIsBusy(key))
5653
+ return 'restartRequired';
5654
+ const sessionId = state.getAgent(key)?.claudeSessionId;
5655
+ const decision = getWorkDirStartDecision(key);
5656
+ if (!sessionId || !decision.ok)
5657
+ return 'restartRequired';
5658
+ markNeedsNewMessage(key);
5659
+ await adapter.resumeSession(key, decision.workDir, sessionId);
5660
+ await persistSessionStart(key, adapter);
5661
+ return 'respawned';
5662
+ }
5663
+ /** After a General pick, a removal or a sign-in: every served conversation follows its resolved profile. */
5664
+ async function applyClaudeProfileEverywhere() {
5665
+ for (const { key } of getServedBindings()) {
5666
+ try {
5667
+ await applyClaudeProfileToThread(key);
5668
+ }
5669
+ catch (e) {
5670
+ console.warn(`[account] ${(0, sessionKey_1.keyToString)(key)}: moving the session onto its profile failed:`, e instanceof Error ? e.message : e);
5671
+ }
5672
+ }
5673
+ }
5674
+ /**
5675
+ * The per-profile usage cache (`utils/claudeUsageRefresher.ts`): every signed-in profile's windows off the
5676
+ * OAuth usage endpoint on a 10-minute timer, on `/account` open and on a `claude_profiles` call, plus the
5677
+ * `rate_limit_event` frames the json-stream sessions report. Snapshots live on the profile records.
5678
+ */
5679
+ const claudeUsageRefresher = (0, claudeUsageRefresher_1.createClaudeUsageRefresher)({
5680
+ listProfiles: () => [
5681
+ { name: claudeProfiles_1.defaultClaudeProfileName, configDir: null, loggedIn: state.getClaudeProfileInfo(claudeProfiles_1.defaultClaudeProfileName)?.loggedIn },
5682
+ ...Object.entries(state.getClaudeProfileRecords()).map(([name, record]) => ({
5683
+ name, configDir: (0, claudeProfiles_1.resolveClaudeProfileDir)((0, state_1.resolveDataDir)(), name), loggedIn: record.loggedIn,
5684
+ })),
5685
+ ],
5686
+ getSnapshot: (name) => state.getClaudeProfileInfo(name)?.usage,
5687
+ saveSnapshot: (name, snapshot) => state.setClaudeProfileUsage(name, snapshot),
5688
+ readToken: (configDir) => (0, claudeUsage_1.readClaudeOAuthToken)(configDir),
5689
+ fetchUsage: (accessToken) => (0, claudeUsage_1.fetchClaudeUsage)(accessToken),
5690
+ });
5691
+ /** The profile that owns a config directory (`null` = default); `null` for a directory outside the profiles root. */
5692
+ function resolveClaudeProfileNameForDir(configDir) {
5693
+ if (configDir === null)
5694
+ return claudeProfiles_1.defaultClaudeProfileName;
5695
+ const root = (0, claudeProfiles_1.resolveClaudeProfilesRoot)((0, state_1.resolveDataDir)());
5696
+ if (path.dirname(configDir) !== root)
5697
+ return null;
5698
+ const name = path.basename(configDir);
5699
+ return state.getClaudeProfileRecord(name) ? name : null;
5700
+ }
5701
+ /**
5702
+ * A json-stream session's `rate_limit_event`: its windows refresh the owning profile's snapshot; a REJECTED
5703
+ * request marks the profile limited until the window resets (the `claude_profiles` ranking skips it; the
5704
+ * auto-switch phase will act on the same mark), an allowed one clears a stale mark.
5705
+ */
5706
+ function onClaudeRateLimitEvent(configDir, event) {
5707
+ const name = resolveClaudeProfileNameForDir(configDir);
5708
+ if (name === null)
5709
+ return;
5710
+ claudeUsageRefresher.noteStreamWindows(name, event.windows);
5711
+ if (event.status === 'rejected' && event.resetsAtMs !== null) {
5712
+ state.setClaudeProfileLimitedUntil(name, new Date(event.resetsAtMs).toISOString());
5713
+ }
5714
+ else if (event.status === 'allowed' && state.getClaudeProfileInfo(name)?.limitedUntil) {
5715
+ state.setClaudeProfileLimitedUntil(name, null);
5716
+ }
5717
+ }
5718
+ /** The model a topic's Claude session runs (or last ran), for the usage ranking's choice of windows. */
5719
+ function getThreadClaudeModel(key) {
5720
+ return (0, createAdapter_1.getThreadAdapter)(key).getCurrentModel?.(key) ?? state.getAgent(key)?.model ?? null;
5721
+ }
5722
+ /** What `/status` names of a Claude profile: its last probed sign-in and email, and its limit mark (none until probed). */
5723
+ function readStatusAccountSource(name) {
5724
+ const info = state.getClaudeProfileInfo(name);
5725
+ return { name, email: info?.email ?? null, loggedIn: info?.loggedIn, limitedUntil: info?.limitedUntil ?? null };
5726
+ }
5727
+ /** Whether the topic's agent is a Claude backend (a usage limit there is a limit of its account profile). */
5728
+ function checkIsClaudeProfileBackend(key) {
5729
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
5730
+ return adapter instanceof claudeJsonStreamAdapter_1.ClaudeJsonStreamAdapter || adapter instanceof claudeCliAdapter_1.ClaudeCliAdapter;
5731
+ }
5732
+ // ── the fallback chain (`/fallback`): `utils/fallbackChain.ts`, `utils/fallbackBridge.ts`, `commands/fallbackChain.ts` ──
5733
+ /** A chain rung for the topic's own language (the picker and the notices name it the same way). */
5734
+ function formatFallbackEntry(entry) {
5735
+ return (0, fallbackChain_1.formatFallbackEntryLabel)(entry, { claude: (0, i18n_1.t)('fallback.backendClaude'), openCode: (0, i18n_1.t)('fallback.backendOpenCode'), topicModel: (0, i18n_1.t)('fallback.topicModel') });
5736
+ }
5737
+ /**
5738
+ * @description What is connected NOW — the input of the chain draft, the picker rows and the ranking: every
5739
+ * Claude profile with its usage view, the OpenCode models the server is authenticated for (its live catalog —
5740
+ * `opencode models` lists only providers with credentials; an absent or failing CLI yields none), the models
5741
+ * topics recently picked (newest first) and the OpenCode limit marks.
5742
+ */
5743
+ async function detectFallbackOptions() {
5744
+ let openCodeModels = [];
5745
+ try {
5746
+ openCodeModels = (await (0, createAdapter_1.getAdapter)(adapterNames_1.openCodeAdapterName).getAvailableModels?.()) ?? [];
5747
+ }
5748
+ catch (e) {
5749
+ console.warn('[fallback] the OpenCode catalog is unavailable:', e instanceof Error ? e.message : e);
5750
+ }
5751
+ return {
5752
+ claudeProfiles: (0, claudeUsage_1.buildClaudeProfileUsageViews)({ dataDir: (0, state_1.resolveDataDir)(), records: state.getAllClaudeProfileInfos() }),
5753
+ openCodeModels,
5754
+ recentOpenCodeModels: (0, openCodeAdapter_1.listSavedOpenCodeModelLabels)().reverse(),
5755
+ openCodeLimits: state.getOpenCodeModelLimits(),
5756
+ };
5757
+ }
5758
+ /**
5759
+ * @description The profile the topic's LIVE Claude process runs on (the config directory it was spawned with),
5760
+ * which differs from the topic's pick while a switch is deferred or the pick was never applied. The pick when
5761
+ * there is no live process or its directory maps to no known profile.
5762
+ */
5763
+ function resolveLiveClaudeProfileName(key) {
5764
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
5765
+ if (adapter instanceof claudeJsonStreamAdapter_1.ClaudeJsonStreamAdapter || adapter instanceof claudeCliAdapter_1.ClaudeCliAdapter) {
5766
+ const dir = adapter.getSessionClaudeConfigDir(key);
5767
+ if (dir !== undefined) {
5768
+ const live = resolveClaudeProfileNameForDir(dir);
5769
+ if (live !== null)
5770
+ return live;
5771
+ }
5772
+ }
5773
+ return state.resolveClaudeProfileName(key);
5774
+ }
5775
+ /**
5776
+ * @description An `/account` pick on a topic: the plain apply, except while a usage-limit wait is armed and the
5777
+ * pick moves the topic off the profile its process runs on. The process cannot work on the limited account, so
5778
+ * the pick is applied NOW and the topic resumes like after a limit switch (wait cancelled, continue nudge).
5779
+ */
5780
+ async function applyClaudePickToThread(key) {
5781
+ const armed = getArmedApiRetry(key);
5782
+ if (armed?.kind === 'usageLimit' && checkIsClaudeProfileBackend(key)) {
5783
+ const from = getTopicFallbackPosition(key);
5784
+ const to = state.resolveClaudeProfileName(key);
5785
+ if (from !== null && from.backend === 'claude' && from.profile !== to) {
5786
+ const { isSwitched } = await switchTopicToFallbackEntry(key, from, { backend: 'claude', profile: to, model: null }, { isAnnounced: true });
5787
+ return isSwitched ? 'respawned' : 'deferred';
5788
+ }
5789
+ }
5790
+ return applyClaudeProfileToThread(key);
5791
+ }
5792
+ /**
5793
+ * @description The rung a topic runs on now: its Claude profile and model on a Claude backend, its
5794
+ * `provider/model` on OpenCode (`null` while OpenCode runs the server default — nothing to mark or compare),
5795
+ * `null` for a terminal or no agent.
5796
+ */
5797
+ function getTopicFallbackPosition(key) {
5798
+ if (checkIsClaudeProfileBackend(key))
5799
+ return { backend: 'claude', profile: resolveLiveClaudeProfileName(key), model: getThreadClaudeModel(key) };
5800
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
5801
+ if (adapter.name !== adapterNames_1.openCodeAdapterName)
5802
+ return null;
5803
+ const label = adapter.getCurrentModel?.(key);
5804
+ const parsed = label ? (0, fallbackChain_1.parseOpenCodeModelLabel)(label) : null;
5805
+ return parsed ? { backend: 'opencode', ...parsed } : null;
5806
+ }
5807
+ /**
5808
+ * @description What a usage limit on `key` leads to (`utils/fallbackChain.ts`): the rung the topic is on is
5809
+ * marked limited until the reset the error named (a Claude profile: else its session window, else the retry's
5810
+ * blind delay; an OpenCode model: else the blind delay), the usage snapshots are brought up to date, and the
5811
+ * chain's AVAILABLE rungs are listed in priority order. Without a configured chain a Claude topic falls back to
5812
+ * the within-Claude headroom ranking (`utils/claudeLimitSwitch.ts`) — the behaviour before the chain existed. An
5813
+ * offer is remembered per thread under its episode so only ITS buttons can act on it. `none` for a non-Telegram
5814
+ * conversation, a topic with no account to switch from, or when no rung can take over.
5815
+ */
5816
+ async function planFallbackSwitch(key, cls, episodeId) {
5817
+ const from = getTopicFallbackPosition(key);
5818
+ if (!(0, sessionKeyCodec_1.checkIsTelegramKey)(key) || from === null)
5819
+ return { action: 'none', from, earliest: null };
5820
+ const k = (0, sessionKey_1.keyToString)(key);
5821
+ const nowMs = Date.now();
5822
+ const resetAtMs = cls.kind === 'usageLimit' ? cls.resetAt : undefined;
5823
+ if (from.backend === 'claude') {
5824
+ state.setClaudeProfileLimitedUntil(from.profile, (0, claudeLimitSwitch_1.resolveClaudeLimitedUntil)({
5825
+ resetAtMs,
5826
+ usage: state.getClaudeProfileInfo(from.profile)?.usage,
5827
+ existingLimitedUntil: state.getClaudeProfileInfo(from.profile)?.limitedUntil ?? null,
5828
+ nowMs,
5829
+ fallbackMs: apiErrorRetry_1.usageLimitDefaultMs,
5830
+ }));
5831
+ }
5832
+ else {
5833
+ const label = (0, fallbackChain_1.buildOpenCodeModelLabel)(from);
5834
+ state.setOpenCodeModelLimitedUntil(label, (0, fallbackChain_1.resolveOpenCodeLimitedUntil)({
5835
+ resetAtMs,
5836
+ existingLimitedUntil: state.getOpenCodeModelLimitedUntil(label),
5837
+ nowMs,
5838
+ fallbackMs: apiErrorRetry_1.usageLimitDefaultMs,
5839
+ }));
5840
+ }
5841
+ // The Claude rungs are judged on fresh figures whatever backend hit the limit (a minute's cache).
5842
+ await claudeUsageRefresher.refreshAll({ maxAgeMs: claudeUsageRefresher_1.claudeUsageOnDemandMaxAgeMs });
5843
+ const detection = await detectFallbackOptions();
5844
+ let chain = state.getFallbackChain();
5845
+ if (chain.length === 0 && from.backend === 'claude') {
5846
+ chain = (0, claudeLimitSwitch_1.pickClaudeLimitSwitchCandidates)({ profiles: detection.claudeProfiles, currentName: from.profile, model: from.model, nowMs: Date.now() })
5847
+ .map((profile) => ({ backend: 'claude', profile, model: null }));
5848
+ }
5849
+ const candidates = (0, fallbackChain_1.pickFallbackCandidates)({ chain, current: from, detection, nowMs: Date.now() });
5850
+ const plan = (0, fallbackChain_1.decideFallbackSwitch)({ candidates, isAutoSwitchEnabled: state.checkIsClaudeAutoSwitchOnLimitEnabled(key) });
5851
+ if (plan.action === 'offer')
5852
+ fallbackSwitchOffers.set(k, { episodeId, from, candidates: plan.candidates });
5853
+ else
5854
+ fallbackSwitchOffers.delete(k);
5855
+ const detail = plan.action === 'auto' ? ` → ${formatFallbackEntry(plan.to)}` : plan.action === 'offer' ? ` [${plan.candidates.map(formatFallbackEntry).join(', ')}]` : '';
5856
+ // Nothing has room: the wait should end on the rung whose limit resets EARLIEST, not necessarily this one.
5857
+ let earliest = null;
5858
+ if (plan.action === 'none') {
5859
+ const explicit = state.getFallbackChain();
5860
+ const rungs = explicit.length > 0 ? explicit : from.backend === 'claude'
5861
+ ? detection.claudeProfiles.filter((profile) => profile.loggedIn !== false).map((profile) => ({ backend: 'claude', profile: profile.name, model: null }))
5862
+ : [];
5863
+ const found = (0, fallbackChain_1.pickEarliestResetRung)({ chain: rungs, current: from, detection, nowMs: Date.now() });
5864
+ if (found && !found.isCurrent)
5865
+ earliest = { entry: found.entry, untilMs: found.untilMs };
5866
+ }
5867
+ console.log(`[fallback] ${k}: usage limit on ${formatFallbackEntry(from)}; switch plan: ${plan.action}${detail}${earliest ? `; earliest reset: ${formatFallbackEntry(earliest.entry)} at ${new Date(earliest.untilMs).toISOString()}` : ''}`);
5868
+ return { ...plan, from, earliest };
5869
+ }
5870
+ /** When the rung that hit the limit comes back, as a clock in the topic's zone; `null` when unknown or past. */
5871
+ function getFallbackResetTime(from) {
5872
+ if (from === null)
5873
+ return null;
5874
+ const until = from.backend === 'claude'
5875
+ ? state.getClaudeProfileInfo(from.profile)?.limitedUntil ?? null
5876
+ : state.getOpenCodeModelLimitedUntil((0, fallbackChain_1.buildOpenCodeModelLabel)(from)) ?? null;
5877
+ const untilMs = until === null ? Number.NaN : Date.parse(until);
5878
+ if (!Number.isFinite(untilMs) || untilMs <= Date.now())
5879
+ return null;
5880
+ return (0, localClock_1.formatLocalClockWithDateIfNotToday)(untilMs, Date.now());
5881
+ }
5882
+ /** The one-line «switched» notice, with the reset of the exhausted rung when known (the topic is never moved back by itself). */
5883
+ function getFallbackSwitchedNotice(from, to) {
5884
+ const fromLabel = from ? formatFallbackEntry(from) : '?';
5885
+ const toLabel = formatFallbackEntry(to);
5886
+ const time = getFallbackResetTime(from);
5887
+ const reset = time === null ? '' : (0, i18n_1.t)('fallback.switchedReset', { from: fromLabel, to: toLabel, time });
5888
+ return (0, i18n_1.t)('fallback.switched', { from: fromLabel, to: toLabel, reset });
5889
+ }
5890
+ /** Whether the topic's Claude session already runs the alias (`fable` against a reported `claude-fable-5-1`). */
5891
+ function checkThreadClaudeModelMatches(key, alias) {
5892
+ const current = getThreadClaudeModel(key);
5893
+ return current !== null && current.toLowerCase().includes(alias.toLowerCase());
5894
+ }
5895
+ /**
5896
+ * @description The context bridge for a cross-backend switch (`utils/fallbackBridge.ts`), built BEFORE the old
5897
+ * adapter is stopped: its last turns off its own transcript (an unreadable one yields a bridge without turns).
5898
+ */
5899
+ async function buildFallbackBridge(key, from, to) {
5900
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
5901
+ const decision = getWorkDirStartDecision(key);
5902
+ const sessionId = (0, ensureSessionPlan_1.getPersistedSessionIdForAdapter)(state.getAgent(key) ?? null, adapter.name, (0, createAdapter_1.checkIsClaudeBackend)(adapter.name));
5903
+ let turns = [];
5904
+ if (decision.ok && sessionId && adapter.getRecentTurns) {
5905
+ try {
5906
+ turns = await adapter.getRecentTurns(key, decision.workDir, sessionId, fallbackBridge_1.fallbackBridgeTurnLimit);
5907
+ }
5908
+ catch (e) {
5909
+ console.warn(`[fallback] ${(0, sessionKey_1.keyToString)(key)}: the previous agent's turns are unreadable:`, e instanceof Error ? e.message : e);
5910
+ }
5911
+ }
5912
+ return (0, fallbackBridge_1.buildFallbackBridgePrompt)({
5913
+ fromLabel: from ? formatFallbackEntry(from) : (0, i18n_1.t)('fallback.backendClaude'),
5914
+ toLabel: formatFallbackEntry(to),
5915
+ turns,
5916
+ resetsAtText: getFallbackResetTime(from),
5917
+ replyLanguage: (0, i18n_1.getLocaleEndonym)((0, i18n_1.getActiveLang)()),
5918
+ });
5919
+ }
5920
+ /**
5921
+ * @description Move a limited topic onto rung `to` and continue the work there. WITHIN a backend the
5922
+ * conversation is kept: a Claude rung sets the topic's profile (the `/account` apply path re-spawns the live
5923
+ * session under the new directory, same transcript) and pins the rung's model when it names one; an OpenCode
5924
+ * rung changes the session's per-prompt model. ACROSS backends the topic's adapter changes (the `/claude` /
5925
+ * `/opencode` path: the old session is stopped and its id parked for a switch back), the new session is started
5926
+ * or resumed, and its first prompt is the context bridge instead of the plain "continue" nudge. Either way the
5927
+ * armed limit wait is disarmed and the prompt goes the way the wait's own resume would deliver it. A Claude
5928
+ * session that works keeps its wait: the switch applies at turn end. Returns whether the work continues now and
5929
+ * the notice that says so (posted here only when `isAnnounced` — the auto-switch has no other voice, while a
5930
+ * tapped button's notice is edited into the same line by its handler).
5931
+ */
5932
+ async function switchTopicToFallbackEntry(key, from, to, options) {
5933
+ const k = (0, sessionKey_1.keyToString)(key);
5934
+ const fromLabel = from ? formatFallbackEntry(from) : '?';
5935
+ const toLabel = formatFallbackEntry(to);
5936
+ const isSameFamily = from !== null && from.backend === to.backend;
5937
+ const say = async (notice) => {
5938
+ if (options.isAnnounced)
5939
+ await replyToThread(key, notice);
5940
+ };
5941
+ const fail = async (error) => {
5942
+ const notice = (0, i18n_1.t)('fallback.switchFailed', { to: toLabel, error });
5943
+ await say(notice);
5944
+ return { isSwitched: false, notice };
5945
+ };
5946
+ let firstPrompt = null;
5947
+ let isStoppingBackground = false;
5948
+ if (isSameFamily && to.backend === 'claude') {
5949
+ await state.setClaudeProfileOverride(key, to.profile);
5950
+ // The turn is dead after a limit: re-spawn now, even over background work (it is stopped; the notice says so).
5951
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
5952
+ isStoppingBackground = adapter instanceof claudeJsonStreamAdapter_1.ClaudeJsonStreamAdapter && adapter.checkHasBackgroundWork(key);
5953
+ const outcome = await applyClaudeProfileToThread(key, { isAfterLimit: true });
5954
+ if (outcome === 'deferred' || outcome === 'restartRequired') {
5955
+ fallbackSwitchOffers.delete(k);
5956
+ const notice = (0, i18n_1.t)('fallback.switchedDeferred', { from: fromLabel, to: toLabel });
5957
+ await say(notice);
5958
+ return { isSwitched: false, notice };
5959
+ }
5960
+ }
5961
+ else if (isSameFamily && to.backend === 'opencode') {
5962
+ const error = await (0, createAdapter_1.getThreadAdapter)(key).setModel?.(key, (0, fallbackChain_1.buildOpenCodeModelLabel)(to));
5963
+ if (error)
5964
+ return fail(error);
5965
+ }
5966
+ else {
5967
+ const bridge = await buildFallbackBridge(key, from, to);
5968
+ const targetName = to.backend === 'claude' ? (0, createAdapter_1.resolveClaudeBackendName)(key) : adapterNames_1.openCodeAdapterName;
5969
+ if (to.backend === 'claude')
5970
+ await state.setClaudeProfileOverride(key, to.profile);
5971
+ await switchThreadAdapter(key, targetName);
5972
+ if (to.backend === 'opencode') {
5973
+ const error = await (0, createAdapter_1.getThreadAdapter)(key).setModel?.(key, (0, fallbackChain_1.buildOpenCodeModelLabel)(to));
5974
+ if (error)
5975
+ return fail(error);
5976
+ }
5977
+ firstPrompt = { text: bridge, isRequestPrompt: false };
5978
+ }
5979
+ cancelApiRetry(key); // retires the offer too: the switch consumed it
5980
+ const notice = `${getFallbackSwitchedNotice(from, to)}${isStoppingBackground ? ` ${(0, i18n_1.t)('fallback.backgroundStopped')}` : ''}`;
5981
+ console.log(`[fallback] ${k}: switched from ${fromLabel} to ${toLabel} after a usage limit${firstPrompt ? ' (cross-backend, bridged)' : ''}; resuming`);
5982
+ await say(notice);
5983
+ const ensured = await ensureAgentSession(key);
5984
+ if (!ensured.ok) {
5985
+ await replyToThread(key, ensured.message);
5986
+ return { isSwitched: true, notice };
5987
+ }
5988
+ if (ensured.message)
5989
+ await replyToThread(key, ensured.message);
5990
+ if (to.backend === 'claude' && to.model !== null && !checkThreadClaudeModelMatches(key, to.model)) {
5991
+ const error = await (0, createAdapter_1.getThreadAdapter)(key).setModel?.(key, to.model);
5992
+ if (error)
5993
+ console.warn(`[fallback] ${k}: pinning the model ${to.model} failed: ${error}`);
5994
+ }
5995
+ const prompt = firstPrompt ?? (0, limitHeldPrompts_1.getLimitResumeMessage)({
5996
+ continueNudge: (0, i18n_1.t)('apiRetry.continueNudge'),
5997
+ untakenRequestPrompt: (0, requestHeader_1.joinPromptsNotTakenIn)(requestLimitWaitAnswerDeps?.ledger.listOpenRequestsOf(key) ?? []),
5998
+ });
5999
+ await deliverPromptOrBuffer(key, prompt.text, startupPromptBuffer.checkIsStarting(k));
6000
+ await requestWakeUpEngine?.trackContinuationTurn(key, { isCountersReset: true, isRequestPrompt: prompt.isRequestPrompt });
6001
+ return { isSwitched: true, notice };
6002
+ }
6003
+ /**
6004
+ * @description A tap on a «switch to …» button of a limit notice: honoured only while the thread's open offer
6005
+ * is the button's episode and the index is inside that offer (a stale notice changes nothing). The
6006
+ * confirmation to edit the notice into, or `null` when the tap expired.
6007
+ */
6008
+ async function takeFallbackSwitchOffer(key, episodeId, index) {
6009
+ const offer = fallbackSwitchOffers.get((0, sessionKey_1.keyToString)(key)) ?? null;
6010
+ const decision = (0, fallbackChain_1.getFallbackSwitchTapDecision)({ openOffer: offer, requestedEpisodeId: episodeId, index });
6011
+ if (decision.kind === 'expired' || offer === null)
6012
+ return null;
6013
+ const { notice } = await switchTopicToFallbackEntry(key, offer.from, decision.to, { isAnnounced: false });
6014
+ return notice;
6015
+ }
6016
+ /** The `claude_profiles` MCP tool's answer: snapshots refreshed on demand (a minute's cache), then the ranking. */
6017
+ async function listClaudeProfilesForAgent(model) {
6018
+ await claudeUsageRefresher.refreshAll({ maxAgeMs: claudeUsageRefresher_1.claudeUsageOnDemandMaxAgeMs });
6019
+ return (0, claudeUsage_1.buildClaudeProfilesReport)((0, claudeUsage_1.buildClaudeProfileUsageViews)({ dataDir: (0, state_1.resolveDataDir)(), records: state.getAllClaudeProfileInfos() }), model, Date.now());
6020
+ }
6021
+ const { registerFallbackChainCommands, registerFallbackChainCallbacks, openLimitsView, applyAutoSwitch } = (0, fallbackChain_2.createFallbackChain)({
6022
+ ...botCore,
6023
+ applyAutoContinue: applyAutoContinueLimits,
6024
+ getArmedLimitRetryFireAt,
6025
+ getInstanceTimezone: getCurrentTimezone,
6026
+ refreshClaudeUsage: (options) => claudeUsageRefresher.refreshAll(options),
6027
+ detectFallbackOptions,
6028
+ getTopicPosition: getTopicFallbackPosition,
6029
+ takeSwitchOffer: takeFallbackSwitchOffer,
6030
+ });
6031
+ const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAwaitingProfileName, cancelProfileNameCapture, submitProfileName, onClaudeProfileAuthStatus: recordClaudeProfileAuthStatus, } = (0, claudeAccounts_1.createClaudeAccounts)({
6032
+ ...botCore,
6033
+ getDataDir: state_1.resolveDataDir,
6034
+ getInstanceTimezone: getCurrentTimezone,
6035
+ refreshClaudeUsage: (options) => claudeUsageRefresher.refreshAll(options),
6036
+ readClaudeAuthStatus: readClaudeAuthStatusUnder,
6037
+ startClaudeProfileLogin: (key, profile) => startClaudeAuthLogin(key, profile),
6038
+ applyClaudeProfile: applyClaudePickToThread,
6039
+ applyClaudeProfileEverywhere,
6040
+ detectOpenCodeProviders: async () => (0, fallbackChain_1.listOpenCodeProviders)((await (0, createAdapter_1.getAdapter)(adapterNames_1.openCodeAdapterName).getAvailableModels?.()) ?? []),
6041
+ disconnectOpenCodeProvider: applyProviderDisconnect,
6042
+ startOpenCodeConnect: showConnectMethodPicker,
6043
+ applyAutoSwitch,
6044
+ });
6045
+ claudeProfileAuthStatusSink = recordClaudeProfileAuthStatus;
6046
+ registerClaudeAccountCommands();
6047
+ registerFallbackChainCommands();
5468
6048
  registerCompactionCommands();
5469
6049
  /**
5470
6050
  * @description Build the agent-picker keyboard.
@@ -5759,8 +6339,12 @@ command('tab', async (_ctx, key) => {
5759
6339
  await replyToThread(key, `Not supported for ${adapter.label}`);
5760
6340
  });
5761
6341
  command(['esc', 'escape'], async (_ctx, key) => {
5762
- // A pending sign-in (`/login`, `/connect` OAuth) takes priority: the first /esc
5763
- // only cancels it, so the topic stops waiting for a pasted code.
6342
+ // A pending «Add account» name prompt, then a pending sign-in (`/login`, `/connect` OAuth) take
6343
+ // priority: the first /esc only cancels it, so the topic stops waiting for typed input.
6344
+ if (cancelProfileNameCapture(key)) {
6345
+ await replyToThread(key, (0, i18n_1.t)('account.askNameCancelled'));
6346
+ return;
6347
+ }
5764
6348
  if (cancelPendingLogin(key)) {
5765
6349
  await replyToThread(key, (0, i18n_1.t)('login.cancelled'));
5766
6350
  return;
@@ -6043,8 +6627,8 @@ const botCommands = new Set([
6043
6627
  'start', 'claude', 'opencode', 'oc', 'terminal', 'agent', 'sessions', 'resume', 'cancel', 'model', 'connect',
6044
6628
  'disconnect', 'stop', 'stopall', 'stop-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'output', 'clear_messages',
6045
6629
  'bind', 'unbind', 'where', 'ls', 'list', 'new', 'clear_session', 'whoami', 'version', 'help', 'language', 'lang',
6046
- 'doctor', 'mcp', 'rename_session', 'compact', 'compact_on_idle', 'compact_summary', 'auto_continue_limits', 'trace', 'timestamps', 'timezone', 'schedule', 'reminders', 'thinking', 'tool_results',
6047
- 'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quit-all', 'quitall', 'pair',
6630
+ 'doctor', 'mcp', 'rename_session', 'compact', 'compact_on_idle', 'compact_summary', 'auto_continue_limits', 'limits', 'trace', 'timestamps', 'timezone', 'schedule', 'reminders', 'thinking', 'tool_results',
6631
+ 'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quit-all', 'quitall', 'pair', 'account', 'accounts', 'fallback',
6048
6632
  ]);
6049
6633
  /**
6050
6634
  * @description The bare `/clear` is no longer bot-owned (it was renamed to
@@ -6075,6 +6659,8 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
6075
6659
  // Slash commands we don't own → forward to the agent (e.g. `/context`, `/help`).
6076
6660
  if (text.startsWith('/')) {
6077
6661
  const cmd = text.slice(1).split(' ')[0].split('@')[0].toLowerCase();
6662
+ // A slash command ends a pending «Add account» name prompt (the command itself runs normally).
6663
+ cancelProfileNameCapture(key);
6078
6664
  if (botCommands.has(cmd))
6079
6665
  return;
6080
6666
  // `/login` on a json-stream Claude thread (idle or per-turn lifecycle) has no
@@ -6118,6 +6704,12 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
6118
6704
  await submitOpenCodeOAuthReply(key, text, ctx.message.message_id);
6119
6705
  return;
6120
6706
  }
6707
+ // `/account` «Add» asked for the new profile's name → this message is it (a bad name keeps the prompt
6708
+ // armed and says so; a slash command above already disarmed it).
6709
+ if (checkIsAwaitingProfileName(key) && !text.startsWith('/')) {
6710
+ await submitProfileName(key, text);
6711
+ return;
6712
+ }
6121
6713
  // Safety net: a pasted OAuth callback URL with NO flow in progress (e.g. a
6122
6714
  // stale one from a browser that timed out, or a re-paste after the flow ended)
6123
6715
  // must NOT be forwarded to the agent as a prompt — that both leaks the code
@@ -7220,6 +7812,8 @@ bot.action(timezonePicker_1.zonePickCallbackRe, async (ctx) => {
7220
7812
  registerModelCallbacks();
7221
7813
  registerCompactionCallbacks();
7222
7814
  registerAutoContinueLimitsCallbacks();
7815
+ registerClaudeAccountCallbacks();
7816
+ registerFallbackChainCallbacks();
7223
7817
  // «⏭ Skip once» in the `/auto_continue_limits` picker: drop THIS armed resume and
7224
7818
  // leave the on/off setting alone. The episode is identified by the `fireAt` baked
7225
7819
  // into the callback data, so an untouched older picker can never cancel a LATER
@@ -8873,8 +9467,6 @@ exports.COMMANDS_MENU = [
8873
9467
  { command: 'mcp', description: '🔌 List active MCP servers' },
8874
9468
  { command: 'claude', description: '▶️ Start Claude Code' },
8875
9469
  { command: 'opencode', description: '▶️ Start OpenCode' },
8876
- { command: 'connect', description: '🔑 Connect an OpenCode provider API key' },
8877
- { command: 'disconnect', description: '🔌 Disconnect an OpenCode provider' },
8878
9470
  { command: 'terminal', description: '🖥 Open a raw shell in the bound folder' },
8879
9471
  { command: 'claude_mode', description: '🔀 Claude backend: json-stream ⇄ per-turn ⇄ tmux-scrape' },
8880
9472
  { command: 'new', description: '🆕 Restart session (alias /clear_session)' },
@@ -8893,7 +9485,8 @@ exports.COMMANDS_MENU = [
8893
9485
  { command: 'compact', description: '🧹 Compact agent context' },
8894
9486
  { command: 'compact_on_idle', description: '🧹 Auto-compact after idle (toggle)' },
8895
9487
  { command: 'compact_summary', description: '📄 Post the compaction summary (toggle)' },
8896
- { command: 'auto_continue_limits', description: '🚧 Auto-resume after a usage limit (toggle)' },
9488
+ { command: 'accounts', description: '👤 Claude accounts and OpenCode providers (pick, add, re-login, remove, connect, disconnect)' },
9489
+ { command: 'limits', description: '🚧 What happens when an agent hits a usage limit' },
8897
9490
  { command: 'schedule', description: '⏰ Schedule a prompt (agent does the work)' },
8898
9491
  { command: 'reminders', description: '🔔 Reminders — add, list, delete (no agent)' },
8899
9492
  { command: 'status', description: '📊 Show status' },
@@ -9084,6 +9677,13 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
9084
9677
  const preAdoptWatermark = state.getAgent(key)?.seenWatermark ?? null;
9085
9678
  if (await claudeAdapter.adoptExistingTmuxSession(key, sessionName, workDir, agent.claudeSessionId)) {
9086
9679
  adopted += 1;
9680
+ // The pane keeps the account it was started with; a changed pick reopens it (idle) or waits for the next start.
9681
+ const spawnedConfigDir = claudeAdapter.getSessionClaudeConfigDir(key);
9682
+ if (spawnedConfigDir !== undefined && spawnedConfigDir !== resolveThreadClaudeConfigDir(key)) {
9683
+ void applyClaudeProfileToThread(key)
9684
+ .then((outcome) => console.log(`[reattach] ${(0, sessionKey_1.keyToString)(key)}: the pane's account profile differs from the topic's pick: ${outcome}`))
9685
+ .catch((e) => console.warn(`[reattach] ${(0, sessionKey_1.keyToString)(key)}: moving the pane onto its profile failed:`, e instanceof Error ? e.message : e));
9686
+ }
9087
9687
  // Fire-and-forget: the body (a Claude fs read) must not serialize the
9088
9688
  // reattach scan. postReattachRecap swallows its own errors; the `.catch`
9089
9689
  // is defensive.
@@ -9201,6 +9801,14 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
9201
9801
  if (ownerAdapter.lifecycle !== 'perTurn')
9202
9802
  stopAdoptedSessionWhenIdle(key, ownerAdapter, 'the stale tool list');
9203
9803
  }
9804
+ // The surviving process keeps the account it was spawned with; a topic whose pick changed while the
9805
+ // bot was down gets it at the next idle point (the resume reads the current profile).
9806
+ const spawnedConfigDir = ownerAdapter.getSessionClaudeConfigDir(key);
9807
+ if (spawnedConfigDir !== undefined && spawnedConfigDir !== resolveThreadClaudeConfigDir(key)) {
9808
+ console.log(`[reattach] ${(0, sessionKey_1.keyToString)(key)}: adopted under profile dir ${spawnedConfigDir ?? 'default'}, the topic now resolves to ${resolveThreadClaudeConfigDir(key) ?? 'default'}`);
9809
+ if (ownerAdapter.lifecycle !== 'perTurn')
9810
+ stopAdoptedSessionWhenIdle(key, ownerAdapter, 'the account profile');
9811
+ }
9204
9812
  }
9205
9813
  else {
9206
9814
  // Dead/zombie — adopt cleaned it up itself; the thread sleeps (below)
@@ -9854,6 +10462,7 @@ function wireScheduler(wiring) {
9854
10462
  },
9855
10463
  sendMessagesToThread,
9856
10464
  compactConversation: (threadKeyStr) => armDeferredCompaction((0, sessionKey_1.keyFromString)(threadKeyStr)),
10465
+ listClaudeProfiles: listClaudeProfilesForAgent,
9857
10466
  ...(jiraConnector
9858
10467
  ? { fetchJiraAttachment: (threadKeyStr, attachmentId) => jiraConnector.fetchAttachment((0, sessionKey_1.keyFromString)(threadKeyStr), attachmentId) }
9859
10468
  : {}),
@@ -10063,6 +10672,12 @@ async function startBot() {
10063
10672
  // phase: its launch defaults (R15) must apply to a session resumed during
10064
10673
  // reattach, and a broken setup stops the start with every reason at once.
10065
10674
  // It starts polling once the sessions are restored.
10675
+ // Every Claude spawn from here on reads the conversation's account profile (`/account`).
10676
+ (0, createAdapter_1.registerClaudeConfigDirReader)(resolveThreadClaudeConfigDir);
10677
+ (0, createAdapter_1.registerClaudeRateLimitSink)(onClaudeRateLimitEvent);
10678
+ // The usage cache: a first poll now (off the boot path), then the timer.
10679
+ claudeUsageRefresher.start();
10680
+ void claudeUsageRefresher.refreshAll();
10066
10681
  const jiraConnector = ENV.servedPlatforms.has('jira') ? await prepareJiraConnectorOrExit() : null;
10067
10682
  if (jiraConnector) {
10068
10683
  const { launchDefaults } = jiraConnector;