charness 3.2.0 → 3.3.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 (313) hide show
  1. package/.env.example +3 -6
  2. package/DEVELOPMENT.md +15 -0
  3. package/README.md +111 -38
  4. package/SETUP.md +121 -16
  5. package/dist/adapters/adapterEventRouting.d.ts +2 -1
  6. package/dist/adapters/adapterEventRouting.d.ts.map +1 -1
  7. package/dist/adapters/adapterEventRouting.js +3 -0
  8. package/dist/adapters/adapterEventRouting.js.map +1 -1
  9. package/dist/adapters/adapterNames.d.ts +13 -0
  10. package/dist/adapters/adapterNames.d.ts.map +1 -1
  11. package/dist/adapters/adapterNames.js +16 -0
  12. package/dist/adapters/adapterNames.js.map +1 -1
  13. package/dist/adapters/backendUnavailableError.d.ts +11 -0
  14. package/dist/adapters/backendUnavailableError.d.ts.map +1 -0
  15. package/dist/adapters/backendUnavailableError.js +19 -0
  16. package/dist/adapters/backendUnavailableError.js.map +1 -0
  17. package/dist/adapters/claudeCliAdapter.d.ts +29 -6
  18. package/dist/adapters/claudeCliAdapter.d.ts.map +1 -1
  19. package/dist/adapters/claudeCliAdapter.js +70 -16
  20. package/dist/adapters/claudeCliAdapter.js.map +1 -1
  21. package/dist/adapters/claudeJsonStreamAdapter.d.ts +36 -3
  22. package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
  23. package/dist/adapters/claudeJsonStreamAdapter.js +93 -30
  24. package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
  25. package/dist/adapters/createAdapter.d.ts +3 -14
  26. package/dist/adapters/createAdapter.d.ts.map +1 -1
  27. package/dist/adapters/createAdapter.js +9 -19
  28. package/dist/adapters/createAdapter.js.map +1 -1
  29. package/dist/adapters/openCodeAdapter.d.ts +140 -12
  30. package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
  31. package/dist/adapters/openCodeAdapter.js +385 -119
  32. package/dist/adapters/openCodeAdapter.js.map +1 -1
  33. package/dist/apiErrorRetry.d.ts +70 -6
  34. package/dist/apiErrorRetry.d.ts.map +1 -1
  35. package/dist/apiErrorRetry.js +312 -34
  36. package/dist/apiErrorRetry.js.map +1 -1
  37. package/dist/apiRetryKick.d.ts +26 -1
  38. package/dist/apiRetryKick.d.ts.map +1 -1
  39. package/dist/apiRetryKick.js +41 -2
  40. package/dist/apiRetryKick.js.map +1 -1
  41. package/dist/bot.d.ts +2 -1
  42. package/dist/bot.d.ts.map +1 -1
  43. package/dist/bot.js +736 -184
  44. package/dist/bot.js.map +1 -1
  45. package/dist/cli/claudeProfiles.d.ts +1 -1
  46. package/dist/cli/claudeProfiles.js +1 -1
  47. package/dist/cli/hot.d.ts +7 -1
  48. package/dist/cli/hot.d.ts.map +1 -1
  49. package/dist/cli/hot.js +29 -3
  50. package/dist/cli/hot.js.map +1 -1
  51. package/dist/configDirFile.d.ts +73 -0
  52. package/dist/configDirFile.d.ts.map +1 -0
  53. package/dist/configDirFile.js +216 -0
  54. package/dist/configDirFile.js.map +1 -0
  55. package/dist/connectors/telegram/commands/autoContinueLimits.d.ts +2 -2
  56. package/dist/connectors/telegram/commands/autoContinueLimits.d.ts.map +1 -1
  57. package/dist/connectors/telegram/commands/autoContinueLimits.js +4 -4
  58. package/dist/connectors/telegram/commands/autoContinueLimits.js.map +1 -1
  59. package/dist/connectors/telegram/commands/backendSwitch.d.ts +25 -0
  60. package/dist/connectors/telegram/commands/backendSwitch.d.ts.map +1 -0
  61. package/dist/connectors/telegram/commands/backendSwitch.js +104 -0
  62. package/dist/connectors/telegram/commands/backendSwitch.js.map +1 -0
  63. package/dist/connectors/telegram/commands/callbackMessage.d.ts +12 -0
  64. package/dist/connectors/telegram/commands/callbackMessage.d.ts.map +1 -1
  65. package/dist/connectors/telegram/commands/callbackMessage.js +24 -0
  66. package/dist/connectors/telegram/commands/callbackMessage.js.map +1 -1
  67. package/dist/connectors/telegram/commands/callbackToast.d.ts +36 -0
  68. package/dist/connectors/telegram/commands/callbackToast.d.ts.map +1 -0
  69. package/dist/connectors/telegram/commands/callbackToast.js +83 -0
  70. package/dist/connectors/telegram/commands/callbackToast.js.map +1 -0
  71. package/dist/connectors/telegram/commands/claudeAccounts.d.ts +18 -0
  72. package/dist/connectors/telegram/commands/claudeAccounts.d.ts.map +1 -1
  73. package/dist/connectors/telegram/commands/claudeAccounts.js +220 -66
  74. package/dist/connectors/telegram/commands/claudeAccounts.js.map +1 -1
  75. package/dist/connectors/telegram/commands/displayModes.d.ts.map +1 -1
  76. package/dist/connectors/telegram/commands/displayModes.js +4 -2
  77. package/dist/connectors/telegram/commands/displayModes.js.map +1 -1
  78. package/dist/connectors/telegram/commands/fallbackChain.d.ts +5 -8
  79. package/dist/connectors/telegram/commands/fallbackChain.d.ts.map +1 -1
  80. package/dist/connectors/telegram/commands/fallbackChain.js +183 -193
  81. package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -1
  82. package/dist/connectors/telegram/commands/limitsBoard.d.ts +40 -0
  83. package/dist/connectors/telegram/commands/limitsBoard.d.ts.map +1 -0
  84. package/dist/connectors/telegram/commands/limitsBoard.js +281 -0
  85. package/dist/connectors/telegram/commands/limitsBoard.js.map +1 -0
  86. package/dist/connectors/telegram/commands/modelPage.d.ts +130 -0
  87. package/dist/connectors/telegram/commands/modelPage.d.ts.map +1 -0
  88. package/dist/connectors/telegram/commands/modelPage.js +120 -0
  89. package/dist/connectors/telegram/commands/modelPage.js.map +1 -0
  90. package/dist/connectors/telegram/commands/modelProviders.d.ts +9 -1
  91. package/dist/connectors/telegram/commands/modelProviders.d.ts.map +1 -1
  92. package/dist/connectors/telegram/commands/modelProviders.js +203 -111
  93. package/dist/connectors/telegram/commands/modelProviders.js.map +1 -1
  94. package/dist/connectors/telegram/commands/stt.d.ts +32 -0
  95. package/dist/connectors/telegram/commands/stt.d.ts.map +1 -0
  96. package/dist/connectors/telegram/commands/stt.js +460 -0
  97. package/dist/connectors/telegram/commands/stt.js.map +1 -0
  98. package/dist/effortLevels.d.ts +57 -7
  99. package/dist/effortLevels.d.ts.map +1 -1
  100. package/dist/effortLevels.js +65 -9
  101. package/dist/effortLevels.js.map +1 -1
  102. package/dist/i18n/de.d.ts.map +1 -1
  103. package/dist/i18n/de.js +160 -17
  104. package/dist/i18n/de.js.map +1 -1
  105. package/dist/i18n/en.d.ts.map +1 -1
  106. package/dist/i18n/en.js +160 -17
  107. package/dist/i18n/en.js.map +1 -1
  108. package/dist/i18n/es.d.ts.map +1 -1
  109. package/dist/i18n/es.js +160 -17
  110. package/dist/i18n/es.js.map +1 -1
  111. package/dist/i18n/fr.d.ts.map +1 -1
  112. package/dist/i18n/fr.js +160 -17
  113. package/dist/i18n/fr.js.map +1 -1
  114. package/dist/i18n/hi.d.ts.map +1 -1
  115. package/dist/i18n/hi.js +160 -17
  116. package/dist/i18n/hi.js.map +1 -1
  117. package/dist/i18n/ja.d.ts.map +1 -1
  118. package/dist/i18n/ja.js +160 -17
  119. package/dist/i18n/ja.js.map +1 -1
  120. package/dist/i18n/ka.d.ts.map +1 -1
  121. package/dist/i18n/ka.js +160 -17
  122. package/dist/i18n/ka.js.map +1 -1
  123. package/dist/i18n/pt.d.ts.map +1 -1
  124. package/dist/i18n/pt.js +160 -17
  125. package/dist/i18n/pt.js.map +1 -1
  126. package/dist/i18n/ru.d.ts.map +1 -1
  127. package/dist/i18n/ru.js +160 -17
  128. package/dist/i18n/ru.js.map +1 -1
  129. package/dist/i18n/uk.d.ts.map +1 -1
  130. package/dist/i18n/uk.js +160 -17
  131. package/dist/i18n/uk.js.map +1 -1
  132. package/dist/i18n/uz.d.ts.map +1 -1
  133. package/dist/i18n/uz.js +160 -17
  134. package/dist/i18n/uz.js.map +1 -1
  135. package/dist/i18n/zh.d.ts.map +1 -1
  136. package/dist/i18n/zh.js +160 -17
  137. package/dist/i18n/zh.js.map +1 -1
  138. package/dist/installManager.d.ts +20 -4
  139. package/dist/installManager.d.ts.map +1 -1
  140. package/dist/installManager.js +82 -14
  141. package/dist/installManager.js.map +1 -1
  142. package/dist/instanceSettings.d.ts +52 -0
  143. package/dist/instanceSettings.d.ts.map +1 -0
  144. package/dist/instanceSettings.js +136 -0
  145. package/dist/instanceSettings.js.map +1 -0
  146. package/dist/pinnedStatus.d.ts +7 -1
  147. package/dist/pinnedStatus.d.ts.map +1 -1
  148. package/dist/pinnedStatus.js +1 -1
  149. package/dist/pinnedStatus.js.map +1 -1
  150. package/dist/requests/requestLedger.d.ts +8 -0
  151. package/dist/requests/requestLedger.d.ts.map +1 -1
  152. package/dist/requests/requestLedger.js +19 -1
  153. package/dist/requests/requestLedger.js.map +1 -1
  154. package/dist/scheduler/mcpSurface.js +4 -4
  155. package/dist/scheduler/mcpSurface.js.map +1 -1
  156. package/dist/shutdown.d.ts +14 -0
  157. package/dist/shutdown.d.ts.map +1 -1
  158. package/dist/shutdown.js +16 -0
  159. package/dist/shutdown.js.map +1 -1
  160. package/dist/state.d.ts +54 -2
  161. package/dist/state.d.ts.map +1 -1
  162. package/dist/state.js +62 -1
  163. package/dist/state.js.map +1 -1
  164. package/dist/stt/sttAutoInstall.d.ts +39 -0
  165. package/dist/stt/sttAutoInstall.d.ts.map +1 -0
  166. package/dist/stt/sttAutoInstall.js +158 -0
  167. package/dist/stt/sttAutoInstall.js.map +1 -0
  168. package/dist/stt/sttChildEnv.d.ts +11 -0
  169. package/dist/stt/sttChildEnv.d.ts.map +1 -0
  170. package/dist/stt/sttChildEnv.js +30 -0
  171. package/dist/stt/sttChildEnv.js.map +1 -0
  172. package/dist/stt/sttConfigEdit.d.ts +71 -0
  173. package/dist/stt/sttConfigEdit.d.ts.map +1 -0
  174. package/dist/stt/sttConfigEdit.js +144 -0
  175. package/dist/stt/sttConfigEdit.js.map +1 -0
  176. package/dist/stt/sttInstall.d.ts +147 -0
  177. package/dist/stt/sttInstall.d.ts.map +1 -0
  178. package/dist/stt/sttInstall.js +491 -0
  179. package/dist/stt/sttInstall.js.map +1 -0
  180. package/dist/stt/sttInstallEntry.d.ts +2 -0
  181. package/dist/stt/sttInstallEntry.d.ts.map +1 -0
  182. package/dist/stt/sttInstallEntry.js +31 -0
  183. package/dist/stt/sttInstallEntry.js.map +1 -0
  184. package/dist/stt/sttInstallReporter.d.ts +45 -0
  185. package/dist/stt/sttInstallReporter.d.ts.map +1 -0
  186. package/dist/stt/sttInstallReporter.js +57 -0
  187. package/dist/stt/sttInstallReporter.js.map +1 -0
  188. package/dist/stt/sttRuntime.d.ts +35 -0
  189. package/dist/stt/sttRuntime.d.ts.map +1 -0
  190. package/dist/stt/sttRuntime.js +73 -0
  191. package/dist/stt/sttRuntime.js.map +1 -0
  192. package/dist/stt/sttScreenPlan.d.ts +193 -0
  193. package/dist/stt/sttScreenPlan.d.ts.map +1 -0
  194. package/dist/stt/sttScreenPlan.js +541 -0
  195. package/dist/stt/sttScreenPlan.js.map +1 -0
  196. package/dist/stt/sttSettings.d.ts +201 -0
  197. package/dist/stt/sttSettings.d.ts.map +1 -0
  198. package/dist/stt/sttSettings.js +387 -0
  199. package/dist/stt/sttSettings.js.map +1 -0
  200. package/dist/stt/sttSidecarOwner.d.ts +115 -0
  201. package/dist/stt/sttSidecarOwner.d.ts.map +1 -0
  202. package/dist/stt/sttSidecarOwner.js +475 -0
  203. package/dist/stt/sttSidecarOwner.js.map +1 -0
  204. package/dist/stt/transcribeChain.d.ts +36 -0
  205. package/dist/stt/transcribeChain.d.ts.map +1 -0
  206. package/dist/stt/transcribeChain.js +42 -0
  207. package/dist/stt/transcribeChain.js.map +1 -0
  208. package/dist/types.d.ts +77 -4
  209. package/dist/types.d.ts.map +1 -1
  210. package/dist/utils/adapterSwitchSessionIds.d.ts +32 -0
  211. package/dist/utils/adapterSwitchSessionIds.d.ts.map +1 -0
  212. package/dist/utils/adapterSwitchSessionIds.js +44 -0
  213. package/dist/utils/adapterSwitchSessionIds.js.map +1 -0
  214. package/dist/utils/backendSwitchPlan.d.ts +63 -0
  215. package/dist/utils/backendSwitchPlan.d.ts.map +1 -0
  216. package/dist/utils/backendSwitchPlan.js +73 -0
  217. package/dist/utils/backendSwitchPlan.js.map +1 -0
  218. package/dist/utils/claudeAccountPickerPlan.d.ts +12 -1
  219. package/dist/utils/claudeAccountPickerPlan.d.ts.map +1 -1
  220. package/dist/utils/claudeAccountPickerPlan.js +20 -1
  221. package/dist/utils/claudeAccountPickerPlan.js.map +1 -1
  222. package/dist/utils/claudeTranscriptContent.d.ts +24 -0
  223. package/dist/utils/claudeTranscriptContent.d.ts.map +1 -0
  224. package/dist/utils/claudeTranscriptContent.js +107 -0
  225. package/dist/utils/claudeTranscriptContent.js.map +1 -0
  226. package/dist/utils/claudeUsage.d.ts +47 -8
  227. package/dist/utils/claudeUsage.d.ts.map +1 -1
  228. package/dist/utils/claudeUsage.js +76 -14
  229. package/dist/utils/claudeUsage.js.map +1 -1
  230. package/dist/utils/claudeUsageRefresher.d.ts +8 -1
  231. package/dist/utils/claudeUsageRefresher.d.ts.map +1 -1
  232. package/dist/utils/claudeUsageRefresher.js +9 -4
  233. package/dist/utils/claudeUsageRefresher.js.map +1 -1
  234. package/dist/utils/effortPickerPlan.d.ts +54 -0
  235. package/dist/utils/effortPickerPlan.d.ts.map +1 -0
  236. package/dist/utils/effortPickerPlan.js +56 -0
  237. package/dist/utils/effortPickerPlan.js.map +1 -0
  238. package/dist/utils/ensureSessionPlan.d.ts +20 -4
  239. package/dist/utils/ensureSessionPlan.d.ts.map +1 -1
  240. package/dist/utils/ensureSessionPlan.js +17 -4
  241. package/dist/utils/ensureSessionPlan.js.map +1 -1
  242. package/dist/utils/fallbackBridge.d.ts +6 -2
  243. package/dist/utils/fallbackBridge.d.ts.map +1 -1
  244. package/dist/utils/fallbackBridge.js +10 -3
  245. package/dist/utils/fallbackBridge.js.map +1 -1
  246. package/dist/utils/fallbackChain.d.ts +36 -0
  247. package/dist/utils/fallbackChain.d.ts.map +1 -1
  248. package/dist/utils/fallbackChain.js +65 -1
  249. package/dist/utils/fallbackChain.js.map +1 -1
  250. package/dist/utils/fallbackSwitchNotice.d.ts +41 -0
  251. package/dist/utils/fallbackSwitchNotice.d.ts.map +1 -0
  252. package/dist/utils/fallbackSwitchNotice.js +75 -0
  253. package/dist/utils/fallbackSwitchNotice.js.map +1 -0
  254. package/dist/utils/fileSendService.d.ts +33 -10
  255. package/dist/utils/fileSendService.d.ts.map +1 -1
  256. package/dist/utils/fileSendService.js +78 -27
  257. package/dist/utils/fileSendService.js.map +1 -1
  258. package/dist/utils/jsonStreamHost.d.ts +39 -0
  259. package/dist/utils/jsonStreamHost.d.ts.map +1 -1
  260. package/dist/utils/jsonStreamHost.js +60 -1
  261. package/dist/utils/jsonStreamHost.js.map +1 -1
  262. package/dist/utils/limitEpisodeRecovery.d.ts +16 -2
  263. package/dist/utils/limitEpisodeRecovery.d.ts.map +1 -1
  264. package/dist/utils/limitEpisodeRecovery.js +44 -3
  265. package/dist/utils/limitEpisodeRecovery.js.map +1 -1
  266. package/dist/utils/limitSettings.d.ts +25 -0
  267. package/dist/utils/limitSettings.d.ts.map +1 -0
  268. package/dist/utils/limitSettings.js +31 -0
  269. package/dist/utils/limitSettings.js.map +1 -0
  270. package/dist/utils/limitsBoardPlan.d.ts +52 -0
  271. package/dist/utils/limitsBoardPlan.d.ts.map +1 -0
  272. package/dist/utils/limitsBoardPlan.js +93 -0
  273. package/dist/utils/limitsBoardPlan.js.map +1 -0
  274. package/dist/utils/openCodeUsage.d.ts +93 -0
  275. package/dist/utils/openCodeUsage.d.ts.map +1 -0
  276. package/dist/utils/openCodeUsage.js +248 -0
  277. package/dist/utils/openCodeUsage.js.map +1 -0
  278. package/dist/utils/operatorSwitchPlan.d.ts +61 -0
  279. package/dist/utils/operatorSwitchPlan.d.ts.map +1 -0
  280. package/dist/utils/operatorSwitchPlan.js +32 -0
  281. package/dist/utils/operatorSwitchPlan.js.map +1 -0
  282. package/dist/utils/promptDelivery.d.ts +40 -0
  283. package/dist/utils/promptDelivery.d.ts.map +1 -1
  284. package/dist/utils/promptDelivery.js +31 -0
  285. package/dist/utils/promptDelivery.js.map +1 -1
  286. package/dist/utils/recvPreviewRedaction.d.ts +14 -4
  287. package/dist/utils/recvPreviewRedaction.d.ts.map +1 -1
  288. package/dist/utils/recvPreviewRedaction.js +19 -7
  289. package/dist/utils/recvPreviewRedaction.js.map +1 -1
  290. package/dist/utils/startupReadiness.d.ts +3 -3
  291. package/dist/utils/startupReadiness.d.ts.map +1 -1
  292. package/dist/utils/startupReadiness.js +2 -2
  293. package/dist/utils/startupReadiness.js.map +1 -1
  294. package/dist/utils/threadStatusReport.d.ts +3 -1
  295. package/dist/utils/threadStatusReport.d.ts.map +1 -1
  296. package/dist/utils/threadStatusReport.js +4 -3
  297. package/dist/utils/threadStatusReport.js.map +1 -1
  298. package/dist/utils/timezone.d.ts +23 -0
  299. package/dist/utils/timezone.d.ts.map +1 -1
  300. package/dist/utils/timezone.js +38 -0
  301. package/dist/utils/timezone.js.map +1 -1
  302. package/dist/utils/transcribeAudio.d.ts +25 -12
  303. package/dist/utils/transcribeAudio.d.ts.map +1 -1
  304. package/dist/utils/transcribeAudio.js +39 -21
  305. package/dist/utils/transcribeAudio.js.map +1 -1
  306. package/dist/utils/usageRefreshBudget.d.ts +9 -0
  307. package/dist/utils/usageRefreshBudget.d.ts.map +1 -0
  308. package/dist/utils/usageRefreshBudget.js +27 -0
  309. package/dist/utils/usageRefreshBudget.js.map +1 -0
  310. package/examples/.env.example +7 -3
  311. package/examples/docker-compose.yml +9 -7
  312. package/package.json +2 -1
  313. package/stt/gigaam_sidecar.py +800 -0
