charness 3.4.0 → 3.6.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 +58 -0
- package/DEVELOPMENT.md +12 -1
- package/README.md +89 -6
- package/SETUP.md +199 -1
- package/dist/adapters/adapterEventRouting.d.ts +2 -2
- package/dist/adapters/adapterEventRouting.d.ts.map +1 -1
- package/dist/adapters/adapterEventRouting.js +4 -3
- package/dist/adapters/adapterEventRouting.js.map +1 -1
- 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 +82 -6
- package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
- package/dist/adapters/claudeJsonStreamAdapter.js +211 -14
- package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
- package/dist/adapters/createAdapter.d.ts +14 -1
- package/dist/adapters/createAdapter.d.ts.map +1 -1
- package/dist/adapters/createAdapter.js +54 -2
- package/dist/adapters/createAdapter.js.map +1 -1
- package/dist/adapters/openCodeAdapter.d.ts +5 -0
- package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/openCodeAdapter.js +7 -0
- package/dist/adapters/openCodeAdapter.js.map +1 -1
- package/dist/agentLogin/agentLogin.d.ts +18 -1
- package/dist/agentLogin/agentLogin.d.ts.map +1 -1
- package/dist/agentLogin/agentLogin.js +59 -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.map +1 -1
- package/dist/bot.js +475 -105
- package/dist/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/connectors/jira/connector.d.ts +2 -0
- package/dist/connectors/jira/connector.d.ts.map +1 -1
- package/dist/connectors/jira/connector.js +5 -4
- package/dist/connectors/jira/connector.js.map +1 -1
- 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/compaction.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/compaction.js +39 -8
- package/dist/connectors/telegram/commands/compaction.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/forumTopicCreated.d.ts +20 -0
- package/dist/connectors/telegram/forumTopicCreated.d.ts.map +1 -0
- package/dist/connectors/telegram/forumTopicCreated.js +16 -0
- package/dist/connectors/telegram/forumTopicCreated.js.map +1 -0
- package/dist/i18n/de.d.ts.map +1 -1
- package/dist/i18n/de.js +42 -1
- package/dist/i18n/de.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +42 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +42 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/fr.d.ts.map +1 -1
- package/dist/i18n/fr.js +42 -1
- package/dist/i18n/fr.js.map +1 -1
- package/dist/i18n/hi.d.ts.map +1 -1
- package/dist/i18n/hi.js +42 -1
- package/dist/i18n/hi.js.map +1 -1
- package/dist/i18n/ja.d.ts.map +1 -1
- package/dist/i18n/ja.js +42 -1
- package/dist/i18n/ja.js.map +1 -1
- package/dist/i18n/ka.d.ts.map +1 -1
- package/dist/i18n/ka.js +42 -1
- package/dist/i18n/ka.js.map +1 -1
- package/dist/i18n/pt.d.ts.map +1 -1
- package/dist/i18n/pt.js +42 -1
- package/dist/i18n/pt.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +42 -1
- package/dist/i18n/ru.js.map +1 -1
- package/dist/i18n/uk.d.ts.map +1 -1
- package/dist/i18n/uk.js +42 -1
- package/dist/i18n/uk.js.map +1 -1
- package/dist/i18n/uz.d.ts.map +1 -1
- package/dist/i18n/uz.js +42 -1
- package/dist/i18n/uz.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +42 -1
- package/dist/i18n/zh.js.map +1 -1
- package/dist/i18n.d.ts +2 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js.map +1 -1
- package/dist/rateLimiter.d.ts +6 -0
- package/dist/rateLimiter.d.ts.map +1 -1
- package/dist/rateLimiter.js +9 -0
- package/dist/rateLimiter.js.map +1 -1
- package/dist/requests/wakeUpEngine.d.ts +4 -0
- package/dist/requests/wakeUpEngine.d.ts.map +1 -1
- package/dist/requests/wakeUpEngine.js +3 -3
- package/dist/requests/wakeUpEngine.js.map +1 -1
- package/dist/requests/wakeUpRules.d.ts +2 -1
- package/dist/requests/wakeUpRules.d.ts.map +1 -1
- package/dist/requests/wakeUpRules.js +3 -2
- package/dist/requests/wakeUpRules.js.map +1 -1
- package/dist/scheduler/mcpSurface.d.ts.map +1 -1
- package/dist/scheduler/mcpSurface.js +5 -3
- package/dist/scheduler/mcpSurface.js.map +1 -1
- package/dist/selfUpdate/updateMessages.d.ts +1 -1
- package/dist/selfUpdate/updateMessages.d.ts.map +1 -1
- package/dist/selfUpdate/updateMessages.js.map +1 -1
- package/dist/state.d.ts +67 -5
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +76 -7
- package/dist/state.js.map +1 -1
- package/dist/testTimings.d.ts +45 -0
- package/dist/testTimings.d.ts.map +1 -0
- package/dist/testTimings.js +89 -0
- package/dist/testTimings.js.map +1 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/adapterSwitchSessionIds.d.ts +5 -1
- package/dist/utils/adapterSwitchSessionIds.d.ts.map +1 -1
- package/dist/utils/adapterSwitchSessionIds.js +1 -1
- package/dist/utils/adapterSwitchSessionIds.js.map +1 -1
- package/dist/utils/adoptedToolList.d.ts +16 -21
- package/dist/utils/adoptedToolList.d.ts.map +1 -1
- package/dist/utils/adoptedToolList.js +18 -17
- package/dist/utils/adoptedToolList.js.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/claudeScheduledJobs.d.ts +49 -0
- package/dist/utils/claudeScheduledJobs.d.ts.map +1 -0
- package/dist/utils/claudeScheduledJobs.js +62 -0
- package/dist/utils/claudeScheduledJobs.js.map +1 -0
- package/dist/utils/claudeStreamJson.d.ts +35 -2
- package/dist/utils/claudeStreamJson.d.ts.map +1 -1
- package/dist/utils/claudeStreamJson.js +31 -8
- 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/compactOnIdle.d.ts +12 -0
- package/dist/utils/compactOnIdle.d.ts.map +1 -1
- package/dist/utils/compactOnIdle.js +15 -1
- package/dist/utils/compactOnIdle.js.map +1 -1
- package/dist/utils/doctorLine.d.ts +13 -0
- package/dist/utils/doctorLine.d.ts.map +1 -0
- package/dist/utils/doctorLine.js +18 -0
- package/dist/utils/doctorLine.js.map +1 -0
- package/dist/utils/effortPickerPlan.d.ts +1 -2
- package/dist/utils/effortPickerPlan.d.ts.map +1 -1
- package/dist/utils/effortPickerPlan.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/jsonStreamHost.d.ts +19 -7
- package/dist/utils/jsonStreamHost.d.ts.map +1 -1
- package/dist/utils/jsonStreamHost.js +25 -11
- package/dist/utils/jsonStreamHost.js.map +1 -1
- package/dist/utils/jsonStreamPendingWork.d.ts +44 -0
- package/dist/utils/jsonStreamPendingWork.d.ts.map +1 -0
- package/dist/utils/jsonStreamPendingWork.js +48 -0
- package/dist/utils/jsonStreamPendingWork.js.map +1 -0
- 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/lostWork.d.ts +117 -0
- package/dist/utils/lostWork.d.ts.map +1 -0
- package/dist/utils/lostWork.js +187 -0
- package/dist/utils/lostWork.js.map +1 -0
- package/dist/utils/nativeWindows.d.ts +29 -0
- package/dist/utils/nativeWindows.d.ts.map +1 -0
- package/dist/utils/nativeWindows.js +50 -0
- package/dist/utils/nativeWindows.js.map +1 -0
- 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 +1 -1
- package/dist/utils/updateScreenPlan.d.ts.map +1 -1
- package/dist/utils/updateScreenPlan.js.map +1 -1
- package/package.json +2 -2
package/dist/bot.js
CHANGED
|
@@ -70,8 +70,11 @@ const terminalAdapter_1 = require("./adapters/terminalAdapter");
|
|
|
70
70
|
const openCodeQuestionFlow_1 = require("./openCodeQuestionFlow");
|
|
71
71
|
const pendingQuestionRepost_1 = require("./pendingQuestionRepost");
|
|
72
72
|
const rateLimiter_1 = require("./rateLimiter");
|
|
73
|
+
const testTimings_1 = require("./testTimings");
|
|
73
74
|
const installManager_1 = require("./installManager");
|
|
74
75
|
const startupReadiness_1 = require("./utils/startupReadiness");
|
|
76
|
+
const doctorLine_1 = require("./utils/doctorLine");
|
|
77
|
+
const nativeWindows_1 = require("./utils/nativeWindows");
|
|
75
78
|
const state_1 = require("./state");
|
|
76
79
|
const instanceLock_1 = require("./instanceLock");
|
|
77
80
|
const shutdown_1 = require("./shutdown");
|
|
@@ -85,6 +88,7 @@ const timezoneRecompute_1 = require("./scheduler/timezoneRecompute");
|
|
|
85
88
|
const validation_1 = require("./validation");
|
|
86
89
|
const bindPicker_1 = require("./connectors/telegram/bindPicker");
|
|
87
90
|
const topicCommand_1 = require("./connectors/telegram/commands/topicCommand");
|
|
91
|
+
const forumTopicCreated_1 = require("./connectors/telegram/forumTopicCreated");
|
|
88
92
|
const providerDisconnectPlan_1 = require("./utils/providerDisconnectPlan");
|
|
89
93
|
const folderName_1 = require("./folderName");
|
|
90
94
|
const threadRouting_1 = require("./threadRouting");
|
|
@@ -138,6 +142,8 @@ const backendUnavailableError_1 = require("./adapters/backendUnavailableError");
|
|
|
138
142
|
const claudeCodeVersion_1 = require("./utils/claudeCodeVersion");
|
|
139
143
|
const keyedTransitionQueue_1 = require("./utils/keyedTransitionQueue");
|
|
140
144
|
const adoptedToolList_1 = require("./utils/adoptedToolList");
|
|
145
|
+
const lostWork_1 = require("./utils/lostWork");
|
|
146
|
+
const jsonStreamPendingWork_1 = require("./utils/jsonStreamPendingWork");
|
|
141
147
|
const compactOnIdle_1 = require("./utils/compactOnIdle");
|
|
142
148
|
const fileSendService_1 = require("./utils/fileSendService");
|
|
143
149
|
const messageSendService_1 = require("./utils/messageSendService");
|
|
@@ -215,6 +221,8 @@ const claudeUsageRefresher_1 = require("./utils/claudeUsageRefresher");
|
|
|
215
221
|
const openCodeUsage_1 = require("./utils/openCodeUsage");
|
|
216
222
|
const usageRefreshBudget_1 = require("./utils/usageRefreshBudget");
|
|
217
223
|
const claudeLimitSwitch_1 = require("./utils/claudeLimitSwitch");
|
|
224
|
+
const anthropicApiKeyCheck_1 = require("./utils/anthropicApiKeyCheck");
|
|
225
|
+
const claudeApiKeyHelper_1 = require("./utils/claudeApiKeyHelper");
|
|
218
226
|
const fallbackSwitchNotice_1 = require("./utils/fallbackSwitchNotice");
|
|
219
227
|
const adapterSwitchSessionIds_1 = require("./utils/adapterSwitchSessionIds");
|
|
220
228
|
const fallbackChain_1 = require("./utils/fallbackChain");
|
|
@@ -226,6 +234,8 @@ const operatorSwitchPlan_1 = require("./utils/operatorSwitchPlan");
|
|
|
226
234
|
const backendSwitchPlan_1 = require("./utils/backendSwitchPlan");
|
|
227
235
|
const selfUpdate_1 = require("./connectors/telegram/commands/selfUpdate");
|
|
228
236
|
const stt_1 = require("./connectors/telegram/commands/stt");
|
|
237
|
+
const claudeApiSpend_1 = require("./utils/claudeApiSpend");
|
|
238
|
+
const claudeProfileUsageLines_1 = require("./utils/claudeProfileUsageLines");
|
|
229
239
|
const resolveBinary_1 = require("./utils/resolveBinary");
|
|
230
240
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
231
241
|
// ENV parsing & fatal validation
|
|
@@ -315,12 +325,17 @@ function parseEnv() {
|
|
|
315
325
|
const code = e.code ?? 'unknown';
|
|
316
326
|
errors.push(`WORK_ROOT="${workRoot}" is not accessible (${code})`);
|
|
317
327
|
}
|
|
328
|
+
// A test instance's timing overrides (never a setting); an unknown key or a bad value stops the boot, naming it.
|
|
329
|
+
const testTimingsParse = (0, testTimings_1.parseTestTimings)(process.env[testTimings_1.testTimingsEnvName]);
|
|
330
|
+
if (!testTimingsParse.ok)
|
|
331
|
+
errors.push(...testTimingsParse.errors);
|
|
318
332
|
if (errors.length > 0) {
|
|
319
333
|
for (const err of errors)
|
|
320
334
|
console.error(`[startup] ${err}`);
|
|
321
335
|
process.exit(1);
|
|
322
336
|
}
|
|
323
337
|
return {
|
|
338
|
+
testTimings: testTimingsParse.ok ? testTimingsParse.timings : {},
|
|
324
339
|
botToken,
|
|
325
340
|
servedPlatforms: (0, connectorSet_1.getServedPlatforms)(connectors),
|
|
326
341
|
isTelegramServed,
|
|
@@ -332,6 +347,15 @@ function parseEnv() {
|
|
|
332
347
|
};
|
|
333
348
|
}
|
|
334
349
|
const ENV = parseEnv();
|
|
350
|
+
const testTimingsBootLine = (0, testTimings_1.formatTestTimingsBootLine)(ENV.testTimings);
|
|
351
|
+
if (testTimingsBootLine !== null)
|
|
352
|
+
console.log(testTimingsBootLine);
|
|
353
|
+
if (ENV.testTimings.telegramSendIntervalMs !== undefined)
|
|
354
|
+
(0, rateLimiter_1.configureGlobalSendInterval)(ENV.testTimings.telegramSendIntervalMs);
|
|
355
|
+
if (ENV.testTimings.backgroundHoldMaxMs !== undefined)
|
|
356
|
+
(0, createAdapter_1.registerPendingWorkHoldMs)(ENV.testTimings.backgroundHoldMaxMs);
|
|
357
|
+
/** The short retry that probes an unconfirmed usage limit (a test instance may shorten it). */
|
|
358
|
+
const limitProbeDelayMs = ENV.testTimings.limitProbeDelayMs ?? apiErrorRetry_1.unconfirmedLimitProbeDelayMs;
|
|
335
359
|
// The instance default effort (`/effort` in General) is `settings.json`'s `defaultEffort`, re-read when the file changes.
|
|
336
360
|
(0, effortLevels_1.setInstanceDefaultEffortReader)((0, instanceSettings_1.createInstanceDefaultEffortReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line)));
|
|
337
361
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -877,6 +901,12 @@ const limitResumeNoticePinnedMessageId = new Map();
|
|
|
877
901
|
* Cleared by {@link cancelApiRetry} (the user took over / the session went away).
|
|
878
902
|
*/
|
|
879
903
|
const autoContinueOffNoticedThreads = new Set();
|
|
904
|
+
/**
|
|
905
|
+
* @description Threads that already got the «API credits used up» notice for the CURRENT episode
|
|
906
|
+
* ({@link handleBalanceExhausted}): that flow arms no retry either, so — like the OFF notice above — a repeated frame
|
|
907
|
+
* of the same error needs its own guard. Cleared by {@link cancelApiRetry} (the user took over / the session went away).
|
|
908
|
+
*/
|
|
909
|
+
const balanceExhaustedNoticedThreads = new Set();
|
|
880
910
|
/**
|
|
881
911
|
* The OPEN «switch» offer per thread (`utils/fallbackChain.ts`): the limit episode the buttons on the notice
|
|
882
912
|
* were posted for, the rung the topic was on and the candidate list the buttons index into. A tap is honoured
|
|
@@ -1142,6 +1172,11 @@ const wedgeRecoveriesInFlight = new Set();
|
|
|
1142
1172
|
*/
|
|
1143
1173
|
function handleApiError(key, cls, options = {}) {
|
|
1144
1174
|
const k = (0, sessionKey_1.keyToString)(key);
|
|
1175
|
+
// A used-up API credit balance: no wait refills it — the account is marked, and the chain or the operator takes over.
|
|
1176
|
+
if (cls.kind === 'usageLimit' && cls.isBalanceExhausted === true) {
|
|
1177
|
+
void handleBalanceExhausted(key, cls);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1145
1180
|
// An unconfirmed usage limit is probed first; it reaches the wait below only once confirmed.
|
|
1146
1181
|
if (cls.kind === 'usageLimit' && cls.isUnconfirmed === true && handleUnconfirmedLimit(key))
|
|
1147
1182
|
return;
|
|
@@ -1236,7 +1271,7 @@ function armApiRetryTimer(key, arming) {
|
|
|
1236
1271
|
// bottom (debounced; no-op when no question is pending).
|
|
1237
1272
|
onThreadActivityWhileQuestionPending(key);
|
|
1238
1273
|
void state
|
|
1239
|
-
.setApiRetry(key, { kind: arming.kind, attempt: arming.attempt, fireAt: arming.fireAt, ...(arming.isLimitProbe ? { isLimitProbe: true } : {}) })
|
|
1274
|
+
.setApiRetry(key, { kind: arming.kind, attempt: arming.attempt, fireAt: arming.fireAt, ...(arming.isLimitProbe ? { isLimitProbe: true } : {}), ...(arming.resumeOn ? { resumeOn: arming.resumeOn } : {}) })
|
|
1240
1275
|
.catch(e => console.error('[apiRetry] persist failed:', e));
|
|
1241
1276
|
clearSavedFiredLimitProbe(key);
|
|
1242
1277
|
const timer = setTimeout(() => {
|
|
@@ -1252,6 +1287,7 @@ function armApiRetryTimer(key, arming) {
|
|
|
1252
1287
|
fireAt: arming.fireAt,
|
|
1253
1288
|
limitWait: arming.limitWait,
|
|
1254
1289
|
isLimitProbe: arming.isLimitProbe,
|
|
1290
|
+
resumeOn: arming.resumeOn,
|
|
1255
1291
|
});
|
|
1256
1292
|
}
|
|
1257
1293
|
/**
|
|
@@ -1276,9 +1312,9 @@ function handleUnconfirmedLimit(key) {
|
|
|
1276
1312
|
return false;
|
|
1277
1313
|
}
|
|
1278
1314
|
console.log(`[apiRetry] ${k}: an unconfirmed usage limit — probing with a short retry before waiting for the reset`);
|
|
1279
|
-
void replyToThread(key, (0, i18n_1.t)('apiRetry.transientNotice', { minutes: Math.round(
|
|
1315
|
+
void replyToThread(key, (0, i18n_1.t)('apiRetry.transientNotice', { minutes: Math.round(limitProbeDelayMs / apiRetryMsPerMinute), attempt: 1 }));
|
|
1280
1316
|
const now = Date.now();
|
|
1281
|
-
armApiRetryTimer(key, { kind: 'transient', attempt: 1, fireAt: now +
|
|
1317
|
+
armApiRetryTimer(key, { kind: 'transient', attempt: 1, fireAt: now + limitProbeDelayMs, delayMs: limitProbeDelayMs, isLimitProbe: true });
|
|
1282
1318
|
return true;
|
|
1283
1319
|
}
|
|
1284
1320
|
/**
|
|
@@ -1443,15 +1479,90 @@ async function announceUsageLimitOrSwitch(key, cls, episodeId, initialWait, atte
|
|
|
1443
1479
|
}
|
|
1444
1480
|
}
|
|
1445
1481
|
const texts = getLimitWaitTexts(key, wait, attempt, resumeOn);
|
|
1446
|
-
const keyboard = plan.action === 'offer'
|
|
1447
|
-
? telegraf_1.Markup.inlineKeyboard(plan.candidates.map((entry, index) => [
|
|
1448
|
-
telegraf_1.Markup.button.callback((0, i18n_1.t)('fallback.switchButton', { label: formatFallbackEntry(entry) }), (0, fallbackChain_1.buildFallbackSwitchCallbackData)(episodeId, index)),
|
|
1449
|
-
]))
|
|
1450
|
-
: undefined;
|
|
1482
|
+
const keyboard = plan.action === 'offer' ? buildFallbackSwitchKeyboard(episodeId, plan.candidates) : undefined;
|
|
1451
1483
|
const { isPlainNoticePosted } = await announceLimitWait(key, wait, texts, isPlainNoticeDue, keyboard);
|
|
1452
1484
|
if (keyboard && !isPlainNoticePosted)
|
|
1453
1485
|
await replyToThread(key, (0, i18n_1.t)('fallback.switchOffer'), keyboard);
|
|
1454
1486
|
}
|
|
1487
|
+
/** The «🔁 Switch to …» buttons of a limit episode's notice, one per candidate (best first; the index is into that episode's list). */
|
|
1488
|
+
function buildFallbackSwitchKeyboard(episodeId, candidates) {
|
|
1489
|
+
return telegraf_1.Markup.inlineKeyboard(candidates.map((entry, index) => [
|
|
1490
|
+
telegraf_1.Markup.button.callback((0, i18n_1.t)('fallback.switchButton', { label: formatFallbackEntry(entry) }), (0, fallbackChain_1.buildFallbackSwitchCallbackData)(episodeId, index)),
|
|
1491
|
+
]));
|
|
1492
|
+
}
|
|
1493
|
+
/** Whose API credits a balance notice names: a Claude account `«console»`, an OpenCode `provider/model`. */
|
|
1494
|
+
function formatBalanceOwner(from) {
|
|
1495
|
+
if (from === null)
|
|
1496
|
+
return (0, i18n_1.t)('fallback.backendClaude');
|
|
1497
|
+
return from.backend === 'claude' ? `«${from.profile}»` : (0, fallbackChain_1.buildOpenCodeModelLabel)(from);
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* @description A used-up API credit balance on `key` («Credit balance is too low», `isBalanceExhausted`): nothing resets
|
|
1501
|
+
* it, so no wait for a reset is armed and no hourly retry runs. The Claude account is marked out of credits
|
|
1502
|
+
* (`creditExhaustedAt`, until the operator picks it or signs it in again, or a billed turn on it goes through), the parked
|
|
1503
|
+
* episode is settled (a restart does not run this again), and the fallback chain decides as for a limit, with that
|
|
1504
|
+
* account out: switching ON moves the topic at once; OFF puts «switch to …» buttons on the notice. With no rung free
|
|
1505
|
+
* now, the topic waits only for a rung that comes back at a KNOWN time (the earliest reset, and only with «wait for
|
|
1506
|
+
* reset» on) and resumes there; otherwise the notice is all, and an open request is answered and no longer woken. A
|
|
1507
|
+
* repeat of the same episode (another frame, another scrape of the line, a scheduled run into the same balance) says
|
|
1508
|
+
* nothing in the topic again; a request opened since — a scheduled run's — is still answered and no longer woken, unless
|
|
1509
|
+
* a wait for another rung's reset is armed.
|
|
1510
|
+
*/
|
|
1511
|
+
async function handleBalanceExhausted(key, cls) {
|
|
1512
|
+
const k = (0, sessionKey_1.keyToString)(key);
|
|
1513
|
+
markLimitEpisodeSettled(key);
|
|
1514
|
+
const position = getTopicFallbackPosition(key);
|
|
1515
|
+
if (position?.backend === 'claude')
|
|
1516
|
+
state.setClaudeProfileCreditExhausted(position.profile, new Date().toISOString());
|
|
1517
|
+
const notice = `${(0, i18n_1.t)('apiRetry.balanceExhaustedNotice', { name: formatBalanceOwner(position) })}${getBalanceSpentLine(position)}`;
|
|
1518
|
+
const texts = { plainNotice: notice, answerBody: (0, i18n_1.t)('requests.limit.answerBalanceExhaustedNotice') };
|
|
1519
|
+
const stopped = { kind: 'autoResumeOff' };
|
|
1520
|
+
if (balanceExhaustedNoticedThreads.has(k)) {
|
|
1521
|
+
// Like the «wait for reset» OFF repeat: without it the wake-up engine re-prods that request into the same refused
|
|
1522
|
+
// balance and ends in an alert.
|
|
1523
|
+
if (getArmedApiRetry(key) === null)
|
|
1524
|
+
await announceLimitWait(key, stopped, texts, false);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
balanceExhaustedNoticedThreads.add(k);
|
|
1528
|
+
const episodeId = Date.now();
|
|
1529
|
+
let plan = { action: 'none', from: position, earliest: null };
|
|
1530
|
+
try {
|
|
1531
|
+
plan = await planFallbackSwitch(key, cls, episodeId);
|
|
1532
|
+
}
|
|
1533
|
+
catch (e) {
|
|
1534
|
+
console.warn(`[fallback] ${k}: the switch plan after a used-up balance failed:`, e instanceof Error ? e.message : e);
|
|
1535
|
+
}
|
|
1536
|
+
if (plan.action === 'auto') {
|
|
1537
|
+
const to = plan.to;
|
|
1538
|
+
await replyToThread(key, notice);
|
|
1539
|
+
const outcome = await switchTopicToFallbackEntry(key, plan.from, to, { isAnnounced: true }).catch((e) => {
|
|
1540
|
+
console.warn(`[fallback] ${k}: the auto-switch to ${formatFallbackEntry(to)} failed:`, e instanceof Error ? e.message : e);
|
|
1541
|
+
return { isSwitched: false };
|
|
1542
|
+
});
|
|
1543
|
+
// The notice is already in the topic; an open request still hears that the work stopped.
|
|
1544
|
+
if (!outcome.isSwitched)
|
|
1545
|
+
await announceLimitWait(key, stopped, texts, false);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (plan.action === 'offer') {
|
|
1549
|
+
const keyboard = buildFallbackSwitchKeyboard(episodeId, plan.candidates);
|
|
1550
|
+
const { isPlainNoticePosted } = await announceLimitWait(key, stopped, texts, true, keyboard);
|
|
1551
|
+
if (!isPlainNoticePosted)
|
|
1552
|
+
await replyToThread(key, (0, i18n_1.t)('fallback.switchOffer'), keyboard);
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
if (plan.earliest && state.checkIsAutoContinueOnLimitEnabled(key)) {
|
|
1556
|
+
const fireAt = plan.earliest.untilMs + apiErrorRetry_1.resetBufferMs;
|
|
1557
|
+
const wait = { kind: 'afterReset', resetAt: plan.earliest.untilMs, fireAt };
|
|
1558
|
+
clearLimitResumeNotice(key);
|
|
1559
|
+
armApiRetryTimer(key, { kind: 'usageLimit', attempt: 1, fireAt, delayMs: Math.max(fireAt - Date.now(), 0), limitWait: wait, resumeOn: plan.earliest.entry });
|
|
1560
|
+
const waitTexts = getLimitWaitTexts(key, wait, 1, plan.earliest.entry);
|
|
1561
|
+
await announceLimitWait(key, wait, { plainNotice: `${notice}\n\n${waitTexts.plainNotice}`, answerBody: waitTexts.answerBody }, true);
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
await announceLimitWait(key, stopped, texts, true);
|
|
1565
|
+
}
|
|
1455
1566
|
/**
|
|
1456
1567
|
* @description Re-aim the ARMED usage-limit wait of `key` at the rung that comes back first: the timer moves to
|
|
1457
1568
|
* that rung's reset (+ the usual buffer), and the persisted retry record carries the rung (`resumeOn`), so the
|
|
@@ -1550,6 +1661,7 @@ function cancelApiRetry(key) {
|
|
|
1550
1661
|
// error — so neither can re-notify per message.
|
|
1551
1662
|
clearLimitResumeNotice(key);
|
|
1552
1663
|
autoContinueOffNoticedThreads.delete(k);
|
|
1664
|
+
balanceExhaustedNoticedThreads.delete(k);
|
|
1553
1665
|
fallbackSwitchOffers.delete(k);
|
|
1554
1666
|
// NOTE: intentionally does NOT touch the logged-out notice. `cancelApiRetry`
|
|
1555
1667
|
// fires on EVERY inbound message (user takeover); clearing the auth notice here
|
|
@@ -1741,6 +1853,8 @@ function clearLimitResumeNotice(key) {
|
|
|
1741
1853
|
* sign-in driver built here), so the driver reaches it through this late-bound slot.
|
|
1742
1854
|
*/
|
|
1743
1855
|
let claudeProfileAuthStatusSink = null;
|
|
1856
|
+
/** Where an API key pasted into an API account's sign-in goes: the `/account` module too, through the same late-bound idiom. */
|
|
1857
|
+
let claudeProfileApiKeySink = null;
|
|
1744
1858
|
// Out-of-band sign-in drivers (`/login`, `/connect` OAuth): `agentLogin/README.md`.
|
|
1745
1859
|
const { cancelPendingLogin, checkIsAuthLoginAwaitingCode, cancelClaudeAuthLogin, startClaudeAuthLogin, submitClaudeAuthLoginCode, checkIsOpenCodeOAuthAwaitingReply, cancelOpenCodeOAuthLogin, startOpenCodeOAuthLogin, submitOpenCodeOAuthReply, checkIsAnyLoginInProgress, } = (0, agentLogin_1.createAgentLogin)({
|
|
1746
1860
|
replyToThread,
|
|
@@ -1748,6 +1862,7 @@ const { cancelPendingLogin, checkIsAuthLoginAwaitingCode, cancelClaudeAuthLogin,
|
|
|
1748
1862
|
clearAuthNotice,
|
|
1749
1863
|
execFileAsync,
|
|
1750
1864
|
onClaudeProfileAuthStatus: (key, profile, status) => claudeProfileAuthStatusSink?.(key, profile, status) ?? Promise.resolve(),
|
|
1865
|
+
onClaudeProfileApiKey: (key, profile, apiKey) => claudeProfileApiKeySink?.(key, profile, apiKey) ?? Promise.resolve((0, i18n_1.t)('account.apiKeyNoDirectory', { name: profile.name })),
|
|
1751
1866
|
});
|
|
1752
1867
|
/**
|
|
1753
1868
|
* @description Buffers prompts typed while an agent session is still booting so
|
|
@@ -3588,6 +3703,9 @@ async function releaseThreadSession(key) {
|
|
|
3588
3703
|
* adapters can't end up polling/streaming the same thread at once — that
|
|
3589
3704
|
* desync caused mixed output, silent `/stop`, and the "I keep getting
|
|
3590
3705
|
* 'Login successful' on every message" bug reported 2026-05-15.
|
|
3706
|
+
*
|
|
3707
|
+
* Resolves the work the stop cut off that the agent's next prompt is told about (`null`: none), so a caller's line to
|
|
3708
|
+
* the operator is derived from the very record the note lists.
|
|
3591
3709
|
*/
|
|
3592
3710
|
async function switchThreadAdapter(key, newName) {
|
|
3593
3711
|
const prevName = (0, createAdapter_1.getThreadAdapterNameRaw)(key);
|
|
@@ -3596,9 +3714,13 @@ async function switchThreadAdapter(key, newName) {
|
|
|
3596
3714
|
// reminding (or, in a terminal, TYPING into) a session that never saw it.
|
|
3597
3715
|
if (prevName && !(0, createAdapter_1.checkIsSameConversationSwitch)(prevName, newName))
|
|
3598
3716
|
cancelConversationRequest(key);
|
|
3717
|
+
let switchCutOffWork = null;
|
|
3599
3718
|
if (prevName && prevName !== newName) {
|
|
3600
3719
|
try {
|
|
3601
3720
|
const prev = (0, createAdapter_1.getAdapter)(prevName);
|
|
3721
|
+
// What dies with the stopped process: its own background work and the turn it was in (an explicit stop records nothing).
|
|
3722
|
+
if (prev.checkIsActive(key))
|
|
3723
|
+
switchCutOffWork = prev.getPendingWork?.(key) ?? null;
|
|
3602
3724
|
if (prev.checkIsActive(key)) {
|
|
3603
3725
|
// `stopSession` is `void` in the AgentAdapter contract — OpenCode
|
|
3604
3726
|
// queues teardown behind its lifecycle lock and returns
|
|
@@ -3620,7 +3742,7 @@ async function switchThreadAdapter(key, newName) {
|
|
|
3620
3742
|
(0, createAdapter_1.setThreadAdapter)(key, newName);
|
|
3621
3743
|
const agent = state.getAgent(key);
|
|
3622
3744
|
if (!agent)
|
|
3623
|
-
return;
|
|
3745
|
+
return null;
|
|
3624
3746
|
// Wipe ids that don't belong to the new adapter. We persist via
|
|
3625
3747
|
// setAgent (which merges), so build a fresh record with only the
|
|
3626
3748
|
// surviving fields.
|
|
@@ -3638,13 +3760,25 @@ async function switchThreadAdapter(key, newName) {
|
|
|
3638
3760
|
// fallback switch or a plain /claude ↔ /opencode comes back to the conversation it left
|
|
3639
3761
|
// (L-D4: a sleeping conversation is woken, `/new` is the fresh start). The reattach loop never reads the parked
|
|
3640
3762
|
// slot, so the audit S12 bug (both ids on the live row) stays fixed.
|
|
3641
|
-
const
|
|
3763
|
+
const parkedBefore = state.getParkedSession(key);
|
|
3764
|
+
const switched = (0, adapterSwitchSessionIds_1.getAdapterSwitchSessionIds)({ previousName: prevName, nextName: newName, agent, parked: parkedBefore, parkedAt: new Date().toISOString() });
|
|
3642
3765
|
if (switched.ids.claudeSessionId)
|
|
3643
3766
|
next.claudeSessionId = switched.ids.claudeSessionId;
|
|
3644
3767
|
if (switched.ids.opencodeSessionId)
|
|
3645
3768
|
next.opencodeSessionId = switched.ids.opencodeSessionId;
|
|
3769
|
+
// Work the switch cut off is told twice, once to each agent: the next prompt (the hand-over bridge, the next message)
|
|
3770
|
+
// tells the agent the topic moves to, and the parked conversation keeps it for its own agent's first prompt back.
|
|
3771
|
+
const cutOffRecord = switchCutOffWork ? { ...switchCutOffWork, reason: 'switched', lostAt: Date.now() } : null;
|
|
3772
|
+
// The parked copy knows the agent the topic moves to hears it first (it may restart some of the work itself).
|
|
3773
|
+
const parkedLoss = switchCutOffWork && cutOffRecord ? { ...cutOffRecord, otherAgentTold: switchCutOffWork } : null;
|
|
3646
3774
|
if (switched.parked !== undefined)
|
|
3647
|
-
await state.setParkedSession(key, switched.parked);
|
|
3775
|
+
await state.setParkedSession(key, switched.parked && parkedLoss ? { ...switched.parked, lostWork: parkedLoss } : switched.parked);
|
|
3776
|
+
// Back to a parked conversation (the side being left may be parked in the same step): what its agent was never told
|
|
3777
|
+
// rides its first prompt.
|
|
3778
|
+
if (switched.isRestored && parkedBefore?.lostWork)
|
|
3779
|
+
await foldLostWorkIntoNextPrompt(key, parkedBefore.lostWork);
|
|
3780
|
+
if (cutOffRecord)
|
|
3781
|
+
await foldLostWorkIntoNextPrompt(key, cutOffRecord);
|
|
3648
3782
|
// The watermark is scoped to its session: it rides along while that session is kept. Every wake of a sleeping
|
|
3649
3783
|
// conversation passes through here, and the wake reads it as the proof the conversation took a turn.
|
|
3650
3784
|
if (agent.seenWatermark && [next.claudeSessionId, next.opencodeSessionId].includes(agent.seenWatermark.sessionId)) {
|
|
@@ -3654,6 +3788,7 @@ async function switchThreadAdapter(key, newName) {
|
|
|
3654
3788
|
// fields we kept.
|
|
3655
3789
|
await state.removeAgent(key);
|
|
3656
3790
|
await state.setAgent(key, next);
|
|
3791
|
+
return switchCutOffWork;
|
|
3657
3792
|
}
|
|
3658
3793
|
/**
|
|
3659
3794
|
* @description The post-start "ready" notice for an agent that just came up — or
|
|
@@ -3758,21 +3893,20 @@ async function persistSessionStart(key, adapter) {
|
|
|
3758
3893
|
}
|
|
3759
3894
|
}
|
|
3760
3895
|
/**
|
|
3761
|
-
* @description
|
|
3762
|
-
*
|
|
3763
|
-
*
|
|
3764
|
-
*
|
|
3765
|
-
*
|
|
3766
|
-
*
|
|
3767
|
-
* or began during the downtime would be misread.
|
|
3896
|
+
* @description A per-turn json-stream process that outlived the bot (L5) is stopped as soon as it is idle — its turn
|
|
3897
|
+
* (if any) ended and none of its own work is pending — the per-turn backend's after-turn stop, which the downtime
|
|
3898
|
+
* swallowed. Polled, since the turn may have ended before the adopt. The first look waits for the adopt's replay:
|
|
3899
|
+
* until the tail has consumed what stdout held at the adopt, the live state is the persisted one plus nothing, and a
|
|
3900
|
+
* turn that ended or began during the downtime would be misread. The idle-lifecycle backend has no such stop: its
|
|
3901
|
+
* adopted process, stale tool list or not, is stopped by the idle fire after the compaction.
|
|
3768
3902
|
*/
|
|
3769
|
-
function
|
|
3903
|
+
function stopAdoptedPerTurnSessionWhenIdle(key, adapter) {
|
|
3770
3904
|
const kStr = (0, sessionKey_1.keyToString)(key);
|
|
3771
3905
|
const tick = () => {
|
|
3772
3906
|
if (!adapter.checkIsActive(key))
|
|
3773
|
-
return; // gone meanwhile (a stop, a crash): nothing to
|
|
3907
|
+
return; // gone meanwhile (a stop, a crash): nothing left to stop
|
|
3774
3908
|
if (adapter.checkIsWorking?.(key)) {
|
|
3775
|
-
const timer = setTimeout(tick,
|
|
3909
|
+
const timer = setTimeout(tick, adoptedPerTurnStopPollMs);
|
|
3776
3910
|
timer.unref?.();
|
|
3777
3911
|
return;
|
|
3778
3912
|
}
|
|
@@ -3780,22 +3914,22 @@ function stopAdoptedSessionWhenIdle(key, adapter, reason) {
|
|
|
3780
3914
|
// the suspend would decline on every poll. Stop looking: a later turn's `init` reports the version,
|
|
3781
3915
|
// and the lifecycle's own stop (the idle timer, the per-turn `turnEnded`) takes over from there.
|
|
3782
3916
|
if (!adapter.checkIsAutoStopSupported(key)) {
|
|
3783
|
-
console.log(`[reattach] ${kStr}: the adopted process is kept (
|
|
3917
|
+
console.log(`[reattach] ${kStr}: the adopted process is kept (the per-turn lifecycle): its Claude Code version is unknown or too old for the auto-stop; its own lifecycle stop takes over once a turn reports it`);
|
|
3784
3918
|
return;
|
|
3785
3919
|
}
|
|
3786
|
-
console.log(`[reattach] ${kStr}: stopping the adopted process (
|
|
3920
|
+
console.log(`[reattach] ${kStr}: stopping the adopted process (the per-turn lifecycle); the next trigger resumes it`);
|
|
3787
3921
|
void suspendThreadSession(key)
|
|
3788
3922
|
.then(() => {
|
|
3789
3923
|
// The suspend declines when a turn began between the look and the transition: look again.
|
|
3790
3924
|
if (adapter.checkIsActive(key))
|
|
3791
|
-
setTimeout(tick,
|
|
3925
|
+
setTimeout(tick, adoptedPerTurnStopPollMs).unref?.();
|
|
3792
3926
|
})
|
|
3793
|
-
.catch((e) => console.warn(`[reattach] ${kStr}: the
|
|
3927
|
+
.catch((e) => console.warn(`[reattach] ${kStr}: the per-turn stop of the adopted process failed:`, e instanceof Error ? e.message : e));
|
|
3794
3928
|
};
|
|
3795
3929
|
void adapter.whenAdoptReplayed(key).then(tick);
|
|
3796
3930
|
}
|
|
3797
|
-
/** How often an adopted
|
|
3798
|
-
const
|
|
3931
|
+
/** How often an adopted per-turn process that is still working is re-checked for idleness (L5). */
|
|
3932
|
+
const adoptedPerTurnStopPollMs = 5000;
|
|
3799
3933
|
async function startAgentSession(key, args) {
|
|
3800
3934
|
const kStr = (0, sessionKey_1.keyToString)(key);
|
|
3801
3935
|
// The bound folder IS the agent's cwd — refuse to start without one. The
|
|
@@ -4064,6 +4198,8 @@ async function suspendThreadSession(key) {
|
|
|
4064
4198
|
}
|
|
4065
4199
|
autoStopUnsupportedLoggedKeys.delete(kStr);
|
|
4066
4200
|
await sessionTransitions.run(kStr, async () => {
|
|
4201
|
+
// Decide on what the process wrote up to now, not on the last tail poll: a background task's turn may have just begun.
|
|
4202
|
+
adapter.refreshWorkingState?.(key);
|
|
4067
4203
|
if (!adapter.checkIsActive(key) || adapter.checkIsWorking?.(key))
|
|
4068
4204
|
return;
|
|
4069
4205
|
startupPromptBuffer.markStarting(kStr);
|
|
@@ -4084,13 +4220,20 @@ async function suspendThreadSession(key) {
|
|
|
4084
4220
|
/** Conversations already told once that their CLI is too old for the auto-stop (L-D10). */
|
|
4085
4221
|
const autoStopUnsupportedLoggedKeys = new Set();
|
|
4086
4222
|
/**
|
|
4087
|
-
* @description `turnEnded` from a json-stream adapter
|
|
4088
|
-
*
|
|
4089
|
-
*
|
|
4090
|
-
*
|
|
4091
|
-
*
|
|
4223
|
+
* @description `turnEnded` from a json-stream adapter. The idle countdown runs from
|
|
4224
|
+
* the end of the last turn: an idle fire that found the turn still running is not
|
|
4225
|
+
* rescheduled (D2), and a turn whose answer went out through `answer_request`
|
|
4226
|
+
* produces no later output to re-arm it — the process would never reach its idle
|
|
4227
|
+
* compaction and stop, and its pending-work hold would never be enforced. (A
|
|
4228
|
+
* bot-issued compaction's own turn end is skipped by the arming's loop guard.)
|
|
4229
|
+
* In the per-turn lifecycle (L5, L-D3) the process is then stopped right after
|
|
4230
|
+
* the turn — unless a deferred compaction is armed (its drain compacts first,
|
|
4231
|
+
* and that turn ends here too) or the process still works (a background task,
|
|
4232
|
+
* input not yet taken in). The session sleeps; the next trigger resumes it with
|
|
4233
|
+
* the prompt cache warm.
|
|
4092
4234
|
*/
|
|
4093
4235
|
function handleAgentTurnEnded(key) {
|
|
4236
|
+
noteThreadActivity(key);
|
|
4094
4237
|
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
4095
4238
|
if (adapter.name !== adapterNames_1.claudePerTurnAdapterName)
|
|
4096
4239
|
return;
|
|
@@ -4194,7 +4337,10 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
|
|
|
4194
4337
|
});
|
|
4195
4338
|
// The request header (S7) leads the per-message body, like the reply quote and
|
|
4196
4339
|
// unlike the once-per-change thread preamble: it names THIS message's request.
|
|
4197
|
-
const
|
|
4340
|
+
const messageBody = options.requestHeader ? `${options.requestHeader}${quotedBody}` : quotedBody;
|
|
4341
|
+
// A loss the agent was not told about yet (`utils/lostWork.ts`) rides the first real prompt, once, ahead of it.
|
|
4342
|
+
const lostWorkRecord = isReplayable && adapter.name !== 'terminal' ? state.getLostWork(key) : undefined;
|
|
4343
|
+
const body = lostWorkRecord ? `${(0, lostWork_1.buildLostWorkNote)(lostWorkRecord, (0, i18n_1.getLocaleEndonym)((0, i18n_1.getActiveLang)()))}\n\n${messageBody}` : messageBody;
|
|
4198
4344
|
// Cache the (reply-folded) prompt so a wedged OpenCode session can be recovered
|
|
4199
4345
|
// by restarting fresh and replaying it — replaying WITH the quote is correct.
|
|
4200
4346
|
// A genuine new prompt also opens a fresh recovery episode (clears the guard).
|
|
@@ -4246,6 +4392,9 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
|
|
|
4246
4392
|
await adapter.detachRunningSubagents(key);
|
|
4247
4393
|
}
|
|
4248
4394
|
adapter.sendInput(key, promptText);
|
|
4395
|
+
// Told only once the prompt carrying the note reached a live session: a forward that failed keeps it for the next.
|
|
4396
|
+
if (lostWorkRecord && adapter.checkIsActive(key))
|
|
4397
|
+
void state.clearLostWork(key);
|
|
4249
4398
|
// S2 busy-onset arm: start the Claude liveness loop the moment the prompt is
|
|
4250
4399
|
// forwarded — driven by `checkIsBusy`, not by waiting for an opportunistic
|
|
4251
4400
|
// scrape emit that may never come during a long quiet think. The arming grace
|
|
@@ -5383,13 +5532,9 @@ bot.action('open_sessions', async (ctx) => {
|
|
|
5383
5532
|
await ctx.answerCbQuery();
|
|
5384
5533
|
await handleSessionsList(key);
|
|
5385
5534
|
});
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
if (line.status === 'warn')
|
|
5390
|
-
return (0, i18n_1.t)('doctor.warn', { label: line.label, hint: line.hint ?? '' });
|
|
5391
|
-
return (0, i18n_1.t)('doctor.fail', { label: line.label, hint: line.hint ?? '' });
|
|
5392
|
-
}
|
|
5535
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
5536
|
+
// /doctor — self-diagnostics (plan §20.1)
|
|
5537
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
5393
5538
|
async function checkCliPresent(cmd) {
|
|
5394
5539
|
try {
|
|
5395
5540
|
await execFileAsync(cmd, ['--version'], { timeout: 1500 });
|
|
@@ -5401,6 +5546,10 @@ async function checkCliPresent(cmd) {
|
|
|
5401
5546
|
}
|
|
5402
5547
|
async function runDoctor() {
|
|
5403
5548
|
const lines = [];
|
|
5549
|
+
// 0. Native Windows runs with limits (no tmux, no self-update restart, no hot mode, no local speech install).
|
|
5550
|
+
const nativeWindowsLine = (0, nativeWindows_1.getNativeWindowsDoctorLine)(process.platform, i18n_1.t);
|
|
5551
|
+
if (nativeWindowsLine)
|
|
5552
|
+
lines.push(nativeWindowsLine);
|
|
5404
5553
|
// 1. Bot admin permissions in the configured group.
|
|
5405
5554
|
const groupId = getAllowedGroupId();
|
|
5406
5555
|
if (groupId === null) {
|
|
@@ -5507,7 +5656,7 @@ command('doctor', async (_ctx, key) => {
|
|
|
5507
5656
|
// Single big edit-in-place would be nicer, but doctor is rarely run and
|
|
5508
5657
|
// gathering the report in series keeps it dead simple.
|
|
5509
5658
|
const lines = await runDoctor();
|
|
5510
|
-
const body = lines.map(formatDoctorLine).join('\n');
|
|
5659
|
+
const body = lines.map((line) => (0, doctorLine_1.formatDoctorLine)(line, i18n_1.t)).join('\n');
|
|
5511
5660
|
await replyToThread(key, `${(0, i18n_1.t)('doctor.header')}\n\n${body}`, { parse_mode: 'Markdown' });
|
|
5512
5661
|
});
|
|
5513
5662
|
function loadMcpFile(filePath) {
|
|
@@ -5668,12 +5817,14 @@ async function applyClaudeBackendSwitch(key, target) {
|
|
|
5668
5817
|
const wasActive = (0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key);
|
|
5669
5818
|
const sessionId = state.getAgent(key)?.claudeSessionId;
|
|
5670
5819
|
// Stops the previous backend, records the pick, keeps `claudeSessionId`.
|
|
5671
|
-
await switchThreadAdapter(key, target);
|
|
5820
|
+
const cutOffWork = await switchThreadAdapter(key, target);
|
|
5821
|
+
// The operator hears that the background work died with the stopped process; the agent hears it in its next prompt.
|
|
5822
|
+
const backgroundStoppedSuffix = (0, lostWork_1.checkHasLostBackgroundWork)(cutOffWork) ? `\n${(0, i18n_1.t)('fallback.backgroundStopped')}` : '';
|
|
5672
5823
|
if (!wasActive)
|
|
5673
5824
|
return (0, i18n_1.t)('claudeMode.set_idle', { label });
|
|
5674
5825
|
const decision = getWorkDirStartDecision(key);
|
|
5675
5826
|
if (!decision.ok)
|
|
5676
|
-
return decision.message
|
|
5827
|
+
return `${decision.message}${backgroundStoppedSuffix}`;
|
|
5677
5828
|
const targetAdapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
5678
5829
|
sendThreadTypingIndicator(key).catch(() => { });
|
|
5679
5830
|
if (sessionId && targetAdapter.resumeSession) {
|
|
@@ -5682,14 +5833,14 @@ async function applyClaudeBackendSwitch(key, target) {
|
|
|
5682
5833
|
try {
|
|
5683
5834
|
await targetAdapter.resumeSession(key, decision.workDir, sessionId, { isWithRecentContext: true });
|
|
5684
5835
|
await persistSessionStart(key, targetAdapter);
|
|
5685
|
-
return (0, i18n_1.t)('claudeMode.switched_resumed', { label })
|
|
5836
|
+
return `${(0, i18n_1.t)('claudeMode.switched_resumed', { label })}${backgroundStoppedSuffix}`;
|
|
5686
5837
|
}
|
|
5687
5838
|
catch (e) {
|
|
5688
5839
|
console.error('[claude_mode] resume failed, starting fresh:', e instanceof Error ? e.message : e);
|
|
5689
5840
|
}
|
|
5690
5841
|
}
|
|
5691
5842
|
const msg = await startAgentSession(key);
|
|
5692
|
-
return msg || (0, i18n_1.t)('claudeMode.switched_fresh', { label })
|
|
5843
|
+
return `${msg || (0, i18n_1.t)('claudeMode.switched_fresh', { label })}${backgroundStoppedSuffix}`;
|
|
5693
5844
|
}
|
|
5694
5845
|
command('claude_mode', async (_ctx, key, parsed) => {
|
|
5695
5846
|
if (checkIsGeneral(key)) {
|
|
@@ -5773,14 +5924,19 @@ function resolveThreadClaudeConfigDir(key) {
|
|
|
5773
5924
|
}
|
|
5774
5925
|
return dir;
|
|
5775
5926
|
}
|
|
5776
|
-
/**
|
|
5927
|
+
/**
|
|
5928
|
+
* `claude auth status --json` under `configDir` (`null` = `~/.claude`) as its sessions see it: never through an
|
|
5929
|
+
* `ANTHROPIC_API_KEY` of the bot's own, with the `apiKeyHelper` of a profile that holds a pasted API key; `null` when
|
|
5930
|
+
* the probe fails.
|
|
5931
|
+
*/
|
|
5777
5932
|
async function readClaudeAuthStatusUnder(configDir) {
|
|
5778
5933
|
const env = { ...process.env };
|
|
5779
5934
|
delete env.ANTHROPIC_API_KEY;
|
|
5780
5935
|
if (configDir !== null)
|
|
5781
5936
|
env[claudeProfiles_1.claudeConfigDirVariableName] = configDir;
|
|
5782
5937
|
try {
|
|
5783
|
-
const
|
|
5938
|
+
const args = [...(0, claudeApiKeyHelper_1.buildClaudeAuthProbeSettingsArgs)(configDir), 'auth', 'status', '--json'];
|
|
5939
|
+
const { stdout } = await execFileAsync((0, resolveBinary_1.resolveClaudeBinary)(), args, { timeout: claudeAuthStatusProbeTimeoutMs, env });
|
|
5784
5940
|
return (0, claudeProfiles_1.parseClaudeAuthStatus)(stdout);
|
|
5785
5941
|
}
|
|
5786
5942
|
catch (e) {
|
|
@@ -5843,9 +5999,12 @@ const openCodeUsagePoller = (0, openCodeUsage_1.createOpenCodeUsagePoller)({
|
|
|
5843
5999
|
*/
|
|
5844
6000
|
const claudeUsageRefresher = (0, claudeUsageRefresher_1.createClaudeUsageRefresher)({
|
|
5845
6001
|
listProfiles: () => [
|
|
5846
|
-
{
|
|
6002
|
+
{
|
|
6003
|
+
name: claudeProfiles_1.defaultClaudeProfileName, configDir: null,
|
|
6004
|
+
loggedIn: state.getClaudeProfileInfo(claudeProfiles_1.defaultClaudeProfileName)?.loggedIn, billing: state.getClaudeProfileInfo(claudeProfiles_1.defaultClaudeProfileName)?.billing,
|
|
6005
|
+
},
|
|
5847
6006
|
...Object.entries(state.getClaudeProfileRecords()).map(([name, record]) => ({
|
|
5848
|
-
name, configDir: (0, claudeProfiles_1.resolveClaudeProfileDir)((0, state_1.resolveDataDir)(), name), loggedIn: record.loggedIn,
|
|
6007
|
+
name, configDir: (0, claudeProfiles_1.resolveClaudeProfileDir)((0, state_1.resolveDataDir)(), name), loggedIn: record.loggedIn, billing: record.billing,
|
|
5849
6008
|
})),
|
|
5850
6009
|
],
|
|
5851
6010
|
getSnapshot: (name) => state.getClaudeProfileInfo(name)?.usage,
|
|
@@ -5881,6 +6040,27 @@ function onClaudeRateLimitEvent(configDir, event) {
|
|
|
5881
6040
|
state.setClaudeProfileLimitedUntil(name, null);
|
|
5882
6041
|
}
|
|
5883
6042
|
}
|
|
6043
|
+
/**
|
|
6044
|
+
* A json-stream turn's cost (`utils/claudeApiSpend.ts`): the topic's conversation baseline moves on for EVERY account
|
|
6045
|
+
* (a later API turn of the same conversation must count only itself), and the turn's spend adds to the account's only
|
|
6046
|
+
* when it is billed to API credits. A billed turn that went through ends a used-up-credits mark on whatever account it
|
|
6047
|
+
* ran on: the balance is back.
|
|
6048
|
+
*/
|
|
6049
|
+
function onClaudeTurnCost(key, configDir, report) {
|
|
6050
|
+
const turnCostUsd = (0, claudeApiSpend_1.computeClaudeTurnCostUsd)({ previous: state.getClaudeCostBaseline(key), ...report });
|
|
6051
|
+
state.setClaudeCostBaseline(key, { sessionId: report.sessionId, totalCostUsd: report.totalCostUsd });
|
|
6052
|
+
const name = resolveClaudeProfileNameForDir(configDir);
|
|
6053
|
+
if (name === null)
|
|
6054
|
+
return;
|
|
6055
|
+
const info = state.getClaudeProfileInfo(name);
|
|
6056
|
+
const outcome = (0, claudeApiSpend_1.getClaudeTurnCostOutcome)({
|
|
6057
|
+
billing: info?.billing ?? null, isCreditExhausted: info?.creditExhaustedAt !== undefined, isError: report.isError, turnCostUsd,
|
|
6058
|
+
});
|
|
6059
|
+
if (outcome.isCreditMarkCleared)
|
|
6060
|
+
state.setClaudeProfileCreditExhausted(name, null);
|
|
6061
|
+
if (outcome.spendUsd !== null)
|
|
6062
|
+
state.setClaudeProfileApiSpend(name, (0, claudeApiSpend_1.addClaudeApiSpend)(info?.apiSpend, outcome.spendUsd, new Date().toISOString()));
|
|
6063
|
+
}
|
|
5884
6064
|
/** The model a topic's Claude session runs (or last ran), for the usage ranking's choice of windows. */
|
|
5885
6065
|
function getThreadClaudeModel(key) {
|
|
5886
6066
|
return (0, createAdapter_1.getThreadAdapter)(key).getCurrentModel?.(key) ?? state.getAgent(key)?.model ?? null;
|
|
@@ -5888,7 +6068,10 @@ function getThreadClaudeModel(key) {
|
|
|
5888
6068
|
/** What `/status` names of a Claude profile: its last probed sign-in and email, and its limit mark (none until probed). */
|
|
5889
6069
|
function readStatusAccountSource(name) {
|
|
5890
6070
|
const info = state.getClaudeProfileInfo(name);
|
|
5891
|
-
return {
|
|
6071
|
+
return {
|
|
6072
|
+
name, email: info?.email ?? null, loggedIn: info?.loggedIn, limitedUntil: info?.limitedUntil ?? null,
|
|
6073
|
+
billing: info?.billing ?? null, creditExhaustedAt: info?.creditExhaustedAt ?? null, ...(info?.apiSpend ? { apiSpend: info.apiSpend } : {}),
|
|
6074
|
+
};
|
|
5892
6075
|
}
|
|
5893
6076
|
/** Whether the topic's agent is a Claude backend (a usage limit there is a limit of its account profile). */
|
|
5894
6077
|
function checkIsClaudeProfileBackend(key) {
|
|
@@ -5987,13 +6170,15 @@ async function planFallbackSwitch(key, cls, episodeId) {
|
|
|
5987
6170
|
const nowMs = Date.now();
|
|
5988
6171
|
const resetAtMs = cls.kind === 'usageLimit' ? cls.resetAt : undefined;
|
|
5989
6172
|
if (from.backend === 'claude') {
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
6173
|
+
// A used-up balance has no reset: its own flow marks the account (`creditExhaustedAt`) instead.
|
|
6174
|
+
if (cls.isBalanceExhausted !== true)
|
|
6175
|
+
state.setClaudeProfileLimitedUntil(from.profile, (0, claudeLimitSwitch_1.resolveClaudeLimitedUntil)({
|
|
6176
|
+
resetAtMs,
|
|
6177
|
+
usage: state.getClaudeProfileInfo(from.profile)?.usage,
|
|
6178
|
+
existingLimitedUntil: state.getClaudeProfileInfo(from.profile)?.limitedUntil ?? null,
|
|
6179
|
+
nowMs,
|
|
6180
|
+
fallbackMs: apiErrorRetry_1.usageLimitDefaultMs,
|
|
6181
|
+
}));
|
|
5997
6182
|
}
|
|
5998
6183
|
else {
|
|
5999
6184
|
const label = (0, fallbackChain_1.buildOpenCodeModelLabel)(from);
|
|
@@ -6023,8 +6208,11 @@ async function planFallbackSwitch(key, cls, episodeId) {
|
|
|
6023
6208
|
let earliest = null;
|
|
6024
6209
|
if (plan.action === 'none') {
|
|
6025
6210
|
const explicit = state.getFallbackChain();
|
|
6211
|
+
// Without a chain the implicit one is the signed-in SUBSCRIPTIONS: an account billed to API credits never ends a wait by itself.
|
|
6026
6212
|
const rungs = explicit.length > 0 ? explicit : from.backend === 'claude'
|
|
6027
|
-
? detection.claudeProfiles
|
|
6213
|
+
? detection.claudeProfiles
|
|
6214
|
+
.filter((profile) => profile.loggedIn !== false && profile.billing !== 'api')
|
|
6215
|
+
.map((profile) => ({ backend: 'claude', profile: profile.name, model: null }))
|
|
6028
6216
|
: [];
|
|
6029
6217
|
const found = (0, fallbackChain_1.pickEarliestResetRung)({ chain: rungs, current: from, detection, nowMs: Date.now() });
|
|
6030
6218
|
if (found && !found.isCurrent)
|
|
@@ -6045,9 +6233,23 @@ function getFallbackResetTime(from) {
|
|
|
6045
6233
|
return null;
|
|
6046
6234
|
return (0, localClock_1.formatLocalClockWithDateIfNotToday)(untilMs, Date.now());
|
|
6047
6235
|
}
|
|
6048
|
-
/**
|
|
6236
|
+
/**
|
|
6237
|
+
* The «switched» notice (`utils/fallbackSwitchNotice.ts`), with the reset of the exhausted rung when known — and, onto
|
|
6238
|
+
* an account billed to API credits, the line that it costs money per request and what it has spent so far.
|
|
6239
|
+
*/
|
|
6049
6240
|
function getFallbackSwitchedNotice(from, to, runningModel) {
|
|
6050
|
-
|
|
6241
|
+
const notice = (0, fallbackSwitchNotice_1.buildFallbackSwitchedNotice)({ from, to, resetTime: getFallbackResetTime(from), runningModel });
|
|
6242
|
+
const info = to.backend === 'claude' ? state.getClaudeProfileInfo(to.profile) : undefined;
|
|
6243
|
+
if (to.backend !== 'claude' || info?.billing !== 'api')
|
|
6244
|
+
return notice;
|
|
6245
|
+
return `${notice}\n${(0, i18n_1.t)('fallback.switchedToApi', { name: to.profile, spent: (0, claudeApiSpend_1.formatUsd)(info.apiSpend?.totalUsd ?? 0) })}`;
|
|
6246
|
+
}
|
|
6247
|
+
/** The spend line a «credits used up» notice ends with (what the account spent through the bot); `''` before any spend. */
|
|
6248
|
+
function getBalanceSpentLine(from) {
|
|
6249
|
+
const spend = from?.backend === 'claude' ? state.getClaudeProfileInfo(from.profile)?.apiSpend : undefined;
|
|
6250
|
+
if (!spend)
|
|
6251
|
+
return '';
|
|
6252
|
+
return `\n${(0, i18n_1.t)('apiRetry.balanceSpent', (0, claudeProfileUsageLines_1.formatClaudeApiSpendWords)(spend, getCurrentTimezone(), Date.now()))}`;
|
|
6051
6253
|
}
|
|
6052
6254
|
/**
|
|
6053
6255
|
* @description The model the topic's Claude session runs ACROSS a move onto `to`, for deciding whether the move
|
|
@@ -6130,15 +6332,25 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
|
|
|
6130
6332
|
if (from.profile !== to.profile)
|
|
6131
6333
|
await state.setClaudeProfileOverride(key, to.profile);
|
|
6132
6334
|
// The turn is dead after a limit: re-spawn now, even over background work (it is stopped; the notice says so).
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6335
|
+
lostWorkFoldingKeys.add(k);
|
|
6336
|
+
let outcome;
|
|
6337
|
+
try {
|
|
6338
|
+
outcome = await applyClaudeProfileToThread(key, { isAfterLimit: true });
|
|
6339
|
+
}
|
|
6340
|
+
finally {
|
|
6341
|
+
lostWorkFoldingKeys.delete(k);
|
|
6342
|
+
}
|
|
6136
6343
|
if (outcome === 'deferred' || outcome === 'restartRequired') {
|
|
6137
6344
|
fallbackSwitchOffers.delete(k);
|
|
6138
6345
|
const notice = (0, fallbackSwitchNotice_1.buildFallbackDeferredNotice)(from, to);
|
|
6139
6346
|
await say(notice);
|
|
6140
6347
|
return { isSwitched: false, notice };
|
|
6141
6348
|
}
|
|
6349
|
+
// The re-spawn took the old process's background work: the notice below says so, the continue nudge tells the agent.
|
|
6350
|
+
const cutOffWork = takeLostWorkOf(key);
|
|
6351
|
+
isStoppingBackground = (0, lostWork_1.checkHasLostBackgroundWork)(cutOffWork);
|
|
6352
|
+
if (cutOffWork)
|
|
6353
|
+
await foldLostWorkIntoNextPrompt(key, { ...cutOffWork, reason: 'restarted', lostAt: Date.now() });
|
|
6142
6354
|
}
|
|
6143
6355
|
else if (isSameFamily && to.backend === 'opencode') {
|
|
6144
6356
|
const error = await (0, createAdapter_1.getThreadAdapter)(key).setModel?.(key, (0, fallbackChain_1.buildOpenCodeModelLabel)(to));
|
|
@@ -6152,7 +6364,7 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
|
|
|
6152
6364
|
reason: 'limit',
|
|
6153
6365
|
resetsAtText: getFallbackResetTime(from),
|
|
6154
6366
|
});
|
|
6155
|
-
await moveTopicToOtherBackend(key, to.backend, to.backend === 'claude' ? to.profile : null);
|
|
6367
|
+
isStoppingBackground = await moveTopicToOtherBackend(key, to.backend, to.backend === 'claude' ? to.profile : null);
|
|
6156
6368
|
if (to.backend === 'opencode') {
|
|
6157
6369
|
const error = await (0, createAdapter_1.getThreadAdapter)(key).setModel?.(key, (0, fallbackChain_1.buildOpenCodeModelLabel)(to));
|
|
6158
6370
|
if (error)
|
|
@@ -6179,12 +6391,13 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
|
|
|
6179
6391
|
* half: record `account` as the topic's own Claude pick when one is given (the chain's rung, the operator's pick — a
|
|
6180
6392
|
* switch that only names the account the topic follows passes `null`), then `switchThreadAdapter` stops the old
|
|
6181
6393
|
* session, parks its id and brings back the target family's parked one. The context bridge is built BEFORE this, while
|
|
6182
|
-
* the adapter being left still runs.
|
|
6394
|
+
* the adapter being left still runs. Resolves whether the stop took background work with it — the caller tells the
|
|
6395
|
+
* operator (the agent is told by the next prompt, `switchThreadAdapter`).
|
|
6183
6396
|
*/
|
|
6184
6397
|
async function moveTopicToOtherBackend(key, backend, account) {
|
|
6185
6398
|
if (account !== null)
|
|
6186
6399
|
await state.setClaudeProfileOverride(key, account);
|
|
6187
|
-
await switchThreadAdapter(key, backend === 'claude' ? (0, createAdapter_1.resolveClaudeBackendName)(key) : adapterNames_1.openCodeAdapterName);
|
|
6400
|
+
return (0, lostWork_1.checkHasLostBackgroundWork)(await switchThreadAdapter(key, backend === 'claude' ? (0, createAdapter_1.resolveClaudeBackendName)(key) : adapterNames_1.openCodeAdapterName));
|
|
6188
6401
|
}
|
|
6189
6402
|
/**
|
|
6190
6403
|
* @description The second half, after a move (across backends or between accounts): start or resume the topic's
|
|
@@ -6275,7 +6488,10 @@ async function executeBackendSwitch(key, target, options) {
|
|
|
6275
6488
|
userMessage: options.userMessage,
|
|
6276
6489
|
})
|
|
6277
6490
|
: null;
|
|
6278
|
-
await moveTopicToOtherBackend(key, target.backend, target.backend === 'claude' && target.isProfilePicked ? target.profile : null);
|
|
6491
|
+
const isStoppingBackground = await moveTopicToOtherBackend(key, target.backend, target.backend === 'claude' && target.isProfilePicked ? target.profile : null);
|
|
6492
|
+
// The operator hears that the background work died with the old session; its agent hears it in the first prompt.
|
|
6493
|
+
if (isStoppingBackground)
|
|
6494
|
+
await replyToThread(key, (0, i18n_1.t)('fallback.backgroundStopped'));
|
|
6279
6495
|
cancelApiRetry(key); // a wait armed for the backend just left is over
|
|
6280
6496
|
await resumeTopicOnNewSession(key, {
|
|
6281
6497
|
modelToPin: (0, backendSwitchPlan_1.getBackendSwitchModelToPin)(target),
|
|
@@ -6314,6 +6530,10 @@ function buildBackendSwitchTargetOfRung(to) {
|
|
|
6314
6530
|
* The outcome tells the screen what to say; the question and the limit move's notice are already posted.
|
|
6315
6531
|
*/
|
|
6316
6532
|
async function switchTopicByOperator(key, to) {
|
|
6533
|
+
// The operator's own pick of an account whose API credits were used up is the act that ends that mark (topped up, or
|
|
6534
|
+
// trying anyway): a failing request marks it again.
|
|
6535
|
+
if (to.backend === 'claude')
|
|
6536
|
+
state.setClaudeProfileCreditExhausted(to.profile, null);
|
|
6317
6537
|
const currentFamily = getTopicBackendFamily(key);
|
|
6318
6538
|
const from = getTopicFallbackPosition(key);
|
|
6319
6539
|
const isUsageLimitWaitArmed = getArmedApiRetry(key)?.kind === 'usageLimit';
|
|
@@ -6418,7 +6638,7 @@ const { registerLimitsBoardCommands, registerLimitsBoardCallbacks } = (0, limits
|
|
|
6418
6638
|
runAutoSwitchCommand,
|
|
6419
6639
|
getArmedLimitRetryFireAt,
|
|
6420
6640
|
});
|
|
6421
|
-
const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAwaitingProfileName, cancelProfileNameCapture, submitProfileName, pickClaudeAccount, onClaudeProfileAuthStatus: recordClaudeProfileAuthStatus, } = (0, claudeAccounts_1.createClaudeAccounts)({
|
|
6641
|
+
const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAwaitingProfileName, cancelProfileNameCapture, submitProfileName, pickClaudeAccount, onClaudeProfileAuthStatus: recordClaudeProfileAuthStatus, onClaudeProfileApiKey: storeClaudeProfileApiKey, } = (0, claudeAccounts_1.createClaudeAccounts)({
|
|
6422
6642
|
...botCore,
|
|
6423
6643
|
getDataDir: state_1.resolveDataDir,
|
|
6424
6644
|
getInstanceTimezone: getCurrentTimezone,
|
|
@@ -6434,8 +6654,10 @@ const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAw
|
|
|
6434
6654
|
disconnectOpenCodeProvider: applyProviderDisconnect,
|
|
6435
6655
|
startOpenCodeConnect: showConnectMethodPicker,
|
|
6436
6656
|
applyAutoSwitch,
|
|
6657
|
+
verifyAnthropicApiKey: (apiKey) => (0, anthropicApiKeyCheck_1.verifyAnthropicApiKey)(apiKey),
|
|
6437
6658
|
});
|
|
6438
6659
|
claudeProfileAuthStatusSink = recordClaudeProfileAuthStatus;
|
|
6660
|
+
claudeProfileApiKeySink = storeClaudeProfileApiKey;
|
|
6439
6661
|
const { registerSttCommands, registerSttCallbacks, startSttAutoInstall, resumeSttInstallAtBoot, getAwaitedSttStep, checkIsAnySttInputAwaited, submitSttInput, cancelSttInput, } = (0, stt_1.createStt)({
|
|
6440
6662
|
...botCore,
|
|
6441
6663
|
getConfigPath: () => (0, sttSettings_1.getSttConfigPath)(),
|
|
@@ -7147,7 +7369,7 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
|
|
|
7147
7369
|
// `/account` «Add» asked for the new profile's name → this message is it (a bad name keeps the prompt
|
|
7148
7370
|
// armed and says so; a slash command above already disarmed it).
|
|
7149
7371
|
if (checkIsAwaitingProfileName(key) && !text.startsWith('/')) {
|
|
7150
|
-
await submitProfileName(key, text);
|
|
7372
|
+
await submitProfileName(key, text, ctx.message.message_id);
|
|
7151
7373
|
return;
|
|
7152
7374
|
}
|
|
7153
7375
|
// `/topic` asked for the new topic's name, or for the name of a folder to create → this message is the answer (a bad
|
|
@@ -7928,7 +8150,11 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
|
|
|
7928
8150
|
// attacker-chosen folder. Stash the topic name so a later authorised-user
|
|
7929
8151
|
// message in the same thread can still benefit from fuzzy auto-bind.
|
|
7930
8152
|
const userId = ctx.from?.id;
|
|
7931
|
-
|
|
8153
|
+
const action = await (0, forumTopicCreated_1.getForumTopicCreatedAction)({ creatorId: userId, botUserId: bot.botInfo?.id, checkIsCreatorAllowed: () => checkIsAllowedUser(ctx) });
|
|
8154
|
+
// `/topic` binds and greets the topic it created itself; its own service message needs nothing more.
|
|
8155
|
+
if (action === 'ignoreOwn')
|
|
8156
|
+
return;
|
|
8157
|
+
if (action === 'cacheNameOnly') {
|
|
7932
8158
|
if (topicName) {
|
|
7933
8159
|
pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now() });
|
|
7934
8160
|
}
|
|
@@ -8338,12 +8564,9 @@ bot.action(/^reask_(\d+)$/, async (ctx) => {
|
|
|
8338
8564
|
// The label goes to the agent as a prompt and nothing else in the topic shows it whole (the buttons clip long
|
|
8339
8565
|
// labels): a label too long for the toast is posted in full, as the record of the answer that was sent.
|
|
8340
8566
|
await (0, callbackToast_1.answerCallbackWithWholeText)(ctx, label, (wholeLabel) => replyToThread(key, wholeLabel));
|
|
8341
|
-
// An answer to the agent's own question is never a request (S7), even delivered as a prompt.
|
|
8342
|
-
|
|
8343
|
-
|
|
8344
|
-
await withThreadLocale(key, () => deliverPromptOrBuffer(key, label, true));
|
|
8345
|
-
return;
|
|
8346
|
-
}
|
|
8567
|
+
// An answer to the agent's own question is never a request (S7), even delivered as a prompt. Routed at delivery,
|
|
8568
|
+
// not on the `isStarting` read above: the stop may have ended during the toast, and a label buffered into its closed
|
|
8569
|
+
// window was never replayed.
|
|
8347
8570
|
const outcome = await withThreadLocale(key, () => deliverActivePrompt(key, adapter, label, null));
|
|
8348
8571
|
// The label reached no session (the topic was told why): the question is still open, so are its buttons.
|
|
8349
8572
|
if (outcome === 'noSession' && labels && !reAskedQuestionOptions.has((0, sessionKey_1.keyToString)(key)))
|
|
@@ -9852,6 +10075,8 @@ function handleQuestionGone(key) {
|
|
|
9852
10075
|
void unpinThreadQuestion(key);
|
|
9853
10076
|
}
|
|
9854
10077
|
function handleAgentClosed(key) {
|
|
10078
|
+
// What the process held that died with it (its own background work), taken before the cleanup below.
|
|
10079
|
+
const lostWork = takeLostWorkOf(key);
|
|
9855
10080
|
// Session is gone — drop any not-yet-sent output AND status frame so they
|
|
9856
10081
|
// don't surface after the "session ended" notice (the trailing-output bug:
|
|
9857
10082
|
// a 429 backlog could let queued deltas land seconds after the close).
|
|
@@ -9872,11 +10097,99 @@ function handleAgentClosed(key) {
|
|
|
9872
10097
|
// A closed session has nothing to compact — drop the idle watchdog + any arm.
|
|
9873
10098
|
clearThreadCompaction(key);
|
|
9874
10099
|
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
9875
|
-
|
|
10100
|
+
// Work died with the process: the lost-work line (and the resume it announces) replaces the bare "session ended".
|
|
10101
|
+
if (lostWork)
|
|
10102
|
+
scheduleLostWorkNote(key, lostWork, 'exited');
|
|
10103
|
+
else
|
|
10104
|
+
replyToThread(key, (0, i18n_1.t)('agent.session_ended', { label: adapter.label })).catch(() => { });
|
|
9876
10105
|
// Banner now reads `idle`; closed sessions may also persist with the
|
|
9877
10106
|
// wrong model/agent label otherwise.
|
|
9878
10107
|
updatePinnedStatus(key).catch(() => { });
|
|
9879
10108
|
}
|
|
10109
|
+
const msPerHour = 60 * 60 * 1000;
|
|
10110
|
+
/** The pending-work hold in force, in hours, as the topic line names it (`utils/jsonStreamPendingWork.ts`; a test instance's shorter one). */
|
|
10111
|
+
const pendingWorkHoldHours = Number(((ENV.testTimings.backgroundHoldMaxMs ?? jsonStreamPendingWork_1.pendingWorkHoldDefaultMs) / msPerHour).toFixed(1));
|
|
10112
|
+
/** What the conversation's last process took with it when it ended, handed over once by its adapter. */
|
|
10113
|
+
function takeLostWorkOf(key, adapter = (0, createAdapter_1.getThreadAdapter)(key)) {
|
|
10114
|
+
return adapter.takeLostWork?.(key) ?? null;
|
|
10115
|
+
}
|
|
10116
|
+
/**
|
|
10117
|
+
* The conversations a usage-limit switch is re-spawning right now: the re-spawn's `started` leaves the loss to the
|
|
10118
|
+
* switch, which folds it into its own first prompt — decided at the call site, never by which of the two ran first.
|
|
10119
|
+
*/
|
|
10120
|
+
const lostWorkFoldingKeys = new Set();
|
|
10121
|
+
/**
|
|
10122
|
+
* @description Keep a loss for the NEXT prompt the conversation gets, with no line and no resume of its own: a move
|
|
10123
|
+
* that already says so in its notice and sends a first prompt (a limit switch's continue nudge, a backend switch's
|
|
10124
|
+
* hand-over bridge, a return to a parked conversation).
|
|
10125
|
+
*/
|
|
10126
|
+
async function foldLostWorkIntoNextPrompt(key, record) {
|
|
10127
|
+
console.log(`[lost-work] ${(0, sessionKey_1.keyToString)(key)}: the agent's own work ended with the process (${record.reason}); the move's first prompt tells the agent`);
|
|
10128
|
+
await state.setLostWork(key, (0, lostWork_1.mergeLostWorkRecords)(state.getLostWork(key), record));
|
|
10129
|
+
}
|
|
10130
|
+
/** {@link noteLostWork} from an event handler: in the conversation's locale, not awaited, a failure logged. */
|
|
10131
|
+
function scheduleLostWorkNote(key, lostWork, reason) {
|
|
10132
|
+
if (!lostWork)
|
|
10133
|
+
return;
|
|
10134
|
+
void withThreadLocale(key, () => noteLostWork(key, lostWork, reason)).catch((e) => console.warn(`[lost-work] ${(0, sessionKey_1.keyToString)(key)}:`, e instanceof Error ? e.message : e));
|
|
10135
|
+
}
|
|
10136
|
+
/**
|
|
10137
|
+
* @description A process ended with the agent's own work pending (`utils/lostWork.ts`): keep the loss until the agent
|
|
10138
|
+
* is told, post the ONE plain line in a topic, and — unless the plan says the note waits for the next prompt — resume
|
|
10139
|
+
* the conversation with the note as its prompt. Runs in the conversation's locale.
|
|
10140
|
+
*/
|
|
10141
|
+
async function noteLostWork(key, lostWork, reason) {
|
|
10142
|
+
if (!lostWork)
|
|
10143
|
+
return;
|
|
10144
|
+
const kStr = (0, sessionKey_1.keyToString)(key);
|
|
10145
|
+
const now = Date.now();
|
|
10146
|
+
const record = (0, lostWork_1.mergeLostWorkRecords)(state.getLostWork(key), { ...lostWork, reason, lostAt: now });
|
|
10147
|
+
await state.setLostWork(key, record);
|
|
10148
|
+
const lastAutoResumeAt = state.getLostWorkResumedAt(key);
|
|
10149
|
+
const isLimitWaitArmed = getArmedApiRetry(key)?.kind === 'usageLimit';
|
|
10150
|
+
const plan = (0, lostWork_1.getLostWorkPlan)({
|
|
10151
|
+
reason,
|
|
10152
|
+
isLimitWaitArmed,
|
|
10153
|
+
msSinceLastAutoResume: lastAutoResumeAt === undefined ? null : now - lastAutoResumeAt,
|
|
10154
|
+
// A tracker issue's agent answers only through a request: a bare note would have nowhere to answer.
|
|
10155
|
+
isRequestRequired: !(0, sessionKeyCodec_1.checkIsTelegramKey)(key),
|
|
10156
|
+
});
|
|
10157
|
+
console.log(`[lost-work] ${kStr}: the process ended (${reason}) with the agent's own work pending (${record.backgroundTasks.length} background task(s), ${record.scheduledJobCount} scheduled job(s)${record.hasUnreadTaskResult ? ', an unread task result' : ''}${record.wasTurnInFlight ? ', a cut-off turn' : ''}); ${plan.isAutoResume ? 'resuming it with a note' : 'the note rides the next prompt'}`);
|
|
10158
|
+
let linePlan = plan;
|
|
10159
|
+
if (plan.isAutoResume) {
|
|
10160
|
+
await state.setLostWorkResumedAt(key, now);
|
|
10161
|
+
// The line says what happened: a resume that failed leaves the note for the next prompt, worded by the same rule.
|
|
10162
|
+
if (!(await resumeConversationForLostWork(key)))
|
|
10163
|
+
linePlan = (0, lostWork_1.getHeldLostWorkPlan)({ reason, isLimitWaitArmed });
|
|
10164
|
+
}
|
|
10165
|
+
if ((0, sessionKeyCodec_1.checkIsTelegramKey)(key))
|
|
10166
|
+
replyToThread(key, (0, lostWork_1.formatLostWorkTopicLine)(record, linePlan, pendingWorkHoldHours, i18n_1.t)).catch(() => { });
|
|
10167
|
+
}
|
|
10168
|
+
/**
|
|
10169
|
+
* @description Resume the conversation with the lost-work note as its prompt; `false` when the post failed — the note
|
|
10170
|
+
* then stays for the next prompt.
|
|
10171
|
+
*/
|
|
10172
|
+
async function resumeConversationForLostWork(key) {
|
|
10173
|
+
const record = state.getLostWork(key);
|
|
10174
|
+
if (!record)
|
|
10175
|
+
return true;
|
|
10176
|
+
await state.clearLostWork(key);
|
|
10177
|
+
const posted = await (0, postToSession_1.postToSession)(createLostWorkPostDeps(), (0, sessionKey_1.keyToString)(key), (0, lostWork_1.buildLostWorkNote)(record, (0, i18n_1.getLocaleEndonym)((0, i18n_1.getActiveLang)())));
|
|
10178
|
+
if (posted.ok)
|
|
10179
|
+
return true;
|
|
10180
|
+
console.warn(`[lost-work] ${(0, sessionKey_1.keyToString)(key)}: the resume failed (${posted.reason === 'forward-failed' ? posted.error : posted.reason}); the note rides the next prompt`);
|
|
10181
|
+
await state.setLostWork(key, (0, lostWork_1.mergeLostWorkRecords)(state.getLostWork(key), record));
|
|
10182
|
+
return false;
|
|
10183
|
+
}
|
|
10184
|
+
/** The session post of a lost-work note: a bot note to the agent, not a request — no request is opened for it. */
|
|
10185
|
+
function createLostWorkPostDeps() {
|
|
10186
|
+
return {
|
|
10187
|
+
...createSessionPostDeps(),
|
|
10188
|
+
forwardPrompt: async (conversationKey, text) => {
|
|
10189
|
+
await deliverPromptOrBuffer((0, sessionKey_1.keyFromString)(conversationKey), text, startupPromptBuffer.checkIsStarting(conversationKey));
|
|
10190
|
+
},
|
|
10191
|
+
};
|
|
10192
|
+
}
|
|
9880
10193
|
function handleAgentError(key, error) {
|
|
9881
10194
|
console.error(`[Bot] adapter error ${(0, sessionKey_1.keyToString)(key)}:`, error.message);
|
|
9882
10195
|
stopClaudeLiveness(key);
|
|
@@ -9912,6 +10225,10 @@ function handleAgentStarted(key) {
|
|
|
9912
10225
|
// activity instant rather than restart at the full 55 minutes. A genuinely fresh
|
|
9913
10226
|
// session has no persisted instant and so gets the full window anyway.
|
|
9914
10227
|
rearmThreadIdleTimer(key);
|
|
10228
|
+
// A re-spawn (model, effort, account) began without the old process's background work — unless a usage-limit switch
|
|
10229
|
+
// is re-spawning it: that switch folds the loss into its own first prompt (`switchTopicToFallbackEntry`).
|
|
10230
|
+
if (!lostWorkFoldingKeys.has((0, sessionKey_1.keyToString)(key)))
|
|
10231
|
+
scheduleLostWorkNote(key, takeLostWorkOf(key), 'restarted');
|
|
9915
10232
|
}
|
|
9916
10233
|
/**
|
|
9917
10234
|
* @description `stopped` from the adapter — flip the banner back to `idle`.
|
|
@@ -9987,6 +10304,8 @@ function dropLostConversationWatermark(key) {
|
|
|
9987
10304
|
function handleAgentSuspended(key) {
|
|
9988
10305
|
clearGoneProcessFrames(key);
|
|
9989
10306
|
updatePinnedStatus(key).catch(() => { });
|
|
10307
|
+
// An idle stop takes pending work with it only once the hold ran out.
|
|
10308
|
+
scheduleLostWorkNote(key, takeLostWorkOf(key), 'holdExpired');
|
|
9990
10309
|
}
|
|
9991
10310
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
9992
10311
|
// Startup orchestration — state init, re-attach, setMyCommands, launch
|
|
@@ -10288,10 +10607,14 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
|
|
|
10288
10607
|
// json-stream thread with a persisted id but no live process SLEEPS
|
|
10289
10608
|
// (lifecycle plan L4): it is NOT re-spawned — its next trigger resumes it
|
|
10290
10609
|
// — and it gets the downtime recap read from the transcript alone. An
|
|
10291
|
-
// adopted process keeps the bot-MCP tool list it got at its own start
|
|
10292
|
-
//
|
|
10293
|
-
//
|
|
10610
|
+
// adopted process keeps the bot-MCP tool list it got at its own start: one
|
|
10611
|
+
// started under an earlier bot build is NOT stopped early — it follows the
|
|
10612
|
+
// normal idle lifecycle (compaction at the idle mark, then the stop, never
|
|
10613
|
+
// while its own work is pending) and the next trigger resumes it with the
|
|
10614
|
+
// current tools (`checkIsAdoptedToolListStale`).
|
|
10294
10615
|
const claudeJsonAdapter = (0, createAdapter_1.getAdapter)(adapterNames_1.claudeJsonStreamAdapterName);
|
|
10616
|
+
/** The conversations whose process was gone at boot with the agent's own work pending, by key — each told once. */
|
|
10617
|
+
const lostWorkFoundAtBoot = new Map();
|
|
10295
10618
|
let jsonAdopted = 0;
|
|
10296
10619
|
let jsonKilled = 0;
|
|
10297
10620
|
let jsonSleeping = 0;
|
|
@@ -10325,33 +10648,31 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
|
|
|
10325
10648
|
jsonAdopted += 1;
|
|
10326
10649
|
if (ownerAdapter.lifecycle === 'perTurn') {
|
|
10327
10650
|
// A per-turn process that outlived the bot is stopped as soon as its turn (if any) ends (L5).
|
|
10328
|
-
|
|
10651
|
+
stopAdoptedPerTurnSessionWhenIdle(key, ownerAdapter);
|
|
10329
10652
|
}
|
|
10330
|
-
|
|
10331
|
-
|
|
10332
|
-
currentDigest: botMcpToolDigestReader?.(key.platform) ?? '',
|
|
10333
|
-
isWorking: ownerAdapter.checkIsWorking(key),
|
|
10334
|
-
});
|
|
10335
|
-
if (refresh !== 'fresh') {
|
|
10336
|
-
// Read from the persisted state (the turn-in-flight flag, the task list); the stop itself
|
|
10337
|
-
// looks again once the downtime frames have replayed.
|
|
10338
|
-
console.log(`[reattach] ${(0, sessionKey_1.keyToString)(key)}: adopted with a stale tool list (${refresh === 'stopNow' ? 'idle: stopped now' : 'working: stopped once idle'})`);
|
|
10339
|
-
if (ownerAdapter.lifecycle !== 'perTurn')
|
|
10340
|
-
stopAdoptedSessionWhenIdle(key, ownerAdapter, 'the stale tool list');
|
|
10653
|
+
if ((0, adoptedToolList_1.checkIsAdoptedToolListStale)({ persistedDigest: agent.mcpToolDigest, currentDigest: botMcpToolDigestReader?.(key.platform) ?? '' })) {
|
|
10654
|
+
console.log(`[reattach] ${(0, sessionKey_1.keyToString)(key)}: adopted with a stale tool list; it gets the current tools when it resumes after its idle stop`);
|
|
10341
10655
|
}
|
|
10342
|
-
// The surviving process keeps the account it was spawned with
|
|
10343
|
-
//
|
|
10656
|
+
// The surviving process keeps the account it was spawned with. A switch the user picked while it worked is
|
|
10657
|
+
// restored with the adopt and still applies at the end of its turn; any other change of the resolved
|
|
10658
|
+
// profile reaches it when it resumes after its idle stop.
|
|
10344
10659
|
const spawnedConfigDir = ownerAdapter.getSessionClaudeConfigDir(key);
|
|
10345
10660
|
if (spawnedConfigDir !== undefined && !(0, claudeProfiles_1.checkIsSameClaudeConfigDir)(spawnedConfigDir, resolveThreadClaudeConfigDir(key))) {
|
|
10346
10661
|
console.log(`[reattach] ${(0, sessionKey_1.keyToString)(key)}: adopted under profile dir ${spawnedConfigDir ?? 'default'}, the topic now resolves to ${resolveThreadClaudeConfigDir(key) ?? 'default'}`);
|
|
10347
|
-
if (ownerAdapter.lifecycle !== 'perTurn')
|
|
10348
|
-
stopAdoptedSessionWhenIdle(key, ownerAdapter, 'the account profile');
|
|
10349
10662
|
}
|
|
10350
10663
|
}
|
|
10351
10664
|
else {
|
|
10352
10665
|
// Dead/zombie — adopt cleaned it up itself; the thread sleeps (below)
|
|
10353
|
-
// and its next trigger resumes it from the persisted session id
|
|
10666
|
+
// and its next trigger resumes it from the persisted session id — at once
|
|
10667
|
+
// when its own work died with it (the agent is told, once the adopts are done).
|
|
10354
10668
|
jsonKilled += 1;
|
|
10669
|
+
const lostWork = takeLostWorkOf(key, ownerAdapter);
|
|
10670
|
+
if (lostWork) {
|
|
10671
|
+
lostWorkFoundAtBoot.set((0, sessionKey_1.keyToString)(key), { key, lostWork });
|
|
10672
|
+
// Told once: the record it came from drops it (the pass over the sleeping conversations below reads it too).
|
|
10673
|
+
if (agent.jsonStreamTail)
|
|
10674
|
+
void state.setJsonStreamTail(key, (0, lostWork_1.getTailRecordWithoutPendingWork)(agent.jsonStreamTail));
|
|
10675
|
+
}
|
|
10355
10676
|
}
|
|
10356
10677
|
}
|
|
10357
10678
|
catch (e) {
|
|
@@ -10373,6 +10694,13 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
|
|
|
10373
10694
|
// Sleeping (L4): no process is started — the next trigger resumes the conversation. What the agent
|
|
10374
10695
|
// produced while the bot was down is still recapped, read from the transcript on disk.
|
|
10375
10696
|
jsonSleeping += 1;
|
|
10697
|
+
// A process that died while the bot was down (its tmux session gone with it) left its pending work in the record:
|
|
10698
|
+
// the agent is told — once: the record drops it now.
|
|
10699
|
+
const goneProcessLostWork = (0, lostWork_1.getLostWorkFromTailRecord)(agent.jsonStreamTail ?? null, agent.claudeSessionId);
|
|
10700
|
+
if (agent.jsonStreamTail && (0, lostWork_1.checkHasLostWork)(goneProcessLostWork) && !lostWorkFoundAtBoot.has((0, sessionKey_1.keyToString)(key))) {
|
|
10701
|
+
lostWorkFoundAtBoot.set((0, sessionKey_1.keyToString)(key), { key, lostWork: goneProcessLostWork });
|
|
10702
|
+
void state.setJsonStreamTail(key, (0, lostWork_1.getTailRecordWithoutPendingWork)(agent.jsonStreamTail));
|
|
10703
|
+
}
|
|
10376
10704
|
// A sleeping per-turn session with a compaction due gets its idle timer from the persisted stamps (L-D7).
|
|
10377
10705
|
if (agent.name === adapterNames_1.claudePerTurnAdapterName)
|
|
10378
10706
|
rearmThreadIdleTimer(key);
|
|
@@ -10382,6 +10710,9 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
|
|
|
10382
10710
|
void postReattachRecap(key, ownerAdapter, workDirDecision.workDir, agent.claudeSessionId, agent.seenWatermark ?? null, !opts.quietReattach).catch(() => { });
|
|
10383
10711
|
}
|
|
10384
10712
|
console.log(`[reattach] claude-json-stream: adopted ${jsonAdopted}, sleeping ${jsonSleeping}, killed ${jsonKilled} orphans (quiet=${opts.quietReattach})`);
|
|
10713
|
+
// Not awaited: a resume must not hold up the rest of the boot (the prompt is buffered while the session starts).
|
|
10714
|
+
for (const { key, lostWork } of lostWorkFoundAtBoot.values())
|
|
10715
|
+
scheduleLostWorkNote(key, lostWork, 'foundDead');
|
|
10385
10716
|
// 3. Terminal — tmux shells (`term-…`). Like the Claude scan but simpler:
|
|
10386
10717
|
// a terminal has no session-id to recover, so adoption keys purely on a
|
|
10387
10718
|
// live binding whose agent is `terminal`. The tmux name is derived from
|
|
@@ -11100,10 +11431,11 @@ async function resolveBotAdminRights(groupId, botUserId) {
|
|
|
11100
11431
|
}
|
|
11101
11432
|
}
|
|
11102
11433
|
/**
|
|
11103
|
-
* @description The General topic the
|
|
11104
|
-
* General (`null` when neither exists — a Jira-only instance has no
|
|
11434
|
+
* @description The General topic the bot speaks to the operator in (the updater, the native-Windows notice): the paired group's
|
|
11435
|
+
* General when a group is paired, else the owner's DM General (`null` when neither exists — a Jira-only instance has no
|
|
11436
|
+
* Telegram to say anything in).
|
|
11105
11437
|
*/
|
|
11106
|
-
function
|
|
11438
|
+
function getOperatorGeneralKey() {
|
|
11107
11439
|
const groupId = getAllowedGroupId();
|
|
11108
11440
|
if (groupId !== null)
|
|
11109
11441
|
return (0, sessionKeyCodec_1.makeTelegramKey)(groupId, threadRouting_1.GENERAL_THREAD_ID);
|
|
@@ -11118,7 +11450,7 @@ function getUpdateGeneralKey() {
|
|
|
11118
11450
|
async function postUpdateMessageToGeneral(message) {
|
|
11119
11451
|
if (!ENV.isTelegramServed)
|
|
11120
11452
|
return;
|
|
11121
|
-
const generalKey =
|
|
11453
|
+
const generalKey = getOperatorGeneralKey();
|
|
11122
11454
|
if (generalKey === null) {
|
|
11123
11455
|
console.log(`[update] no General topic to post "${message.kind}" in`);
|
|
11124
11456
|
return;
|
|
@@ -11243,6 +11575,20 @@ async function sendStartupStatus(isHotReload) {
|
|
|
11243
11575
|
}
|
|
11244
11576
|
console.warn('[startup-status] no reachable target — status not delivered');
|
|
11245
11577
|
}
|
|
11578
|
+
/**
|
|
11579
|
+
* @description On native Windows, say once per cold start in General which features do not work there and point to WSL2,
|
|
11580
|
+
* worded in that chat's language. Best-effort like the readiness status: no General topic to speak in is not an error (the
|
|
11581
|
+
* console line already said it), and a failed send is logged by the send path.
|
|
11582
|
+
*/
|
|
11583
|
+
async function postNativeWindowsNoticeToGeneral(isHotReload) {
|
|
11584
|
+
const generalKey = getOperatorGeneralKey();
|
|
11585
|
+
if (generalKey === null)
|
|
11586
|
+
return;
|
|
11587
|
+
const text = withThreadLocale(generalKey, () => (0, nativeWindows_1.getNativeWindowsGeneralNotice)({ platform: process.platform, isHotReload }, i18n_1.t));
|
|
11588
|
+
if (text === null)
|
|
11589
|
+
return;
|
|
11590
|
+
await replyToThread(generalKey, text);
|
|
11591
|
+
}
|
|
11246
11592
|
/**
|
|
11247
11593
|
* @description Load and prepare the Jira connector, or end the start: a broken
|
|
11248
11594
|
* config, a token of another account or an unknown trigger status is fatal, with
|
|
@@ -11255,6 +11601,7 @@ async function prepareJiraConnectorOrExit() {
|
|
|
11255
11601
|
dataDir: path.dirname(state.stateFilePath),
|
|
11256
11602
|
workRoot: ENV.workRoot,
|
|
11257
11603
|
openCodeUrl: process.env.OPENCODE_URL,
|
|
11604
|
+
pollIntervalMsOverride: ENV.testTimings.jiraPollIntervalMs,
|
|
11258
11605
|
});
|
|
11259
11606
|
}
|
|
11260
11607
|
catch (error) {
|
|
@@ -11307,7 +11654,7 @@ async function startBot() {
|
|
|
11307
11654
|
// 1. State store. The `[startup] DATA_DIR=` shape is load-bearing: the
|
|
11308
11655
|
// README's two-instances troubleshooting tells the operator to compare this
|
|
11309
11656
|
// exact line across instances.
|
|
11310
|
-
state = await (0, state_1.getStateStore)();
|
|
11657
|
+
state = await (0, state_1.getStateStore)({ saveDebounceMs: ENV.testTimings.stateSaveDebounceMs });
|
|
11311
11658
|
console.log(`[startup] DATA_DIR=${path.dirname(state.stateFilePath)}`);
|
|
11312
11659
|
// R13: before anything reads, rewrites or acts on the state, it must hold only
|
|
11313
11660
|
// conversations of the platforms this instance serves.
|
|
@@ -11336,6 +11683,9 @@ async function startBot() {
|
|
|
11336
11683
|
// Every Claude spawn from here on reads the conversation's account profile (`/account`).
|
|
11337
11684
|
(0, createAdapter_1.registerClaudeConfigDirReader)(resolveThreadClaudeConfigDir);
|
|
11338
11685
|
(0, createAdapter_1.registerClaudeRateLimitSink)(onClaudeRateLimitEvent);
|
|
11686
|
+
(0, createAdapter_1.registerClaudeTurnCostSink)(onClaudeTurnCost);
|
|
11687
|
+
if (ENV.testTimings.openCodeServerUpBudgetMs !== undefined)
|
|
11688
|
+
(0, createAdapter_1.registerOpenCodeServerUpBudgetMs)(ENV.testTimings.openCodeServerUpBudgetMs);
|
|
11339
11689
|
// The usage cache: a first poll now (off the boot path), then the timer.
|
|
11340
11690
|
claudeUsageRefresher.start();
|
|
11341
11691
|
void claudeUsageRefresher.refreshAll();
|
|
@@ -11389,6 +11739,8 @@ async function startBot() {
|
|
|
11389
11739
|
return result.ok ? (result.alertRef ?? null) : null;
|
|
11390
11740
|
},
|
|
11391
11741
|
backstopMs: (0, wakeUpRules_1.getRequestBackstopMs)(process.env.REQUEST_BACKSTOP_MINUTES),
|
|
11742
|
+
sweepIntervalMs: ENV.testTimings.requestSweepIntervalMs,
|
|
11743
|
+
firstPostRetryDelayMs: ENV.testTimings.requestFirstPostRetryDelayMs,
|
|
11392
11744
|
// Only a Telegram topic in the answers-only view hides the stream from someone who expects to see the work.
|
|
11393
11745
|
checkIsAnswerTailWatched: (key) => (0, sessionKeyCodec_1.checkIsTelegramKey)(key) && !(0, topicView_1.checkIsStreamShown)(state.getDisplayPrefs(key).view),
|
|
11394
11746
|
remindAnswerTail,
|
|
@@ -11412,6 +11764,9 @@ async function startBot() {
|
|
|
11412
11764
|
const bootMode = (0, bootClassifier_1.classifyBoot)(downtimeMs);
|
|
11413
11765
|
console.log(`Boot mode: ${bootMode.isHotReload ? 'HOT RELOAD' : 'COLD START'} ` +
|
|
11414
11766
|
`(downtime=${downtimeMs === null ? 'unknown' : `${downtimeMs}ms`})`);
|
|
11767
|
+
const nativeWindowsConsoleLine = (0, nativeWindows_1.getNativeWindowsConsoleLine)(process.platform, i18n_1.t);
|
|
11768
|
+
if (nativeWindowsConsoleLine)
|
|
11769
|
+
console.warn(nativeWindowsConsoleLine);
|
|
11415
11770
|
console.log(`Chat mode: ${getChatMode()}`);
|
|
11416
11771
|
// In `both`, the DM surface only lights up when an OWNER_USER_ID is set;
|
|
11417
11772
|
// otherwise the instance serves the group only. Surface that explicitly so an
|
|
@@ -11473,10 +11828,19 @@ async function startBot() {
|
|
|
11473
11828
|
onApiError: (key, error) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'apiError', () => withThreadLocale(key, () => handleApiError(key, error))),
|
|
11474
11829
|
onNoResponse: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'noResponse', () => handleNoResponse(key)),
|
|
11475
11830
|
onQuestionGone: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'questionGone', () => withThreadLocale(key, () => handleQuestionGone(key))),
|
|
11476
|
-
onClosed: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'closed', () => withThreadLocale(key, () => handleAgentClosed(key)), () =>
|
|
11477
|
-
|
|
11831
|
+
onClosed: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'closed', () => withThreadLocale(key, () => handleAgentClosed(key)), () => {
|
|
11832
|
+
const lostWork = takeLostWorkOf(key);
|
|
11833
|
+
clearForeignSessionState(key);
|
|
11834
|
+
scheduleLostWorkNote(key, lostWork, 'exited');
|
|
11835
|
+
}),
|
|
11836
|
+
// An issue's process gets the idle countdown and the lost-work note of a re-spawn: it has no banner.
|
|
11837
|
+
onStarted: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'started', () => withThreadLocale(key, () => handleAgentStarted(key)), () => {
|
|
11838
|
+
rearmThreadIdleTimer(key);
|
|
11839
|
+
if (!lostWorkFoldingKeys.has((0, sessionKey_1.keyToString)(key)))
|
|
11840
|
+
scheduleLostWorkNote(key, takeLostWorkOf(key), 'restarted');
|
|
11841
|
+
}),
|
|
11478
11842
|
onStopped: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'stopped', () => withThreadLocale(key, () => handleAgentStopped(key)), () => clearForeignSessionState(key)),
|
|
11479
|
-
onSuspended: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'suspended', () => withThreadLocale(key, () => handleAgentSuspended(key))),
|
|
11843
|
+
onSuspended: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'suspended', () => withThreadLocale(key, () => handleAgentSuspended(key)), () => scheduleLostWorkNote(key, takeLostWorkOf(key), 'holdExpired')),
|
|
11480
11844
|
onTurnEnded: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'turnEnded', () => handleAgentTurnEnded(key)),
|
|
11481
11845
|
onContextCompacted: (key, trigger) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'contextCompacted', () => noteConversationContextReset(key, `compaction (${trigger ?? 'bot'})`)),
|
|
11482
11846
|
onConversationLost: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'conversationLost', () => withThreadLocale(key, () => handleConversationLost(key)), () => { dropLostConversationWatermark(key); noteConversationContextReset(key, 'its transcript was lost'); }),
|
|
@@ -11920,6 +12284,12 @@ async function startBot() {
|
|
|
11920
12284
|
catch (e) {
|
|
11921
12285
|
console.warn('[startup-status] failed:', e instanceof Error ? e.message : e);
|
|
11922
12286
|
}
|
|
12287
|
+
try {
|
|
12288
|
+
await postNativeWindowsNoticeToGeneral(bootMode.isHotReload);
|
|
12289
|
+
}
|
|
12290
|
+
catch (e) {
|
|
12291
|
+
console.warn('[platform] native Windows notice failed:', e instanceof Error ? e.message : e);
|
|
12292
|
+
}
|
|
11923
12293
|
await startUpdates();
|
|
11924
12294
|
// The local speech engine: clean up after an earlier run, re-attach to / start its install. Starts no process.
|
|
11925
12295
|
void bootSttLocalEngine().catch((e) => console.warn('[STT] boot step failed:', e instanceof Error ? e.message : e));
|