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