charness 3.2.0 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -6
- package/DEVELOPMENT.md +15 -0
- package/README.md +111 -38
- package/SETUP.md +121 -16
- package/dist/adapters/adapterEventRouting.d.ts +2 -1
- package/dist/adapters/adapterEventRouting.d.ts.map +1 -1
- package/dist/adapters/adapterEventRouting.js +3 -0
- package/dist/adapters/adapterEventRouting.js.map +1 -1
- package/dist/adapters/adapterNames.d.ts +13 -0
- package/dist/adapters/adapterNames.d.ts.map +1 -1
- package/dist/adapters/adapterNames.js +16 -0
- package/dist/adapters/adapterNames.js.map +1 -1
- package/dist/adapters/backendUnavailableError.d.ts +11 -0
- package/dist/adapters/backendUnavailableError.d.ts.map +1 -0
- package/dist/adapters/backendUnavailableError.js +19 -0
- package/dist/adapters/backendUnavailableError.js.map +1 -0
- package/dist/adapters/claudeCliAdapter.d.ts +29 -6
- package/dist/adapters/claudeCliAdapter.d.ts.map +1 -1
- package/dist/adapters/claudeCliAdapter.js +70 -16
- package/dist/adapters/claudeCliAdapter.js.map +1 -1
- package/dist/adapters/claudeJsonStreamAdapter.d.ts +36 -3
- package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
- package/dist/adapters/claudeJsonStreamAdapter.js +93 -30
- package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
- package/dist/adapters/createAdapter.d.ts +3 -14
- package/dist/adapters/createAdapter.d.ts.map +1 -1
- package/dist/adapters/createAdapter.js +9 -19
- package/dist/adapters/createAdapter.js.map +1 -1
- package/dist/adapters/openCodeAdapter.d.ts +140 -12
- package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/openCodeAdapter.js +385 -119
- package/dist/adapters/openCodeAdapter.js.map +1 -1
- package/dist/apiErrorRetry.d.ts +70 -6
- package/dist/apiErrorRetry.d.ts.map +1 -1
- package/dist/apiErrorRetry.js +312 -34
- package/dist/apiErrorRetry.js.map +1 -1
- package/dist/apiRetryKick.d.ts +26 -1
- package/dist/apiRetryKick.d.ts.map +1 -1
- package/dist/apiRetryKick.js +41 -2
- package/dist/apiRetryKick.js.map +1 -1
- package/dist/bot.d.ts +2 -1
- package/dist/bot.d.ts.map +1 -1
- package/dist/bot.js +736 -184
- package/dist/bot.js.map +1 -1
- package/dist/cli/claudeProfiles.d.ts +1 -1
- package/dist/cli/claudeProfiles.js +1 -1
- package/dist/cli/hot.d.ts +7 -1
- package/dist/cli/hot.d.ts.map +1 -1
- package/dist/cli/hot.js +29 -3
- package/dist/cli/hot.js.map +1 -1
- package/dist/configDirFile.d.ts +73 -0
- package/dist/configDirFile.d.ts.map +1 -0
- package/dist/configDirFile.js +216 -0
- package/dist/configDirFile.js.map +1 -0
- package/dist/connectors/telegram/commands/autoContinueLimits.d.ts +2 -2
- package/dist/connectors/telegram/commands/autoContinueLimits.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/autoContinueLimits.js +4 -4
- package/dist/connectors/telegram/commands/autoContinueLimits.js.map +1 -1
- package/dist/connectors/telegram/commands/backendSwitch.d.ts +25 -0
- package/dist/connectors/telegram/commands/backendSwitch.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/backendSwitch.js +104 -0
- package/dist/connectors/telegram/commands/backendSwitch.js.map +1 -0
- package/dist/connectors/telegram/commands/callbackMessage.d.ts +12 -0
- package/dist/connectors/telegram/commands/callbackMessage.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/callbackMessage.js +24 -0
- package/dist/connectors/telegram/commands/callbackMessage.js.map +1 -1
- package/dist/connectors/telegram/commands/callbackToast.d.ts +36 -0
- package/dist/connectors/telegram/commands/callbackToast.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/callbackToast.js +83 -0
- package/dist/connectors/telegram/commands/callbackToast.js.map +1 -0
- package/dist/connectors/telegram/commands/claudeAccounts.d.ts +18 -0
- package/dist/connectors/telegram/commands/claudeAccounts.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/claudeAccounts.js +220 -66
- package/dist/connectors/telegram/commands/claudeAccounts.js.map +1 -1
- package/dist/connectors/telegram/commands/displayModes.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/displayModes.js +4 -2
- package/dist/connectors/telegram/commands/displayModes.js.map +1 -1
- package/dist/connectors/telegram/commands/fallbackChain.d.ts +5 -8
- package/dist/connectors/telegram/commands/fallbackChain.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/fallbackChain.js +183 -193
- package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -1
- package/dist/connectors/telegram/commands/limitsBoard.d.ts +40 -0
- package/dist/connectors/telegram/commands/limitsBoard.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/limitsBoard.js +281 -0
- package/dist/connectors/telegram/commands/limitsBoard.js.map +1 -0
- package/dist/connectors/telegram/commands/modelPage.d.ts +130 -0
- package/dist/connectors/telegram/commands/modelPage.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/modelPage.js +120 -0
- package/dist/connectors/telegram/commands/modelPage.js.map +1 -0
- package/dist/connectors/telegram/commands/modelProviders.d.ts +9 -1
- package/dist/connectors/telegram/commands/modelProviders.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/modelProviders.js +203 -111
- package/dist/connectors/telegram/commands/modelProviders.js.map +1 -1
- package/dist/connectors/telegram/commands/stt.d.ts +32 -0
- package/dist/connectors/telegram/commands/stt.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/stt.js +460 -0
- package/dist/connectors/telegram/commands/stt.js.map +1 -0
- package/dist/effortLevels.d.ts +57 -7
- package/dist/effortLevels.d.ts.map +1 -1
- package/dist/effortLevels.js +65 -9
- package/dist/effortLevels.js.map +1 -1
- package/dist/i18n/de.d.ts.map +1 -1
- package/dist/i18n/de.js +160 -17
- package/dist/i18n/de.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +160 -17
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +160 -17
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/fr.d.ts.map +1 -1
- package/dist/i18n/fr.js +160 -17
- package/dist/i18n/fr.js.map +1 -1
- package/dist/i18n/hi.d.ts.map +1 -1
- package/dist/i18n/hi.js +160 -17
- package/dist/i18n/hi.js.map +1 -1
- package/dist/i18n/ja.d.ts.map +1 -1
- package/dist/i18n/ja.js +160 -17
- package/dist/i18n/ja.js.map +1 -1
- package/dist/i18n/ka.d.ts.map +1 -1
- package/dist/i18n/ka.js +160 -17
- package/dist/i18n/ka.js.map +1 -1
- package/dist/i18n/pt.d.ts.map +1 -1
- package/dist/i18n/pt.js +160 -17
- package/dist/i18n/pt.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +160 -17
- package/dist/i18n/ru.js.map +1 -1
- package/dist/i18n/uk.d.ts.map +1 -1
- package/dist/i18n/uk.js +160 -17
- package/dist/i18n/uk.js.map +1 -1
- package/dist/i18n/uz.d.ts.map +1 -1
- package/dist/i18n/uz.js +160 -17
- package/dist/i18n/uz.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +160 -17
- package/dist/i18n/zh.js.map +1 -1
- package/dist/installManager.d.ts +20 -4
- package/dist/installManager.d.ts.map +1 -1
- package/dist/installManager.js +82 -14
- package/dist/installManager.js.map +1 -1
- package/dist/instanceSettings.d.ts +52 -0
- package/dist/instanceSettings.d.ts.map +1 -0
- package/dist/instanceSettings.js +136 -0
- package/dist/instanceSettings.js.map +1 -0
- package/dist/pinnedStatus.d.ts +7 -1
- package/dist/pinnedStatus.d.ts.map +1 -1
- package/dist/pinnedStatus.js +1 -1
- package/dist/pinnedStatus.js.map +1 -1
- package/dist/requests/requestLedger.d.ts +8 -0
- package/dist/requests/requestLedger.d.ts.map +1 -1
- package/dist/requests/requestLedger.js +19 -1
- package/dist/requests/requestLedger.js.map +1 -1
- package/dist/scheduler/mcpSurface.js +4 -4
- package/dist/scheduler/mcpSurface.js.map +1 -1
- package/dist/shutdown.d.ts +14 -0
- package/dist/shutdown.d.ts.map +1 -1
- package/dist/shutdown.js +16 -0
- package/dist/shutdown.js.map +1 -1
- package/dist/state.d.ts +54 -2
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +62 -1
- package/dist/state.js.map +1 -1
- package/dist/stt/sttAutoInstall.d.ts +39 -0
- package/dist/stt/sttAutoInstall.d.ts.map +1 -0
- package/dist/stt/sttAutoInstall.js +158 -0
- package/dist/stt/sttAutoInstall.js.map +1 -0
- package/dist/stt/sttChildEnv.d.ts +11 -0
- package/dist/stt/sttChildEnv.d.ts.map +1 -0
- package/dist/stt/sttChildEnv.js +30 -0
- package/dist/stt/sttChildEnv.js.map +1 -0
- package/dist/stt/sttConfigEdit.d.ts +71 -0
- package/dist/stt/sttConfigEdit.d.ts.map +1 -0
- package/dist/stt/sttConfigEdit.js +144 -0
- package/dist/stt/sttConfigEdit.js.map +1 -0
- package/dist/stt/sttInstall.d.ts +147 -0
- package/dist/stt/sttInstall.d.ts.map +1 -0
- package/dist/stt/sttInstall.js +491 -0
- package/dist/stt/sttInstall.js.map +1 -0
- package/dist/stt/sttInstallEntry.d.ts +2 -0
- package/dist/stt/sttInstallEntry.d.ts.map +1 -0
- package/dist/stt/sttInstallEntry.js +31 -0
- package/dist/stt/sttInstallEntry.js.map +1 -0
- package/dist/stt/sttInstallReporter.d.ts +45 -0
- package/dist/stt/sttInstallReporter.d.ts.map +1 -0
- package/dist/stt/sttInstallReporter.js +57 -0
- package/dist/stt/sttInstallReporter.js.map +1 -0
- package/dist/stt/sttRuntime.d.ts +35 -0
- package/dist/stt/sttRuntime.d.ts.map +1 -0
- package/dist/stt/sttRuntime.js +73 -0
- package/dist/stt/sttRuntime.js.map +1 -0
- package/dist/stt/sttScreenPlan.d.ts +193 -0
- package/dist/stt/sttScreenPlan.d.ts.map +1 -0
- package/dist/stt/sttScreenPlan.js +541 -0
- package/dist/stt/sttScreenPlan.js.map +1 -0
- package/dist/stt/sttSettings.d.ts +201 -0
- package/dist/stt/sttSettings.d.ts.map +1 -0
- package/dist/stt/sttSettings.js +387 -0
- package/dist/stt/sttSettings.js.map +1 -0
- package/dist/stt/sttSidecarOwner.d.ts +115 -0
- package/dist/stt/sttSidecarOwner.d.ts.map +1 -0
- package/dist/stt/sttSidecarOwner.js +475 -0
- package/dist/stt/sttSidecarOwner.js.map +1 -0
- package/dist/stt/transcribeChain.d.ts +36 -0
- package/dist/stt/transcribeChain.d.ts.map +1 -0
- package/dist/stt/transcribeChain.js +42 -0
- package/dist/stt/transcribeChain.js.map +1 -0
- package/dist/types.d.ts +77 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/adapterSwitchSessionIds.d.ts +32 -0
- package/dist/utils/adapterSwitchSessionIds.d.ts.map +1 -0
- package/dist/utils/adapterSwitchSessionIds.js +44 -0
- package/dist/utils/adapterSwitchSessionIds.js.map +1 -0
- package/dist/utils/backendSwitchPlan.d.ts +63 -0
- package/dist/utils/backendSwitchPlan.d.ts.map +1 -0
- package/dist/utils/backendSwitchPlan.js +73 -0
- package/dist/utils/backendSwitchPlan.js.map +1 -0
- package/dist/utils/claudeAccountPickerPlan.d.ts +12 -1
- package/dist/utils/claudeAccountPickerPlan.d.ts.map +1 -1
- package/dist/utils/claudeAccountPickerPlan.js +20 -1
- package/dist/utils/claudeAccountPickerPlan.js.map +1 -1
- package/dist/utils/claudeTranscriptContent.d.ts +24 -0
- package/dist/utils/claudeTranscriptContent.d.ts.map +1 -0
- package/dist/utils/claudeTranscriptContent.js +107 -0
- package/dist/utils/claudeTranscriptContent.js.map +1 -0
- package/dist/utils/claudeUsage.d.ts +47 -8
- package/dist/utils/claudeUsage.d.ts.map +1 -1
- package/dist/utils/claudeUsage.js +76 -14
- package/dist/utils/claudeUsage.js.map +1 -1
- package/dist/utils/claudeUsageRefresher.d.ts +8 -1
- package/dist/utils/claudeUsageRefresher.d.ts.map +1 -1
- package/dist/utils/claudeUsageRefresher.js +9 -4
- package/dist/utils/claudeUsageRefresher.js.map +1 -1
- package/dist/utils/effortPickerPlan.d.ts +54 -0
- package/dist/utils/effortPickerPlan.d.ts.map +1 -0
- package/dist/utils/effortPickerPlan.js +56 -0
- package/dist/utils/effortPickerPlan.js.map +1 -0
- package/dist/utils/ensureSessionPlan.d.ts +20 -4
- package/dist/utils/ensureSessionPlan.d.ts.map +1 -1
- package/dist/utils/ensureSessionPlan.js +17 -4
- package/dist/utils/ensureSessionPlan.js.map +1 -1
- package/dist/utils/fallbackBridge.d.ts +6 -2
- package/dist/utils/fallbackBridge.d.ts.map +1 -1
- package/dist/utils/fallbackBridge.js +10 -3
- package/dist/utils/fallbackBridge.js.map +1 -1
- package/dist/utils/fallbackChain.d.ts +36 -0
- package/dist/utils/fallbackChain.d.ts.map +1 -1
- package/dist/utils/fallbackChain.js +65 -1
- package/dist/utils/fallbackChain.js.map +1 -1
- package/dist/utils/fallbackSwitchNotice.d.ts +41 -0
- package/dist/utils/fallbackSwitchNotice.d.ts.map +1 -0
- package/dist/utils/fallbackSwitchNotice.js +75 -0
- package/dist/utils/fallbackSwitchNotice.js.map +1 -0
- package/dist/utils/fileSendService.d.ts +33 -10
- package/dist/utils/fileSendService.d.ts.map +1 -1
- package/dist/utils/fileSendService.js +78 -27
- package/dist/utils/fileSendService.js.map +1 -1
- package/dist/utils/jsonStreamHost.d.ts +39 -0
- package/dist/utils/jsonStreamHost.d.ts.map +1 -1
- package/dist/utils/jsonStreamHost.js +60 -1
- package/dist/utils/jsonStreamHost.js.map +1 -1
- package/dist/utils/limitEpisodeRecovery.d.ts +16 -2
- package/dist/utils/limitEpisodeRecovery.d.ts.map +1 -1
- package/dist/utils/limitEpisodeRecovery.js +44 -3
- package/dist/utils/limitEpisodeRecovery.js.map +1 -1
- package/dist/utils/limitSettings.d.ts +25 -0
- package/dist/utils/limitSettings.d.ts.map +1 -0
- package/dist/utils/limitSettings.js +31 -0
- package/dist/utils/limitSettings.js.map +1 -0
- package/dist/utils/limitsBoardPlan.d.ts +52 -0
- package/dist/utils/limitsBoardPlan.d.ts.map +1 -0
- package/dist/utils/limitsBoardPlan.js +93 -0
- package/dist/utils/limitsBoardPlan.js.map +1 -0
- package/dist/utils/openCodeUsage.d.ts +93 -0
- package/dist/utils/openCodeUsage.d.ts.map +1 -0
- package/dist/utils/openCodeUsage.js +248 -0
- package/dist/utils/openCodeUsage.js.map +1 -0
- package/dist/utils/operatorSwitchPlan.d.ts +61 -0
- package/dist/utils/operatorSwitchPlan.d.ts.map +1 -0
- package/dist/utils/operatorSwitchPlan.js +32 -0
- package/dist/utils/operatorSwitchPlan.js.map +1 -0
- package/dist/utils/promptDelivery.d.ts +40 -0
- package/dist/utils/promptDelivery.d.ts.map +1 -1
- package/dist/utils/promptDelivery.js +31 -0
- package/dist/utils/promptDelivery.js.map +1 -1
- package/dist/utils/recvPreviewRedaction.d.ts +14 -4
- package/dist/utils/recvPreviewRedaction.d.ts.map +1 -1
- package/dist/utils/recvPreviewRedaction.js +19 -7
- package/dist/utils/recvPreviewRedaction.js.map +1 -1
- package/dist/utils/startupReadiness.d.ts +3 -3
- package/dist/utils/startupReadiness.d.ts.map +1 -1
- package/dist/utils/startupReadiness.js +2 -2
- package/dist/utils/startupReadiness.js.map +1 -1
- package/dist/utils/threadStatusReport.d.ts +3 -1
- package/dist/utils/threadStatusReport.d.ts.map +1 -1
- package/dist/utils/threadStatusReport.js +4 -3
- package/dist/utils/threadStatusReport.js.map +1 -1
- package/dist/utils/timezone.d.ts +23 -0
- package/dist/utils/timezone.d.ts.map +1 -1
- package/dist/utils/timezone.js +38 -0
- package/dist/utils/timezone.js.map +1 -1
- package/dist/utils/transcribeAudio.d.ts +25 -12
- package/dist/utils/transcribeAudio.d.ts.map +1 -1
- package/dist/utils/transcribeAudio.js +39 -21
- package/dist/utils/transcribeAudio.js.map +1 -1
- package/dist/utils/usageRefreshBudget.d.ts +9 -0
- package/dist/utils/usageRefreshBudget.d.ts.map +1 -0
- package/dist/utils/usageRefreshBudget.js +27 -0
- package/dist/utils/usageRefreshBudget.js.map +1 -0
- package/examples/.env.example +7 -3
- package/examples/docker-compose.yml +9 -7
- package/package.json +5 -4
- 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
|
|
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
|
|
560
|
-
// at record time so the secret never
|
|
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
|
|
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
|
-
// `/
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
1225
|
+
apiRetryTimers.set((0, sessionKey_1.keyToString)(key), {
|
|
1174
1226
|
timer,
|
|
1175
|
-
attempt:
|
|
1176
|
-
kind:
|
|
1227
|
+
attempt: arming.attempt,
|
|
1228
|
+
kind: arming.kind,
|
|
1177
1229
|
firedAt: null,
|
|
1178
|
-
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
|
-
* `/
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
3262
|
-
*
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
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 (
|
|
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
|
-
//
|
|
3537
|
-
//
|
|
3538
|
-
// the
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
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 =
|
|
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
|
-
|
|
4002
|
+
const resumeFailure = await resumeSleepingSession(key, plan.sessionId);
|
|
4003
|
+
if (resumeFailure === null)
|
|
3861
4004
|
return { ok: true, message: '', isFresh: false };
|
|
3862
|
-
|
|
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
|
-
* `
|
|
3900
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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`)
|
|
4474
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5777
|
-
*
|
|
5778
|
-
*
|
|
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
|
-
|
|
5782
|
-
if (armed?.kind === 'usageLimit' && checkIsClaudeProfileBackend(key)) {
|
|
5944
|
+
if (checkIsClaudeProfileBackend(key)) {
|
|
5783
5945
|
const from = getTopicFallbackPosition(key);
|
|
5784
|
-
const to = state.resolveClaudeProfileName(key);
|
|
5785
|
-
|
|
5786
|
-
|
|
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
|
|
5883
|
-
function getFallbackSwitchedNotice(from, to) {
|
|
5884
|
-
|
|
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
|
-
/**
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
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
|
|
5897
|
-
*
|
|
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
|
|
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
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
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
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
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
|
|
5924
|
-
* rung changes the session's per-prompt model. ACROSS backends the topic's adapter
|
|
5925
|
-
* `/opencode` path: the old session is stopped and its id parked for a switch back), the
|
|
5926
|
-
* or resumed, and its first prompt is the context bridge instead of the plain "continue"
|
|
5927
|
-
* armed limit wait is disarmed and the prompt goes the way the wait's own resume would
|
|
5928
|
-
*
|
|
5929
|
-
*
|
|
5930
|
-
*
|
|
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
|
-
|
|
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 (
|
|
5949
|
-
|
|
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,
|
|
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,
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
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 ?
|
|
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
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
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 (
|
|
5991
|
-
|
|
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 ${
|
|
6202
|
+
console.warn(`[fallback] ${k}: pinning the model ${options.modelToPin} failed: ${error}`);
|
|
5994
6203
|
}
|
|
5995
|
-
const prompt =
|
|
5996
|
-
|
|
5997
|
-
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
6967
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8096
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
9489
|
-
{ command: 'limits', description: '🚧
|
|
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
|
-
* `/
|
|
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
|
|
10016
|
-
*
|
|
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
|
-
|
|
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: () =>
|
|
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({
|