package/dist/bot.js CHANGED
@@ -105,7 +105,11 @@ const apiRetryKick_1 = require("./apiRetryKick");
105
105
  const limitHeldPromptQueue_1 = require("./utils/limitHeldPromptQueue");
106
106
  const foreignKeyFallbacks_1 = require("./connectors/telegram/foreignKeyFallbacks");
107
107
  const download_1 = require("./utils/download");
108
- const transcribeAudio_1 = require("./utils/transcribeAudio");
108
+ const sttSettings_1 = require("./stt/sttSettings");
109
+ const sttRuntime_1 = require("./stt/sttRuntime");
110
+ const sttSidecarOwner_1 = require("./stt/sttSidecarOwner");
111
+ const sttAutoInstall_1 = require("./stt/sttAutoInstall");
112
+ const transcribeChain_1 = require("./stt/transcribeChain");
109
113
  const utils_1 = require("./utils");
110
114
  const recvPreviewRedaction_1 = require("./utils/recvPreviewRedaction");
111
115
  const sendErrorClassifier_1 = require("./sendErrorClassifier");
@@ -129,6 +133,7 @@ const clearThreadOutputQueues_1 = require("./utils/clearThreadOutputQueues");
129
133
  const groupFinalizePlan_1 = require("./utils/groupFinalizePlan");
130
134
  const persistAdapterSessionIds_1 = require("./utils/persistAdapterSessionIds");
131
135
  const ensureSessionPlan_1 = require("./utils/ensureSessionPlan");
136
+ const backendUnavailableError_1 = require("./adapters/backendUnavailableError");
132
137
  const claudeCodeVersion_1 = require("./utils/claudeCodeVersion");
133
138
  const keyedTransitionQueue_1 = require("./utils/keyedTransitionQueue");
134
139
  const adoptedToolList_1 = require("./utils/adoptedToolList");
@@ -151,6 +156,7 @@ const voiceQueue_1 = require("./voiceQueue");
151
156
  const claudeLivenessAction_1 = require("./utils/claudeLivenessAction");
152
157
  const isoTimestamp_1 = require("./utils/isoTimestamp");
153
158
  const effortLevels_1 = require("./effortLevels");
159
+ const instanceSettings_1 = require("./instanceSettings");
154
160
  const threadContextPreamble_1 = require("./threadContextPreamble");
155
161
  const pinnedBannerSkipDecision_1 = require("./utils/pinnedBannerSkipDecision");
156
162
  const replyQuote_1 = require("./utils/replyQuote");
@@ -191,16 +197,26 @@ const questionKeyboards_1 = require("./connectors/telegram/questionKeyboards");
191
197
  const displayModes_1 = require("./connectors/telegram/commands/displayModes");
192
198
  const reminders_1 = require("./connectors/telegram/commands/reminders");
193
199
  const modelProviders_1 = require("./connectors/telegram/commands/modelProviders");
200
+ const callbackToast_1 = require("./connectors/telegram/commands/callbackToast");
194
201
  const compaction_1 = require("./connectors/telegram/commands/compaction");
195
202
  const autoContinueLimits_1 = require("./connectors/telegram/commands/autoContinueLimits");
196
203
  const claudeAccounts_1 = require("./connectors/telegram/commands/claudeAccounts");
197
204
  const claudeProfiles_1 = require("./utils/claudeProfiles");
198
205
  const claudeUsage_1 = require("./utils/claudeUsage");
199
206
  const claudeUsageRefresher_1 = require("./utils/claudeUsageRefresher");
207
+ const openCodeUsage_1 = require("./utils/openCodeUsage");
208
+ const usageRefreshBudget_1 = require("./utils/usageRefreshBudget");
200
209
  const claudeLimitSwitch_1 = require("./utils/claudeLimitSwitch");
210
+ const fallbackSwitchNotice_1 = require("./utils/fallbackSwitchNotice");
211
+ const adapterSwitchSessionIds_1 = require("./utils/adapterSwitchSessionIds");
201
212
  const fallbackChain_1 = require("./utils/fallbackChain");
202
213
  const fallbackBridge_1 = require("./utils/fallbackBridge");
203
214
  const fallbackChain_2 = require("./connectors/telegram/commands/fallbackChain");
215
+ const limitsBoard_1 = require("./connectors/telegram/commands/limitsBoard");
216
+ const backendSwitch_1 = require("./connectors/telegram/commands/backendSwitch");
217
+ const operatorSwitchPlan_1 = require("./utils/operatorSwitchPlan");
218
+ const backendSwitchPlan_1 = require("./utils/backendSwitchPlan");
219
+ const stt_1 = require("./connectors/telegram/commands/stt");
204
220
  const resolveBinary_1 = require("./utils/resolveBinary");
205
221
  // ═══════════════════════════════════════════════════════════════════════════════
206
222
  // ENV parsing & fatal validation
@@ -304,11 +320,11 @@ function parseEnv() {
304
320
  isDmSurfaceInert,
305
321
  allowedGroupId,
306
322
  workRoot,
307
- openaiApiKey: process.env.OPENAI_API_KEY,
308
- groqApiKey: process.env.GROQ_API_KEY,
309
323
  };
310
324
  }
311
325
  const ENV = parseEnv();
326
+ // The instance default effort (`/effort` in General) is `settings.json`'s `defaultEffort`, re-read when the file changes.
327
+ (0, effortLevels_1.setInstanceDefaultEffortReader)((0, instanceSettings_1.createInstanceDefaultEffortReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line)));
312
328
  // ═══════════════════════════════════════════════════════════════════════════════
313
329
  // Effective group id — single source of truth for the forum supergroup
314
330
  //
