charness 3.3.2 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +639 -0
- package/DEVELOPMENT.md +76 -0
- package/README.md +78 -7
- package/SETUP.md +65 -19
- package/dist/adapters/claudeCliAdapter.js +2 -2
- package/dist/adapters/claudeCliAdapter.js.map +1 -1
- package/dist/adapters/claudeConfigDir.d.ts +18 -0
- package/dist/adapters/claudeConfigDir.d.ts.map +1 -1
- package/dist/adapters/claudeJsonStreamAdapter.d.ts +8 -1
- package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
- package/dist/adapters/claudeJsonStreamAdapter.js +24 -6
- package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
- package/dist/adapters/createAdapter.d.ts +2 -1
- package/dist/adapters/createAdapter.d.ts.map +1 -1
- package/dist/adapters/createAdapter.js +16 -0
- package/dist/adapters/createAdapter.js.map +1 -1
- package/dist/adapters/openCodeAdapter.d.ts +11 -0
- package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/openCodeAdapter.js +24 -9
- package/dist/adapters/openCodeAdapter.js.map +1 -1
- package/dist/agentLogin/agentLogin.d.ts +19 -1
- package/dist/agentLogin/agentLogin.d.ts.map +1 -1
- package/dist/agentLogin/agentLogin.js +64 -18
- package/dist/agentLogin/agentLogin.js.map +1 -1
- package/dist/apiErrorRetry.d.ts +3 -1
- package/dist/apiErrorRetry.d.ts.map +1 -1
- package/dist/apiErrorRetry.js +11 -1
- package/dist/apiErrorRetry.js.map +1 -1
- package/dist/bot.d.ts +0 -3
- package/dist/bot.d.ts.map +1 -1
- package/dist/bot.js +487 -182
- package/dist/bot.js.map +1 -1
- package/dist/changelogEntries.d.ts +25 -0
- package/dist/changelogEntries.d.ts.map +1 -0
- package/dist/changelogEntries.js +66 -0
- package/dist/changelogEntries.js.map +1 -0
- package/dist/cli/bot.d.ts +6 -2
- package/dist/cli/bot.d.ts.map +1 -1
- package/dist/cli/bot.js +34 -2
- package/dist/cli/bot.js.map +1 -1
- package/dist/cli/claudeProfiles.d.ts +1 -1
- package/dist/cli/claudeProfiles.d.ts.map +1 -1
- package/dist/cli/claudeProfiles.js +11 -6
- package/dist/cli/claudeProfiles.js.map +1 -1
- package/dist/cli/hot.d.ts +7 -0
- package/dist/cli/hot.d.ts.map +1 -1
- package/dist/cli/hot.js +53 -3
- package/dist/cli/hot.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -1
- package/dist/connectors/telegram/bindPicker.d.ts +64 -0
- package/dist/connectors/telegram/bindPicker.d.ts.map +1 -0
- package/dist/connectors/telegram/bindPicker.js +97 -0
- package/dist/connectors/telegram/bindPicker.js.map +1 -0
- package/dist/connectors/telegram/commands/claudeAccounts.d.ts +6 -2
- package/dist/connectors/telegram/commands/claudeAccounts.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/claudeAccounts.js +127 -28
- package/dist/connectors/telegram/commands/claudeAccounts.js.map +1 -1
- package/dist/connectors/telegram/commands/fallbackChain.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/fallbackChain.js +6 -2
- package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -1
- package/dist/connectors/telegram/commands/selfUpdate.d.ts +16 -0
- package/dist/connectors/telegram/commands/selfUpdate.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/selfUpdate.js +210 -0
- package/dist/connectors/telegram/commands/selfUpdate.js.map +1 -0
- package/dist/connectors/telegram/commands/stt.d.ts +1 -0
- package/dist/connectors/telegram/commands/stt.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/stt.js +5 -0
- package/dist/connectors/telegram/commands/stt.js.map +1 -1
- package/dist/connectors/telegram/commands/topicCommand.d.ts +75 -0
- package/dist/connectors/telegram/commands/topicCommand.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/topicCommand.js +391 -0
- package/dist/connectors/telegram/commands/topicCommand.js.map +1 -0
- package/dist/i18n/de.d.ts.map +1 -1
- package/dist/i18n/de.js +136 -3
- package/dist/i18n/de.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +136 -3
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +136 -3
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/fr.d.ts.map +1 -1
- package/dist/i18n/fr.js +136 -3
- package/dist/i18n/fr.js.map +1 -1
- package/dist/i18n/hi.d.ts.map +1 -1
- package/dist/i18n/hi.js +136 -3
- package/dist/i18n/hi.js.map +1 -1
- package/dist/i18n/ja.d.ts.map +1 -1
- package/dist/i18n/ja.js +136 -3
- package/dist/i18n/ja.js.map +1 -1
- package/dist/i18n/ka.d.ts.map +1 -1
- package/dist/i18n/ka.js +136 -3
- package/dist/i18n/ka.js.map +1 -1
- package/dist/i18n/pt.d.ts.map +1 -1
- package/dist/i18n/pt.js +136 -3
- package/dist/i18n/pt.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +136 -3
- package/dist/i18n/ru.js.map +1 -1
- package/dist/i18n/uk.d.ts.map +1 -1
- package/dist/i18n/uk.js +136 -3
- package/dist/i18n/uk.js.map +1 -1
- package/dist/i18n/uz.d.ts.map +1 -1
- package/dist/i18n/uz.js +136 -3
- package/dist/i18n/uz.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +136 -3
- package/dist/i18n/zh.js.map +1 -1
- package/dist/instanceSettings.d.ts +12 -1
- package/dist/instanceSettings.d.ts.map +1 -1
- package/dist/instanceSettings.js +40 -4
- package/dist/instanceSettings.js.map +1 -1
- package/dist/scheduler/mcpSurface.d.ts.map +1 -1
- package/dist/scheduler/mcpSurface.js +4 -2
- package/dist/scheduler/mcpSurface.js.map +1 -1
- package/dist/selfUpdate/bootRollback.d.ts +22 -0
- package/dist/selfUpdate/bootRollback.d.ts.map +1 -0
- package/dist/selfUpdate/bootRollback.js +79 -0
- package/dist/selfUpdate/bootRollback.js.map +1 -0
- package/dist/selfUpdate/commandRunner.d.ts +21 -0
- package/dist/selfUpdate/commandRunner.d.ts.map +1 -0
- package/dist/selfUpdate/commandRunner.js +32 -0
- package/dist/selfUpdate/commandRunner.js.map +1 -0
- package/dist/selfUpdate/constants.d.ts +26 -0
- package/dist/selfUpdate/constants.d.ts.map +1 -0
- package/dist/selfUpdate/constants.js +31 -0
- package/dist/selfUpdate/constants.js.map +1 -0
- package/dist/selfUpdate/gitSource.d.ts +58 -0
- package/dist/selfUpdate/gitSource.d.ts.map +1 -0
- package/dist/selfUpdate/gitSource.js +183 -0
- package/dist/selfUpdate/gitSource.js.map +1 -0
- package/dist/selfUpdate/hostLock.d.ts +16 -0
- package/dist/selfUpdate/hostLock.d.ts.map +1 -0
- package/dist/selfUpdate/hostLock.js +101 -0
- package/dist/selfUpdate/hostLock.js.map +1 -0
- package/dist/selfUpdate/hotSupervisor.d.ts +47 -0
- package/dist/selfUpdate/hotSupervisor.d.ts.map +1 -0
- package/dist/selfUpdate/hotSupervisor.js +125 -0
- package/dist/selfUpdate/hotSupervisor.js.map +1 -0
- package/dist/selfUpdate/installKind.d.ts +45 -0
- package/dist/selfUpdate/installKind.d.ts.map +1 -0
- package/dist/selfUpdate/installKind.js +99 -0
- package/dist/selfUpdate/installKind.js.map +1 -0
- package/dist/selfUpdate/launchEnvironment.d.ts +3 -0
- package/dist/selfUpdate/launchEnvironment.d.ts.map +1 -0
- package/dist/selfUpdate/launchEnvironment.js +16 -0
- package/dist/selfUpdate/launchEnvironment.js.map +1 -0
- package/dist/selfUpdate/npmSource.d.ts +34 -0
- package/dist/selfUpdate/npmSource.d.ts.map +1 -0
- package/dist/selfUpdate/npmSource.js +107 -0
- package/dist/selfUpdate/npmSource.js.map +1 -0
- package/dist/selfUpdate/releaseNotes.d.ts +33 -0
- package/dist/selfUpdate/releaseNotes.d.ts.map +1 -0
- package/dist/selfUpdate/releaseNotes.js +115 -0
- package/dist/selfUpdate/releaseNotes.js.map +1 -0
- package/dist/selfUpdate/restart.d.ts +33 -0
- package/dist/selfUpdate/restart.d.ts.map +1 -0
- package/dist/selfUpdate/restart.js +94 -0
- package/dist/selfUpdate/restart.js.map +1 -0
- package/dist/selfUpdate/selfCheck.d.ts +20 -0
- package/dist/selfUpdate/selfCheck.d.ts.map +1 -0
- package/dist/selfUpdate/selfCheck.js +126 -0
- package/dist/selfUpdate/selfCheck.js.map +1 -0
- package/dist/selfUpdate/semver.d.ts +24 -0
- package/dist/selfUpdate/semver.d.ts.map +1 -0
- package/dist/selfUpdate/semver.js +165 -0
- package/dist/selfUpdate/semver.js.map +1 -0
- package/dist/selfUpdate/types.d.ts +51 -0
- package/dist/selfUpdate/types.d.ts.map +1 -0
- package/dist/selfUpdate/types.js +3 -0
- package/dist/selfUpdate/types.js.map +1 -0
- package/dist/selfUpdate/updateEngine.d.ts +110 -0
- package/dist/selfUpdate/updateEngine.d.ts.map +1 -0
- package/dist/selfUpdate/updateEngine.js +727 -0
- package/dist/selfUpdate/updateEngine.js.map +1 -0
- package/dist/selfUpdate/updateMessages.d.ts +84 -0
- package/dist/selfUpdate/updateMessages.d.ts.map +1 -0
- package/dist/selfUpdate/updateMessages.js +115 -0
- package/dist/selfUpdate/updateMessages.js.map +1 -0
- package/dist/selfUpdate/updateModes.d.ts +17 -0
- package/dist/selfUpdate/updateModes.d.ts.map +1 -0
- package/dist/selfUpdate/updateModes.js +14 -0
- package/dist/selfUpdate/updateModes.js.map +1 -0
- package/dist/selfUpdate/updatePending.d.ts +76 -0
- package/dist/selfUpdate/updatePending.d.ts.map +1 -0
- package/dist/selfUpdate/updatePending.js +131 -0
- package/dist/selfUpdate/updatePending.js.map +1 -0
- package/dist/selfUpdate/updatePolicy.d.ts +115 -0
- package/dist/selfUpdate/updatePolicy.d.ts.map +1 -0
- package/dist/selfUpdate/updatePolicy.js +83 -0
- package/dist/selfUpdate/updatePolicy.js.map +1 -0
- package/dist/selfUpdate/updateRuntime.d.ts +22 -0
- package/dist/selfUpdate/updateRuntime.d.ts.map +1 -0
- package/dist/selfUpdate/updateRuntime.js +148 -0
- package/dist/selfUpdate/updateRuntime.js.map +1 -0
- package/dist/startupPromptBuffer.d.ts +2 -0
- package/dist/startupPromptBuffer.d.ts.map +1 -1
- package/dist/startupPromptBuffer.js +4 -0
- package/dist/startupPromptBuffer.js.map +1 -1
- package/dist/state.d.ts +43 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +54 -0
- package/dist/state.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/anthropicApiKeyCheck.d.ts +19 -0
- package/dist/utils/anthropicApiKeyCheck.d.ts.map +1 -0
- package/dist/utils/anthropicApiKeyCheck.js +41 -0
- package/dist/utils/anthropicApiKeyCheck.js.map +1 -0
- package/dist/utils/claudeAccountPickerPlan.d.ts +6 -1
- package/dist/utils/claudeAccountPickerPlan.d.ts.map +1 -1
- package/dist/utils/claudeAccountPickerPlan.js +5 -1
- package/dist/utils/claudeAccountPickerPlan.js.map +1 -1
- package/dist/utils/claudeApiKeyHelper.d.ts +19 -0
- package/dist/utils/claudeApiKeyHelper.d.ts.map +1 -0
- package/dist/utils/claudeApiKeyHelper.js +32 -0
- package/dist/utils/claudeApiKeyHelper.js.map +1 -0
- package/dist/utils/claudeApiSpend.d.ts +73 -0
- package/dist/utils/claudeApiSpend.d.ts.map +1 -0
- package/dist/utils/claudeApiSpend.js +82 -0
- package/dist/utils/claudeApiSpend.js.map +1 -0
- package/dist/utils/claudeCompactHook.d.ts +11 -6
- package/dist/utils/claudeCompactHook.d.ts.map +1 -1
- package/dist/utils/claudeCompactHook.js +28 -11
- package/dist/utils/claudeCompactHook.js.map +1 -1
- package/dist/utils/claudeLimitSwitch.d.ts +2 -1
- package/dist/utils/claudeLimitSwitch.d.ts.map +1 -1
- package/dist/utils/claudeLimitSwitch.js +3 -2
- package/dist/utils/claudeLimitSwitch.js.map +1 -1
- package/dist/utils/claudeProfileUsageLines.d.ts +18 -3
- package/dist/utils/claudeProfileUsageLines.d.ts.map +1 -1
- package/dist/utils/claudeProfileUsageLines.js +23 -2
- package/dist/utils/claudeProfileUsageLines.js.map +1 -1
- package/dist/utils/claudeProfiles.d.ts +57 -2
- package/dist/utils/claudeProfiles.d.ts.map +1 -1
- package/dist/utils/claudeProfiles.js +96 -3
- package/dist/utils/claudeProfiles.js.map +1 -1
- package/dist/utils/claudeStreamJson.d.ts +4 -1
- package/dist/utils/claudeStreamJson.d.ts.map +1 -1
- package/dist/utils/claudeStreamJson.js +6 -0
- package/dist/utils/claudeStreamJson.js.map +1 -1
- package/dist/utils/claudeTranscriptContent.d.ts +8 -0
- package/dist/utils/claudeTranscriptContent.d.ts.map +1 -1
- package/dist/utils/claudeTranscriptContent.js +44 -0
- package/dist/utils/claudeTranscriptContent.js.map +1 -1
- package/dist/utils/claudeUsage.d.ts +22 -6
- package/dist/utils/claudeUsage.d.ts.map +1 -1
- package/dist/utils/claudeUsage.js +12 -5
- package/dist/utils/claudeUsage.js.map +1 -1
- package/dist/utils/claudeUsageRefresher.d.ts +6 -0
- package/dist/utils/claudeUsageRefresher.d.ts.map +1 -1
- package/dist/utils/claudeUsageRefresher.js +2 -2
- package/dist/utils/claudeUsageRefresher.js.map +1 -1
- package/dist/utils/fallbackChain.d.ts +15 -8
- package/dist/utils/fallbackChain.d.ts.map +1 -1
- package/dist/utils/fallbackChain.js +30 -13
- package/dist/utils/fallbackChain.js.map +1 -1
- package/dist/utils/limitsBoardPlan.d.ts +4 -2
- package/dist/utils/limitsBoardPlan.d.ts.map +1 -1
- package/dist/utils/limitsBoardPlan.js +12 -5
- package/dist/utils/limitsBoardPlan.js.map +1 -1
- package/dist/utils/recvPreviewRedaction.d.ts +7 -2
- package/dist/utils/recvPreviewRedaction.d.ts.map +1 -1
- package/dist/utils/recvPreviewRedaction.js +10 -4
- package/dist/utils/recvPreviewRedaction.js.map +1 -1
- package/dist/utils/threadStatusReport.d.ts +12 -1
- package/dist/utils/threadStatusReport.d.ts.map +1 -1
- package/dist/utils/threadStatusReport.js +13 -4
- package/dist/utils/threadStatusReport.js.map +1 -1
- package/dist/utils/updateScreenPlan.d.ts +38 -0
- package/dist/utils/updateScreenPlan.d.ts.map +1 -0
- package/dist/utils/updateScreenPlan.js +143 -0
- package/dist/utils/updateScreenPlan.js.map +1 -0
- package/package.json +3 -1
package/dist/bot.js
CHANGED
|
@@ -34,7 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.COMMANDS_MENU = exports.telegramInbound = exports.routedCommandTriggerDeps = void 0;
|
|
37
|
-
exports.buildBindKeyboard = buildBindKeyboard;
|
|
38
37
|
exports.getStartReadyMessage = getStartReadyMessage;
|
|
39
38
|
exports.checkIsRoutedCommand = checkIsRoutedCommand;
|
|
40
39
|
exports.handleRoutedCommandMessage = handleRoutedCommandMessage;
|
|
@@ -84,6 +83,8 @@ const timezone_1 = require("./utils/timezone");
|
|
|
84
83
|
const timezonePicker_1 = require("./connectors/telegram/timezonePicker");
|
|
85
84
|
const timezoneRecompute_1 = require("./scheduler/timezoneRecompute");
|
|
86
85
|
const validation_1 = require("./validation");
|
|
86
|
+
const bindPicker_1 = require("./connectors/telegram/bindPicker");
|
|
87
|
+
const topicCommand_1 = require("./connectors/telegram/commands/topicCommand");
|
|
87
88
|
const providerDisconnectPlan_1 = require("./utils/providerDisconnectPlan");
|
|
88
89
|
const folderName_1 = require("./folderName");
|
|
89
90
|
const threadRouting_1 = require("./threadRouting");
|
|
@@ -157,6 +158,13 @@ const claudeLivenessAction_1 = require("./utils/claudeLivenessAction");
|
|
|
157
158
|
const isoTimestamp_1 = require("./utils/isoTimestamp");
|
|
158
159
|
const effortLevels_1 = require("./effortLevels");
|
|
159
160
|
const instanceSettings_1 = require("./instanceSettings");
|
|
161
|
+
const constants_1 = require("./selfUpdate/constants");
|
|
162
|
+
const launchEnvironment_1 = require("./selfUpdate/launchEnvironment");
|
|
163
|
+
const restart_1 = require("./selfUpdate/restart");
|
|
164
|
+
const updateEngine_1 = require("./selfUpdate/updateEngine");
|
|
165
|
+
const updateMessages_1 = require("./selfUpdate/updateMessages");
|
|
166
|
+
const updatePolicy_1 = require("./selfUpdate/updatePolicy");
|
|
167
|
+
const updateRuntime_1 = require("./selfUpdate/updateRuntime");
|
|
160
168
|
const threadContextPreamble_1 = require("./threadContextPreamble");
|
|
161
169
|
const pinnedBannerSkipDecision_1 = require("./utils/pinnedBannerSkipDecision");
|
|
162
170
|
const replyQuote_1 = require("./utils/replyQuote");
|
|
@@ -207,6 +215,8 @@ const claudeUsageRefresher_1 = require("./utils/claudeUsageRefresher");
|
|
|
207
215
|
const openCodeUsage_1 = require("./utils/openCodeUsage");
|
|
208
216
|
const usageRefreshBudget_1 = require("./utils/usageRefreshBudget");
|
|
209
217
|
const claudeLimitSwitch_1 = require("./utils/claudeLimitSwitch");
|
|
218
|
+
const anthropicApiKeyCheck_1 = require("./utils/anthropicApiKeyCheck");
|
|
219
|
+
const claudeApiKeyHelper_1 = require("./utils/claudeApiKeyHelper");
|
|
210
220
|
const fallbackSwitchNotice_1 = require("./utils/fallbackSwitchNotice");
|
|
211
221
|
const adapterSwitchSessionIds_1 = require("./utils/adapterSwitchSessionIds");
|
|
212
222
|
const fallbackChain_1 = require("./utils/fallbackChain");
|
|
@@ -216,7 +226,10 @@ const limitsBoard_1 = require("./connectors/telegram/commands/limitsBoard");
|
|
|
216
226
|
const backendSwitch_1 = require("./connectors/telegram/commands/backendSwitch");
|
|
217
227
|
const operatorSwitchPlan_1 = require("./utils/operatorSwitchPlan");
|
|
218
228
|
const backendSwitchPlan_1 = require("./utils/backendSwitchPlan");
|
|
229
|
+
const selfUpdate_1 = require("./connectors/telegram/commands/selfUpdate");
|
|
219
230
|
const stt_1 = require("./connectors/telegram/commands/stt");
|
|
231
|
+
const claudeApiSpend_1 = require("./utils/claudeApiSpend");
|
|
232
|
+
const claudeProfileUsageLines_1 = require("./utils/claudeProfileUsageLines");
|
|
220
233
|
const resolveBinary_1 = require("./utils/resolveBinary");
|
|
221
234
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
222
235
|
// ENV parsing & fatal validation
|
|
@@ -595,6 +608,18 @@ bot.use(async (ctx, next) => {
|
|
|
595
608
|
});
|
|
596
609
|
return next();
|
|
597
610
|
});
|
|
611
|
+
/**
|
|
612
|
+
* Epoch ms of the last update a human sent (a message, a button tap), `null` since the start. The updater restarts the bot only
|
|
613
|
+
* after a quiet period (`quietPeriodMs`) — somebody typing right now is the one case it must not interrupt.
|
|
614
|
+
*/
|
|
615
|
+
let lastHumanInboundAt = null;
|
|
616
|
+
bot.use(async (ctx, next) => {
|
|
617
|
+
if (ctx.message || ctx.callbackQuery)
|
|
618
|
+
lastHumanInboundAt = Date.now();
|
|
619
|
+
return next();
|
|
620
|
+
});
|
|
621
|
+
/** The update engine of this process; built at boot (`startBot`), read by the `/update` command. */
|
|
622
|
+
let updateEngine = null;
|
|
598
623
|
// Locale is a Telegram-chat concern, not a process env knob. Every update runs
|
|
599
624
|
// inside an async i18n context: explicit `/language` override for the DM/group
|
|
600
625
|
// wins, else this sender's Telegram `language_code`, else the last seen Telegram
|
|
@@ -856,6 +881,12 @@ const limitResumeNoticePinnedMessageId = new Map();
|
|
|
856
881
|
* Cleared by {@link cancelApiRetry} (the user took over / the session went away).
|
|
857
882
|
*/
|
|
858
883
|
const autoContinueOffNoticedThreads = new Set();
|
|
884
|
+
/**
|
|
885
|
+
* @description Threads that already got the «API credits used up» notice for the CURRENT episode
|
|
886
|
+
* ({@link handleBalanceExhausted}): that flow arms no retry either, so — like the OFF notice above — a repeated frame
|
|
887
|
+
* of the same error needs its own guard. Cleared by {@link cancelApiRetry} (the user took over / the session went away).
|
|
888
|
+
*/
|
|
889
|
+
const balanceExhaustedNoticedThreads = new Set();
|
|
859
890
|
/**
|
|
860
891
|
* The OPEN «switch» offer per thread (`utils/fallbackChain.ts`): the limit episode the buttons on the notice
|
|
861
892
|
* were posted for, the rung the topic was on and the candidate list the buttons index into. A tap is honoured
|
|
@@ -1121,6 +1152,11 @@ const wedgeRecoveriesInFlight = new Set();
|
|
|
1121
1152
|
*/
|
|
1122
1153
|
function handleApiError(key, cls, options = {}) {
|
|
1123
1154
|
const k = (0, sessionKey_1.keyToString)(key);
|
|
1155
|
+
// A used-up API credit balance: no wait refills it — the account is marked, and the chain or the operator takes over.
|
|
1156
|
+
if (cls.kind === 'usageLimit' && cls.isBalanceExhausted === true) {
|
|
1157
|
+
void handleBalanceExhausted(key, cls);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1124
1160
|
// An unconfirmed usage limit is probed first; it reaches the wait below only once confirmed.
|
|
1125
1161
|
if (cls.kind === 'usageLimit' && cls.isUnconfirmed === true && handleUnconfirmedLimit(key))
|
|
1126
1162
|
return;
|
|
@@ -1215,7 +1251,7 @@ function armApiRetryTimer(key, arming) {
|
|
|
1215
1251
|
// bottom (debounced; no-op when no question is pending).
|
|
1216
1252
|
onThreadActivityWhileQuestionPending(key);
|
|
1217
1253
|
void state
|
|
1218
|
-
.setApiRetry(key, { kind: arming.kind, attempt: arming.attempt, fireAt: arming.fireAt, ...(arming.isLimitProbe ? { isLimitProbe: true } : {}) })
|
|
1254
|
+
.setApiRetry(key, { kind: arming.kind, attempt: arming.attempt, fireAt: arming.fireAt, ...(arming.isLimitProbe ? { isLimitProbe: true } : {}), ...(arming.resumeOn ? { resumeOn: arming.resumeOn } : {}) })
|
|
1219
1255
|
.catch(e => console.error('[apiRetry] persist failed:', e));
|
|
1220
1256
|
clearSavedFiredLimitProbe(key);
|
|
1221
1257
|
const timer = setTimeout(() => {
|
|
@@ -1231,6 +1267,7 @@ function armApiRetryTimer(key, arming) {
|
|
|
1231
1267
|
fireAt: arming.fireAt,
|
|
1232
1268
|
limitWait: arming.limitWait,
|
|
1233
1269
|
isLimitProbe: arming.isLimitProbe,
|
|
1270
|
+
resumeOn: arming.resumeOn,
|
|
1234
1271
|
});
|
|
1235
1272
|
}
|
|
1236
1273
|
/**
|
|
@@ -1422,15 +1459,90 @@ async function announceUsageLimitOrSwitch(key, cls, episodeId, initialWait, atte
|
|
|
1422
1459
|
}
|
|
1423
1460
|
}
|
|
1424
1461
|
const texts = getLimitWaitTexts(key, wait, attempt, resumeOn);
|
|
1425
|
-
const keyboard = plan.action === 'offer'
|
|
1426
|
-
? telegraf_1.Markup.inlineKeyboard(plan.candidates.map((entry, index) => [
|
|
1427
|
-
telegraf_1.Markup.button.callback((0, i18n_1.t)('fallback.switchButton', { label: formatFallbackEntry(entry) }), (0, fallbackChain_1.buildFallbackSwitchCallbackData)(episodeId, index)),
|
|
1428
|
-
]))
|
|
1429
|
-
: undefined;
|
|
1462
|
+
const keyboard = plan.action === 'offer' ? buildFallbackSwitchKeyboard(episodeId, plan.candidates) : undefined;
|
|
1430
1463
|
const { isPlainNoticePosted } = await announceLimitWait(key, wait, texts, isPlainNoticeDue, keyboard);
|
|
1431
1464
|
if (keyboard && !isPlainNoticePosted)
|
|
1432
1465
|
await replyToThread(key, (0, i18n_1.t)('fallback.switchOffer'), keyboard);
|
|
1433
1466
|
}
|
|
1467
|
+
/** The «🔁 Switch to …» buttons of a limit episode's notice, one per candidate (best first; the index is into that episode's list). */
|
|
1468
|
+
function buildFallbackSwitchKeyboard(episodeId, candidates) {
|
|
1469
|
+
return telegraf_1.Markup.inlineKeyboard(candidates.map((entry, index) => [
|
|
1470
|
+
telegraf_1.Markup.button.callback((0, i18n_1.t)('fallback.switchButton', { label: formatFallbackEntry(entry) }), (0, fallbackChain_1.buildFallbackSwitchCallbackData)(episodeId, index)),
|
|
1471
|
+
]));
|
|
1472
|
+
}
|
|
1473
|
+
/** Whose API credits a balance notice names: a Claude account `«console»`, an OpenCode `provider/model`. */
|
|
1474
|
+
function formatBalanceOwner(from) {
|
|
1475
|
+
if (from === null)
|
|
1476
|
+
return (0, i18n_1.t)('fallback.backendClaude');
|
|
1477
|
+
return from.backend === 'claude' ? `«${from.profile}»` : (0, fallbackChain_1.buildOpenCodeModelLabel)(from);
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* @description A used-up API credit balance on `key` («Credit balance is too low», `isBalanceExhausted`): nothing resets
|
|
1481
|
+
* it, so no wait for a reset is armed and no hourly retry runs. The Claude account is marked out of credits
|
|
1482
|
+
* (`creditExhaustedAt`, until the operator picks it or signs it in again, or a billed turn on it goes through), the parked
|
|
1483
|
+
* episode is settled (a restart does not run this again), and the fallback chain decides as for a limit, with that
|
|
1484
|
+
* account out: switching ON moves the topic at once; OFF puts «switch to …» buttons on the notice. With no rung free
|
|
1485
|
+
* now, the topic waits only for a rung that comes back at a KNOWN time (the earliest reset, and only with «wait for
|
|
1486
|
+
* reset» on) and resumes there; otherwise the notice is all, and an open request is answered and no longer woken. A
|
|
1487
|
+
* repeat of the same episode (another frame, another scrape of the line, a scheduled run into the same balance) says
|
|
1488
|
+
* nothing in the topic again; a request opened since — a scheduled run's — is still answered and no longer woken, unless
|
|
1489
|
+
* a wait for another rung's reset is armed.
|
|
1490
|
+
*/
|
|
1491
|
+
async function handleBalanceExhausted(key, cls) {
|
|
1492
|
+
const k = (0, sessionKey_1.keyToString)(key);
|
|
1493
|
+
markLimitEpisodeSettled(key);
|
|
1494
|
+
const position = getTopicFallbackPosition(key);
|
|
1495
|
+
if (position?.backend === 'claude')
|
|
1496
|
+
state.setClaudeProfileCreditExhausted(position.profile, new Date().toISOString());
|
|
1497
|
+
const notice = `${(0, i18n_1.t)('apiRetry.balanceExhaustedNotice', { name: formatBalanceOwner(position) })}${getBalanceSpentLine(position)}`;
|
|
1498
|
+
const texts = { plainNotice: notice, answerBody: (0, i18n_1.t)('requests.limit.answerBalanceExhaustedNotice') };
|
|
1499
|
+
const stopped = { kind: 'autoResumeOff' };
|
|
1500
|
+
if (balanceExhaustedNoticedThreads.has(k)) {
|
|
1501
|
+
// Like the «wait for reset» OFF repeat: without it the wake-up engine re-prods that request into the same refused
|
|
1502
|
+
// balance and ends in an alert.
|
|
1503
|
+
if (getArmedApiRetry(key) === null)
|
|
1504
|
+
await announceLimitWait(key, stopped, texts, false);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
balanceExhaustedNoticedThreads.add(k);
|
|
1508
|
+
const episodeId = Date.now();
|
|
1509
|
+
let plan = { action: 'none', from: position, earliest: null };
|
|
1510
|
+
try {
|
|
1511
|
+
plan = await planFallbackSwitch(key, cls, episodeId);
|
|
1512
|
+
}
|
|
1513
|
+
catch (e) {
|
|
1514
|
+
console.warn(`[fallback] ${k}: the switch plan after a used-up balance failed:`, e instanceof Error ? e.message : e);
|
|
1515
|
+
}
|
|
1516
|
+
if (plan.action === 'auto') {
|
|
1517
|
+
const to = plan.to;
|
|
1518
|
+
await replyToThread(key, notice);
|
|
1519
|
+
const outcome = await switchTopicToFallbackEntry(key, plan.from, to, { isAnnounced: true }).catch((e) => {
|
|
1520
|
+
console.warn(`[fallback] ${k}: the auto-switch to ${formatFallbackEntry(to)} failed:`, e instanceof Error ? e.message : e);
|
|
1521
|
+
return { isSwitched: false };
|
|
1522
|
+
});
|
|
1523
|
+
// The notice is already in the topic; an open request still hears that the work stopped.
|
|
1524
|
+
if (!outcome.isSwitched)
|
|
1525
|
+
await announceLimitWait(key, stopped, texts, false);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (plan.action === 'offer') {
|
|
1529
|
+
const keyboard = buildFallbackSwitchKeyboard(episodeId, plan.candidates);
|
|
1530
|
+
const { isPlainNoticePosted } = await announceLimitWait(key, stopped, texts, true, keyboard);
|
|
1531
|
+
if (!isPlainNoticePosted)
|
|
1532
|
+
await replyToThread(key, (0, i18n_1.t)('fallback.switchOffer'), keyboard);
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (plan.earliest && state.checkIsAutoContinueOnLimitEnabled(key)) {
|
|
1536
|
+
const fireAt = plan.earliest.untilMs + apiErrorRetry_1.resetBufferMs;
|
|
1537
|
+
const wait = { kind: 'afterReset', resetAt: plan.earliest.untilMs, fireAt };
|
|
1538
|
+
clearLimitResumeNotice(key);
|
|
1539
|
+
armApiRetryTimer(key, { kind: 'usageLimit', attempt: 1, fireAt, delayMs: Math.max(fireAt - Date.now(), 0), limitWait: wait, resumeOn: plan.earliest.entry });
|
|
1540
|
+
const waitTexts = getLimitWaitTexts(key, wait, 1, plan.earliest.entry);
|
|
1541
|
+
await announceLimitWait(key, wait, { plainNotice: `${notice}\n\n${waitTexts.plainNotice}`, answerBody: waitTexts.answerBody }, true);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
await announceLimitWait(key, stopped, texts, true);
|
|
1545
|
+
}
|
|
1434
1546
|
/**
|
|
1435
1547
|
* @description Re-aim the ARMED usage-limit wait of `key` at the rung that comes back first: the timer moves to
|
|
1436
1548
|
* that rung's reset (+ the usual buffer), and the persisted retry record carries the rung (`resumeOn`), so the
|
|
@@ -1529,6 +1641,7 @@ function cancelApiRetry(key) {
|
|
|
1529
1641
|
// error — so neither can re-notify per message.
|
|
1530
1642
|
clearLimitResumeNotice(key);
|
|
1531
1643
|
autoContinueOffNoticedThreads.delete(k);
|
|
1644
|
+
balanceExhaustedNoticedThreads.delete(k);
|
|
1532
1645
|
fallbackSwitchOffers.delete(k);
|
|
1533
1646
|
// NOTE: intentionally does NOT touch the logged-out notice. `cancelApiRetry`
|
|
1534
1647
|
// fires on EVERY inbound message (user takeover); clearing the auth notice here
|
|
@@ -1720,13 +1833,16 @@ function clearLimitResumeNotice(key) {
|
|
|
1720
1833
|
* sign-in driver built here), so the driver reaches it through this late-bound slot.
|
|
1721
1834
|
*/
|
|
1722
1835
|
let claudeProfileAuthStatusSink = null;
|
|
1836
|
+
/** Where an API key pasted into an API account's sign-in goes: the `/account` module too, through the same late-bound idiom. */
|
|
1837
|
+
let claudeProfileApiKeySink = null;
|
|
1723
1838
|
// Out-of-band sign-in drivers (`/login`, `/connect` OAuth): `agentLogin/README.md`.
|
|
1724
|
-
const { cancelPendingLogin, checkIsAuthLoginAwaitingCode, cancelClaudeAuthLogin, startClaudeAuthLogin, submitClaudeAuthLoginCode, checkIsOpenCodeOAuthAwaitingReply, cancelOpenCodeOAuthLogin, startOpenCodeOAuthLogin, submitOpenCodeOAuthReply, } = (0, agentLogin_1.createAgentLogin)({
|
|
1839
|
+
const { cancelPendingLogin, checkIsAuthLoginAwaitingCode, cancelClaudeAuthLogin, startClaudeAuthLogin, submitClaudeAuthLoginCode, checkIsOpenCodeOAuthAwaitingReply, cancelOpenCodeOAuthLogin, startOpenCodeOAuthLogin, submitOpenCodeOAuthReply, checkIsAnyLoginInProgress, } = (0, agentLogin_1.createAgentLogin)({
|
|
1725
1840
|
replyToThread,
|
|
1726
1841
|
deleteThreadMessage,
|
|
1727
1842
|
clearAuthNotice,
|
|
1728
1843
|
execFileAsync,
|
|
1729
1844
|
onClaudeProfileAuthStatus: (key, profile, status) => claudeProfileAuthStatusSink?.(key, profile, status) ?? Promise.resolve(),
|
|
1845
|
+
onClaudeProfileApiKey: (key, profile, apiKey) => claudeProfileApiKeySink?.(key, profile, apiKey) ?? Promise.resolve((0, i18n_1.t)('account.apiKeyNoDirectory', { name: profile.name })),
|
|
1730
1846
|
});
|
|
1731
1847
|
/**
|
|
1732
1848
|
* @description Buffers prompts typed while an agent session is still booting so
|
|
@@ -3466,8 +3582,6 @@ function getWorkDirStartDecision(key) {
|
|
|
3466
3582
|
// `validateSubdir` + `BindError` live in `src/validation.ts` so the
|
|
3467
3583
|
// security-critical bits can be unit-tested without booting Telegraf
|
|
3468
3584
|
// (plan §11 Этап 7, R3). The import at the top of this file pulls them in.
|
|
3469
|
-
/** Inline-keyboard page size for `/bind`. 20 fits one phone screen comfortably. */
|
|
3470
|
-
const BIND_PAGE_SIZE = 20;
|
|
3471
3585
|
/**
|
|
3472
3586
|
* @description List immediate subdirectories of `WORK_ROOT` for the `/bind`
|
|
3473
3587
|
* inline-button picker and the `forum_topic_created` welcome message.
|
|
@@ -3519,71 +3633,6 @@ function listAvailableSubdirs(workRoot, limit = 200) {
|
|
|
3519
3633
|
subdirCache.set(cacheKey, { value: result, ts: Date.now() });
|
|
3520
3634
|
return result;
|
|
3521
3635
|
}
|
|
3522
|
-
/**
|
|
3523
|
-
* @description Build a `subdir`-suggestion keyboard for `/bind` and friends.
|
|
3524
|
-
*
|
|
3525
|
-
* Lists are paginated at {@link BIND_PAGE_SIZE} entries: a `WORK_ROOT` with
|
|
3526
|
-
* many subfolders (mono-repo workspace, packages dir, etc.) would otherwise
|
|
3527
|
-
* push the inline keyboard past Telegram's per-message height limit and
|
|
3528
|
-
* lose entries below the fold.
|
|
3529
|
-
*
|
|
3530
|
-
* The nav row appears only when there's more than one page. The middle
|
|
3531
|
-
* `pageLabel` button is a no-op (`bind_page_noop` callback) — it exists
|
|
3532
|
-
* solely so the user sees "2/5" without us having to fake a divider with
|
|
3533
|
-
* a separate message.
|
|
3534
|
-
*
|
|
3535
|
-
* Subdirs whose callback_data exceeds 64 bytes are filtered out upstream
|
|
3536
|
-
* in `listAvailableSubdirs`. `page` is clamped to a valid index so a stale
|
|
3537
|
-
* callback (folders disappeared since the keyboard was sent) just lands
|
|
3538
|
-
* on the last available page.
|
|
3539
|
-
*
|
|
3540
|
-
* When `isBound` the FIRST row is a full-width «leave current dir» button
|
|
3541
|
-
* (`bindLeaveCurrentCallback`, the folded-in `/unbind`); the «create new folder»
|
|
3542
|
-
* button (`bindCreateFolderCallback`) follows. Both callbacks deliberately do
|
|
3543
|
-
* NOT start with `bind_`, so they can't be mistaken for a folder pick by the
|
|
3544
|
-
* `bind_<subdir>` action regex. They ride on every page so they're reachable
|
|
3545
|
-
* regardless of pagination.
|
|
3546
|
-
*/
|
|
3547
|
-
const bindCreateFolderCallback = 'bindCreateFolder';
|
|
3548
|
-
/**
|
|
3549
|
-
* «Leave current dir» button — the folded-in `/unbind`. Like
|
|
3550
|
-
* `bindCreateFolderCallback`, the callback id deliberately does NOT start with
|
|
3551
|
-
* `bind_`, so the `bind_<subdir>` action regex can't mistake it for a folder
|
|
3552
|
-
* pick. Only rendered in a bound topic.
|
|
3553
|
-
*/
|
|
3554
|
-
const bindLeaveCurrentCallback = 'bindLeaveCurrent';
|
|
3555
|
-
function buildBindKeyboard(subdirs, page = 0, pageSize = BIND_PAGE_SIZE, isBound = false) {
|
|
3556
|
-
const { slice, currentPage, totalPages } = (0, validation_1.paginateBindList)(subdirs, page, pageSize);
|
|
3557
|
-
const rows = [
|
|
3558
|
-
[telegraf_1.Markup.button.callback((0, i18n_1.t)('bind.create_button'), bindCreateFolderCallback)],
|
|
3559
|
-
];
|
|
3560
|
-
// A bound topic gets a full-width «leave current dir» row PREPENDED above the
|
|
3561
|
-
// «create new folder» row — this is the folded-in `/unbind`. Its callback
|
|
3562
|
-
// deliberately avoids the `bind_` prefix so the `bind_<subdir>` action regex
|
|
3563
|
-
// can't catch it. The row is omitted when unbound (nothing to leave).
|
|
3564
|
-
if (isBound) {
|
|
3565
|
-
rows.unshift([telegraf_1.Markup.button.callback((0, i18n_1.t)('bind.leave_button'), bindLeaveCurrentCallback)]);
|
|
3566
|
-
}
|
|
3567
|
-
for (let i = 0; i < slice.length; i += 2) {
|
|
3568
|
-
const row = [telegraf_1.Markup.button.callback(`📁 ${slice[i]}`, `bind_${slice[i]}`)];
|
|
3569
|
-
if (slice[i + 1]) {
|
|
3570
|
-
row.push(telegraf_1.Markup.button.callback(`📁 ${slice[i + 1]}`, `bind_${slice[i + 1]}`));
|
|
3571
|
-
}
|
|
3572
|
-
rows.push(row);
|
|
3573
|
-
}
|
|
3574
|
-
if (totalPages > 1) {
|
|
3575
|
-
const nav = [];
|
|
3576
|
-
if (currentPage > 0) {
|
|
3577
|
-
nav.push(telegraf_1.Markup.button.callback('⬅️ Prev', `bind_page_${currentPage - 1}`));
|
|
3578
|
-
}
|
|
3579
|
-
nav.push(telegraf_1.Markup.button.callback(`${currentPage + 1}/${totalPages}`, 'bind_page_noop'));
|
|
3580
|
-
if (currentPage < totalPages - 1) {
|
|
3581
|
-
nav.push(telegraf_1.Markup.button.callback('Next ➡️', `bind_page_${currentPage + 1}`));
|
|
3582
|
-
}
|
|
3583
|
-
rows.push(nav);
|
|
3584
|
-
}
|
|
3585
|
-
return telegraf_1.Markup.inlineKeyboard(rows);
|
|
3586
|
-
}
|
|
3587
3636
|
/**
|
|
3588
3637
|
* @description Bot-level wrapper around the pure sweep helper in
|
|
3589
3638
|
* `createAdapter.ts`. Binds the production adapter resolver so callers
|
|
@@ -4495,6 +4544,13 @@ const { checkIsThreadCompacting, noteThreadActivity, noteThreadUserActivity, mar
|
|
|
4495
4544
|
function checkIsRoutedCommand(name) {
|
|
4496
4545
|
return commandRouter.checkIsRegistered(name);
|
|
4497
4546
|
}
|
|
4547
|
+
/** `/esc` and its other names: the interrupt, which also cancels a pending sign-in or `/topic` prompt first. */
|
|
4548
|
+
const escapeCommandNames = ['esc', 'escape', 'interrupt'];
|
|
4549
|
+
/** @description Whether `messageText` is one of the {@link escapeCommandNames} (`@botmention` tolerated). */
|
|
4550
|
+
function checkIsEscapeCommandText(messageText) {
|
|
4551
|
+
const [commandToken = ''] = (0, utils_1.stripCommandBotMention)(messageText.trim()).split(/\s+/, 1);
|
|
4552
|
+
return escapeCommandNames.includes(commandToken.replace(/^\//, '').toLowerCase());
|
|
4553
|
+
}
|
|
4498
4554
|
/**
|
|
4499
4555
|
* @description Running ANY command exits the single-purpose input modes an
|
|
4500
4556
|
* earlier command armed:
|
|
@@ -4503,6 +4559,7 @@ function checkIsRoutedCommand(name) {
|
|
|
4503
4559
|
* it afterwards via its own callback);
|
|
4504
4560
|
* - `/connect`'s pending provider connect — cancelled out loud, unless the
|
|
4505
4561
|
* command IS another `/connect` (which continues the flow);
|
|
4562
|
+
* - `/topic`'s pending name prompts (the topic's, the new folder's) — but `/esc`, which cancels them with a notice of its own;
|
|
4506
4563
|
* - an in-flight `/reminders` wizard, at whatever step. Unconditional rather
|
|
4507
4564
|
* than only during the step-4 text wait: that is what makes a repeat
|
|
4508
4565
|
* `/reminders` retire the previous wizard (relabelling its message) instead
|
|
@@ -4512,6 +4569,9 @@ function checkIsRoutedCommand(name) {
|
|
|
4512
4569
|
async function exitPendingInputModes(key, messageText) {
|
|
4513
4570
|
const keyString = (0, sessionKey_1.keyToString)(key);
|
|
4514
4571
|
awaitingFolderName.delete(keyString);
|
|
4572
|
+
// `/topic`'s name prompts end silently, except for `/esc`, which cancels them out loud in its own handler.
|
|
4573
|
+
if (!checkIsEscapeCommandText(messageText))
|
|
4574
|
+
cancelTopicInput(key);
|
|
4515
4575
|
const hadPendingProviderConnect = pendingProviderConnects.delete(keyString);
|
|
4516
4576
|
if (hadPendingProviderConnect && !(0, recvPreviewRedaction_1.checkIsConnectCommandText)(messageText)) {
|
|
4517
4577
|
await replyToThread(key, (0, i18n_1.t)('connect.cancelled'));
|
|
@@ -4892,6 +4952,8 @@ function armFolderCreation(key) {
|
|
|
4892
4952
|
const kStr = (0, sessionKey_1.keyToString)(key);
|
|
4893
4953
|
awaitingModelSelection.delete(kStr);
|
|
4894
4954
|
awaitingSessionSelection.delete(kStr);
|
|
4955
|
+
// The `/topic` prompts are other readers of the next plain message.
|
|
4956
|
+
cancelTopicInput(key);
|
|
4895
4957
|
// A stale pending question would otherwise consume the message AFTER the
|
|
4896
4958
|
// folder name as an answer to the old (pre-rebind) agent question.
|
|
4897
4959
|
clearPendingQuestion(key);
|
|
@@ -4909,21 +4971,18 @@ function mapNewFolderError(reason) {
|
|
|
4909
4971
|
}
|
|
4910
4972
|
}
|
|
4911
4973
|
/**
|
|
4912
|
-
* @description Create a new folder under WORK_ROOT from a user-typed name
|
|
4913
|
-
*
|
|
4914
|
-
*
|
|
4915
|
-
*
|
|
4916
|
-
*
|
|
4917
|
-
*
|
|
4918
|
-
* Returns `{ ok }` so the caller knows whether to disarm the await-name mode:
|
|
4919
|
-
* an invalid name keeps the thread armed for a retry; success or a real
|
|
4920
|
-
* filesystem failure disarms it.
|
|
4974
|
+
* @description Create a new folder under WORK_ROOT from a user-typed name:
|
|
4975
|
+
* validate the name first (no traversal/slashes/dots), then `mkdir` (recursive:
|
|
4976
|
+
* an already-existing folder is fine — the caller just uses it, and the topic is
|
|
4977
|
+
* told so). A refusal or a filesystem failure is reported to `key`'s topic here;
|
|
4978
|
+
* the outcome says which, so the caller knows whether a retry is still wanted
|
|
4979
|
+
* (`invalid`) or not (`failed`).
|
|
4921
4980
|
*/
|
|
4922
|
-
async function
|
|
4981
|
+
async function createWorkFolder(key, rawName) {
|
|
4923
4982
|
const validated = (0, folderName_1.validateNewFolderName)(rawName);
|
|
4924
4983
|
if (!validated.ok) {
|
|
4925
4984
|
await replyToThread(key, mapNewFolderError(validated.reason));
|
|
4926
|
-
return {
|
|
4985
|
+
return { outcome: 'invalid' };
|
|
4927
4986
|
}
|
|
4928
4987
|
const targetPath = path.join(ENV.workRoot, validated.name);
|
|
4929
4988
|
const alreadyExisted = fs.existsSync(targetPath);
|
|
@@ -4934,7 +4993,7 @@ async function createAndBindFolder(key, rawName) {
|
|
|
4934
4993
|
await replyToThread(key, (0, i18n_1.t)('bind.create_failed', {
|
|
4935
4994
|
error: e instanceof Error ? e.message : String(e),
|
|
4936
4995
|
}));
|
|
4937
|
-
return {
|
|
4996
|
+
return { outcome: 'failed' };
|
|
4938
4997
|
}
|
|
4939
4998
|
// Folder freshly on disk → the cached subdir list is stale; drop it so the
|
|
4940
4999
|
// next picker render includes the new folder.
|
|
@@ -4942,12 +5001,43 @@ async function createAndBindFolder(key, rawName) {
|
|
|
4942
5001
|
if (alreadyExisted) {
|
|
4943
5002
|
await replyToThread(key, (0, i18n_1.t)('bind.create_exists', { subdir: validated.name }));
|
|
4944
5003
|
}
|
|
4945
|
-
|
|
5004
|
+
return { outcome: 'ready', subdir: validated.name };
|
|
5005
|
+
}
|
|
5006
|
+
/**
|
|
5007
|
+
* @description Create a new folder under WORK_ROOT from a user-typed name and
|
|
5008
|
+
* bind the thread to it ({@link createWorkFolder}), routing through
|
|
5009
|
+
* `applyBinding` for the same containment/symlink defence + welcome stack the
|
|
5010
|
+
* picker uses.
|
|
5011
|
+
*
|
|
5012
|
+
* Returns `{ ok }` so the caller knows whether to disarm the await-name mode:
|
|
5013
|
+
* an invalid name keeps the thread armed for a retry; success or a real
|
|
5014
|
+
* filesystem failure disarms it.
|
|
5015
|
+
*/
|
|
5016
|
+
async function createAndBindFolder(key, rawName) {
|
|
5017
|
+
const folder = await createWorkFolder(key, rawName);
|
|
5018
|
+
if (folder.outcome === 'invalid')
|
|
5019
|
+
return { ok: false };
|
|
5020
|
+
if (folder.outcome === 'failed')
|
|
5021
|
+
return { ok: true };
|
|
5022
|
+
const result = await applyBinding(key, folder.subdir);
|
|
4946
5023
|
await replyToThread(key, result.message);
|
|
4947
5024
|
if (result.ok)
|
|
4948
5025
|
await sendBindingWelcome(key, result.subdir);
|
|
4949
5026
|
return { ok: true };
|
|
4950
5027
|
}
|
|
5028
|
+
/**
|
|
5029
|
+
* @description Bind a topic the bot has just created (`/topic`) to `subdir` and greet it with the usual binding welcome:
|
|
5030
|
+
* the folder's stats and the start buttons. No agent starts by itself: it is started there as in any bound topic. The binding's own notice
|
|
5031
|
+
* is left out: its warning about other topics on the folder would warn of what the person just asked for. The topic's name
|
|
5032
|
+
* rides on the binding for the thread-context preamble. Resolves why it could not bind, `null` once it is bound.
|
|
5033
|
+
*/
|
|
5034
|
+
async function bindNewTopic(key, subdir, topicName) {
|
|
5035
|
+
const result = await applyBinding(key, subdir, { topicName });
|
|
5036
|
+
if (!result.ok)
|
|
5037
|
+
return result.message;
|
|
5038
|
+
await sendBindingWelcome(key, result.subdir);
|
|
5039
|
+
return null;
|
|
5040
|
+
}
|
|
4951
5041
|
command('bind', async (_ctx, key, parsed) => {
|
|
4952
5042
|
if (checkIsGeneral(key)) {
|
|
4953
5043
|
await replyToThread(key, (0, i18n_1.t)('bind.in_general'));
|
|
@@ -4971,7 +5061,7 @@ command('bind', async (_ctx, key, parsed) => {
|
|
|
4971
5061
|
// «create new folder» button, which is the only way to bootstrap a folder
|
|
4972
5062
|
// from an empty WORK_ROOT without the slash form. When already bound it
|
|
4973
5063
|
// also carries the «leave current dir» button (the folded-in /unbind).
|
|
4974
|
-
await replyToThread(key, usage, buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!binding));
|
|
5064
|
+
await replyToThread(key, usage, (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!binding));
|
|
4975
5065
|
return;
|
|
4976
5066
|
}
|
|
4977
5067
|
const result = await applyBinding(key, arg);
|
|
@@ -5158,26 +5248,27 @@ async function getCliVersion(cmd, args = ['--version']) {
|
|
|
5158
5248
|
}
|
|
5159
5249
|
}
|
|
5160
5250
|
command('version', async (_ctx, key) => {
|
|
5161
|
-
//
|
|
5162
|
-
//
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
}
|
|
5168
|
-
catch { /* fall through to unknown */ }
|
|
5251
|
+
// The version this process STARTED as, read once at boot by the updater's runtime: an in-place install rewrites package.json
|
|
5252
|
+
// under a running bot, and the new number must not show before the restart that makes it true.
|
|
5253
|
+
const updateStatus = updateEngine?.getStatus();
|
|
5254
|
+
const botVersion = updateStatus?.running.version ?? (0, i18n_1.t)('version.unknown');
|
|
5255
|
+
const offer = updateStatus?.offer;
|
|
5256
|
+
const availableUpdate = offer && offer.action !== 'skipped' && offer.action !== 'needsNode' ? offer.target : null;
|
|
5169
5257
|
const [tmuxV, claudeV, opencodeV] = await Promise.all([
|
|
5170
5258
|
getCliVersion('tmux', ['-V']),
|
|
5171
5259
|
getCliVersion('claude'),
|
|
5172
5260
|
getCliVersion('opencode'),
|
|
5173
5261
|
]);
|
|
5174
|
-
|
|
5262
|
+
const report = (0, i18n_1.t)('version.report', {
|
|
5175
5263
|
bot: botVersion,
|
|
5176
5264
|
node: process.version,
|
|
5177
5265
|
tmux: tmuxV,
|
|
5178
5266
|
claude: claudeV,
|
|
5179
5267
|
opencode: opencodeV,
|
|
5180
|
-
})
|
|
5268
|
+
});
|
|
5269
|
+
await replyToThread(key, availableUpdate === null ? report : `${report}\n${(0, i18n_1.t)('version.updateAvailable', { version: availableUpdate })}`, {
|
|
5270
|
+
parse_mode: 'Markdown',
|
|
5271
|
+
});
|
|
5181
5272
|
});
|
|
5182
5273
|
command('whoami', async (ctx, key) => {
|
|
5183
5274
|
const userId = ctx.from?.id ?? 0;
|
|
@@ -5246,7 +5337,7 @@ command('help', async (_ctx, key) => {
|
|
|
5246
5337
|
const binding = state.getBinding(key);
|
|
5247
5338
|
if (!binding) {
|
|
5248
5339
|
const subdirs = listAvailableSubdirs(ENV.workRoot);
|
|
5249
|
-
const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
5340
|
+
const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
5250
5341
|
await replyToThread(key, (0, i18n_1.t)('help.thread_unbound'), {
|
|
5251
5342
|
parse_mode: 'Markdown',
|
|
5252
5343
|
...(extra ?? {}),
|
|
@@ -5591,7 +5682,7 @@ async function handleAgentStart(key, adapterName, args) {
|
|
|
5591
5682
|
// natural-language path in the text handler (plan §11 Этап 4).
|
|
5592
5683
|
if (!state.getBinding(key)) {
|
|
5593
5684
|
const subdirs = listAvailableSubdirs(ENV.workRoot);
|
|
5594
|
-
const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
5685
|
+
const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
5595
5686
|
await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
|
|
5596
5687
|
return;
|
|
5597
5688
|
}
|
|
@@ -5777,14 +5868,19 @@ function resolveThreadClaudeConfigDir(key) {
|
|
|
5777
5868
|
}
|
|
5778
5869
|
return dir;
|
|
5779
5870
|
}
|
|
5780
|
-
/**
|
|
5871
|
+
/**
|
|
5872
|
+
* `claude auth status --json` under `configDir` (`null` = `~/.claude`) as its sessions see it: never through an
|
|
5873
|
+
* `ANTHROPIC_API_KEY` of the bot's own, with the `apiKeyHelper` of a profile that holds a pasted API key; `null` when
|
|
5874
|
+
* the probe fails.
|
|
5875
|
+
*/
|
|
5781
5876
|
async function readClaudeAuthStatusUnder(configDir) {
|
|
5782
5877
|
const env = { ...process.env };
|
|
5783
5878
|
delete env.ANTHROPIC_API_KEY;
|
|
5784
5879
|
if (configDir !== null)
|
|
5785
5880
|
env[claudeProfiles_1.claudeConfigDirVariableName] = configDir;
|
|
5786
5881
|
try {
|
|
5787
|
-
const
|
|
5882
|
+
const args = [...(0, claudeApiKeyHelper_1.buildClaudeAuthProbeSettingsArgs)(configDir), 'auth', 'status', '--json'];
|
|
5883
|
+
const { stdout } = await execFileAsync((0, resolveBinary_1.resolveClaudeBinary)(), args, { timeout: claudeAuthStatusProbeTimeoutMs, env });
|
|
5788
5884
|
return (0, claudeProfiles_1.parseClaudeAuthStatus)(stdout);
|
|
5789
5885
|
}
|
|
5790
5886
|
catch (e) {
|
|
@@ -5847,9 +5943,12 @@ const openCodeUsagePoller = (0, openCodeUsage_1.createOpenCodeUsagePoller)({
|
|
|
5847
5943
|
*/
|
|
5848
5944
|
const claudeUsageRefresher = (0, claudeUsageRefresher_1.createClaudeUsageRefresher)({
|
|
5849
5945
|
listProfiles: () => [
|
|
5850
|
-
{
|
|
5946
|
+
{
|
|
5947
|
+
name: claudeProfiles_1.defaultClaudeProfileName, configDir: null,
|
|
5948
|
+
loggedIn: state.getClaudeProfileInfo(claudeProfiles_1.defaultClaudeProfileName)?.loggedIn, billing: state.getClaudeProfileInfo(claudeProfiles_1.defaultClaudeProfileName)?.billing,
|
|
5949
|
+
},
|
|
5851
5950
|
...Object.entries(state.getClaudeProfileRecords()).map(([name, record]) => ({
|
|
5852
|
-
name, configDir: (0, claudeProfiles_1.resolveClaudeProfileDir)((0, state_1.resolveDataDir)(), name), loggedIn: record.loggedIn,
|
|
5951
|
+
name, configDir: (0, claudeProfiles_1.resolveClaudeProfileDir)((0, state_1.resolveDataDir)(), name), loggedIn: record.loggedIn, billing: record.billing,
|
|
5853
5952
|
})),
|
|
5854
5953
|
],
|
|
5855
5954
|
getSnapshot: (name) => state.getClaudeProfileInfo(name)?.usage,
|
|
@@ -5885,6 +5984,27 @@ function onClaudeRateLimitEvent(configDir, event) {
|
|
|
5885
5984
|
state.setClaudeProfileLimitedUntil(name, null);
|
|
5886
5985
|
}
|
|
5887
5986
|
}
|
|
5987
|
+
/**
|
|
5988
|
+
* A json-stream turn's cost (`utils/claudeApiSpend.ts`): the topic's conversation baseline moves on for EVERY account
|
|
5989
|
+
* (a later API turn of the same conversation must count only itself), and the turn's spend adds to the account's only
|
|
5990
|
+
* when it is billed to API credits. A billed turn that went through ends a used-up-credits mark on whatever account it
|
|
5991
|
+
* ran on: the balance is back.
|
|
5992
|
+
*/
|
|
5993
|
+
function onClaudeTurnCost(key, configDir, report) {
|
|
5994
|
+
const turnCostUsd = (0, claudeApiSpend_1.computeClaudeTurnCostUsd)({ previous: state.getClaudeCostBaseline(key), ...report });
|
|
5995
|
+
state.setClaudeCostBaseline(key, { sessionId: report.sessionId, totalCostUsd: report.totalCostUsd });
|
|
5996
|
+
const name = resolveClaudeProfileNameForDir(configDir);
|
|
5997
|
+
if (name === null)
|
|
5998
|
+
return;
|
|
5999
|
+
const info = state.getClaudeProfileInfo(name);
|
|
6000
|
+
const outcome = (0, claudeApiSpend_1.getClaudeTurnCostOutcome)({
|
|
6001
|
+
billing: info?.billing ?? null, isCreditExhausted: info?.creditExhaustedAt !== undefined, isError: report.isError, turnCostUsd,
|
|
6002
|
+
});
|
|
6003
|
+
if (outcome.isCreditMarkCleared)
|
|
6004
|
+
state.setClaudeProfileCreditExhausted(name, null);
|
|
6005
|
+
if (outcome.spendUsd !== null)
|
|
6006
|
+
state.setClaudeProfileApiSpend(name, (0, claudeApiSpend_1.addClaudeApiSpend)(info?.apiSpend, outcome.spendUsd, new Date().toISOString()));
|
|
6007
|
+
}
|
|
5888
6008
|
/** The model a topic's Claude session runs (or last ran), for the usage ranking's choice of windows. */
|
|
5889
6009
|
function getThreadClaudeModel(key) {
|
|
5890
6010
|
return (0, createAdapter_1.getThreadAdapter)(key).getCurrentModel?.(key) ?? state.getAgent(key)?.model ?? null;
|
|
@@ -5892,7 +6012,10 @@ function getThreadClaudeModel(key) {
|
|
|
5892
6012
|
/** What `/status` names of a Claude profile: its last probed sign-in and email, and its limit mark (none until probed). */
|
|
5893
6013
|
function readStatusAccountSource(name) {
|
|
5894
6014
|
const info = state.getClaudeProfileInfo(name);
|
|
5895
|
-
return {
|
|
6015
|
+
return {
|
|
6016
|
+
name, email: info?.email ?? null, loggedIn: info?.loggedIn, limitedUntil: info?.limitedUntil ?? null,
|
|
6017
|
+
billing: info?.billing ?? null, creditExhaustedAt: info?.creditExhaustedAt ?? null, ...(info?.apiSpend ? { apiSpend: info.apiSpend } : {}),
|
|
6018
|
+
};
|
|
5896
6019
|
}
|
|
5897
6020
|
/** Whether the topic's agent is a Claude backend (a usage limit there is a limit of its account profile). */
|
|
5898
6021
|
function checkIsClaudeProfileBackend(key) {
|
|
@@ -5991,13 +6114,15 @@ async function planFallbackSwitch(key, cls, episodeId) {
|
|
|
5991
6114
|
const nowMs = Date.now();
|
|
5992
6115
|
const resetAtMs = cls.kind === 'usageLimit' ? cls.resetAt : undefined;
|
|
5993
6116
|
if (from.backend === 'claude') {
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6117
|
+
// A used-up balance has no reset: its own flow marks the account (`creditExhaustedAt`) instead.
|
|
6118
|
+
if (cls.isBalanceExhausted !== true)
|
|
6119
|
+
state.setClaudeProfileLimitedUntil(from.profile, (0, claudeLimitSwitch_1.resolveClaudeLimitedUntil)({
|
|
6120
|
+
resetAtMs,
|
|
6121
|
+
usage: state.getClaudeProfileInfo(from.profile)?.usage,
|
|
6122
|
+
existingLimitedUntil: state.getClaudeProfileInfo(from.profile)?.limitedUntil ?? null,
|
|
6123
|
+
nowMs,
|
|
6124
|
+
fallbackMs: apiErrorRetry_1.usageLimitDefaultMs,
|
|
6125
|
+
}));
|
|
6001
6126
|
}
|
|
6002
6127
|
else {
|
|
6003
6128
|
const label = (0, fallbackChain_1.buildOpenCodeModelLabel)(from);
|
|
@@ -6027,8 +6152,11 @@ async function planFallbackSwitch(key, cls, episodeId) {
|
|
|
6027
6152
|
let earliest = null;
|
|
6028
6153
|
if (plan.action === 'none') {
|
|
6029
6154
|
const explicit = state.getFallbackChain();
|
|
6155
|
+
// Without a chain the implicit one is the signed-in SUBSCRIPTIONS: an account billed to API credits never ends a wait by itself.
|
|
6030
6156
|
const rungs = explicit.length > 0 ? explicit : from.backend === 'claude'
|
|
6031
|
-
? detection.claudeProfiles
|
|
6157
|
+
? detection.claudeProfiles
|
|
6158
|
+
.filter((profile) => profile.loggedIn !== false && profile.billing !== 'api')
|
|
6159
|
+
.map((profile) => ({ backend: 'claude', profile: profile.name, model: null }))
|
|
6032
6160
|
: [];
|
|
6033
6161
|
const found = (0, fallbackChain_1.pickEarliestResetRung)({ chain: rungs, current: from, detection, nowMs: Date.now() });
|
|
6034
6162
|
if (found && !found.isCurrent)
|
|
@@ -6049,9 +6177,23 @@ function getFallbackResetTime(from) {
|
|
|
6049
6177
|
return null;
|
|
6050
6178
|
return (0, localClock_1.formatLocalClockWithDateIfNotToday)(untilMs, Date.now());
|
|
6051
6179
|
}
|
|
6052
|
-
/**
|
|
6180
|
+
/**
|
|
6181
|
+
* The «switched» notice (`utils/fallbackSwitchNotice.ts`), with the reset of the exhausted rung when known — and, onto
|
|
6182
|
+
* an account billed to API credits, the line that it costs money per request and what it has spent so far.
|
|
6183
|
+
*/
|
|
6053
6184
|
function getFallbackSwitchedNotice(from, to, runningModel) {
|
|
6054
|
-
|
|
6185
|
+
const notice = (0, fallbackSwitchNotice_1.buildFallbackSwitchedNotice)({ from, to, resetTime: getFallbackResetTime(from), runningModel });
|
|
6186
|
+
const info = to.backend === 'claude' ? state.getClaudeProfileInfo(to.profile) : undefined;
|
|
6187
|
+
if (to.backend !== 'claude' || info?.billing !== 'api')
|
|
6188
|
+
return notice;
|
|
6189
|
+
return `${notice}\n${(0, i18n_1.t)('fallback.switchedToApi', { name: to.profile, spent: (0, claudeApiSpend_1.formatUsd)(info.apiSpend?.totalUsd ?? 0) })}`;
|
|
6190
|
+
}
|
|
6191
|
+
/** The spend line a «credits used up» notice ends with (what the account spent through the bot); `''` before any spend. */
|
|
6192
|
+
function getBalanceSpentLine(from) {
|
|
6193
|
+
const spend = from?.backend === 'claude' ? state.getClaudeProfileInfo(from.profile)?.apiSpend : undefined;
|
|
6194
|
+
if (!spend)
|
|
6195
|
+
return '';
|
|
6196
|
+
return `\n${(0, i18n_1.t)('apiRetry.balanceSpent', (0, claudeProfileUsageLines_1.formatClaudeApiSpendWords)(spend, getCurrentTimezone(), Date.now()))}`;
|
|
6055
6197
|
}
|
|
6056
6198
|
/**
|
|
6057
6199
|
* @description The model the topic's Claude session runs ACROSS a move onto `to`, for deciding whether the move
|
|
@@ -6318,6 +6460,10 @@ function buildBackendSwitchTargetOfRung(to) {
|
|
|
6318
6460
|
* The outcome tells the screen what to say; the question and the limit move's notice are already posted.
|
|
6319
6461
|
*/
|
|
6320
6462
|
async function switchTopicByOperator(key, to) {
|
|
6463
|
+
// The operator's own pick of an account whose API credits were used up is the act that ends that mark (topped up, or
|
|
6464
|
+
// trying anyway): a failing request marks it again.
|
|
6465
|
+
if (to.backend === 'claude')
|
|
6466
|
+
state.setClaudeProfileCreditExhausted(to.profile, null);
|
|
6321
6467
|
const currentFamily = getTopicBackendFamily(key);
|
|
6322
6468
|
const from = getTopicFallbackPosition(key);
|
|
6323
6469
|
const isUsageLimitWaitArmed = getArmedApiRetry(key)?.kind === 'usageLimit';
|
|
@@ -6422,7 +6568,7 @@ const { registerLimitsBoardCommands, registerLimitsBoardCallbacks } = (0, limits
|
|
|
6422
6568
|
runAutoSwitchCommand,
|
|
6423
6569
|
getArmedLimitRetryFireAt,
|
|
6424
6570
|
});
|
|
6425
|
-
const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAwaitingProfileName, cancelProfileNameCapture, submitProfileName, pickClaudeAccount, onClaudeProfileAuthStatus: recordClaudeProfileAuthStatus, } = (0, claudeAccounts_1.createClaudeAccounts)({
|
|
6571
|
+
const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAwaitingProfileName, cancelProfileNameCapture, submitProfileName, pickClaudeAccount, onClaudeProfileAuthStatus: recordClaudeProfileAuthStatus, onClaudeProfileApiKey: storeClaudeProfileApiKey, } = (0, claudeAccounts_1.createClaudeAccounts)({
|
|
6426
6572
|
...botCore,
|
|
6427
6573
|
getDataDir: state_1.resolveDataDir,
|
|
6428
6574
|
getInstanceTimezone: getCurrentTimezone,
|
|
@@ -6438,9 +6584,11 @@ const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAw
|
|
|
6438
6584
|
disconnectOpenCodeProvider: applyProviderDisconnect,
|
|
6439
6585
|
startOpenCodeConnect: showConnectMethodPicker,
|
|
6440
6586
|
applyAutoSwitch,
|
|
6587
|
+
verifyAnthropicApiKey: (apiKey) => (0, anthropicApiKeyCheck_1.verifyAnthropicApiKey)(apiKey),
|
|
6441
6588
|
});
|
|
6442
6589
|
claudeProfileAuthStatusSink = recordClaudeProfileAuthStatus;
|
|
6443
|
-
|
|
6590
|
+
claudeProfileApiKeySink = storeClaudeProfileApiKey;
|
|
6591
|
+
const { registerSttCommands, registerSttCallbacks, startSttAutoInstall, resumeSttInstallAtBoot, getAwaitedSttStep, checkIsAnySttInputAwaited, submitSttInput, cancelSttInput, } = (0, stt_1.createStt)({
|
|
6444
6592
|
...botCore,
|
|
6445
6593
|
getConfigPath: () => (0, sttSettings_1.getSttConfigPath)(),
|
|
6446
6594
|
getDataDir: state_1.resolveDataDir,
|
|
@@ -6453,10 +6601,30 @@ const { registerSttCommands, registerSttCallbacks, startSttAutoInstall, resumeSt
|
|
|
6453
6601
|
return groupId !== null ? (0, sessionKeyCodec_1.makeTelegramKey)(groupId, threadRouting_1.GENERAL_THREAD_ID) : null;
|
|
6454
6602
|
},
|
|
6455
6603
|
});
|
|
6604
|
+
const { registerTopicCommand, registerTopicCallbacks, checkIsAwaitingTopicInput, submitTopicInput, cancelTopicInput, cancelTopicWizard, } = (0, topicCommand_1.createTopicCommand)({
|
|
6605
|
+
...botCore,
|
|
6606
|
+
checkIsDmKey,
|
|
6607
|
+
disarmFolderCreation: (key) => {
|
|
6608
|
+
awaitingFolderName.delete((0, sessionKey_1.keyToString)(key));
|
|
6609
|
+
},
|
|
6610
|
+
listAvailableSubdirs: () => listAvailableSubdirs(ENV.workRoot),
|
|
6611
|
+
createWorkFolder,
|
|
6612
|
+
bindNewTopic,
|
|
6613
|
+
});
|
|
6456
6614
|
registerClaudeAccountCommands();
|
|
6615
|
+
registerTopicCommand();
|
|
6457
6616
|
registerFallbackChainCommands();
|
|
6458
6617
|
registerLimitsBoardCommands();
|
|
6459
6618
|
registerSttCommands();
|
|
6619
|
+
const { registerSelfUpdateCommands, registerSelfUpdateCallbacks } = (0, selfUpdate_1.createSelfUpdate)({
|
|
6620
|
+
...botCore,
|
|
6621
|
+
getUpdateEngine: () => updateEngine,
|
|
6622
|
+
getSettingsPath: () => (0, instanceSettings_1.getInstanceSettingsPath)(),
|
|
6623
|
+
now: () => Date.now(),
|
|
6624
|
+
// The instance's time zone is `process.env.TZ` (`/timezone`), which `toLocaleTimeString` follows.
|
|
6625
|
+
formatClock: (epochMs) => new Date(epochMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }),
|
|
6626
|
+
});
|
|
6627
|
+
registerSelfUpdateCommands();
|
|
6460
6628
|
registerCompactionCommands();
|
|
6461
6629
|
/**
|
|
6462
6630
|
* @description Build the agent-picker keyboard.
|
|
@@ -6490,7 +6658,7 @@ async function handleSessionsList(key) {
|
|
|
6490
6658
|
}
|
|
6491
6659
|
if (!state.getBinding(key)) {
|
|
6492
6660
|
const subdirs = listAvailableSubdirs(ENV.workRoot);
|
|
6493
|
-
const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
6661
|
+
const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
6494
6662
|
await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
|
|
6495
6663
|
return;
|
|
6496
6664
|
}
|
|
@@ -6750,8 +6918,8 @@ command('tab', async (_ctx, key) => {
|
|
|
6750
6918
|
else
|
|
6751
6919
|
await replyToThread(key, `Not supported for ${adapter.label}`);
|
|
6752
6920
|
});
|
|
6753
|
-
command(
|
|
6754
|
-
// A pending «Add account» name prompt,
|
|
6921
|
+
command(escapeCommandNames, async (_ctx, key) => {
|
|
6922
|
+
// A pending «Add account» name prompt, a pending sign-in (`/login`, `/connect` OAuth) and a `/topic` wizard take
|
|
6755
6923
|
// priority: the first /esc only cancels it, so the topic stops waiting for typed input.
|
|
6756
6924
|
if (cancelProfileNameCapture(key)) {
|
|
6757
6925
|
await replyToThread(key, (0, i18n_1.t)('account.askNameCancelled'));
|
|
@@ -6761,12 +6929,21 @@ command(['esc', 'escape'], async (_ctx, key) => {
|
|
|
6761
6929
|
await replyToThread(key, (0, i18n_1.t)('login.cancelled'));
|
|
6762
6930
|
return;
|
|
6763
6931
|
}
|
|
6932
|
+
// A `/topic` wizard is ended at whatever step it waits (it says so itself).
|
|
6933
|
+
if (await cancelTopicWizard(key))
|
|
6934
|
+
return;
|
|
6764
6935
|
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
6765
6936
|
if (adapter.sendEscape) {
|
|
6766
6937
|
// The operator interrupted the turn: its open request closes silently.
|
|
6767
6938
|
cancelConversationRequest(key);
|
|
6768
6939
|
markNeedsNewMessage(key);
|
|
6940
|
+
// A turn blocked on a question ends with it: the question is closed on the agent first (an open OpenCode one is
|
|
6941
|
+
// re-found at every reattach; a Claude stream-json one is denied), then the turn is stopped — neither waits for Telegram. Only
|
|
6942
|
+
// then the question's buttons are retired, as when a prompt walks away from it (that edit goes through the send pacer).
|
|
6943
|
+
adapter.rejectQuestion?.(key);
|
|
6769
6944
|
adapter.sendEscape(key);
|
|
6945
|
+
if (pendingQuestions.has((0, sessionKey_1.keyToString)(key)))
|
|
6946
|
+
await retirePendingQuestion(key);
|
|
6770
6947
|
}
|
|
6771
6948
|
else {
|
|
6772
6949
|
await replyToThread(key, `Not supported for ${adapter.label}`);
|
|
@@ -7037,10 +7214,10 @@ command('clear_messages', async (ctx, key) => {
|
|
|
7037
7214
|
// agent.
|
|
7038
7215
|
const botCommands = new Set([
|
|
7039
7216
|
'start', 'claude', 'opencode', 'oc', 'terminal', 'agent', 'sessions', 'resume', 'cancel', 'model', 'connect',
|
|
7040
|
-
'disconnect', 'stop', 'stopall', 'stop-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'output', 'clear_messages',
|
|
7041
|
-
'bind', 'unbind', 'where', 'ls', 'list', 'new', 'clear_session', 'whoami', 'version', 'help', 'language', 'lang',
|
|
7217
|
+
'disconnect', 'stop', 'stopall', 'stop-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'interrupt', 'output', 'clear_messages',
|
|
7218
|
+
'bind', 'topic', 'unbind', 'where', 'ls', 'list', 'new', 'clear_session', 'whoami', 'version', 'help', 'language', 'lang',
|
|
7042
7219
|
'doctor', 'mcp', 'rename_session', 'compact', 'compact_on_idle', 'compact_summary', 'auto_continue_limits', 'limits', 'limits_autoswitch', 'trace', 'timestamps', 'timezone', 'schedule', 'reminders', 'thinking', 'tool_results',
|
|
7043
|
-
'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quit-all', 'quitall', 'pair', 'account', 'accounts', 'fallback', 'stt', 'voice', 'speech',
|
|
7220
|
+
'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quit-all', 'quitall', 'pair', 'account', 'accounts', 'fallback', 'stt', 'voice', 'speech', 'update',
|
|
7044
7221
|
]);
|
|
7045
7222
|
/**
|
|
7046
7223
|
* @description The bare `/clear` is no longer bot-owned (it was renamed to
|
|
@@ -7073,6 +7250,7 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
|
|
|
7073
7250
|
const cmd = text.slice(1).split(' ')[0].split('@')[0].toLowerCase();
|
|
7074
7251
|
// A slash command ends a pending «Add account» name prompt (the command itself runs normally).
|
|
7075
7252
|
cancelProfileNameCapture(key);
|
|
7253
|
+
cancelTopicInput(key);
|
|
7076
7254
|
// ... and an armed `/stt` address or key step.
|
|
7077
7255
|
await cancelSttInput(key);
|
|
7078
7256
|
if (botCommands.has(cmd))
|
|
@@ -7121,7 +7299,13 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
|
|
|
7121
7299
|
// `/account` «Add» asked for the new profile's name → this message is it (a bad name keeps the prompt
|
|
7122
7300
|
// armed and says so; a slash command above already disarmed it).
|
|
7123
7301
|
if (checkIsAwaitingProfileName(key) && !text.startsWith('/')) {
|
|
7124
|
-
await submitProfileName(key, text);
|
|
7302
|
+
await submitProfileName(key, text, ctx.message.message_id);
|
|
7303
|
+
return;
|
|
7304
|
+
}
|
|
7305
|
+
// `/topic` asked for the new topic's name, or for the name of a folder to create → this message is the answer (a bad
|
|
7306
|
+
// name keeps the prompt armed and says so; a slash command above already ended it).
|
|
7307
|
+
if (checkIsAwaitingTopicInput(key) && !text.startsWith('/')) {
|
|
7308
|
+
await submitTopicInput(key, text);
|
|
7125
7309
|
return;
|
|
7126
7310
|
}
|
|
7127
7311
|
// `/stt` asked for a server address or an API key → this message is the answer. A plausible key is deleted at
|
|
@@ -7289,7 +7473,7 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
|
|
|
7289
7473
|
});
|
|
7290
7474
|
if (!result.ok && result.reason === 'unbound') {
|
|
7291
7475
|
const subdirs = listAvailableSubdirs(ENV.workRoot);
|
|
7292
|
-
const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7476
|
+
const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7293
7477
|
await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
|
|
7294
7478
|
return;
|
|
7295
7479
|
}
|
|
@@ -7370,7 +7554,7 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
|
|
|
7370
7554
|
const binding = state.getBinding(key);
|
|
7371
7555
|
if (!binding) {
|
|
7372
7556
|
const subdirs = listAvailableSubdirs(ENV.workRoot);
|
|
7373
|
-
const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7557
|
+
const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7374
7558
|
await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
|
|
7375
7559
|
return;
|
|
7376
7560
|
}
|
|
@@ -7501,7 +7685,7 @@ async function processVoiceJob(key, fileId, durationSeconds, requesterId, sentAt
|
|
|
7501
7685
|
});
|
|
7502
7686
|
if (!result.ok && result.reason === 'unbound') {
|
|
7503
7687
|
const subdirs = listAvailableSubdirs(ENV.workRoot);
|
|
7504
|
-
const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7688
|
+
const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7505
7689
|
await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
|
|
7506
7690
|
return;
|
|
7507
7691
|
}
|
|
@@ -7521,7 +7705,7 @@ async function processVoiceJob(key, fileId, durationSeconds, requesterId, sentAt
|
|
|
7521
7705
|
const binding = state.getBinding(key);
|
|
7522
7706
|
if (!binding) {
|
|
7523
7707
|
const subdirs = listAvailableSubdirs(ENV.workRoot);
|
|
7524
|
-
const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7708
|
+
const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7525
7709
|
await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
|
|
7526
7710
|
return;
|
|
7527
7711
|
}
|
|
@@ -7589,7 +7773,7 @@ async function checkFileIntakeGatePassed(key, isStarting, sendHint) {
|
|
|
7589
7773
|
const binding = state.getBinding(key);
|
|
7590
7774
|
if (!binding) {
|
|
7591
7775
|
const subdirs = listAvailableSubdirs(ENV.workRoot);
|
|
7592
|
-
const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7776
|
+
const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7593
7777
|
await sendHint((0, i18n_1.t)('thread.no_binding'), extra);
|
|
7594
7778
|
return false;
|
|
7595
7779
|
}
|
|
@@ -7924,7 +8108,7 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
|
|
|
7924
8108
|
// Fall through to picker.
|
|
7925
8109
|
}
|
|
7926
8110
|
}
|
|
7927
|
-
const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
8111
|
+
const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
7928
8112
|
await replyToThread(key, (0, i18n_1.t)('thread.welcome_pick'), extra);
|
|
7929
8113
|
});
|
|
7930
8114
|
/**
|
|
@@ -8038,7 +8222,7 @@ bot.action(/^bind_page_(\d+)$/, async (ctx) => {
|
|
|
8038
8222
|
}
|
|
8039
8223
|
const page = parseInt(ctx.match[1], 10);
|
|
8040
8224
|
const subdirs = listAvailableSubdirs(ENV.workRoot);
|
|
8041
|
-
const keyboard = buildBindKeyboard(subdirs, page, BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
8225
|
+
const keyboard = (0, bindPicker_1.buildBindKeyboard)(subdirs, page, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
8042
8226
|
try {
|
|
8043
8227
|
await ctx.editMessageReplyMarkup(keyboard.reply_markup);
|
|
8044
8228
|
}
|
|
@@ -8051,14 +8235,14 @@ bot.action(/^bind_page_(\d+)$/, async (ctx) => {
|
|
|
8051
8235
|
await ctx.answerCbQuery();
|
|
8052
8236
|
});
|
|
8053
8237
|
// Middle "N/M" pill in the nav row — pure UI, no state change.
|
|
8054
|
-
bot.action(
|
|
8238
|
+
bot.action(bindPicker_1.bindPageNoopCallback, async (ctx) => {
|
|
8055
8239
|
await ctx.answerCbQuery();
|
|
8056
8240
|
});
|
|
8057
8241
|
// «Create new folder» — first option in the /bind picker. Arms the thread's
|
|
8058
8242
|
// await-folder-name mode; the next text message is the name (see the text
|
|
8059
8243
|
// handler). Callback id deliberately avoids the `bind_` prefix so it can't be
|
|
8060
8244
|
// matched by the `bind_<subdir>` regex below.
|
|
8061
|
-
bot.action(bindCreateFolderCallback, async (ctx) => {
|
|
8245
|
+
bot.action(bindPicker_1.bindCreateFolderCallback, async (ctx) => {
|
|
8062
8246
|
const key = await authoriseContext(ctx);
|
|
8063
8247
|
if (!key) {
|
|
8064
8248
|
await ctx.answerCbQuery((0, i18n_1.t)('cb.access_denied'));
|
|
@@ -8075,7 +8259,7 @@ bot.action(bindCreateFolderCallback, async (ctx) => {
|
|
|
8075
8259
|
// «Leave current dir» — the folded-in `/unbind`, shown only in a bound topic.
|
|
8076
8260
|
// Callback id deliberately avoids the `bind_` prefix so it can't be matched by
|
|
8077
8261
|
// the `bind_<subdir>` regex below.
|
|
8078
|
-
bot.action(bindLeaveCurrentCallback, async (ctx) => {
|
|
8262
|
+
bot.action(bindPicker_1.bindLeaveCurrentCallback, async (ctx) => {
|
|
8079
8263
|
const key = await authoriseContext(ctx);
|
|
8080
8264
|
if (!key) {
|
|
8081
8265
|
await ctx.answerCbQuery((0, i18n_1.t)('cb.access_denied'));
|
|
@@ -8088,6 +8272,8 @@ bot.action(bindLeaveCurrentCallback, async (ctx) => {
|
|
|
8088
8272
|
await ctx.answerCbQuery();
|
|
8089
8273
|
await unbindThread(key);
|
|
8090
8274
|
});
|
|
8275
|
+
// `/topic`'s buttons: patterns of their own (`topic_…`), disjoint from the `bind_<subdir>` regex below.
|
|
8276
|
+
registerTopicCallbacks();
|
|
8091
8277
|
bot.action(/^bind_(.+)$/, async (ctx) => {
|
|
8092
8278
|
const key = await authoriseContext(ctx);
|
|
8093
8279
|
if (!key) {
|
|
@@ -8246,6 +8432,7 @@ registerClaudeAccountCallbacks();
|
|
|
8246
8432
|
registerFallbackChainCallbacks();
|
|
8247
8433
|
registerLimitsBoardCallbacks();
|
|
8248
8434
|
registerSttCallbacks();
|
|
8435
|
+
registerSelfUpdateCallbacks();
|
|
8249
8436
|
// «⏭ Skip once» on the `/limits` board and the `/limits_autoswitch` screen: drop THIS armed resume and
|
|
8250
8437
|
// leave the on/off setting alone. The episode is identified by the `fireAt` baked
|
|
8251
8438
|
// into the callback data, so an untouched older picker can never cancel a LATER
|
|
@@ -8568,32 +8755,38 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
|
|
|
8568
8755
|
return outcome;
|
|
8569
8756
|
}
|
|
8570
8757
|
/**
|
|
8571
|
-
* @description
|
|
8572
|
-
*
|
|
8573
|
-
*
|
|
8574
|
-
*
|
|
8575
|
-
* aborted question), abort the wedged OpenCode turn (`SIGINT` → `/session/:id/
|
|
8576
|
-
* abort`), post the "previous question cancelled" notice, then forward.
|
|
8758
|
+
* @description Retire the on-screen state of a pending OpenCode question the user walked away from: clear the bot's
|
|
8759
|
+
* pending-question state and neutralize the stale buttons message (edit it to a "cancelled" label so a late tap can't
|
|
8760
|
+
* re-answer an aborted question). Closing the question on the server is the caller's, before it aborts the turn.
|
|
8761
|
+
* Returns whether the buttons message itself was relabelled — it IS the cancellation notice then.
|
|
8577
8762
|
*
|
|
8578
|
-
* The
|
|
8579
|
-
*
|
|
8580
|
-
* `messageId` + header are captured BEFORE clearing, since `clearPendingQuestion`
|
|
8581
|
-
* drops the state.
|
|
8763
|
+
* The edit runs through `runQuestionLifecycleOp` (same serializer as the answer path) so it can't race a concurrent
|
|
8764
|
+
* re-post-to-bottom. The `messageId` + header are captured BEFORE clearing, since `clearPendingQuestion` drops the state.
|
|
8582
8765
|
*/
|
|
8583
|
-
async function
|
|
8766
|
+
async function retirePendingQuestion(key) {
|
|
8584
8767
|
const pending = pendingQuestions.get((0, sessionKey_1.keyToString)(key));
|
|
8585
8768
|
const cancelledMessageId = pending?.messageId ?? null;
|
|
8586
8769
|
const cancelledQuestion = pending?.data.questions[pending.currentIndex];
|
|
8587
|
-
// Whether we relabel the on-screen question bubble itself to "❌ … cancelled"
|
|
8588
|
-
// below. When we do, that IS the cancellation notice — a second standalone
|
|
8589
|
-
// "previous question cancelled" line is pure noise (the reported triple
|
|
8590
|
-
// message). The standalone notice is kept only for the no-bubble case.
|
|
8591
|
-
const didLabelQuestionMessage = cancelledMessageId !== null && cancelledQuestion !== undefined;
|
|
8592
8770
|
clearPendingQuestion(key);
|
|
8593
|
-
if (cancelledMessageId
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
}
|
|
8771
|
+
if (cancelledMessageId === null || !cancelledQuestion)
|
|
8772
|
+
return false;
|
|
8773
|
+
const header = cancelledQuestion.header || cancelledQuestion.question;
|
|
8774
|
+
await runQuestionLifecycleOp(key, () => editThreadMessage(key, cancelledMessageId, (0, i18n_1.t)('agent.question_cancelled_msg_label', { header })));
|
|
8775
|
+
return true;
|
|
8776
|
+
}
|
|
8777
|
+
/**
|
|
8778
|
+
* @description Cancel a pending OpenCode question because the user sent free-form
|
|
8779
|
+
* input instead of answering it, then deliver that input as a fresh prompt. In
|
|
8780
|
+
* order: retire the question's on-screen state ({@link retirePendingQuestion}),
|
|
8781
|
+
* close it on the server, abort the wedged OpenCode turn (`SIGINT` → `/session/:id/
|
|
8782
|
+
* abort`), post the "previous question cancelled" notice, then forward.
|
|
8783
|
+
*/
|
|
8784
|
+
async function cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, requestHeader) {
|
|
8785
|
+
// When the question bubble itself was relabelled to "❌ … cancelled", that IS the
|
|
8786
|
+
// cancellation notice — a second standalone "previous question cancelled" line is
|
|
8787
|
+
// pure noise (the reported triple message). The standalone notice is kept only
|
|
8788
|
+
// for the no-bubble case.
|
|
8789
|
+
const didLabelQuestionMessage = await retirePendingQuestion(key);
|
|
8597
8790
|
// Close the question on the OpenCode server too (not just the bot-local +
|
|
8598
8791
|
// Telegram state above): an abandoned-but-still-open question keeps getting
|
|
8599
8792
|
// re-found by `restoreOpenQuestion` (`GET /question` on every reattach) and
|
|
@@ -9954,6 +10147,7 @@ exports.COMMANDS_MENU = [
|
|
|
9954
10147
|
{ command: 'help', description: '❓ Context-aware help' },
|
|
9955
10148
|
{ command: 'doctor', description: '🔍 Self-diagnostics' },
|
|
9956
10149
|
{ command: 'bind', description: '📁 Bind thread to a subfolder' },
|
|
10150
|
+
{ command: 'topic', description: '🧵 New topic on a folder (name, then folder)' },
|
|
9957
10151
|
{ command: 'ls', description: '📂 List WORK_ROOT subfolders' },
|
|
9958
10152
|
{ command: 'list', description: '🧵 List all bound threads' },
|
|
9959
10153
|
{ command: 'mcp', description: '🔌 List active MCP servers' },
|
|
@@ -9981,6 +10175,7 @@ exports.COMMANDS_MENU = [
|
|
|
9981
10175
|
{ command: 'limits', description: '🚧 Usage limits — switch this topic' },
|
|
9982
10176
|
{ command: 'limits_autoswitch', description: '🔁 Automatic switching on a usage limit' },
|
|
9983
10177
|
{ command: 'stt', description: '🎙 Voice recognition: engines, language, local engine' },
|
|
10178
|
+
{ command: 'update', description: '🔄 Bot updates: automatic, notify only, off' },
|
|
9984
10179
|
{ command: 'schedule', description: '⏰ Schedule a prompt (agent does the work)' },
|
|
9985
10180
|
{ command: 'reminders', description: '🔔 Reminders — add, list, delete (no agent)' },
|
|
9986
10181
|
{ command: 'status', description: '📊 Show status' },
|
|
@@ -9996,7 +10191,8 @@ exports.COMMANDS_MENU = [
|
|
|
9996
10191
|
{ command: 'up', description: '⬆️ Arrow Up' },
|
|
9997
10192
|
{ command: 'down', description: '⬇️ Arrow Down' },
|
|
9998
10193
|
{ command: 'tab', description: '⇥ Tab' },
|
|
9999
|
-
{ command: '
|
|
10194
|
+
{ command: 'interrupt', description: '⏹ Interrupt the running turn — the next message steers it (alias /esc)' },
|
|
10195
|
+
{ command: 'esc', description: '⎋ Escape (interrupt / dismiss / cancel a pending login; alias /interrupt)' },
|
|
10000
10196
|
{ command: 'y', description: '✅ Send "y"' },
|
|
10001
10197
|
{ command: 'n', description: '❌ Send "n"' },
|
|
10002
10198
|
{ command: 'c', description: '🛑 Ctrl+C' },
|
|
@@ -11055,6 +11251,66 @@ async function resolveBotAdminRights(groupId, botUserId) {
|
|
|
11055
11251
|
return null;
|
|
11056
11252
|
}
|
|
11057
11253
|
}
|
|
11254
|
+
/**
|
|
11255
|
+
* @description The General topic the updater talks in: the paired group's General when a group is paired, else the owner's DM
|
|
11256
|
+
* General (`null` when neither exists — a Jira-only instance has no Telegram to say anything in).
|
|
11257
|
+
*/
|
|
11258
|
+
function getUpdateGeneralKey() {
|
|
11259
|
+
const groupId = getAllowedGroupId();
|
|
11260
|
+
if (groupId !== null)
|
|
11261
|
+
return (0, sessionKeyCodec_1.makeTelegramKey)(groupId, threadRouting_1.GENERAL_THREAD_ID);
|
|
11262
|
+
const ownerUserId = getOwnerUserId();
|
|
11263
|
+
return Number.isFinite(ownerUserId) ? (0, sessionKeyCodec_1.makeTelegramKey)(ownerUserId, threadRouting_1.DM_GENERAL_THREAD_ID) : null;
|
|
11264
|
+
}
|
|
11265
|
+
/**
|
|
11266
|
+
* @description Say an update message in General, worded in that chat's language: pinned when it asks the operator to act, with
|
|
11267
|
+
* the install tap (the version printed on the button is the version it installs) when it offers one. A failed send or pin is
|
|
11268
|
+
* logged — the updater never fails because Telegram did.
|
|
11269
|
+
*/
|
|
11270
|
+
async function postUpdateMessageToGeneral(message) {
|
|
11271
|
+
if (!ENV.isTelegramServed)
|
|
11272
|
+
return;
|
|
11273
|
+
const generalKey = getUpdateGeneralKey();
|
|
11274
|
+
if (generalKey === null) {
|
|
11275
|
+
console.log(`[update] no General topic to post "${message.kind}" in`);
|
|
11276
|
+
return;
|
|
11277
|
+
}
|
|
11278
|
+
const rendered = withThreadLocale(generalKey, () => (0, updateMessages_1.renderUpdateMessage)(message, (code, vars) => (0, i18n_1.t)(code, vars)));
|
|
11279
|
+
const extra = rendered.buttons.length > 0
|
|
11280
|
+
? telegraf_1.Markup.inlineKeyboard(rendered.buttons.map((button) => [telegraf_1.Markup.button.callback(button.label, button.callbackData)]))
|
|
11281
|
+
: {};
|
|
11282
|
+
const messageId = await replyToThread(generalKey, rendered.text, extra);
|
|
11283
|
+
if (messageId !== null && rendered.pin)
|
|
11284
|
+
await pinMessageQuiet(generalKey, messageId, { disableNotification: false });
|
|
11285
|
+
}
|
|
11286
|
+
/**
|
|
11287
|
+
* @description Whether the bot may be restarted now, from what it knows: no agent working in any bound topic (`checkIsWorking`, else
|
|
11288
|
+
* `checkIsBusy`), no input an adapter has not taken in, nothing held in the startup buffer, no sign-in, no in-memory wizard or pasted
|
|
11289
|
+
* key, and no human message within the quiet period. A sleeping or idle-but-alive session does not count: it survives the restart.
|
|
11290
|
+
*/
|
|
11291
|
+
function getUpdateIdleVerdict(isUserRequested) {
|
|
11292
|
+
const keys = getServedBindings().map(({ key }) => key);
|
|
11293
|
+
const isAnyAgentWorking = keys.some((key) => {
|
|
11294
|
+
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
11295
|
+
return adapter.checkIsWorking ? adapter.checkIsWorking(key) : adapter.checkIsBusy?.(key) === true;
|
|
11296
|
+
});
|
|
11297
|
+
return (0, updatePolicy_1.getIdleVerdict)({
|
|
11298
|
+
isAnyAgentWorking,
|
|
11299
|
+
hasUnconsumedInput: keys.some((key) => (0, createAdapter_1.getThreadAdapter)(key).checkHasUnconsumedInput?.(key) === true),
|
|
11300
|
+
hasBufferedStartupPrompt: startupPromptBuffer.checkIsAnyPending(),
|
|
11301
|
+
isSignInRunning: checkIsAnyLoginInProgress(),
|
|
11302
|
+
isWizardOpen: reminderWizards.size > 0 ||
|
|
11303
|
+
awaitingFolderName.size > 0 ||
|
|
11304
|
+
awaitingModelSelection.size > 0 ||
|
|
11305
|
+
awaitingSessionSelection.size > 0 ||
|
|
11306
|
+
pendingProviderConnects.size > 0 ||
|
|
11307
|
+
checkIsAnySttInputAwaited(),
|
|
11308
|
+
lastHumanMessageAt: lastHumanInboundAt,
|
|
11309
|
+
now: Date.now(),
|
|
11310
|
+
quietMs: constants_1.quietPeriodMs,
|
|
11311
|
+
isUserRequested,
|
|
11312
|
+
});
|
|
11313
|
+
}
|
|
11058
11314
|
/**
|
|
11059
11315
|
* @description Boot-time readiness status (plan 2026-07-12): on startup tell the
|
|
11060
11316
|
* owner whether the bot can process messages, or list the setup steps still
|
|
@@ -11232,6 +11488,7 @@ async function startBot() {
|
|
|
11232
11488
|
// Every Claude spawn from here on reads the conversation's account profile (`/account`).
|
|
11233
11489
|
(0, createAdapter_1.registerClaudeConfigDirReader)(resolveThreadClaudeConfigDir);
|
|
11234
11490
|
(0, createAdapter_1.registerClaudeRateLimitSink)(onClaudeRateLimitEvent);
|
|
11491
|
+
(0, createAdapter_1.registerClaudeTurnCostSink)(onClaudeTurnCost);
|
|
11235
11492
|
// The usage cache: a first poll now (off the boot path), then the timer.
|
|
11236
11493
|
claudeUsageRefresher.start();
|
|
11237
11494
|
void claudeUsageRefresher.refreshAll();
|
|
@@ -11715,36 +11972,82 @@ async function startBot() {
|
|
|
11715
11972
|
// `stopOpenCodeServer()` or `adapter.stopSession()` here — tmux and
|
|
11716
11973
|
// opencode survive the bot process by design and are picked back up
|
|
11717
11974
|
// by `reattachExistingSessions` on the next boot.
|
|
11975
|
+
// The ordered sequence is the same for a signal and for an update's restart; only what happens AFTER it differs
|
|
11976
|
+
// (`exit` ends the process, the restart replaces it with a fresh image of the same install).
|
|
11977
|
+
const runShutdown = (signal, exit) => (0, shutdown_1.gracefulShutdown)({
|
|
11978
|
+
signal,
|
|
11979
|
+
// Without Telegram there is no polling to stop, and `bot.stop` throws when it never launched.
|
|
11980
|
+
bot: ENV.isTelegramServed ? bot : { stop: () => { } },
|
|
11981
|
+
state,
|
|
11982
|
+
releaseLock: instanceLock_1.releaseLock,
|
|
11983
|
+
exit,
|
|
11984
|
+
drainPendingUpdates: () => updateDispatcher.drainIdle(updateDrainTimeoutMs),
|
|
11985
|
+
finalizePendingOutput: () => finalizePendingOutputOnShutdown(),
|
|
11986
|
+
clearTransientFrames: () => sweepTransientFramesOnShutdown(),
|
|
11987
|
+
// A hot worker's reload keeps the sidecar for the next worker to adopt; the supervisor stops it for good.
|
|
11988
|
+
stopSidecars: (0, installManager_1.checkIsOpenCodeExternalHost)() ? undefined : () => sttOwner.stop(),
|
|
11989
|
+
cleanupTimers: () => {
|
|
11990
|
+
clearInterval(inMemoryGcInterval);
|
|
11991
|
+
clearInterval(heartbeatInterval);
|
|
11992
|
+
clearInterval(fileSweepInterval);
|
|
11993
|
+
clearInterval(rateSummaryInterval);
|
|
11994
|
+
// Scheduler (S8): clear every armed job timer; persisted nextRunAt
|
|
11995
|
+
// means the next boot's rearmAll picks them back up (catch-up replay).
|
|
11996
|
+
schedulerEngine?.shutdown();
|
|
11997
|
+
requestWakeUpEngine?.stop();
|
|
11998
|
+
jiraConnector?.stop();
|
|
11999
|
+
if (isSchedulerMcpStarted)
|
|
12000
|
+
void schedulerMcpHandle.stop().catch(() => { });
|
|
12001
|
+
updateEngine?.stop();
|
|
12002
|
+
},
|
|
12003
|
+
});
|
|
11718
12004
|
const shutdown = (signal) => {
|
|
11719
|
-
void (
|
|
11720
|
-
signal,
|
|
11721
|
-
// Without Telegram there is no polling to stop, and `bot.stop` throws when it never launched.
|
|
11722
|
-
bot: ENV.isTelegramServed ? bot : { stop: () => { } },
|
|
11723
|
-
state,
|
|
11724
|
-
releaseLock: instanceLock_1.releaseLock,
|
|
11725
|
-
exit: (code) => process.exit(code),
|
|
11726
|
-
drainPendingUpdates: () => updateDispatcher.drainIdle(updateDrainTimeoutMs),
|
|
11727
|
-
finalizePendingOutput: () => finalizePendingOutputOnShutdown(),
|
|
11728
|
-
clearTransientFrames: () => sweepTransientFramesOnShutdown(),
|
|
11729
|
-
// A hot worker's reload keeps the sidecar for the next worker to adopt; the supervisor stops it for good.
|
|
11730
|
-
stopSidecars: (0, installManager_1.checkIsOpenCodeExternalHost)() ? undefined : () => sttOwner.stop(),
|
|
11731
|
-
cleanupTimers: () => {
|
|
11732
|
-
clearInterval(inMemoryGcInterval);
|
|
11733
|
-
clearInterval(heartbeatInterval);
|
|
11734
|
-
clearInterval(fileSweepInterval);
|
|
11735
|
-
clearInterval(rateSummaryInterval);
|
|
11736
|
-
// Scheduler (S8): clear every armed job timer; persisted nextRunAt
|
|
11737
|
-
// means the next boot's rearmAll picks them back up (catch-up replay).
|
|
11738
|
-
schedulerEngine?.shutdown();
|
|
11739
|
-
requestWakeUpEngine?.stop();
|
|
11740
|
-
jiraConnector?.stop();
|
|
11741
|
-
if (isSchedulerMcpStarted)
|
|
11742
|
-
void schedulerMcpHandle.stop().catch(() => { });
|
|
11743
|
-
},
|
|
11744
|
-
});
|
|
12005
|
+
void runShutdown(signal, (code) => process.exit(code));
|
|
11745
12006
|
};
|
|
11746
12007
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
11747
12008
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
12009
|
+
// 7b. Automatic updates (`src/selfUpdate/`): the engine finds a new version, installs it when nobody is using the bot and
|
|
12010
|
+
// restarts it in place — the same sequence as a signal, then `process.execve` with the launch environment.
|
|
12011
|
+
const updateRuntime = await (0, updateRuntime_1.createUpdateRuntime)({
|
|
12012
|
+
dataDir: path.dirname(state.stateFilePath),
|
|
12013
|
+
packageRoot: path.resolve(__dirname, '..'),
|
|
12014
|
+
execPath: process.execPath,
|
|
12015
|
+
platform: process.platform,
|
|
12016
|
+
nodeVersion: process.versions.node,
|
|
12017
|
+
argv: process.argv,
|
|
12018
|
+
isHotMode: (0, installManager_1.checkIsOpenCodeExternalHost)(),
|
|
12019
|
+
});
|
|
12020
|
+
const readUpdateSettings = (0, instanceSettings_1.createUpdateSettingsReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line));
|
|
12021
|
+
updateEngine = (0, updateEngine_1.createUpdateEngine)({
|
|
12022
|
+
...updateRuntime,
|
|
12023
|
+
now: () => Date.now(),
|
|
12024
|
+
setTimer: (callback, delayMs) => {
|
|
12025
|
+
const timer = setTimeout(callback, delayMs);
|
|
12026
|
+
timer.unref();
|
|
12027
|
+
return timer;
|
|
12028
|
+
},
|
|
12029
|
+
clearTimer: (handle) => clearTimeout(handle),
|
|
12030
|
+
log: (line) => console.log(line),
|
|
12031
|
+
getSettings: readUpdateSettings,
|
|
12032
|
+
state: {
|
|
12033
|
+
getRecord: () => state.getSelfUpdateRecord(),
|
|
12034
|
+
setRecord: (record) => state.setSelfUpdateRecord(record),
|
|
12035
|
+
getLastRun: () => state.getLastRunVersion(),
|
|
12036
|
+
setLastRun: (lastRun) => state.setLastRunVersion(lastRun),
|
|
12037
|
+
},
|
|
12038
|
+
getIdleVerdict: getUpdateIdleVerdict,
|
|
12039
|
+
postToGeneral: postUpdateMessageToGeneral,
|
|
12040
|
+
restartInPlace: () => runShutdown('update', () => (0, restart_1.reexecProcess)(updateRuntime.reexecTarget, (0, launchEnvironment_1.getLaunchEnvironment)(), (0, restart_1.createDefaultReexecDeps)())),
|
|
12041
|
+
});
|
|
12042
|
+
const startUpdates = async () => {
|
|
12043
|
+
try {
|
|
12044
|
+
await updateEngine?.runBootStep();
|
|
12045
|
+
}
|
|
12046
|
+
catch (e) {
|
|
12047
|
+
console.warn('[update] boot step failed:', e instanceof Error ? e.message : e);
|
|
12048
|
+
}
|
|
12049
|
+
updateEngine?.start();
|
|
12050
|
+
};
|
|
11748
12051
|
// 8. Launch. `dropPendingUpdates` follows the boot classifier: on a hot
|
|
11749
12052
|
// reload we KEEP the buffered backlog so a message typed during the
|
|
11750
12053
|
// ~1s reload window still routes to its live agent; on a cold start
|
|
@@ -11761,6 +12064,7 @@ async function startBot() {
|
|
|
11761
12064
|
if (!ENV.isTelegramServed) {
|
|
11762
12065
|
// No polling: the MCP server and the connector timers keep the process alive.
|
|
11763
12066
|
console.log('[boot] running without Telegram (connectors: ' + [...ENV.servedPlatforms].join(', ') + ')');
|
|
12067
|
+
await startUpdates();
|
|
11764
12068
|
return;
|
|
11765
12069
|
}
|
|
11766
12070
|
try {
|
|
@@ -11769,6 +12073,7 @@ async function startBot() {
|
|
|
11769
12073
|
catch (e) {
|
|
11770
12074
|
console.warn('[startup-status] failed:', e instanceof Error ? e.message : e);
|
|
11771
12075
|
}
|
|
12076
|
+
await startUpdates();
|
|
11772
12077
|
// The local speech engine: clean up after an earlier run, re-attach to / start its install. Starts no process.
|
|
11773
12078
|
void bootSttLocalEngine().catch((e) => console.warn('[STT] boot step failed:', e instanceof Error ? e.message : e));
|
|
11774
12079
|
console.log(`Launching Telegraf bot (long polling, dropPendingUpdates=${bootMode.dropPendingUpdates})...`);
|