@@ -556,14 +572,16 @@ async function checkIsForumAdmin(chatId, userId) {
556
572
  // recorded before any gating (access control, group routing) can drop it.
557
573
  // Security: while the thread awaits a `/connect` key paste (same
558
574
  // `pendingProviderConnects` state the text handler consumes later in this
559
- // update), the message text is a provider API key — the preview is redacted
560
- // at record time so the secret never lands in the on-disk trace.
575
+ // update, or while `/stt` waits for a speech-engine key), the message text is
576
+ // an API key — the preview is redacted at record time so the secret never
577
+ // lands in the on-disk trace.
561
578
  bot.use(async (ctx, next) => {
562
579
  const message = ctx.message;
563
580
  const messageText = message && 'text' in message ? message.text : undefined;
564
581
  const callbackData = ctx.callbackQuery && 'data' in ctx.callbackQuery ? ctx.callbackQuery.data : undefined;
565
582
  const threadKey = messageText === undefined ? null : getThreadKey(ctx);
566
583
  const isThreadPendingProviderConnect = threadKey !== null && pendingProviderConnects.has((0, sessionKey_1.keyToString)(threadKey));
584
+ const awaitedSttStep = threadKey !== null ? getAwaitedSttStep(threadKey) : null;
567
585
  (0, outputTrace_1.traceRecvUpdate)({
568
586
  updateType: ctx.updateType,
569
587
  updateId: ctx.update.update_id,
@@ -572,7 +590,7 @@ bot.use(async (ctx, next) => {
572
590
  threadId: message?.message_thread_id,
573
591
  preview: messageText === undefined
574
592
  ? callbackData
575
- : (0, recvPreviewRedaction_1.getRecvTracePreview)(messageText, isThreadPendingProviderConnect),
593
+ : (0, recvPreviewRedaction_1.getRecvTracePreview)(messageText, isThreadPendingProviderConnect, awaitedSttStep),
576
594
  tgDateSec: message?.date,
577
595
  });
578
596
  return next();
@@ -1017,6 +1035,10 @@ const apiRetryTimers = new Map();
1017
1035
  function clearSavedApiRetry(key) {
1018
1036
  void state.clearApiRetry(key).catch(e => console.error('[apiRetry] clear failed:', e));
1019
1037
  }
1038
+ /** Drop the saved fire time of `key`'s limit probe: a retry armed, cancelled or given up after it ends what it decided. */
1039
+ function clearSavedFiredLimitProbe(key) {
1040
+ void state.clearLimitProbeFired(key).catch(e => console.error('[apiRetry] fired-probe clear failed:', e));
1041
+ }
1020
1042
  /**
1021
1043
  * Threads whose retry timer FIRED and whose "continue" nudge is not forwarded
1022
1044
  * yet ({@link fireApiRetry}). The retry is no longer armed then, but the idle
@@ -1091,14 +1113,17 @@ const wedgeRecoveriesInFlight = new Set();
1091
1113
  * Dedup is delegated to `decideRetryAction` (a `pending` retry → `ignore`), so a
1092
1114
  * repeated Claude scrape frame or a duplicate `session.error` never double-arms.
1093
1115
  *
1094
- * `/auto_continue_limits` gates the `usageLimit` class ONLY: with it OFF the bot says so
1116
+ * The «wait for the reset» setting (`/limits_autoswitch`, formerly `/auto_continue_limits`) gates the `usageLimit` class ONLY: with it OFF the bot says so
1095
1117
  * once per topic (and once per open request, which also stops being woken) and
1096
1118
  * stops, arming nothing and persisting nothing. `transient` (the short
1097
1119
  * 5/10/20-min retries) and `auth` (surfaced, never retried) are untouched by the
1098
1120
  * toggle — a rate-limit hiccup always wants the automatic retry.
1099
1121
  */
1100
- function handleApiError(key, cls) {
1122
+ function handleApiError(key, cls, options = {}) {
1101
1123
  const k = (0, sessionKey_1.keyToString)(key);
1124
+ // An unconfirmed usage limit is probed first; it reaches the wait below only once confirmed.
1125
+ if (cls.kind === 'usageLimit' && cls.isUnconfirmed === true && handleUnconfirmedLimit(key))
1126
+ return;
1102
1127
  if (cls.kind === 'usageLimit' && !state.checkIsAutoContinueOnLimitEnabled(key)) {
1103
1128
  const isFirstOffNotice = !autoContinueOffNoticedThreads.has(k);
1104
1129
  autoContinueOffNoticedThreads.add(k);
@@ -1112,10 +1137,11 @@ function handleApiError(key, cls) {
1112
1137
  const prev = entry
1113
1138
  ? { attempt: entry.attempt, firedAt: entry.firedAt, pending: entry.timer !== null }
1114
1139
  : null;
1140
+ const now = Date.now();
1115
1141
  const action = (0, apiErrorRetry_1.decideRetryAction)({
1116
1142
  kind: cls.kind,
1117
1143
  resetAt: cls.resetAt,
1118
- now: Date.now(),
1144
+ now,
1119
1145
  prev,
1120
1146
  });
1121
1147
  // auth / logged out → surface a pinned notice (never a timer). Deduped to one
@@ -1130,13 +1156,16 @@ function handleApiError(key, cls) {
1130
1156
  void replyToThread(key, (0, i18n_1.t)('apiRetry.giveUp', { attempts: action.attempts }));
1131
1157
  clearSavedApiRetry(key);
1132
1158
  apiRetryTimers.delete(k);
1159
+ clearSavedFiredLimitProbe(key);
1133
1160
  // Nothing resumes this limit any more: a reminder would hit it and arm a fresh episode, endlessly.
1134
- if (cls.kind === 'usageLimit')
1161
+ if (cls.kind === 'usageLimit') {
1135
1162
  void requestWakeUpEngine?.stopWakingForLimitWait(key);
1163
+ markLimitEpisodeSettled(key);
1164
+ }
1136
1165
  return;
1137
1166
  }
1138
1167
  // action === 'arm'. A LIMIT notice ends with the shared pointer at
1139
- // `/auto_continue_limits` (the escape hatch: skip this one resume, or turn the
1168
+ // `/limits` and `/limits_autoswitch` (the escape hatches: skip this one resume, or turn the
1140
1169
  // mode off). The transient notice gets none — it is not governed by the toggle
1141
1170
  // and resumes within minutes anyway.
1142
1171
  let limitWait;
@@ -1151,7 +1180,19 @@ function handleApiError(key, cls) {
1151
1180
  limitWait = cls.resetAt !== undefined
1152
1181
  ? { kind: 'afterReset', resetAt: cls.resetAt, fireAt: action.fireAt }
1153
1182
  : { kind: 'nextAttempt', fireAt: action.fireAt };
1154
- void announceUsageLimitOrSwitch(key, cls, action.fireAt, limitWait, action.attempt, true);
1183
+ // A recovered episode whose window is already over is no limit: the retry fires
1184
+ // at once and its "work resumed" notice says it all. A LIVE error is always
1185
+ // announced, whatever its reset says (a clock behind the provider's included).
1186
+ if (options.isRecoveredEpisode === true && (0, apiErrorRetry_1.checkIsUsageLimitWindowOver)(cls, now)) {
1187
+ console.log(`[apiRetry] ${k}: the recovered limit window has already reset — resuming without a limit notice`);
1188
+ }
1189
+ else {
1190
+ void announceUsageLimitOrSwitch(key, cls, action.fireAt, limitWait, action.attempt, true);
1191
+ }
1192
+ }
1193
+ else if (cls.kind === 'conversationTail') {
1194
+ // The topic's one line for it: the retry follows in a moment, and its raw provider text was not posted.
1195
+ void replyToThread(key, (0, i18n_1.t)('apiRetry.conversationTailNotice'));
1155
1196
  }
1156
1197
  else {
1157
1198
  void replyToThread(key, (0, i18n_1.t)('apiRetry.transientNotice', {
@@ -1159,26 +1200,78 @@ function handleApiError(key, cls) {
1159
1200
  attempt: action.attempt,
1160
1201
  }));
1161
1202
  }
1203
+ armApiRetryTimer(key, { kind: cls.kind, attempt: action.attempt, fireAt: action.fireAt, delayMs: action.delayMs, limitWait });
1204
+ }
1205
+ /**
1206
+ * @description Arm a thread's retry once its notice is posted: keep a pending question last, persist the record
1207
+ * (it means ARMED), start the unref'd timer whose callback is {@link fireApiRetry}, and keep the in-memory entry.
1208
+ */
1209
+ function armApiRetryTimer(key, arming) {
1210
+ // One armed timer per thread: a still-pending one it replaces must not fire as well.
1211
+ const previousTimer = apiRetryTimers.get((0, sessionKey_1.keyToString)(key))?.timer;
1212
+ if (previousTimer)
1213
+ clearTimeout(previousTimer);
1162
1214
  // S3: the notice landed below any pending question — bring it back to the
1163
1215
  // bottom (debounced; no-op when no question is pending).
1164
1216
  onThreadActivityWhileQuestionPending(key);
1165
1217
  void state
1166
- .setApiRetry(key, { kind: cls.kind, attempt: action.attempt, fireAt: action.fireAt })
1218
+ .setApiRetry(key, { kind: arming.kind, attempt: arming.attempt, fireAt: arming.fireAt, ...(arming.isLimitProbe ? { isLimitProbe: true } : {}) })
1167
1219
  .catch(e => console.error('[apiRetry] persist failed:', e));
1168
- const delayMs = Math.min(action.delayMs, engine_1.maxTimeoutMs);
1220
+ clearSavedFiredLimitProbe(key);
1169
1221
  const timer = setTimeout(() => {
1170
1222
  void fireApiRetry(key);
1171
- }, delayMs);
1223
+ }, Math.min(arming.delayMs, engine_1.maxTimeoutMs));
1172
1224
  timer.unref?.();
1173
- apiRetryTimers.set(k, {
1225
+ apiRetryTimers.set((0, sessionKey_1.keyToString)(key), {
1174
1226
  timer,
1175
- attempt: action.attempt,
1176
- kind: cls.kind,
1227
+ attempt: arming.attempt,
1228
+ kind: arming.kind,
1177
1229
  firedAt: null,
1178
- fireAt: action.fireAt,
1179
- limitWait,
1230
+ fireAt: arming.fireAt,
1231
+ limitWait: arming.limitWait,
1232
+ isLimitProbe: arming.isLimitProbe,
1180
1233
  });
1181
1234
  }
1235
+ /**
1236
+ * @description An UNCONFIRMED usage limit (`isUnconfirmed`) of `key`: the probe retry it gets first — or `false` when the
1237
+ * limit is confirmed and takes the usual usage-limit wait. Confirmed when it came again right after the probe fired, or
1238
+ * the topic's account shows a window at 100 % ({@link checkIsTopicAccountUsageExhausted}); a retry already pending —
1239
+ * the probe or a short retry — takes it with it (`true`, nothing more to do).
1240
+ */
1241
+ function handleUnconfirmedLimit(key) {
1242
+ const k = (0, sessionKey_1.keyToString)(key);
1243
+ const entry = apiRetryTimers.get(k);
1244
+ // A retry already pending (the probe itself, or a short retry) takes this error with it — as `decideRetryAction`
1245
+ // ignores a repeat while armed. A probe armed over it replaced its record and left its timer: two "continue" nudges.
1246
+ if (entry?.timer != null) {
1247
+ console.log(`[apiRetry] ${k}: an unconfirmed usage limit — a retry is already pending, it stays`);
1248
+ return true;
1249
+ }
1250
+ const isRepeatAfterProbe = entry?.isLimitProbe === true && entry.firedAt !== null && Date.now() - entry.firedAt <= apiErrorRetry_1.retryRecurrenceGraceMs;
1251
+ const action = (0, apiErrorRetry_1.getUnconfirmedLimitAction)({ isRepeatAfterProbe, isUsageExhausted: checkIsTopicAccountUsageExhausted(key) });
1252
+ if (action === 'waitForReset') {
1253
+ console.log(`[apiRetry] ${k}: the unconfirmed usage limit is confirmed (${isRepeatAfterProbe ? 'it came again after the probe' : 'the account is marked limited or reads a window at 100 %'}) — waiting for the reset`);
1254
+ return false;
1255
+ }
1256
+ console.log(`[apiRetry] ${k}: an unconfirmed usage limit — probing with a short retry before waiting for the reset`);
1257
+ void replyToThread(key, (0, i18n_1.t)('apiRetry.transientNotice', { minutes: Math.round(apiErrorRetry_1.unconfirmedLimitProbeDelayMs / apiRetryMsPerMinute), attempt: 1 }));
1258
+ const now = Date.now();
1259
+ armApiRetryTimer(key, { kind: 'transient', attempt: 1, fireAt: now + apiErrorRetry_1.unconfirmedLimitProbeDelayMs, delayMs: apiErrorRetry_1.unconfirmedLimitProbeDelayMs, isLimitProbe: true });
1260
+ return true;
1261
+ }
1262
+ /**
1263
+ * @description Whether the topic's account usage reading proves its limit: a Claude topic's live profile marked limited
1264
+ * (a rejected `rate_limit_event`) or showing a window its model drains at 100 %. Other backends carry no reading for the
1265
+ * account an unconfirmed wording names: `false`, and only a repeat confirms it.
1266
+ */
1267
+ function checkIsTopicAccountUsageExhausted(key) {
1268
+ if (!checkIsClaudeProfileBackend(key))
1269
+ return false;
1270
+ const info = state.getClaudeProfileInfo(resolveLiveClaudeProfileName(key));
1271
+ const limitedUntilMs = info?.limitedUntil ? Date.parse(info.limitedUntil) : Number.NaN;
1272
+ const nowMs = Date.now();
1273
+ return (Number.isFinite(limitedUntilMs) && limitedUntilMs > nowMs) || (0, claudeUsage_1.checkIsClaudeUsageExhausted)(info?.usage, getThreadClaudeModel(key), nowMs);
1274
+ }
1182
1275
  /**
1183
1276
  * @description R23: hold a prompt that arrives while `key` waits out an ARMED
1184
1277
  * usage limit — posting it would only hit the limit again. `false` when no
@@ -1266,6 +1359,9 @@ const apiRetryKickDeps = {
1266
1359
  return outcome.isSwitched;
1267
1360
  },
1268
1361
  clearSavedRetry: clearSavedApiRetry,
1362
+ saveFiredLimitProbe: (key, firedAt) => {
1363
+ void state.setLimitProbeFired(key, firedAt).catch(e => console.error('[apiRetry] fired-probe persist failed:', e));
1364
+ },
1269
1365
  };
1270
1366
  /**
1271
1367
  * @description Post a usage-limit wait (request/answer core S5): as the bot's own
@@ -1379,7 +1475,7 @@ function announceLimitWaitToNewRequest(key) {
1379
1475
  /**
1380
1476
  * @description Both texts of a limit wait, from ONE branch on how it ends, in the
1381
1477
  * thread's locale. A wait ending on another day names the date. The
1382
- * `/auto_continue_limits` pointer names a Telegram command — elsewhere it would
1478
+ * `/limits` + `/limits_autoswitch` pointer names Telegram commands — elsewhere it would
1383
1479
  * be noise; the "auto-resume is off" text carries its own.
1384
1480
  */
1385
1481
  function getLimitWaitTexts(key, wait, attempt, resumeOn) {
@@ -1416,11 +1512,15 @@ function cancelApiRetry(key) {
1416
1512
  const entry = apiRetryTimers.get(k);
1417
1513
  if (entry?.timer)
1418
1514
  clearTimeout(entry.timer);
1515
+ // A limit wait that ends here ends without its resume: the error it waited on stays the log's last word.
1516
+ if (entry?.kind === 'usageLimit')
1517
+ markLimitEpisodeSettled(key);
1419
1518
  // Prompts held during the wait (R23) are not dropped with it: they ride the next
1420
1519
  // prompt forwarded to the session — the operator's message that cancelled the
1421
1520
  // wait — or go to the next session on their own (`limitHeldPrompts`).
1422
1521
  apiRetryTimers.delete(k);
1423
1522
  clearSavedApiRetry(key);
1523
+ clearSavedFiredLimitProbe(key);
1424
1524
  // The limit episode is over for this thread → retire its two one-shot markers.
1425
1525
  // Unlike the logged-out notice below, BOTH are safe to drop here: the pinned
1426
1526
  // "work resumed" message announces a PAST event (nothing re-posts it), and the
@@ -2121,6 +2221,7 @@ function clearInMemoryThreadState(key) {
2121
2221
  threadSessionLists.delete(k);
2122
2222
  awaitingSessionSelection.delete(k);
2123
2223
  pendingProviderConnects.delete(k);
2224
+ void cancelSttInput(key, { isSilent: true }); // teardown → drop an armed /stt step and its timer
2124
2225
  connectMethodLists.delete(k);
2125
2226
  // Compound (thread + message) keys — drop every picker this thread rendered.
2126
2227
  for (const pickerKey of (0, providerDisconnectPlan_1.getDisconnectPickerKeysForThread)(disconnectProviderLists.keys(), k)) {
@@ -2246,7 +2347,7 @@ function computePinnedStatusText(key) {
2246
2347
  return null;
2247
2348
  let agentLabel = null;
2248
2349
  let model = null;
2249
- let effort = null;
2350
+ let effortView = null;
2250
2351
  let isActive = false;
2251
2352
  const agent = state.getAgent(key);
2252
2353
  if (agent?.name) {
@@ -2255,7 +2356,7 @@ function computePinnedStatusText(key) {
2255
2356
  agentLabel = adapter.label;
2256
2357
  isActive = adapter.checkIsActive(key);
2257
2358
  model = adapter.getCurrentModel?.(key) ?? agent.model ?? null;
2258
- effort = adapter.getEffort?.(key) ?? null;
2359
+ effortView = (0, effortLevels_1.getThreadEffortView)(adapter, key);
2259
2360
  }
2260
2361
  catch {
2261
2362
  // Unknown adapter name from a stale binding — fall back to raw name
@@ -2264,7 +2365,7 @@ function computePinnedStatusText(key) {
2264
2365
  model = agent.model ?? null;
2265
2366
  }
2266
2367
  }
2267
- return (0, pinnedStatus_1.formatPinnedStatus)({ binding, agentLabel, model, effort, isActive });
2368
+ return (0, pinnedStatus_1.formatPinnedStatus)({ binding, agentLabel, model, effort: effortView?.level ?? null, effortNextStart: effortView?.nextStartLevel ?? null, isActive });
2268
2369
  }
2269
2370
  /**
2270
2371
  * @description Send-or-edit the pinned status banner for a thread.
@@ -3258,27 +3359,71 @@ const FILE_DOWNLOAD_CAP_MB = Math.floor(fileIntake_1.telegramFileDownloadCapByte
3258
3359
  */
3259
3360
  const ALBUM_DEBOUNCE_MS = 2_000;
3260
3361
  /**
3261
- * @description Transcribe a downloaded voice note for thread `key`. Transient
3262
- * provider failures (timeout / network error / 408 / 409 / 5xx / 429) are
3263
- * retried inside `transcribeAudio`; before each retry the topic gets a short
3264
- * notice naming the error and the pause, so the user knows the voice note
3265
- * isn't lost. The notice is UNPACED like the transcript echo it precedes:
3266
- * queued behind agent output it could land after the retry already succeeded.
3267
- */
3268
- async function transcribeVoiceFile(key, filePath) {
3269
- const endpoint = (0, transcribeAudio_1.getTranscriptionEndpoint)({ groqApiKey: ENV.groqApiKey, openaiApiKey: ENV.openaiApiKey });
3270
- if (!endpoint)
3271
- return { ok: false, error: 'no api key configured' };
3272
- const result = await (0, transcribeAudio_1.transcribeAudio)(filePath, endpoint, {
3273
- timeoutMs: DOWNLOAD_TIMEOUT_MS,
3362
+ * The local speech engine's process (started on demand, adopted across hot reloads, stopped with its owner),
3363
+ * the voice settings resolved against it, and the install job that fetches the engine. See `src/stt/README.md`.
3364
+ */
3365
+ const sttOwner = (0, sttSidecarOwner_1.createSttSidecarOwner)((0, sttSidecarOwner_1.createDefaultSttSidecarOwnerDeps)(state_1.resolveDataDir));
3366
+ const sttInstallJobs = (0, sttAutoInstall_1.createSttInstallJobManager)((0, sttAutoInstall_1.createDefaultSttInstallJobDeps)(state_1.resolveDataDir));
3367
+ const sttRuntime = (0, sttRuntime_1.createSttRuntime)({
3368
+ getDataDir: state_1.resolveDataDir,
3369
+ getConfigPath: () => (0, sttSettings_1.getSttConfigPath)(),
3370
+ owner: sttOwner,
3371
+ getOwnerContext: () => (0, sttSidecarOwner_1.resolveSttOwnerContext)(),
3372
+ logInfo: (line) => console.log(line),
3373
+ logProblem: (line) => console.warn(line),
3374
+ // A hand edit (or a save) that names the local engine before it is installed: fetch it by itself.
3375
+ onNeedsAutoInstall: () => { void startSttAutoInstall(); },
3376
+ });
3377
+ /**
3378
+ * @description The speech-engine chain in effect NOW: `stt.json` is re-read when its stamp changes (a hand edit
3379
+ * or a `/stt` save applies to the next voice, no restart), and a change in what is wrong with it is logged once
3380
+ * as `[STT] …`.
3381
+ */
3382
+ function getResolvedSttSettings() {
3383
+ return sttRuntime.getResolved();
3384
+ }
3385
+ /**
3386
+ * @description The local engine at boot, after the state is loaded: NO process is started (the first voice that
3387
+ * needs it does that); a stale record of a dead or foreign sidecar is cleaned; an install a reload interrupted
3388
+ * is picked up (or settled, when it ended while the bot was down); and a `stt.json` that names the engine before
3389
+ * it is installed starts the install.
3390
+ */
3391
+ async function bootSttLocalEngine() {
3392
+ await sttOwner.cleanupAtBoot();
3393
+ await resumeSttInstallAtBoot();
3394
+ }
3395
+ /**
3396
+ * @description Transcribe a downloaded voice note for thread `key` through the
3397
+ * engine chain. A failing engine falls through to the next one at once (logged
3398
+ * only); the LAST engine retries transient failures, and before each retry the
3399
+ * topic gets a short notice naming the error and the pause, so the user knows
3400
+ * the voice note isn't lost. The notice is UNPACED like the transcript echo it
3401
+ * precedes: queued behind agent output it could land after the retry already
3402
+ * succeeded.
3403
+ */
3404
+ async function transcribeVoiceFile(key, filePath, durationSeconds) {
3405
+ const { engines, settings } = getResolvedSttSettings();
3406
+ if (engines.length === 0)
3407
+ return { ok: false, error: 'no speech engine available' };
3408
+ const startedAtMs = Date.now();
3409
+ const result = await (0, transcribeChain_1.transcribeWithEngines)(filePath, engines, {
3410
+ durationSeconds,
3411
+ language: settings.language,
3412
+ onFallback: ({ from, to, error }) => {
3413
+ console.warn(`[transcribe] ${from} failed (${error}); falling back to ${to}`);
3414
+ },
3274
3415
  onRetry: ({ attemptNumber, error, delayMs }) => {
3275
3416
  console.warn(`[transcribe] attempt ${attemptNumber} failed (${error}); retrying in ${delayMs}ms`);
3276
3417
  const seconds = Math.round(delayMs / 1000);
3277
3418
  void replyToThread(key, (0, i18n_1.t)('voice.retrying', { error, seconds }), {}, { unpaced: true }).catch(() => { });
3278
3419
  },
3279
3420
  });
3280
- if (!result.ok)
3421
+ if (result.ok) {
3422
+ console.log(`[transcribe] ok via ${result.engine} in ${Date.now() - startedAtMs}ms (${durationSeconds}s audio)`);
3423
+ }
3424
+ else {
3281
3425
  console.warn(`[transcribe] failed: ${result.error}`);
3426
+ }
3282
3427
  return result;
3283
3428
  }
3284
3429
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -3533,32 +3678,22 @@ async function switchThreadAdapter(key, newName) {
3533
3678
  // with "now" afterwards, so carrying it here can't wrongly age a new session.
3534
3679
  if (agent.startedAt !== undefined)
3535
3680
  next.startedAt = agent.startedAt;
3536
- // Both Claude backends share the on-disk transcript, so keep the session id
3537
- // when switching between tmux-scrape and json-stream — the new backend resumes
3538
- // the SAME conversation (`/claude_mode` live switch).
3539
- if ((0, createAdapter_1.checkIsClaudeBackend)(newName) && agent.claudeSessionId)
3540
- next.claudeSessionId = agent.claudeSessionId;
3541
- if (newName === adapterNames_1.openCodeAdapterName && agent.opencodeSessionId)
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);
3681
+ // The ids the new family keeps, and the parked slot: the family the thread LEAVES is parked and the parked id of the
3682
+ // family it RETURNS to is restored, in the same step (`utils/adapterSwitchSessionIds.ts`) — a cross-backend
3683
+ // fallback switch or a plain /claude ↔ /opencode comes back to the conversation it left
3684
+ // (L-D4: a sleeping conversation is woken, `/new` is the fresh start). The reattach loop never reads the parked
3685
+ // slot, so the audit S12 bug (both ids on the live row) stays fixed.
3686
+ const switched = (0, adapterSwitchSessionIds_1.getAdapterSwitchSessionIds)({ previousName: prevName, nextName: newName, agent, parked: state.getParkedSession(key), parkedAt: new Date().toISOString() });
3687
+ if (switched.ids.claudeSessionId)
3688
+ next.claudeSessionId = switched.ids.claudeSessionId;
3689
+ if (switched.ids.opencodeSessionId)
3690
+ next.opencodeSessionId = switched.ids.opencodeSessionId;
3691
+ if (switched.parked !== undefined)
3692
+ await state.setParkedSession(key, switched.parked);
3693
+ // The watermark is scoped to its session: it rides along while that session is kept. Every wake of a sleeping
3694
+ // conversation passes through here, and the wake reads it as the proof the conversation took a turn.
3695
+ if (agent.seenWatermark && [next.claudeSessionId, next.opencodeSessionId].includes(agent.seenWatermark.sessionId)) {
3696
+ next.seenWatermark = agent.seenWatermark;
3562
3697
  }
3563
3698
  // Overwrite by removing the row first; setAgent then writes only the
3564
3699
  // fields we kept.
@@ -3766,7 +3901,7 @@ async function startAgentSession(key, args) {
3766
3901
  // async transcript/HTTP read and must not be added to the start path. The
3767
3902
  // agent row was just written by `persistAdapterSessionIds`, so the persisted
3768
3903
  // fallback is populated by now.
3769
- const effort = adapter.getEffort ? (adapter.getEffort(key) ?? effortLevels_1.defaultEffortLevel) : null;
3904
+ const effort = (0, effortLevels_1.getThreadEffortView)(adapter, key)?.level ?? null;
3770
3905
  const model = (0, threadStatusReport_1.getThreadStatusModel)({
3771
3906
  adapterModel: adapter.getCurrentModel?.(key) ?? null,
3772
3907
  runtimeModel: null,
@@ -3780,12 +3915,19 @@ async function startAgentSession(key, args) {
3780
3915
  // self-greeting start armed the sustained loader, and no output is coming.
3781
3916
  startupPromptBuffer.discardPrompts(kStr);
3782
3917
  getOutboundFor(key).setActivity(key, 'idle');
3783
- return (0, i18n_1.t)('agent.start_failed', {
3784
- label: adapter.label,
3785
- error: e instanceof Error ? e.message : String(e),
3786
- });
3918
+ return getAgentStartFailureText(adapter.label, e, (error) => (0, i18n_1.t)('agent.start_failed', { label: adapter.label, error }));
3787
3919
  }
3788
3920
  }
3921
+ /**
3922
+ * @description The topic's text for a session start or resume that threw: a backend whose server could not be
3923
+ * brought up gets one localized line (its cause is already in the log, and means nothing to the reader); any other
3924
+ * failure keeps its own text, rendered by `formatError`.
3925
+ */
3926
+ function getAgentStartFailureText(adapterLabel, error, formatError) {
3927
+ if (error instanceof backendUnavailableError_1.AgentBackendUnavailableError)
3928
+ return (0, i18n_1.t)('agent.serverUnavailableNotice', { label: adapterLabel });
3929
+ return formatError(error instanceof Error ? error.message : String(error));
3930
+ }
3789
3931
  /**
3790
3932
  * @description Ensure the thread `key` has an agent session ready to take a
3791
3933
  * prompt, starting one if needed — the single choke point shared by the
@@ -3857,9 +3999,14 @@ async function ensureAgentSessionNow(key, options) {
3857
3999
  return { ok: false, reason: 'nothing-to-resume', message: (0, i18n_1.t)('agent.no_session') };
3858
4000
  case 'resume': {
3859
4001
  await switchThreadAdapter(key, plan.adapterName);
3860
- if (await resumeSleepingSession(key, plan.sessionId))
4002
+ const resumeFailure = await resumeSleepingSession(key, plan.sessionId);
4003
+ if (resumeFailure === null)
3861
4004
  return { ok: true, message: '', isFresh: false };
3862
- if ((0, ensureSessionPlan_1.getResumeFailureAction)(options.isResumeOnly === true) === 'fail') {
4005
+ const failureAction = (0, ensureSessionPlan_1.getResumeFailureAction)(resumeFailure, options.isResumeOnly === true);
4006
+ if (failureAction === 'reportUnavailable') {
4007
+ return { ok: false, reason: 'start-failed', message: (0, i18n_1.t)('agent.serverUnavailableNotice', { label: (0, createAdapter_1.getThreadAdapter)(key).label }) };
4008
+ }
4009
+ if (failureAction === 'fail') {
3863
4010
  return { ok: false, reason: 'resume-failed', message: (0, i18n_1.t)('agent.resume_failed_fresh') };
3864
4011
  }
3865
4012
  // The previous conversation is lost to this topic: say so in one line, then start fresh.
@@ -3896,29 +4043,32 @@ function getResumableSessionId(key) {
3896
4043
  * a message typed meanwhile is buffered and replayed, like a fresh start. The
3897
4044
  * conversation keeps its compaction latch, its thread-context marker and its
3898
4045
  * start time: nothing about it changed, only the process is back. Resolves
3899
- * `false` when the backend refused the id or the process did not come up — the
3900
- * caller decides between a fresh start and giving up.
4046
+ * `null` once resumed, else how it failed — the backend refused the id or the
4047
+ * process did not come up, or the backend's server could not be brought up; the
4048
+ * caller decides what follows ({@link getResumeFailureAction}).
3901
4049
  */
3902
4050
  async function resumeSleepingSession(key, sessionId) {
3903
4051
  const kStr = (0, sessionKey_1.keyToString)(key);
3904
4052
  const workDirDecision = getWorkDirStartDecision(key);
3905
4053
  if (!workDirDecision.ok)
3906
- return false;
4054
+ return 'refused';
3907
4055
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
3908
4056
  startupPromptBuffer.markStarting(kStr);
3909
4057
  markNeedsNewMessage(key);
3910
4058
  try {
3911
- await adapter.resumeSession(key, workDirDecision.workDir, sessionId);
4059
+ // A conversation the bot saw take a turn whose transcript is now missing is lost, not new: the resume fails.
4060
+ const isConversationRecorded = (0, ensureSessionPlan_1.checkIsConversationRecorded)(state.getAgent(key)?.seenWatermark, sessionId);
4061
+ await adapter.resumeSession(key, workDirDecision.workDir, sessionId, { isConversationRecorded });
3912
4062
  await persistSessionStart(key, adapter);
3913
4063
  console.log(`[ensure] resumed the sleeping session of ${kStr}`);
3914
4064
  void replayBufferedPrompts(key);
3915
- return adapter.checkIsActive(key);
4065
+ return adapter.checkIsActive(key) ? null : 'refused';
3916
4066
  }
3917
4067
  catch (e) {
3918
4068
  console.warn(`[ensure] could not resume the sleeping session of ${kStr}:`, e instanceof Error ? e.message : e);
3919
4069
  // A fresh start (if the caller takes one) re-opens its own window; otherwise the held prompts have nowhere to go.
3920
4070
  startupPromptBuffer.discardPrompts(kStr);
3921
- return false;
4071
+ return e instanceof backendUnavailableError_1.AgentBackendUnavailableError ? 'backendUnavailable' : 'refused';
3922
4072
  }
3923
4073
  }
3924
4074
  /**
@@ -4359,6 +4509,7 @@ async function exitPendingInputModes(key, messageText) {
4359
4509
  if (hadPendingProviderConnect && !(0, recvPreviewRedaction_1.checkIsConnectCommandText)(messageText)) {
4360
4510
  await replyToThread(key, (0, i18n_1.t)('connect.cancelled'));
4361
4511
  }
4512
+ await cancelSttInput(key);
4362
4513
  await cancelReminderWizard(key);
4363
4514
  }
4364
4515
  /**
@@ -4470,19 +4621,19 @@ command('status', async (_ctx, key) => {
4470
4621
  }
4471
4622
  // Model / effort / start date are session properties — only meaningful while
4472
4623
  // a session is live. Effort resolves exactly like the pinned banner
4473
- // (`computePinnedStatusText`), plus a fallback to the default the agent
4474
- // actually runs when none is picked. Model resolution is DELIBERATELY wider
4624
+ // (`computePinnedStatusText`) and the ready notice: the level the running session
4625
+ // uses, with the next start's level when it differs (`getThreadEffortView`). Model resolution is DELIBERATELY wider
4475
4626
  // than the banner's: it adds the runtime's self-reported model, which the
4476
4627
  // banner cannot share because `computePinnedStatusText` is synchronous while
4477
4628
  // reading runtime info is an async transcript/HTTP read. So on the Claude tmux
4478
4629
  // backend `/status` can name a model the banner still leaves blank.
4479
4630
  let model = null;
4480
- let effort = null;
4631
+ let effortView = null;
4481
4632
  let startedAt = null;
4482
4633
  let runtime = null;
4483
4634
  if (isActive) {
4484
4635
  const agent = state.getAgent(key);
4485
- effort = adapter.getEffort ? (adapter.getEffort(key) ?? effortLevels_1.defaultEffortLevel) : null;
4636
+ effortView = (0, effortLevels_1.getThreadEffortView)(adapter, key);
4486
4637
  startedAt = agent?.startedAt ?? null;
4487
4638
  if (adapter.getRuntimeInfo) {
4488
4639
  runtime = await adapter.getRuntimeInfo(key).catch((error) => {
@@ -4509,7 +4660,7 @@ command('status', async (_ctx, key) => {
4509
4660
  account = { current: readStatusAccountSource(live), pending: picked === live ? null : readStatusAccountSource(picked), nowMs };
4510
4661
  }
4511
4662
  await replyToThread(key, (0, threadStatusReport_1.getThreadStatusReport)({
4512
- agentLine, subdir, isActive, isSleeping: !isActive && getResumableSessionId(key) !== null, workDir, model, effort, startedAt, runtime, account,
4663
+ agentLine, subdir, isActive, isSleeping: !isActive && getResumableSessionId(key) !== null, workDir, model, effort: effortView?.level ?? null, effortNextStart: effortView?.nextStartLevel ?? null, startedAt, runtime, account,
4513
4664
  view: (0, displayModes_1.formatTopicView)(state.getDisplayPrefs(key).view),
4514
4665
  timezone,
4515
4666
  timezoneNow: (0, timezone_1.formatZoneNow)(timezone, nowMs),
@@ -5441,6 +5592,10 @@ async function handleAgentStart(key, adapterName, args) {
5441
5592
  // json-stream); open the thread's picked backend, default tmux-scrape (for now).
5442
5593
  if (adapterName === 'claude')
5443
5594
  adapterName = (0, createAdapter_1.resolveClaudeBackendName)(key);
5595
+ // The other backend, with a conversation to hand over: ask first; the answer makes the switch.
5596
+ const targetFamily = (0, backendSwitchPlan_1.getBackendFamily)(adapterName);
5597
+ if (targetFamily !== null && await askBeforeCrossBackendSwitch(key, targetFamily, args))
5598
+ return;
5444
5599
  await switchThreadAdapter(key, adapterName);
5445
5600
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
5446
5601
  if (adapter.checkIsActive(key)) {
@@ -5462,7 +5617,7 @@ async function handleAgentStart(key, adapterName, args) {
5462
5617
  function handleStartCommand(parsed, key, adapterName) {
5463
5618
  return handleAgentStart(key, adapterName, parsed.argsText || undefined);
5464
5619
  }
5465
- const { showConnectMethodPicker, applyProviderDisconnect, handleProviderConnectKey, applyNumberedModelPick, registerProviderCommands, registerModelCommands, registerModelCallbacks, } = (0, modelProviders_1.createModelProviders)({
5620
+ const { showConnectMethodPicker, applyProviderDisconnect, handleProviderConnectKey, applyNumberedModelPick, applyModelSelection, registerProviderCommands, registerModelCommands, registerModelCallbacks, } = (0, modelProviders_1.createModelProviders)({
5466
5621
  ...botCore,
5467
5622
  awaitingModelSelection,
5468
5623
  awaitingSessionSelection,
@@ -5472,6 +5627,7 @@ const { showConnectMethodPicker, applyProviderDisconnect, handleProviderConnectK
5472
5627
  connectMethodLists,
5473
5628
  disconnectProviderLists,
5474
5629
  startOpenCodeOAuthLogin,
5630
+ getInstanceSettingsPath: () => (0, instanceSettings_1.getInstanceSettingsPath)(),
5475
5631
  });
5476
5632
  command('claude', (_ctx, key, parsed) => handleStartCommand(parsed, key, 'claude'));
5477
5633
  command(['opencode', 'oc'], (_ctx, key, parsed) => handleStartCommand(parsed, key, 'opencode'));
@@ -5593,7 +5749,7 @@ command('rename_session', async (_ctx, key, parsed) => {
5593
5749
  });
5594
5750
  const { applyAutoContinueLimits, consumeAutoContinueLimitsPicker, registerAutoContinueLimitsCommands, registerAutoContinueLimitsCallbacks, } = (0, autoContinueLimits_1.createAutoContinueLimits)({
5595
5751
  ...botCore,
5596
- openLimitsView: (key) => openLimitsView(key),
5752
+ openAutoSwitchView: (key) => openAutoSwitchView(key),
5597
5753
  getArmedLimitRetryFireAt,
5598
5754
  cancelApiRetry,
5599
5755
  getRequestWakeUpEngine: () => requestWakeUpEngine,
@@ -5671,6 +5827,12 @@ async function applyClaudeProfileEverywhere() {
5671
5827
  }
5672
5828
  }
5673
5829
  }
5830
+ /** The OpenCode providers' usage (`utils/openCodeUsage.ts`): polled with the Claude profiles below, cached in `state.json`. */
5831
+ const openCodeUsagePoller = (0, openCodeUsage_1.createOpenCodeUsagePoller)({
5832
+ readCredentials: () => (0, openCodeUsage_1.readOpenCodeCredentials)((0, openCodeAuthLogin_1.getOpenCodeAuthFilePath)({ XDG_DATA_HOME: process.env.XDG_DATA_HOME, HOME: os.homedir() })),
5833
+ getRecord: (providerId) => state.getOpenCodeProviderUsage(providerId),
5834
+ saveRecord: (providerId, record) => state.setOpenCodeProviderUsage(providerId, record),
5835
+ });
5674
5836
  /**
5675
5837
  * The per-profile usage cache (`utils/claudeUsageRefresher.ts`): every signed-in profile's windows off the
5676
5838
  * OAuth usage endpoint on a 10-minute timer, on `/account` open and on a `claude_profiles` call, plus the
@@ -5687,6 +5849,7 @@ const claudeUsageRefresher = (0, claudeUsageRefresher_1.createClaudeUsageRefresh
5687
5849
  saveSnapshot: (name, snapshot) => state.setClaudeProfileUsage(name, snapshot),
5688
5850
  readToken: (configDir) => (0, claudeUsage_1.readClaudeOAuthToken)(configDir),
5689
5851
  fetchUsage: (accessToken) => (0, claudeUsage_1.fetchClaudeUsage)(accessToken),
5852
+ refreshOpenCode: (options) => openCodeUsagePoller.refreshAll(options),
5690
5853
  });
5691
5854
  /** The profile that owns a config directory (`null` = default); `null` for a directory outside the profiles root. */
5692
5855
  function resolveClaudeProfileNameForDir(configDir) {
@@ -5773,17 +5936,17 @@ function resolveLiveClaudeProfileName(key) {
5773
5936
  return state.resolveClaudeProfileName(key);
5774
5937
  }
5775
5938
  /**
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).
5939
+ * @description Apply the account a Claude topic now resolves to (the follow-the-default tap): the plain apply, except
5940
+ * while a usage-limit wait is armed and the account differs from the one the process runs — then it is the same limit
5941
+ * move an operator's pick makes ({@link switchTopicByOperator}): applied NOW, the topic resumes like after a limit switch.
5779
5942
  */
5780
5943
  async function applyClaudePickToThread(key) {
5781
- const armed = getArmedApiRetry(key);
5782
- if (armed?.kind === 'usageLimit' && checkIsClaudeProfileBackend(key)) {
5944
+ if (checkIsClaudeProfileBackend(key)) {
5783
5945
  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 });
5946
+ const to = { backend: 'claude', profile: state.resolveClaudeProfileName(key), model: null };
5947
+ const route = (0, operatorSwitchPlan_1.decideOperatorSwitch)({ isUsageLimitWaitArmed: getArmedApiRetry(key)?.kind === 'usageLimit', from, to, currentFamily: 'claude', hasConversation: false });
5948
+ if (route === 'limitMove') {
5949
+ const { isSwitched } = await switchTopicToFallbackEntry(key, from, to, { isAnnounced: true });
5787
5950
  return isSwitched ? 'respawned' : 'deferred';
5788
5951
  }
5789
5952
  }
@@ -5879,61 +6042,75 @@ function getFallbackResetTime(from) {
5879
6042
  return null;
5880
6043
  return (0, localClock_1.formatLocalClockWithDateIfNotToday)(untilMs, Date.now());
5881
6044
  }
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 });
6045
+ /** The «switched» notice (`utils/fallbackSwitchNotice.ts`), with the reset of the exhausted rung when known. */
6046
+ function getFallbackSwitchedNotice(from, to, runningModel) {
6047
+ return (0, fallbackSwitchNotice_1.buildFallbackSwitchedNotice)({ from, to, resetTime: getFallbackResetTime(from), runningModel });
5889
6048
  }
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());
6049
+ /**
6050
+ * @description The model the topic's Claude session runs ACROSS a move onto `to`, for deciding whether the move
6051
+ * pins one (`getFallbackModelToPin`). A re-spawn onto another account keeps the model, but the new process reports
6052
+ * it only with its first frame — so it is read BEFORE the move, or the rung's alias would look "not running" and be
6053
+ * named and pinned (an extra re-spawn) for a session that already runs it. A move from another backend starts a
6054
+ * Claude session that runs no model yet (`null`: the rung's pin applies).
6055
+ */
6056
+ function readRunningModelBeforeMove(key, from, to) {
6057
+ return from?.backend === 'claude' && to.backend === 'claude' ? getThreadClaudeModel(key) : null;
5894
6058
  }
5895
6059
  /**
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).
6060
+ * @description The last `limit` turns (oldest first) of the conversation the topic's agent holds, off its own
6061
+ * transcript. `[]` when it has none yet (no session id persisted); `null` when they cannot be read (no bound
6062
+ * folder, an adapter without a transcript reader, a read that failed).
5898
6063
  */
5899
- async function buildFallbackBridge(key, from, to) {
6064
+ async function readConversationTurns(key, limit) {
5900
6065
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
5901
6066
  const decision = getWorkDirStartDecision(key);
5902
6067
  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
- }
6068
+ if (!sessionId)
6069
+ return [];
6070
+ if (!decision.ok || !adapter.getRecentTurns)
6071
+ return null;
6072
+ try {
6073
+ return await adapter.getRecentTurns(key, decision.workDir, sessionId, limit);
5911
6074
  }
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
- });
6075
+ catch (e) {
6076
+ console.warn(`[fallback] ${(0, sessionKey_1.keyToString)(key)}: the previous agent's turns are unreadable:`, e instanceof Error ? e.message : e);
6077
+ return null;
6078
+ }
6079
+ }
6080
+ /**
6081
+ * @description The context bridge for a cross-backend switch (`utils/fallbackBridge.ts`), built BEFORE the old
6082
+ * adapter is stopped: its last turns off its own transcript (an unreadable one yields a bridge without turns).
6083
+ * `reason` is why the agent changes — a usage limit or the operator's own switch.
6084
+ */
6085
+ async function buildFallbackBridge(key, input) {
6086
+ const turns = await readConversationTurns(key, fallbackBridge_1.fallbackBridgeTurnLimit) ?? [];
6087
+ return (0, fallbackBridge_1.buildFallbackBridgePrompt)({ ...input, turns, replyLanguage: (0, i18n_1.getLocaleEndonym)((0, i18n_1.getActiveLang)()) });
6088
+ }
6089
+ /** The backend the topic's agent runs on, by name (`Claude` / `OpenCode`), for a label whose rung is unknown. */
6090
+ function formatThreadBackend(key) {
6091
+ const family = (0, backendSwitchPlan_1.getBackendFamily)((0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name);
6092
+ return family === null ? (0, i18n_1.t)('fallback.backendClaude') : (0, backendSwitchPlan_1.formatBackendFamily)(family);
5919
6093
  }
5920
6094
  /**
5921
6095
  * @description Move a limited topic onto rung `to` and continue the work there. WITHIN a backend the
5922
6096
  * 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).
6097
+ * session under the new directory, same transcript) and pins the rung's model when it names one the session is not
6098
+ * already running; an OpenCode rung changes the session's per-prompt model. ACROSS backends the topic's adapter
6099
+ * changes (the `/claude` / `/opencode` path: the old session is stopped and its id parked for a switch back), the
6100
+ * new session is started or resumed, and its first prompt is the context bridge instead of the plain "continue"
6101
+ * nudge. Either way the armed limit wait is disarmed and the prompt goes the way the wait's own resume would
6102
+ * deliver it. After a limit the turn is dead, so a working Claude session is moved at once; only one that cannot
6103
+ * be re-spawned yet (a tmux session with nothing to resume) keeps its wait and moves at turn end. Returns whether
6104
+ * the work continues now and the notice that says so (posted here only when `isAnnounced` — the auto-switch has no
6105
+ * other voice, while a tapped button's notice is edited into the same line by its handler).
5931
6106
  */
5932
6107
  async function switchTopicToFallbackEntry(key, from, to, options) {
5933
6108
  const k = (0, sessionKey_1.keyToString)(key);
5934
6109
  const fromLabel = from ? formatFallbackEntry(from) : '?';
5935
6110
  const toLabel = formatFallbackEntry(to);
5936
- const isSameFamily = from !== null && from.backend === to.backend;
6111
+ // An OpenCode topic on the server's default model has no rung (`from` is null), yet it is on OpenCode all the same.
6112
+ const isSameFamily = (from?.backend ?? getTopicBackendFamily(key)) === to.backend;
6113
+ const runningModel = readRunningModelBeforeMove(key, from, to);
5937
6114
  const say = async (notice) => {
5938
6115
  if (options.isAnnounced)
5939
6116
  await replyToThread(key, notice);
@@ -5941,19 +6118,21 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
5941
6118
  const fail = async (error) => {
5942
6119
  const notice = (0, i18n_1.t)('fallback.switchFailed', { to: toLabel, error });
5943
6120
  await say(notice);
5944
- return { isSwitched: false, notice };
6121
+ return { isSwitched: false, notice, isFailed: true };
5945
6122
  };
5946
6123
  let firstPrompt = null;
5947
6124
  let isStoppingBackground = false;
5948
- if (isSameFamily && to.backend === 'claude') {
5949
- await state.setClaudeProfileOverride(key, to.profile);
6125
+ if (from?.backend === 'claude' && to.backend === 'claude') {
6126
+ // The same account on another model (a pick beyond the limit's scope) is no account pick: the topic keeps following General.
6127
+ if (from.profile !== to.profile)
6128
+ await state.setClaudeProfileOverride(key, to.profile);
5950
6129
  // The turn is dead after a limit: re-spawn now, even over background work (it is stopped; the notice says so).
5951
6130
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
5952
6131
  isStoppingBackground = adapter instanceof claudeJsonStreamAdapter_1.ClaudeJsonStreamAdapter && adapter.checkHasBackgroundWork(key);
5953
6132
  const outcome = await applyClaudeProfileToThread(key, { isAfterLimit: true });
5954
6133
  if (outcome === 'deferred' || outcome === 'restartRequired') {
5955
6134
  fallbackSwitchOffers.delete(k);
5956
- const notice = (0, i18n_1.t)('fallback.switchedDeferred', { from: fromLabel, to: toLabel });
6135
+ const notice = (0, fallbackSwitchNotice_1.buildFallbackDeferredNotice)(from, to);
5957
6136
  await say(notice);
5958
6137
  return { isSwitched: false, notice };
5959
6138
  }
@@ -5964,11 +6143,13 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
5964
6143
  return fail(error);
5965
6144
  }
5966
6145
  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);
6146
+ const bridge = await buildFallbackBridge(key, {
6147
+ fromLabel: from ? formatFallbackEntry(from) : formatThreadBackend(key),
6148
+ toLabel: toLabel,
6149
+ reason: 'limit',
6150
+ resetsAtText: getFallbackResetTime(from),
6151
+ });
6152
+ await moveTopicToOtherBackend(key, to.backend, to.backend === 'claude' ? to.profile : null);
5972
6153
  if (to.backend === 'opencode') {
5973
6154
  const error = await (0, createAdapter_1.getThreadAdapter)(key).setModel?.(key, (0, fallbackChain_1.buildOpenCodeModelLabel)(to));
5974
6155
  if (error)
@@ -5977,29 +6158,223 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
5977
6158
  firstPrompt = { text: bridge, isRequestPrompt: false };
5978
6159
  }
5979
6160
  cancelApiRetry(key); // retires the offer too: the switch consumed it
5980
- const notice = `${getFallbackSwitchedNotice(from, to)}${isStoppingBackground ? ` ${(0, i18n_1.t)('fallback.backgroundStopped')}` : ''}`;
6161
+ const notice = `${getFallbackSwitchedNotice(from, to, runningModel)}${isStoppingBackground ? `\n${(0, i18n_1.t)('fallback.backgroundStopped')}` : ''}`;
5981
6162
  console.log(`[fallback] ${k}: switched from ${fromLabel} to ${toLabel} after a usage limit${firstPrompt ? ' (cross-backend, bridged)' : ''}; resuming`);
5982
6163
  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
- }
6164
+ await resumeTopicOnNewSession(key, {
6165
+ modelToPin: (0, fallbackChain_1.getFallbackModelToPin)(to, runningModel),
6166
+ // Read after the session is up: the open requests may have changed while it started.
6167
+ getFirstPrompt: () => firstPrompt ?? (0, limitHeldPrompts_1.getLimitResumeMessage)({
6168
+ continueNudge: (0, i18n_1.t)('apiRetry.continueNudge'),
6169
+ untakenRequestPrompt: (0, requestHeader_1.joinPromptsNotTakenIn)(requestLimitWaitAnswerDeps?.ledger.listOpenRequestsOf(key) ?? []),
6170
+ }),
6171
+ });
6172
+ return { isSwitched: true, notice };
6173
+ }
6174
+ /**
6175
+ * @description The move ACROSS backends both the usage-limit switch and the operator's manual switch make, first
6176
+ * half: record `account` as the topic's own Claude pick when one is given (the chain's rung, the operator's pick — a
6177
+ * switch that only names the account the topic follows passes `null`), then `switchThreadAdapter` stops the old
6178
+ * session, parks its id and brings back the target family's parked one. The context bridge is built BEFORE this, while
6179
+ * the adapter being left still runs.
6180
+ */
6181
+ async function moveTopicToOtherBackend(key, backend, account) {
6182
+ if (account !== null)
6183
+ await state.setClaudeProfileOverride(key, account);
6184
+ await switchThreadAdapter(key, backend === 'claude' ? (0, createAdapter_1.resolveClaudeBackendName)(key) : adapterNames_1.openCodeAdapterName);
6185
+ }
6186
+ /**
6187
+ * @description The second half, after a move (across backends or between accounts): start or resume the topic's
6188
+ * session, pin the model the move asks for, and send it the first prompt (`getFirstPrompt`, read once the session is up;
6189
+ * `null` sends nothing — a topic with nothing to hand over just starts, `args` being the text typed with the command).
6190
+ * A session that cannot start posts why and sends nothing.
6191
+ */
6192
+ async function resumeTopicOnNewSession(key, options) {
6193
+ const k = (0, sessionKey_1.keyToString)(key);
6194
+ const ensured = await ensureAgentSession(key, { args: options.args });
5988
6195
  if (ensured.message)
5989
6196
  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);
6197
+ if (!ensured.ok)
6198
+ return;
6199
+ if (options.modelToPin !== null) {
6200
+ const error = await (0, createAdapter_1.getThreadAdapter)(key).setModel?.(key, options.modelToPin);
5992
6201
  if (error)
5993
- console.warn(`[fallback] ${k}: pinning the model ${to.model} failed: ${error}`);
6202
+ console.warn(`[fallback] ${k}: pinning the model ${options.modelToPin} failed: ${error}`);
5994
6203
  }
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
- });
6204
+ const prompt = options.getFirstPrompt();
6205
+ if (prompt === null)
6206
+ return;
5999
6207
  await deliverPromptOrBuffer(key, prompt.text, startupPromptBuffer.checkIsStarting(k));
6000
6208
  await requestWakeUpEngine?.trackContinuationTurn(key, { isCountersReset: true, isRequestPrompt: prompt.isRequestPrompt });
6001
- return { isSwitched: true, notice };
6002
6209
  }
6210
+ // ── a MANUAL switch between the two backends (`utils/backendSwitchPlan.ts`, `commands/backendSwitch.ts`) ──
6211
+ /** The backend family the topic's agent runs on now (its in-memory pick, else the persisted one); `null` for none or a shell. */
6212
+ function getTopicBackendFamily(key) {
6213
+ return (0, backendSwitchPlan_1.getBackendFamily)((0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name);
6214
+ }
6215
+ /**
6216
+ * Whether the topic's agent holds a conversation — running, or asleep with its session kept — with at least one turn
6217
+ * to hand over. An agent just started and not yet spoken to has none; a transcript that cannot be read counts as
6218
+ * one, since a needless question costs a tap and a silent switch costs the context.
6219
+ */
6220
+ async function checkHasConversationToHandOver(key) {
6221
+ if (getTopicBackendFamily(key) === null)
6222
+ return false;
6223
+ if (!(0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key) && getResumableSessionId(key) === null)
6224
+ return false;
6225
+ const turns = await readConversationTurns(key, 1);
6226
+ return turns === null || turns.length > 0;
6227
+ }
6228
+ /**
6229
+ * @description Where a manual switch to `backend` takes the topic: Claude → its account (the pick, else the
6230
+ * default); OpenCode → the model it will run (a saved `/model` pick, else the server's default; `null` when the
6231
+ * server cannot say). The OpenCode model is for the question only — the switch leaves it alone.
6232
+ */
6233
+ async function resolveBackendSwitchTarget(key, backend) {
6234
+ if (backend === 'claude')
6235
+ return { backend: 'claude', profile: state.resolveClaudeProfileName(key), isProfilePicked: false, model: null };
6236
+ const modelLabel = await (0, createAdapter_1.getAdapter)(adapterNames_1.openCodeAdapterName).getProspectiveModelLabel?.(key).catch(() => null);
6237
+ return { backend: 'opencode', modelLabel: modelLabel ?? null, model: null };
6238
+ }
6239
+ /**
6240
+ * @description The gate every MANUAL start of the other backend goes through (`/claude`, `/opencode`, the agent
6241
+ * buttons, the natural-language "start …" phrases): when the topic runs the other backend and holds a conversation,
6242
+ * ask first and let the answer make the switch. Returns whether it asked — the caller then stops; `false` means
6243
+ * nothing to hand over (a fresh topic, the same backend, a shell) and the caller goes ahead as it always did.
6244
+ * `userMessage` is the text typed with the command; the switch passes it on inside the context bridge.
6245
+ */
6246
+ async function askBeforeCrossBackendSwitch(key, backend, userMessage) {
6247
+ const currentFamily = getTopicBackendFamily(key);
6248
+ if (currentFamily === null || !(0, backendSwitchPlan_1.checkNeedsBackendSwitchConfirmation)({
6249
+ currentAdapterName: (0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name,
6250
+ targetBackend: backend,
6251
+ hasConversation: await checkHasConversationToHandOver(key),
6252
+ }))
6253
+ return false;
6254
+ await askBackendSwitch(key, { from: currentFamily, target: await resolveBackendSwitchTarget(key, backend), userMessage });
6255
+ return true;
6256
+ }
6257
+ /**
6258
+ * @description Move the topic onto the other backend by the operator's own choice — the cross-backend move of the
6259
+ * usage-limit switch (`moveTopicToOtherBackend` + `resumeTopicOnNewSession`): the context bridge is built from the
6260
+ * conversation being left, the target family's parked conversation comes back, and the bridge is the new session's
6261
+ * first prompt. A Claude account the operator PICKED (`isProfilePicked`) becomes the topic's own pick; `/claude` leaves
6262
+ * the topic following General. A topic with nothing to hand over (`isBridged: false`) just starts the target the way
6263
+ * `/claude` / `/opencode` do. `userMessage` rides inside the bridge.
6264
+ */
6265
+ async function executeBackendSwitch(key, target, options) {
6266
+ const bridge = options.isBridged
6267
+ ? await buildFallbackBridge(key, {
6268
+ fromLabel: formatThreadBackend(key),
6269
+ toLabel: (0, backendSwitchPlan_1.formatBackendSwitchBridgeLabel)(target),
6270
+ reason: 'manual',
6271
+ resetsAtText: null,
6272
+ userMessage: options.userMessage,
6273
+ })
6274
+ : null;
6275
+ await moveTopicToOtherBackend(key, target.backend, target.backend === 'claude' && target.isProfilePicked ? target.profile : null);
6276
+ cancelApiRetry(key); // a wait armed for the backend just left is over
6277
+ await resumeTopicOnNewSession(key, {
6278
+ modelToPin: (0, backendSwitchPlan_1.getBackendSwitchModelToPin)(target),
6279
+ getFirstPrompt: () => (bridge === null ? null : { text: bridge, isRequestPrompt: false }),
6280
+ args: bridge === null ? options.userMessage : undefined,
6281
+ });
6282
+ }
6283
+ /**
6284
+ * @description Whether a pick, made while a usage-limit wait is armed, names another model of the SAME Claude account that the
6285
+ * limit does not cover — the usage windows that model drains are all known and below 100 % (a Fable-only weekly limit does not
6286
+ * stop `sonnet`; an exhausted session window stops every model; a model draining the same windows as the one that hit the limit
6287
+ * is covered whatever the figures say). Such a pick lets the work continue, so it is the limit move; the
6288
+ * figures are brought up to date first (bounded: a slow endpoint never holds the tap). Any other pick is not beyond a limit.
6289
+ */
6290
+ async function checkIsPickBeyondLimit(from, to) {
6291
+ if (from?.backend !== 'claude' || to.backend !== 'claude' || to.model === null || from.profile !== to.profile)
6292
+ return false;
6293
+ await (0, usageRefreshBudget_1.refreshUsageWithinBudget)((options) => claudeUsageRefresher.refreshAll(options));
6294
+ return (0, claudeUsage_1.checkIsClaudeModelClearOfLimit)(state.getClaudeProfileInfo(to.profile)?.usage, from.model, to.model);
6295
+ }
6296
+ /** The rung as a manual switch's target: a picked Claude account (+ model alias), or an OpenCode model, both named in the question. */
6297
+ function buildBackendSwitchTargetOfRung(to) {
6298
+ if (to.backend === 'claude')
6299
+ return { backend: 'claude', profile: to.profile, isProfilePicked: true, model: to.model };
6300
+ return { backend: 'opencode', modelLabel: (0, fallbackChain_1.buildOpenCodeModelLabel)(to), model: { providerId: to.providerId, modelId: to.modelId } };
6301
+ }
6302
+ /**
6303
+ * @description The ONE path an operator's own pick takes — a row of `/limits` or `/accounts`, a model page,
6304
+ * `/accounts use` (`utils/operatorSwitchPlan.ts` decides which move it is):
6305
+ * - a usage-limit wait is armed → the limit move (the wait is dropped and the work continues, a conversation is
6306
+ * handed over without the question);
6307
+ * - the backend the topic runs → a Claude pick changes the account (the live session is re-spawned, same
6308
+ * conversation) and pins the rung's model when it names one; an OpenCode pick is the `/model` apply (its reply);
6309
+ * a topic with no agent only records a Claude pick for its next start;
6310
+ * - the other backend → the hand-over question when there is a conversation, else the switch at once.
6311
+ * The outcome tells the screen what to say; the question and the limit move's notice are already posted.
6312
+ */
6313
+ async function switchTopicByOperator(key, to) {
6314
+ const currentFamily = getTopicBackendFamily(key);
6315
+ const from = getTopicFallbackPosition(key);
6316
+ const isUsageLimitWaitArmed = getArmedApiRetry(key)?.kind === 'usageLimit';
6317
+ const route = (0, operatorSwitchPlan_1.decideOperatorSwitch)({
6318
+ isUsageLimitWaitArmed,
6319
+ from,
6320
+ to,
6321
+ currentFamily,
6322
+ hasConversation: currentFamily !== null && currentFamily !== to.backend && await checkHasConversationToHandOver(key),
6323
+ isPickBeyondLimit: isUsageLimitWaitArmed && await checkIsPickBeyondLimit(from, to),
6324
+ });
6325
+ const target = buildBackendSwitchTargetOfRung(to);
6326
+ const label = (0, backendSwitchPlan_1.formatBackendSwitchTarget)(target);
6327
+ switch (route) {
6328
+ case 'limitMove': {
6329
+ const moved = await switchTopicToFallbackEntry(key, from, to, { isAnnounced: true });
6330
+ if (moved.isFailed)
6331
+ return { kind: 'refused', text: moved.notice };
6332
+ if (to.backend === 'opencode')
6333
+ return { kind: 'switched', text: (0, i18n_1.t)('backendSwitch.switched', { to: label }), label };
6334
+ return { kind: 'switched', text: (0, i18n_1.t)('account.setThisTopic', { name: to.profile, apply: (0, i18n_1.t)(`account.applied.${moved.isSwitched ? 'respawned' : 'deferred'}`) }), label };
6335
+ }
6336
+ case 'sameFamily': {
6337
+ if (to.backend === 'opencode') {
6338
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
6339
+ const { isOk, message, setModelError } = await applyModelSelection(adapter, key, (0, fallbackChain_1.buildOpenCodeModelLabel)(to));
6340
+ return isOk ? { kind: 'switched', text: message, label } : { kind: 'refused', text: (0, i18n_1.t)('cb.model_error', { error: setModelError ?? message }) };
6341
+ }
6342
+ const runningModel = readRunningModelBeforeMove(key, from, to);
6343
+ // A model named for the account the topic already runs is a model change: it must not turn the topic's
6344
+ // following of General into a pick of its own.
6345
+ const isModelChangeOnly = to.model !== null && to.profile === resolveLiveClaudeProfileName(key);
6346
+ let switchedText = '';
6347
+ if (!isModelChangeOnly) {
6348
+ await state.setClaudeProfileOverride(key, to.profile);
6349
+ const outcome = await applyClaudeProfileToThread(key);
6350
+ switchedText = (0, i18n_1.t)('account.setThisTopic', { name: to.profile, apply: (0, i18n_1.t)(`account.applied.${outcome}`) });
6351
+ }
6352
+ const modelToPin = (0, fallbackChain_1.getFallbackModelToPin)(to, runningModel);
6353
+ if (modelToPin === null)
6354
+ return { kind: 'switched', text: switchedText === '' && to.model !== null ? (0, i18n_1.t)('cb.model_set', { model: to.model }) : switchedText, label };
6355
+ const { isOk, message, setModelError } = await applyModelSelection((0, createAdapter_1.getThreadAdapter)(key), key, modelToPin);
6356
+ if (!isOk)
6357
+ return { kind: 'refused', text: (0, i18n_1.t)('cb.model_error', { error: setModelError ?? message }) };
6358
+ return { kind: 'switched', text: switchedText === '' ? message : `${switchedText}\n${message}`, label };
6359
+ }
6360
+ case 'askFirst': {
6361
+ // The route asks only when the topic runs a backend to hand over from.
6362
+ if (currentFamily === null)
6363
+ throw new Error(`${(0, sessionKey_1.keyToString)(key)}: the hand-over question was asked for a topic that runs no agent`);
6364
+ await askBackendSwitch(key, { from: currentFamily, target });
6365
+ return { kind: 'asked' };
6366
+ }
6367
+ case 'switchNow': {
6368
+ await executeBackendSwitch(key, target, { isBridged: false });
6369
+ return { kind: 'switched', text: (0, i18n_1.t)('backendSwitch.switched', { to: label }), label };
6370
+ }
6371
+ }
6372
+ }
6373
+ const { askBackendSwitch, registerBackendSwitchCallbacks } = (0, backendSwitch_1.createBackendSwitch)({
6374
+ ...botCore,
6375
+ getCurrentFamily: getTopicBackendFamily,
6376
+ performBackendSwitch: (key, target, options) => executeBackendSwitch(key, target, { isBridged: true, userMessage: options.userMessage }),
6377
+ });
6003
6378
  /**
6004
6379
  * @description A tap on a «switch to …» button of a limit notice: honoured only while the thread's open offer
6005
6380
  * is the button's episode and the index is inside that offer (a stale notice changes nothing). The
@@ -6018,7 +6393,7 @@ async function listClaudeProfilesForAgent(model) {
6018
6393
  await claudeUsageRefresher.refreshAll({ maxAgeMs: claudeUsageRefresher_1.claudeUsageOnDemandMaxAgeMs });
6019
6394
  return (0, claudeUsage_1.buildClaudeProfilesReport)((0, claudeUsage_1.buildClaudeProfileUsageViews)({ dataDir: (0, state_1.resolveDataDir)(), records: state.getAllClaudeProfileInfos() }), model, Date.now());
6020
6395
  }
6021
- const { registerFallbackChainCommands, registerFallbackChainCallbacks, openLimitsView, applyAutoSwitch } = (0, fallbackChain_2.createFallbackChain)({
6396
+ const { registerFallbackChainCommands, registerFallbackChainCallbacks, openAutoSwitchView, runAutoSwitchCommand, applyAutoSwitch } = (0, fallbackChain_2.createFallbackChain)({
6022
6397
  ...botCore,
6023
6398
  applyAutoContinue: applyAutoContinueLimits,
6024
6399
  getArmedLimitRetryFireAt,
@@ -6028,7 +6403,19 @@ const { registerFallbackChainCommands, registerFallbackChainCallbacks, openLimit
6028
6403
  getTopicPosition: getTopicFallbackPosition,
6029
6404
  takeSwitchOffer: takeFallbackSwitchOffer,
6030
6405
  });
6031
- const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAwaitingProfileName, cancelProfileNameCapture, submitProfileName, onClaudeProfileAuthStatus: recordClaudeProfileAuthStatus, } = (0, claudeAccounts_1.createClaudeAccounts)({
6406
+ const { registerLimitsBoardCommands, registerLimitsBoardCallbacks } = (0, limitsBoard_1.createLimitsBoard)({
6407
+ ...botCore,
6408
+ getInstanceTimezone: getCurrentTimezone,
6409
+ refreshUsage: (options) => claudeUsageRefresher.refreshAll(options),
6410
+ detectFallbackOptions,
6411
+ getTopicPosition: getTopicFallbackPosition,
6412
+ pickClaudeAccount: (key, isGeneral, name) => pickClaudeAccount(key, isGeneral, name),
6413
+ switchTopicByOperator,
6414
+ listClaudeModels: async (key) => (await (0, createAdapter_1.getThreadAdapter)(key).getAvailableModels?.()) ?? [],
6415
+ runAutoSwitchCommand,
6416
+ getArmedLimitRetryFireAt,
6417
+ });
6418
+ const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAwaitingProfileName, cancelProfileNameCapture, submitProfileName, pickClaudeAccount, onClaudeProfileAuthStatus: recordClaudeProfileAuthStatus, } = (0, claudeAccounts_1.createClaudeAccounts)({
6032
6419
  ...botCore,
6033
6420
  getDataDir: state_1.resolveDataDir,
6034
6421
  getInstanceTimezone: getCurrentTimezone,
@@ -6037,14 +6424,32 @@ const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAw
6037
6424
  startClaudeProfileLogin: (key, profile) => startClaudeAuthLogin(key, profile),
6038
6425
  applyClaudeProfile: applyClaudePickToThread,
6039
6426
  applyClaudeProfileEverywhere,
6427
+ switchTopicByOperator,
6040
6428
  detectOpenCodeProviders: async () => (0, fallbackChain_1.listOpenCodeProviders)((await (0, createAdapter_1.getAdapter)(adapterNames_1.openCodeAdapterName).getAvailableModels?.()) ?? []),
6429
+ listOpenCodeModels: async () => (await (0, createAdapter_1.getAdapter)(adapterNames_1.openCodeAdapterName).getAvailableModels?.()) ?? [],
6430
+ getTopicRun: (key) => ({ family: getTopicBackendFamily(key), rung: getTopicFallbackPosition(key) }),
6041
6431
  disconnectOpenCodeProvider: applyProviderDisconnect,
6042
6432
  startOpenCodeConnect: showConnectMethodPicker,
6043
6433
  applyAutoSwitch,
6044
6434
  });
6045
6435
  claudeProfileAuthStatusSink = recordClaudeProfileAuthStatus;
6436
+ const { registerSttCommands, registerSttCallbacks, startSttAutoInstall, resumeSttInstallAtBoot, getAwaitedSttStep, submitSttInput, cancelSttInput, } = (0, stt_1.createStt)({
6437
+ ...botCore,
6438
+ getConfigPath: () => (0, sttSettings_1.getSttConfigPath)(),
6439
+ getDataDir: state_1.resolveDataDir,
6440
+ getResolvedSettings: getResolvedSttSettings,
6441
+ getOwnerContext: () => (0, sttSidecarOwner_1.resolveSttOwnerContext)(),
6442
+ owner: sttOwner,
6443
+ installJobs: sttInstallJobs,
6444
+ getGeneralKey: () => {
6445
+ const groupId = getAllowedGroupId();
6446
+ return groupId !== null ? (0, sessionKeyCodec_1.makeTelegramKey)(groupId, threadRouting_1.GENERAL_THREAD_ID) : null;
6447
+ },
6448
+ });
6046
6449
  registerClaudeAccountCommands();
6047
6450
  registerFallbackChainCommands();
6451
+ registerLimitsBoardCommands();
6452
+ registerSttCommands();
6048
6453
  registerCompactionCommands();
6049
6454
  /**
6050
6455
  * @description Build the agent-picker keyboard.
@@ -6164,7 +6569,7 @@ async function resumeSessionByIndex(key, idx, onExpired) {
6164
6569
  return (0, i18n_1.t)('session.resumed');
6165
6570
  }
6166
6571
  catch (e) {
6167
- return (0, i18n_1.t)('session.resume_failed', { error: e instanceof Error ? e.message : String(e) });
6572
+ return getAgentStartFailureText(adapter.label, e, (error) => (0, i18n_1.t)('session.resume_failed', { error }));
6168
6573
  }
6169
6574
  }
6170
6575
  command(['sessions', 'resume'], (_ctx, key) => handleSessionsList(key));
@@ -6627,8 +7032,8 @@ const botCommands = new Set([
6627
7032
  'start', 'claude', 'opencode', 'oc', 'terminal', 'agent', 'sessions', 'resume', 'cancel', 'model', 'connect',
6628
7033
  'disconnect', 'stop', 'stopall', 'stop-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'output', 'clear_messages',
6629
7034
  'bind', 'unbind', 'where', 'ls', 'list', 'new', 'clear_session', 'whoami', 'version', 'help', 'language', 'lang',
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',
7035
+ 'doctor', 'mcp', 'rename_session', 'compact', 'compact_on_idle', 'compact_summary', 'auto_continue_limits', 'limits', 'limits_autoswitch', 'trace', 'timestamps', 'timezone', 'schedule', 'reminders', 'thinking', 'tool_results',
7036
+ 'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quit-all', 'quitall', 'pair', 'account', 'accounts', 'fallback', 'stt', 'voice', 'speech',
6632
7037
  ]);
6633
7038
  /**
6634
7039
  * @description The bare `/clear` is no longer bot-owned (it was renamed to
@@ -6661,6 +7066,8 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
6661
7066
  const cmd = text.slice(1).split(' ')[0].split('@')[0].toLowerCase();
6662
7067
  // A slash command ends a pending «Add account» name prompt (the command itself runs normally).
6663
7068
  cancelProfileNameCapture(key);
7069
+ // ... and an armed `/stt` address or key step.
7070
+ await cancelSttInput(key);
6664
7071
  if (botCommands.has(cmd))
6665
7072
  return;
6666
7073
  // `/login` on a json-stream Claude thread (idle or per-turn lifecycle) has no
@@ -6710,6 +7117,12 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
6710
7117
  await submitProfileName(key, text);
6711
7118
  return;
6712
7119
  }
7120
+ // `/stt` asked for a server address or an API key → this message is the answer. A plausible key is deleted at
7121
+ // once and never echoed; anything else re-arms the step. A slash command above already disarmed it.
7122
+ if (getAwaitedSttStep(key) !== null && !text.startsWith('/')) {
7123
+ await submitSttInput(key, text, ctx.message.message_id);
7124
+ return;
7125
+ }
6713
7126
  // Safety net: a pasted OAuth callback URL with NO flow in progress (e.g. a
6714
7127
  // stale one from a browser that timed out, or a re-paste after the flow ended)
6715
7128
  // must NOT be forwarded to the agent as a prompt — that both leaks the code
@@ -6854,6 +7267,10 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
6854
7267
  await replyToThread(key, (0, i18n_1.t)('error.start_in_general'));
6855
7268
  return;
6856
7269
  }
7270
+ // The other backend, with a conversation to hand over: ask first (a shell is no backend to hand over to).
7271
+ const requestedFamily = (0, backendSwitchPlan_1.getBackendFamily)(startMatch.adapterName);
7272
+ if (requestedFamily !== null && state.getBinding(key) && await askBeforeCrossBackendSwitch(key, requestedFamily, startMatch.args))
7273
+ return;
6857
7274
  // The shared `ensureAgentSession` does the bind-check + switch + start.
6858
7275
  // The explicit matched adapter wins (`preferredAdapterName`); the phrase's
6859
7276
  // trailing args ride along. On `unbound` we keep this path's own reply:
@@ -6963,8 +7380,9 @@ bot.on((0, filters_1.message)('voice'), async (ctx) => {
6963
7380
  // it silently, like the text handler.
6964
7381
  cancelApiRetry(key);
6965
7382
  await state.pushMessageId(key, ctx.message.message_id);
6966
- if (!ENV.groqApiKey && !ENV.openaiApiKey) {
6967
- await replyToThread(key, (0, i18n_1.t)('voice.no_api_key'));
7383
+ const sttSettings = getResolvedSttSettings();
7384
+ if (sttSettings.engines.length === 0) {
7385
+ await replyToThread(key, (0, i18n_1.t)((0, sttSettings_1.getNoEngineNoticeCode)(sttSettings)));
6968
7386
  return;
6969
7387
  }
6970
7388
  // Offload the slow download+transcribe+forward off the telegraf update loop:
@@ -6975,13 +7393,14 @@ bot.on((0, filters_1.message)('voice'), async (ctx) => {
6975
7393
  // rejected job is guarded so it can never become an unhandledRejection
6976
7394
  // (processVoiceJob already reports its own errors via replyToThread).
6977
7395
  const fileId = ctx.message.voice.file_id;
7396
+ const durationSeconds = ctx.message.voice.duration;
6978
7397
  const sentAtMs = ctx.message.date * 1000;
6979
7398
  // A Telegram REPLY on the voice note folds the quoted message into the prompt.
6980
7399
  // Compute it HERE (only the handler has `ctx`); the job runs later off the
6981
7400
  // update loop and only sees `key`/`bot`.
6982
7401
  const replyBlock = getReplyQuoteBlock(key, ctx.message);
6983
7402
  void (0, voiceQueue_1.getVoiceTranscriptionQueue)(key)
6984
- .run(() => processVoiceJob(key, fileId, (0, topicRequest_1.getTopicRequesterId)(ctx.from), sentAtMs, replyBlock))
7403
+ .run(() => processVoiceJob(key, fileId, durationSeconds, (0, topicRequest_1.getTopicRequesterId)(ctx.from), sentAtMs, replyBlock))
6985
7404
  .catch((err) => {
6986
7405
  console.error('[Bot] Voice job error (already handled):', err);
6987
7406
  });
@@ -6999,7 +7418,7 @@ bot.on((0, filters_1.message)('voice'), async (ctx) => {
6999
7418
  * the voice note), plumbed here since the job no longer has `ctx`; so is
7000
7419
  * `requesterId`, the sender the transcript's request is filed under.
7001
7420
  */
7002
- async function processVoiceJob(key, fileId, requesterId, sentAtMs, replyContext) {
7421
+ async function processVoiceJob(key, fileId, durationSeconds, requesterId, sentAtMs, replyContext) {
7003
7422
  try {
7004
7423
  // Audit S14 / #33: `getFileLink` builds the bot-token URL in one
7005
7424
  // place inside Telegraf instead of us materialising the token in a
@@ -7029,7 +7448,7 @@ async function processVoiceJob(key, fileId, requesterId, sentAtMs, replyContext)
7029
7448
  await replyToThread(key, `${(0, i18n_1.t)('voice.failed')} (${msg})`);
7030
7449
  return;
7031
7450
  }
7032
- const result = await transcribeVoiceFile(key, tempFile);
7451
+ const result = await transcribeVoiceFile(key, tempFile, durationSeconds);
7033
7452
  fs.unlink(tempFile, () => { });
7034
7453
  if (!result.ok) {
7035
7454
  await replyToThread(key, `${(0, i18n_1.t)('voice.failed')} (${result.error})`);
@@ -7064,6 +7483,9 @@ async function processVoiceJob(key, fileId, requesterId, sentAtMs, replyContext)
7064
7483
  await replyToThread(key, (0, i18n_1.t)('error.start_in_general'));
7065
7484
  return;
7066
7485
  }
7486
+ const requestedFamily = (0, backendSwitchPlan_1.getBackendFamily)(startMatch.adapterName);
7487
+ if (requestedFamily !== null && state.getBinding(key) && await askBeforeCrossBackendSwitch(key, requestedFamily, startMatch.args))
7488
+ return;
7067
7489
  // Same choke point as the text trigger (L-D4): a sleeping conversation is
7068
7490
  // woken, never replaced by a fresh start; `unbound` keeps the folder picker.
7069
7491
  const result = await ensureAgentSession(key, {
@@ -7812,9 +8234,12 @@ bot.action(timezonePicker_1.zonePickCallbackRe, async (ctx) => {
7812
8234
  registerModelCallbacks();
7813
8235
  registerCompactionCallbacks();
7814
8236
  registerAutoContinueLimitsCallbacks();
8237
+ registerBackendSwitchCallbacks();
7815
8238
  registerClaudeAccountCallbacks();
7816
8239
  registerFallbackChainCallbacks();
7817
- // «⏭ Skip once» in the `/auto_continue_limits` picker: drop THIS armed resume and
8240
+ registerLimitsBoardCallbacks();
8241
+ registerSttCallbacks();
8242
+ // «⏭ Skip once» on the `/limits` board and the `/limits_autoswitch` screen: drop THIS armed resume and
7818
8243
  // leave the on/off setting alone. The episode is identified by the `fireAt` baked
7819
8244
  // into the callback data, so an untouched older picker can never cancel a LATER
7820
8245
  // episode (pure `getArmedRetrySkipDecision`); a stale tap changes nothing and says
@@ -7858,15 +8283,25 @@ bot.action(/^reask_(\d+)$/, async (ctx) => {
7858
8283
  await ctx.answerCbQuery((0, i18n_1.t)('cb.no_pending_question'));
7859
8284
  return;
7860
8285
  }
8286
+ // The idle fire posts the re-asked question just BEFORE it stops the process, and a stopping process still reads as
8287
+ // active: a tap inside that window (or inside a start) is buffered like a typed prompt and replayed once a session is up.
8288
+ const isStarting = startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key));
7861
8289
  // The answer to a re-asked question wakes a sleeping conversation (L-D4) — the question is its own.
7862
- if (!(0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key) && !(await resumeSleepingSessionForPrompt(key))) {
8290
+ if (!isStarting && !(0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key) && !(await resumeSleepingSessionForPrompt(key))) {
7863
8291
  await ctx.answerCbQuery((0, i18n_1.t)('agent.no_session'));
7864
8292
  return;
7865
8293
  }
7866
8294
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
7867
8295
  reAskedQuestionOptions.delete((0, sessionKey_1.keyToString)(key));
7868
- await ctx.answerCbQuery(label);
8296
+ // The label goes to the agent as a prompt and nothing else in the topic shows it whole (the buttons clip long
8297
+ // labels): a label too long for the toast is posted in full, as the record of the answer that was sent.
8298
+ await (0, callbackToast_1.answerCallbackWithWholeText)(ctx, label, (wholeLabel) => replyToThread(key, wholeLabel));
7869
8299
  // An answer to the agent's own question is never a request (S7), even delivered as a prompt.
8300
+ if (isStarting) {
8301
+ noteThreadUserActivity(key);
8302
+ await withThreadLocale(key, () => deliverPromptOrBuffer(key, label, true));
8303
+ return;
8304
+ }
7870
8305
  await withThreadLocale(key, () => deliverActivePrompt(key, adapter, label, null));
7871
8306
  });
7872
8307
  registerReminderCallbacks();
@@ -8011,8 +8446,17 @@ bot.action(/^qa_(\d+)_(\d+)$/, async (ctx) => {
8011
8446
  return;
8012
8447
  }
8013
8448
  const selectedLabel = question.options[optIdx].label;
8014
- await applyQuestionAnswer(key, [selectedLabel]);
8015
- await ctx.answerCbQuery(selectedLabel);
8449
+ const isAnswerShown = await applyQuestionAnswer(key, [selectedLabel]);
8450
+ if (isAnswerShown) {
8451
+ // The question message was just edited to "✅ <header>: <answer>", which carries the whole answer: the toast only
8452
+ // confirms the tap, so a label too long for it is cut there and not posted a second time.
8453
+ await (0, callbackToast_1.answerCallbackWithFittedText)(ctx, selectedLabel);
8454
+ }
8455
+ else {
8456
+ // The message could not be edited (it is gone, or the edit failed): nothing in the topic shows the answer, so a
8457
+ // label too long for the toast is posted in full.
8458
+ await (0, callbackToast_1.answerCallbackWithWholeText)(ctx, selectedLabel, (wholeLabel) => replyToThread(key, wholeLabel));
8459
+ }
8016
8460
  });
8017
8461
  /**
8018
8462
  * @description Buffer a free-form prompt (text OR voice) that arrived while the
@@ -8092,8 +8536,24 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
8092
8536
  // wait (the handlers' `cancelApiRetry`) and before the forward, so its id is
8093
8537
  // durable by the time the agent reads it.
8094
8538
  const opening = await openTopicRequest(key, text, intake);
8095
- await forwardPromptToAgent(key, adapter, text, sentAtMs, { replyContext, requestHeader: opening.header });
8096
- trackTopicRequestTurn(key, opening);
8539
+ // Routed now, not when the message arrived: the idle stop may have begun or ended during the awaits since.
8540
+ const outcome = await (0, promptDelivery_1.deliverLatePrompt)({
8541
+ checkIsWindowOpen: () => startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key)),
8542
+ checkIsSessionActive: () => adapter.checkIsActive(key),
8543
+ buffer: async () => {
8544
+ await deliverPromptOrBuffer(key, `${opening.header}${getPromptWithThreadReplyQuote(key, text, replyContext)}`, true);
8545
+ },
8546
+ wake: () => resumeSleepingSessionForPrompt(key),
8547
+ forward: () => forwardPromptToAgent(key, (0, createAdapter_1.getThreadAdapter)(key), text, sentAtMs, { replyContext, requestHeader: opening.header }),
8548
+ reportNoSession: async () => {
8549
+ // The prompt reaches no agent: the request it opened would only wait for a turn that never comes.
8550
+ if (opening.requestId !== null)
8551
+ await topicRequestLedger?.closeRequest(opening.requestId, 'cancelled');
8552
+ await replyToThread(key, (0, i18n_1.t)('agent.no_session'));
8553
+ },
8554
+ });
8555
+ if (outcome !== 'noSession')
8556
+ trackTopicRequestTurn(key, opening);
8097
8557
  }
8098
8558
  /**
8099
8559
  * @description Cancel a pending OpenCode question because the user sent free-form
@@ -8149,19 +8609,18 @@ async function applyQuestionAnswer(key, answerForCurrent) {
8149
8609
  // the answer reads `messageId` for its ✅-edit — otherwise the edit targets
8150
8610
  // a message the re-post just deleted (live 2026-06-10: "message to edit not
8151
8611
  // found" + the next question never reached the topic).
8152
- await runQuestionLifecycleOp(key, () => applyQuestionAnswerInner(key, answerForCurrent));
8612
+ return runQuestionLifecycleOp(key, () => applyQuestionAnswerInner(key, answerForCurrent));
8153
8613
  }
8614
+ /** Resolves to whether the topic now shows the whole answer: the answered question's message was edited to carry it. */
8154
8615
  async function applyQuestionAnswerInner(key, answerForCurrent) {
8155
8616
  const kStr = (0, sessionKey_1.keyToString)(key);
8156
8617
  const pending = pendingQuestions.get(kStr);
8157
8618
  if (!pending)
8158
- return;
8619
+ return false;
8159
8620
  const answeredQuestion = pending.data.questions[pending.currentIndex];
8160
8621
  const { nextState, action } = (0, openCodeQuestionFlow_1.recordAnswerAndAdvance)(pending, answerForCurrent);
8161
8622
  // Mark the answered question's message as done (the chosen answer in place).
8162
- if (pending.messageId !== null && answeredQuestion) {
8163
- await editThreadMessage(key, pending.messageId, `✅ ${answeredQuestion.header || answeredQuestion.question}: ${answerForCurrent.join(', ')}`);
8164
- }
8623
+ const isAnswerShown = pending.messageId !== null && answeredQuestion !== undefined && await editThreadMessage(key, pending.messageId, `✅ ${answeredQuestion.header || answeredQuestion.question}: ${answerForCurrent.join(', ')}`);
8165
8624
  if (action.kind === 'submit') {
8166
8625
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
8167
8626
  clearPendingQuestion(key);
@@ -8171,12 +8630,13 @@ async function applyQuestionAnswerInner(key, answerForCurrent) {
8171
8630
  adapter.answerQuestion(key, action.matrix);
8172
8631
  markNeedsNewMessage(key);
8173
8632
  }
8174
- return;
8633
+ return isAnswerShown;
8175
8634
  }
8176
8635
  // More questions remain: persist the advanced state, then post the next one
8177
8636
  // (which re-owns `messageId`). DO NOT reply to the agent yet.
8178
8637
  setPendingQuestion(key, nextState);
8179
8638
  await postPendingQuestionAt(key);
8639
+ return isAnswerShown;
8180
8640
  }
8181
8641
  // ═══════════════════════════════════════════════════════════════════════════════
8182
8642
  // Adapter event handlers (output / status / question / closed / error)
@@ -9442,6 +9902,26 @@ function handleAgentStopped(key) {
9442
9902
  clearThreadCompaction(key);
9443
9903
  updatePinnedStatus(key).catch(() => { });
9444
9904
  }
9905
+ /**
9906
+ * @description `conversationLost` from the adapter: a live re-spawn (an account, model or effort change) found the
9907
+ * conversation's transcript gone — it held turns, the stopped process left only a metadata stub — and began a NEW
9908
+ * conversation under the same id. The agent has forgotten everything, so say so in the one line a failed resume gets
9909
+ * (`agent.resume_failed_fresh`), and forget that the thread-context preamble was injected: the new conversation was
9910
+ * never told it.
9911
+ */
9912
+ function handleConversationLost(key) {
9913
+ dropLostConversationWatermark(key);
9914
+ clearThreadContextMarker(key);
9915
+ replyToThread(key, (0, i18n_1.t)('agent.resume_failed_fresh')).catch(() => { });
9916
+ }
9917
+ /**
9918
+ * @description The seen watermark measured the conversation a live re-spawn found gone; the one begun under its id
9919
+ * has taken no turn. Kept, it would make that conversation's next wake fail as lost — a second notice for one loss
9920
+ * (Telegram) or a second whole prompt (a tracker). Its first turn writes a new one.
9921
+ */
9922
+ function dropLostConversationWatermark(key) {
9923
+ state.clearSeenWatermark(key).catch((e) => console.error(`[Bot] clearSeenWatermark failed for ${(0, sessionKey_1.keyToString)(key)}:`, e));
9924
+ }
9445
9925
  /**
9446
9926
  * @description `suspended` from the adapter — the idle stop (L3): the process is
9447
9927
  * gone, the conversation stays. The frame cleanup of {@link handleAgentStopped}
@@ -9485,8 +9965,10 @@ exports.COMMANDS_MENU = [
9485
9965
  { command: 'compact', description: '🧹 Compact agent context' },
9486
9966
  { command: 'compact_on_idle', description: '🧹 Auto-compact after idle (toggle)' },
9487
9967
  { command: 'compact_summary', description: '📄 Post the compaction summary (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' },
9968
+ { command: 'accounts', description: '👤 Claude accounts and OpenCode providers (switch, add, re-login, remove, connect, disconnect)' },
9969
+ { command: 'limits', description: '🚧 Usage limits — switch this topic' },
9970
+ { command: 'limits_autoswitch', description: '🔁 Automatic switching on a usage limit' },
9971
+ { command: 'stt', description: '🎙 Voice recognition: engines, language, local engine' },
9490
9972
  { command: 'schedule', description: '⏰ Schedule a prompt (agent does the work)' },
9491
9973
  { command: 'reminders', description: '🔔 Reminders — add, list, delete (no agent)' },
9492
9974
  { command: 'status', description: '📊 Show status' },
@@ -9985,6 +10467,20 @@ function restorePendingQuestions() {
9985
10467
  * dropped (Jira plan J3b, R10): its kick would start that conversation's session
9986
10468
  * here, under this instance's environment and tmux server.
9987
10469
  */
10470
+ /**
10471
+ * @description Put back the limit probes that fired shortly before this boot (`restoreFiredLimitProbes`,
10472
+ * `apiRetryKick.ts`), so the same error on a probe's resume confirms its limit even when this bot reads it. MUST run
10473
+ * BEFORE {@link reattachExistingSessions}: an adopted json-stream session replays the error it wrote meanwhile.
10474
+ */
10475
+ function restoreFiredLimitProbesAtBoot() {
10476
+ const restored = (0, apiRetryKick_1.restoreFiredLimitProbes)({
10477
+ entries: apiRetryTimers,
10478
+ now: () => Date.now(),
10479
+ isServed: (key) => (0, connectorSet_1.checkIsServedConversation)(key, ENV.servedPlatforms),
10480
+ dropRecord: clearSavedFiredLimitProbe,
10481
+ }, state.getLimitProbesFired(), apiErrorRetry_1.retryRecurrenceGraceMs);
10482
+ console.log(`[reattach] fired limit probes: restored ${restored}`);
10483
+ }
9988
10484
  function restoreApiRetries() {
9989
10485
  const restored = (0, apiRetryKick_1.restoreApiRetryTimers)({
9990
10486
  entries: apiRetryTimers,
@@ -9996,6 +10492,49 @@ function restoreApiRetries() {
9996
10492
  }, state.getApiRetries());
9997
10493
  console.log(`[reattach] api retries: re-armed ${restored}`);
9998
10494
  }
10495
+ /**
10496
+ * @description Record a json-stream conversation's stdout log, as it is now, as a limit episode already handled
10497
+ * (`limitEpisodesRecovered`, the stamp {@link recoverLimitEpisodesFromDisk} checks). A live wait that ends without
10498
+ * its resume — «⏭ Skip once», a give-up, a takeover — leaves the error it waited on as the log's last terminal frame,
10499
+ * and the next boot's recovery armed it again: only the recovery itself wrote the stamp. A later turn writes to the
10500
+ * log and changes its identity, so a NEW parked error is still recovered.
10501
+ */
10502
+ function markLimitEpisodeSettled(key) {
10503
+ const agent = state.getAgent(key);
10504
+ if (!agent || !(0, adapterNames_1.checkIsJsonStreamBackend)(agent.name))
10505
+ return;
10506
+ const { stdoutFile } = (0, jsonStreamHost_1.getJsonStreamSessionPaths)((0, jsonStreamHost_1.resolveJsonStreamSessionDir)(getDataDir(), key));
10507
+ let log;
10508
+ try {
10509
+ const stat = fs.statSync(stdoutFile);
10510
+ log = { sizeBytes: stat.size, mtimeMs: stat.mtimeMs };
10511
+ }
10512
+ catch {
10513
+ return; // no stdout log for this thread — nothing a boot could recover
10514
+ }
10515
+ void state
10516
+ .setLimitEpisodeRecovered(key, log)
10517
+ .catch((e) => console.error('[limitRecovery] marker persist failed:', e));
10518
+ }
10519
+ /**
10520
+ * @description When the parked `errorText` of a json-stream conversation was written, from its Claude transcript's
10521
+ * API-error entry ({@link getParkedErrorWrittenAtMs}); `null` when the transcript tail cannot be read or holds no such
10522
+ * entry — the recovery then goes by the stdout log's mtime.
10523
+ */
10524
+ function readParkedErrorWrittenAtMs(key, claudeSessionId, errorText) {
10525
+ const workDirDecision = getWorkDirStartDecision(key);
10526
+ if (!workDirDecision.ok)
10527
+ return null;
10528
+ const transcriptPath = (0, claudeCliAdapter_1.getClaudeTranscriptPath)(workDirDecision.workDir, claudeSessionId);
10529
+ try {
10530
+ const sizeBytes = fs.statSync(transcriptPath).size;
10531
+ const tail = (0, jsonStreamHost_1.readFileByteRange)(transcriptPath, Math.max(0, sizeBytes - limitEpisodeRecovery_1.limitEpisodeTailMaxBytes), sizeBytes);
10532
+ return (0, limitEpisodeRecovery_1.getParkedErrorWrittenAtMs)(tail.toString('utf8'), errorText);
10533
+ }
10534
+ catch {
10535
+ return null; // no transcript on disk — the mtime decides
10536
+ }
10537
+ }
9999
10538
  /**
10000
10539
  * @description Recover a usage/session-limit episode that ENDED BEFORE this boot
10001
10540
  * and left no armed retry behind — the case {@link restoreApiRetries} cannot help
@@ -10008,12 +10547,13 @@ function restoreApiRetries() {
10008
10547
  * end of the session's append-only `stdout.jsonl`. Only the TAIL is read
10009
10548
  * ({@link limitEpisodeTailMaxBytes}), never the whole log. A recovered episode goes
10010
10549
  * through {@link handleApiError}, so the notice, the timer, the persistence and the
10011
- * `/auto_continue_limits` gate behave exactly as for a live error — there is no second
10550
+ * «wait for the reset» gate (`/limits_autoswitch`) behave exactly as for a live error — there is no second
10012
10551
  * arming path to keep in sync.
10013
10552
  *
10014
10553
  * MUST run AFTER {@link restoreApiRetries} so an already-armed thread is visible
10015
- * and skipped. A re-armed episode whose reset time has passed needs no special
10016
- * case: the retry plan clamps the negative delay to zero.
10554
+ * and skipped. A re-armed episode whose reset time has passed (read against the
10555
+ * error's own time) resumes at once: the retry plan clamps the negative delay to
10556
+ * zero, and {@link handleApiError} announces no limit for a window that is over.
10017
10557
  *
10018
10558
  * Each arming STAMPS the log's identity (`state.json` `limitEpisodesRecovered`), so
10019
10559
  * an episode is recovered at most once: «⏭ Skip once», a user takeover and a
@@ -10060,6 +10600,7 @@ function recoverLimitEpisodesFromDisk() {
10060
10600
  }
10061
10601
  const decision = (0, limitEpisodeRecovery_1.decideLimitEpisodeRecovery)({
10062
10602
  errorText,
10603
+ errorWrittenAtMs: errorText === null ? null : readParkedErrorWrittenAtMs(key, agent.claudeSessionId, errorText),
10063
10604
  log,
10064
10605
  handled: state.getLimitEpisodeRecovered(key),
10065
10606
  now,
@@ -10077,7 +10618,7 @@ function recoverLimitEpisodesFromDisk() {
10077
10618
  void state
10078
10619
  .setLimitEpisodeRecovered(key, log)
10079
10620
  .catch(e => console.error('[limitRecovery] marker persist failed:', e));
10080
- void withThreadLocale(key, () => handleApiError(key, decision.cls));
10621
+ void withThreadLocale(key, () => handleApiError(key, decision.cls, { isRecoveredEpisode: true }));
10081
10622
  recovered += 1;
10082
10623
  }
10083
10624
  if (recovered > 0)
@@ -10531,7 +11072,7 @@ async function sendStartupStatus(isHotReload) {
10531
11072
  botRights,
10532
11073
  hasBinding,
10533
11074
  availableAgents,
10534
- hasGroq: !!ENV.groqApiKey,
11075
+ hasSpeechEngine: getResolvedSttSettings().engines.length > 0,
10535
11076
  ownerSet,
10536
11077
  usedGeneralFallback: !ownerSet,
10537
11078
  };
@@ -10667,6 +11208,9 @@ async function startBot() {
10667
11208
  // environment is left exactly as launched.
10668
11209
  (0, timezone_1.applyProcessTimezone)(state.getTimezone());
10669
11210
  console.log(`Timezone: ${(0, timezone_1.getEffectiveTimezone)(state.getTimezone())} (host: ${(0, timezone_1.getHostTimezone)()})`);
11211
+ // 1a''. The voice settings (`stt.json`): the old env keys move into it once,
11212
+ // before the first voice note or the readiness check reads the chain.
11213
+ sttRuntime.init();
10670
11214
  // 1a'. The Jira connector (Jira plan J5), loaded only when CONNECTORS lists it
10671
11215
  // (R20: a Telegram-only instance never loads its code). Prepared BEFORE the
10672
11216
  // answer sinks (its comments are the jira sink, J6) and the session boot
@@ -10819,6 +11363,7 @@ async function startBot() {
10819
11363
  onSuspended: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'suspended', () => withThreadLocale(key, () => handleAgentSuspended(key))),
10820
11364
  onTurnEnded: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'turnEnded', () => handleAgentTurnEnded(key)),
10821
11365
  onContextCompacted: (key, trigger) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'contextCompacted', () => noteConversationContextReset(key, `compaction (${trigger ?? 'bot'})`)),
11366
+ onConversationLost: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'conversationLost', () => withThreadLocale(key, () => handleConversationLost(key)), () => { dropLostConversationWatermark(key); noteConversationContextReset(key, 'its transcript was lost'); }),
10822
11367
  onError: (key, error) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'error', () => withThreadLocale(key, () => handleAgentError(key, error)), () => {
10823
11368
  console.error(`[Bot] adapter error ${(0, sessionKey_1.keyToString)(key)}:`, error.message);
10824
11369
  }),
@@ -10930,7 +11475,10 @@ async function startBot() {
10930
11475
  // A successful re-adopt is silent; `quietReattach` only suppresses the
10931
11476
  // reattach ERROR notice (workDir vanished) during a hot reload, surfaced on
10932
11477
  // a real cold start.
10933
- reattachSessions: () => reattachExistingSessions({ quietReattach: bootMode.isHotReload }),
11478
+ reattachSessions: () => {
11479
+ restoreFiredLimitProbesAtBoot();
11480
+ return reattachExistingSessions({ quietReattach: bootMode.isHotReload });
11481
+ },
10934
11482
  restoreAfterReattach: () => {
10935
11483
  // Re-arm pending interactive questions that survived the restart, so the
10936
11484
  // existing option buttons keep working. A question is restored only for a
@@ -11166,6 +11714,8 @@ async function startBot() {
11166
11714
  drainPendingUpdates: () => updateDispatcher.drainIdle(updateDrainTimeoutMs),
11167
11715
  finalizePendingOutput: () => finalizePendingOutputOnShutdown(),
11168
11716
  clearTransientFrames: () => sweepTransientFramesOnShutdown(),
11717
+ // A hot worker's reload keeps the sidecar for the next worker to adopt; the supervisor stops it for good.
11718
+ stopSidecars: (0, installManager_1.checkIsOpenCodeExternalHost)() ? undefined : () => sttOwner.stop(),
11169
11719
  cleanupTimers: () => {
11170
11720
  clearInterval(inMemoryGcInterval);
11171
11721
  clearInterval(heartbeatInterval);
@@ -11207,6 +11757,8 @@ async function startBot() {
11207
11757
  catch (e) {
11208
11758
  console.warn('[startup-status] failed:', e instanceof Error ? e.message : e);
11209
11759
  }
11760
+ // The local speech engine: clean up after an earlier run, re-attach to / start its install. Starts no process.
11761
+ void bootSttLocalEngine().catch((e) => console.warn('[STT] boot step failed:', e instanceof Error ? e.message : e));
11210
11762
  console.log(`Launching Telegraf bot (long polling, dropPendingUpdates=${bootMode.dropPendingUpdates})...`);
11211
11763
  try {
11212
11764
  await bot.launch({