charness 3.6.1 → 3.7.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 +267 -0
- package/DEVELOPMENT.md +31 -5
- package/MIGRATION_RENAME_TO_CHARNESS.md +3 -2
- package/README.md +84 -782
- package/SETUP.md +37 -10
- package/agent-plugin/.claude-plugin/plugin.json +7 -0
- package/agent-plugin/README.md +35 -0
- package/agent-plugin/skills/charness-delegate-claude/SKILL.md +85 -0
- package/agent-plugin/skills/charness-delegate-claude/summarize-events.py +90 -0
- package/agent-plugin/skills/charness-delegate-opencode/SKILL.md +68 -0
- package/agent-plugin/skills/charness-manager/SKILL.md +152 -0
- package/dist/adapters/adapterNames.d.ts +6 -0
- package/dist/adapters/adapterNames.d.ts.map +1 -1
- package/dist/adapters/adapterNames.js +8 -2
- package/dist/adapters/adapterNames.js.map +1 -1
- package/dist/adapters/claudeCliAdapter.d.ts +8 -0
- package/dist/adapters/claudeCliAdapter.d.ts.map +1 -1
- package/dist/adapters/claudeCliAdapter.js +42 -5
- package/dist/adapters/claudeCliAdapter.js.map +1 -1
- package/dist/adapters/claudeJsonStreamAdapter.d.ts +10 -9
- package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
- package/dist/adapters/claudeJsonStreamAdapter.js +53 -55
- package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
- package/dist/adapters/createAdapter.d.ts +8 -0
- package/dist/adapters/createAdapter.d.ts.map +1 -1
- package/dist/adapters/createAdapter.js +14 -2
- package/dist/adapters/createAdapter.js.map +1 -1
- package/dist/adapters/openCodeAdapter.d.ts +43 -6
- package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/openCodeAdapter.js +95 -6
- package/dist/adapters/openCodeAdapter.js.map +1 -1
- package/dist/agentLogin/agentLogin.d.ts +1 -1
- package/dist/agentLogin/agentLogin.d.ts.map +1 -1
- package/dist/apiRetryKick.d.ts +4 -3
- package/dist/apiRetryKick.d.ts.map +1 -1
- package/dist/apiRetryKick.js +16 -9
- package/dist/apiRetryKick.js.map +1 -1
- package/dist/bot.d.ts.map +1 -1
- package/dist/bot.js +1313 -381
- package/dist/bot.js.map +1 -1
- package/dist/cli/claudeProfiles.d.ts +11 -6
- package/dist/cli/claudeProfiles.d.ts.map +1 -1
- package/dist/cli/claudeProfiles.js +50 -22
- package/dist/cli/claudeProfiles.js.map +1 -1
- package/dist/cli/hot.d.ts +5 -0
- package/dist/cli/hot.d.ts.map +1 -1
- package/dist/cli/hot.js +71 -25
- package/dist/cli/hot.js.map +1 -1
- package/dist/cli/tscWatchGuard.d.ts +95 -0
- package/dist/cli/tscWatchGuard.d.ts.map +1 -0
- package/dist/cli/tscWatchGuard.js +295 -0
- package/dist/cli/tscWatchGuard.js.map +1 -0
- package/dist/cli.js +12 -1
- package/dist/cli.js.map +1 -1
- package/dist/connectors/jira/answerSink.d.ts +10 -0
- package/dist/connectors/jira/answerSink.d.ts.map +1 -1
- package/dist/connectors/jira/answerSink.js +13 -0
- package/dist/connectors/jira/answerSink.js.map +1 -1
- package/dist/connectors/jira/config.d.ts +2 -0
- package/dist/connectors/jira/config.d.ts.map +1 -1
- package/dist/connectors/jira/config.js +9 -1
- package/dist/connectors/jira/config.js.map +1 -1
- package/dist/connectors/jira/connector.d.ts +11 -1
- package/dist/connectors/jira/connector.d.ts.map +1 -1
- package/dist/connectors/jira/connector.js +7 -0
- package/dist/connectors/jira/connector.js.map +1 -1
- package/dist/connectors/jira/inbound.d.ts +27 -4
- package/dist/connectors/jira/inbound.d.ts.map +1 -1
- package/dist/connectors/jira/inbound.js +69 -8
- package/dist/connectors/jira/inbound.js.map +1 -1
- package/dist/connectors/jira/issueBlocks.d.ts +5 -0
- package/dist/connectors/jira/issueBlocks.d.ts.map +1 -1
- package/dist/connectors/jira/issueBlocks.js +11 -0
- package/dist/connectors/jira/issueBlocks.js.map +1 -1
- package/dist/connectors/telegram/bindPicker.d.ts +5 -0
- package/dist/connectors/telegram/bindPicker.d.ts.map +1 -1
- package/dist/connectors/telegram/bindPicker.js +18 -1
- package/dist/connectors/telegram/bindPicker.js.map +1 -1
- package/dist/connectors/telegram/bindPickerClaims.d.ts +46 -0
- package/dist/connectors/telegram/bindPickerClaims.d.ts.map +1 -0
- package/dist/connectors/telegram/bindPickerClaims.js +83 -0
- package/dist/connectors/telegram/bindPickerClaims.js.map +1 -0
- package/dist/connectors/telegram/bindSweep.d.ts +32 -0
- package/dist/connectors/telegram/bindSweep.d.ts.map +1 -0
- package/dist/connectors/telegram/bindSweep.js +47 -0
- package/dist/connectors/telegram/bindSweep.js.map +1 -0
- package/dist/connectors/telegram/bindingWelcome.d.ts +23 -0
- package/dist/connectors/telegram/bindingWelcome.d.ts.map +1 -0
- package/dist/connectors/telegram/bindingWelcome.js +26 -0
- package/dist/connectors/telegram/bindingWelcome.js.map +1 -0
- package/dist/connectors/telegram/botApiConnection.d.ts +74 -0
- package/dist/connectors/telegram/botApiConnection.d.ts.map +1 -0
- package/dist/connectors/telegram/botApiConnection.js +201 -0
- package/dist/connectors/telegram/botApiConnection.js.map +1 -0
- package/dist/connectors/telegram/commands/botCore.d.ts +8 -2
- package/dist/connectors/telegram/commands/botCore.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/botSkills.d.ts +21 -0
- package/dist/connectors/telegram/commands/botSkills.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/botSkills.js +133 -0
- package/dist/connectors/telegram/commands/botSkills.js.map +1 -0
- package/dist/connectors/telegram/commands/callbackMessage.d.ts +5 -0
- package/dist/connectors/telegram/commands/callbackMessage.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/callbackMessage.js +9 -0
- package/dist/connectors/telegram/commands/callbackMessage.js.map +1 -1
- package/dist/connectors/telegram/commands/compaction.d.ts +65 -3
- package/dist/connectors/telegram/commands/compaction.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/compaction.js +512 -94
- package/dist/connectors/telegram/commands/compaction.js.map +1 -1
- package/dist/connectors/telegram/commands/displayModes.d.ts +1 -1
- package/dist/connectors/telegram/commands/displayModes.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/displayModes.js +6 -18
- package/dist/connectors/telegram/commands/displayModes.js.map +1 -1
- package/dist/connectors/telegram/commands/fallbackChain.js +5 -5
- package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -1
- package/dist/connectors/telegram/commands/reminders.d.ts +1 -1
- package/dist/connectors/telegram/commands/reminders.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/reminders.js +4 -15
- package/dist/connectors/telegram/commands/reminders.js.map +1 -1
- package/dist/connectors/telegram/commands/selfUpdate.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/selfUpdate.js +1 -11
- package/dist/connectors/telegram/commands/selfUpdate.js.map +1 -1
- package/dist/connectors/telegram/commands/taskAgents.d.ts +22 -0
- package/dist/connectors/telegram/commands/taskAgents.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/taskAgents.js +104 -0
- package/dist/connectors/telegram/commands/taskAgents.js.map +1 -0
- package/dist/connectors/telegram/commands/todo.d.ts +82 -0
- package/dist/connectors/telegram/commands/todo.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/todo.js +384 -0
- package/dist/connectors/telegram/commands/todo.js.map +1 -0
- package/dist/connectors/telegram/commands/topicCommand.d.ts +6 -2
- package/dist/connectors/telegram/commands/topicCommand.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/topicCommand.js +6 -9
- package/dist/connectors/telegram/commands/topicCommand.js.map +1 -1
- package/dist/connectors/telegram/openBindMessages.d.ts +45 -0
- package/dist/connectors/telegram/openBindMessages.d.ts.map +1 -0
- package/dist/connectors/telegram/openBindMessages.js +67 -0
- package/dist/connectors/telegram/openBindMessages.js.map +1 -0
- package/dist/connectors/telegram/outputFlushPlan.d.ts +49 -0
- package/dist/connectors/telegram/outputFlushPlan.d.ts.map +1 -1
- package/dist/connectors/telegram/outputFlushPlan.js +46 -0
- package/dist/connectors/telegram/outputFlushPlan.js.map +1 -1
- package/dist/connectors/telegram/pendingTopicName.d.ts +26 -0
- package/dist/connectors/telegram/pendingTopicName.d.ts.map +1 -0
- package/dist/connectors/telegram/pendingTopicName.js +27 -0
- package/dist/connectors/telegram/pendingTopicName.js.map +1 -0
- package/dist/connectors/test/connector.d.ts.map +1 -1
- package/dist/connectors/test/connector.js +6 -1
- package/dist/connectors/test/connector.js.map +1 -1
- package/dist/i18n/de.d.ts.map +1 -1
- package/dist/i18n/de.js +90 -7
- package/dist/i18n/de.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +90 -7
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +90 -7
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/fr.d.ts.map +1 -1
- package/dist/i18n/fr.js +90 -7
- package/dist/i18n/fr.js.map +1 -1
- package/dist/i18n/hi.d.ts.map +1 -1
- package/dist/i18n/hi.js +90 -7
- package/dist/i18n/hi.js.map +1 -1
- package/dist/i18n/ja.d.ts.map +1 -1
- package/dist/i18n/ja.js +90 -7
- package/dist/i18n/ja.js.map +1 -1
- package/dist/i18n/ka.d.ts.map +1 -1
- package/dist/i18n/ka.js +90 -7
- package/dist/i18n/ka.js.map +1 -1
- package/dist/i18n/pt.d.ts.map +1 -1
- package/dist/i18n/pt.js +90 -7
- package/dist/i18n/pt.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +90 -7
- package/dist/i18n/ru.js.map +1 -1
- package/dist/i18n/uk.d.ts.map +1 -1
- package/dist/i18n/uk.js +90 -7
- package/dist/i18n/uk.js.map +1 -1
- package/dist/i18n/uz.d.ts.map +1 -1
- package/dist/i18n/uz.js +90 -7
- package/dist/i18n/uz.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +90 -7
- package/dist/i18n/zh.js.map +1 -1
- package/dist/i18n.d.ts +1 -1
- package/dist/i18n.js +1 -1
- package/dist/installManager.d.ts +13 -0
- package/dist/installManager.d.ts.map +1 -1
- package/dist/installManager.js +38 -4
- package/dist/installManager.js.map +1 -1
- package/dist/instanceSettings.d.ts +40 -1
- package/dist/instanceSettings.d.ts.map +1 -1
- package/dist/instanceSettings.js +105 -26
- package/dist/instanceSettings.js.map +1 -1
- package/dist/openCodeQuestionFlow.d.ts +30 -0
- package/dist/openCodeQuestionFlow.d.ts.map +1 -1
- package/dist/openCodeQuestionFlow.js +43 -0
- package/dist/openCodeQuestionFlow.js.map +1 -1
- package/dist/postToSession.d.ts +20 -5
- package/dist/postToSession.d.ts.map +1 -1
- package/dist/postToSession.js +38 -7
- package/dist/postToSession.js.map +1 -1
- package/dist/remoteRun/cliArgs.d.ts +73 -0
- package/dist/remoteRun/cliArgs.d.ts.map +1 -0
- package/dist/remoteRun/cliArgs.js +197 -0
- package/dist/remoteRun/cliArgs.js.map +1 -0
- package/dist/remoteRun/commands.d.ts +56 -0
- package/dist/remoteRun/commands.d.ts.map +1 -0
- package/dist/remoteRun/commands.js +306 -0
- package/dist/remoteRun/commands.js.map +1 -0
- package/dist/remoteRun/depsPlan.d.ts +16 -0
- package/dist/remoteRun/depsPlan.d.ts.map +1 -0
- package/dist/remoteRun/depsPlan.js +82 -0
- package/dist/remoteRun/depsPlan.js.map +1 -0
- package/dist/remoteRun/exitCodes.d.ts +34 -0
- package/dist/remoteRun/exitCodes.d.ts.map +1 -0
- package/dist/remoteRun/exitCodes.js +38 -0
- package/dist/remoteRun/exitCodes.js.map +1 -0
- package/dist/remoteRun/foreground.d.ts +29 -0
- package/dist/remoteRun/foreground.d.ts.map +1 -0
- package/dist/remoteRun/foreground.js +159 -0
- package/dist/remoteRun/foreground.js.map +1 -0
- package/dist/remoteRun/interrupts.d.ts +17 -0
- package/dist/remoteRun/interrupts.d.ts.map +1 -0
- package/dist/remoteRun/interrupts.js +41 -0
- package/dist/remoteRun/interrupts.js.map +1 -0
- package/dist/remoteRun/jobFiles.d.ts +31 -0
- package/dist/remoteRun/jobFiles.d.ts.map +1 -0
- package/dist/remoteRun/jobFiles.js +84 -0
- package/dist/remoteRun/jobFiles.js.map +1 -0
- package/dist/remoteRun/jobFormat.d.ts +16 -0
- package/dist/remoteRun/jobFormat.d.ts.map +1 -0
- package/dist/remoteRun/jobFormat.js +130 -0
- package/dist/remoteRun/jobFormat.js.map +1 -0
- package/dist/remoteRun/jobId.d.ts +15 -0
- package/dist/remoteRun/jobId.d.ts.map +1 -0
- package/dist/remoteRun/jobId.js +85 -0
- package/dist/remoteRun/jobId.js.map +1 -0
- package/dist/remoteRun/jobMeta.d.ts +18 -0
- package/dist/remoteRun/jobMeta.d.ts.map +1 -0
- package/dist/remoteRun/jobMeta.js +33 -0
- package/dist/remoteRun/jobMeta.js.map +1 -0
- package/dist/remoteRun/jobQuery.d.ts +12 -0
- package/dist/remoteRun/jobQuery.d.ts.map +1 -0
- package/dist/remoteRun/jobQuery.js +22 -0
- package/dist/remoteRun/jobQuery.js.map +1 -0
- package/dist/remoteRun/jobRecords.d.ts +22 -0
- package/dist/remoteRun/jobRecords.d.ts.map +1 -0
- package/dist/remoteRun/jobRecords.js +145 -0
- package/dist/remoteRun/jobRecords.js.map +1 -0
- package/dist/remoteRun/jobState.d.ts +62 -0
- package/dist/remoteRun/jobState.d.ts.map +1 -0
- package/dist/remoteRun/jobState.js +132 -0
- package/dist/remoteRun/jobState.js.map +1 -0
- package/dist/remoteRun/leaseTiming.d.ts +18 -0
- package/dist/remoteRun/leaseTiming.d.ts.map +1 -0
- package/dist/remoteRun/leaseTiming.js +43 -0
- package/dist/remoteRun/leaseTiming.js.map +1 -0
- package/dist/remoteRun/output.d.ts +35 -0
- package/dist/remoteRun/output.d.ts.map +1 -0
- package/dist/remoteRun/output.js +82 -0
- package/dist/remoteRun/output.js.map +1 -0
- package/dist/remoteRun/pathPatterns.d.ts +33 -0
- package/dist/remoteRun/pathPatterns.d.ts.map +1 -0
- package/dist/remoteRun/pathPatterns.js +115 -0
- package/dist/remoteRun/pathPatterns.js.map +1 -0
- package/dist/remoteRun/probe.d.ts +56 -0
- package/dist/remoteRun/probe.d.ts.map +1 -0
- package/dist/remoteRun/probe.js +123 -0
- package/dist/remoteRun/probe.js.map +1 -0
- package/dist/remoteRun/project.d.ts +26 -0
- package/dist/remoteRun/project.d.ts.map +1 -0
- package/dist/remoteRun/project.js +159 -0
- package/dist/remoteRun/project.js.map +1 -0
- package/dist/remoteRun/projectConfig.d.ts +38 -0
- package/dist/remoteRun/projectConfig.d.ts.map +1 -0
- package/dist/remoteRun/projectConfig.js +87 -0
- package/dist/remoteRun/projectConfig.js.map +1 -0
- package/dist/remoteRun/remoteRunCli.d.ts +15 -0
- package/dist/remoteRun/remoteRunCli.d.ts.map +1 -0
- package/dist/remoteRun/remoteRunCli.js +144 -0
- package/dist/remoteRun/remoteRunCli.js.map +1 -0
- package/dist/remoteRun/remoteRunError.d.ts +14 -0
- package/dist/remoteRun/remoteRunError.d.ts.map +1 -0
- package/dist/remoteRun/remoteRunError.js +28 -0
- package/dist/remoteRun/remoteRunError.js.map +1 -0
- package/dist/remoteRun/rsync.d.ts +17 -0
- package/dist/remoteRun/rsync.d.ts.map +1 -0
- package/dist/remoteRun/rsync.js +39 -0
- package/dist/remoteRun/rsync.js.map +1 -0
- package/dist/remoteRun/runnerClient.d.ts +50 -0
- package/dist/remoteRun/runnerClient.d.ts.map +1 -0
- package/dist/remoteRun/runnerClient.js +139 -0
- package/dist/remoteRun/runnerClient.js.map +1 -0
- package/dist/remoteRun/runners.d.ts +57 -0
- package/dist/remoteRun/runners.d.ts.map +1 -0
- package/dist/remoteRun/runners.js +181 -0
- package/dist/remoteRun/runners.js.map +1 -0
- package/dist/remoteRun/runnersConfig.d.ts +44 -0
- package/dist/remoteRun/runnersConfig.d.ts.map +1 -0
- package/dist/remoteRun/runnersConfig.js +107 -0
- package/dist/remoteRun/runnersConfig.js.map +1 -0
- package/dist/remoteRun/safeCopy.d.ts +31 -0
- package/dist/remoteRun/safeCopy.d.ts.map +1 -0
- package/dist/remoteRun/safeCopy.js +137 -0
- package/dist/remoteRun/safeCopy.js.map +1 -0
- package/dist/remoteRun/sendPlan.d.ts +67 -0
- package/dist/remoteRun/sendPlan.d.ts.map +1 -0
- package/dist/remoteRun/sendPlan.js +139 -0
- package/dist/remoteRun/sendPlan.js.map +1 -0
- package/dist/remoteRun/ssh.d.ts +62 -0
- package/dist/remoteRun/ssh.d.ts.map +1 -0
- package/dist/remoteRun/ssh.js +202 -0
- package/dist/remoteRun/ssh.js.map +1 -0
- package/dist/remoteRun/submit.d.ts +29 -0
- package/dist/remoteRun/submit.d.ts.map +1 -0
- package/dist/remoteRun/submit.js +142 -0
- package/dist/remoteRun/submit.js.map +1 -0
- package/dist/requests/answerRequest.d.ts +5 -0
- package/dist/requests/answerRequest.d.ts.map +1 -1
- package/dist/requests/answerRequest.js +16 -1
- package/dist/requests/answerRequest.js.map +1 -1
- package/dist/requests/requestLedger.d.ts +13 -0
- package/dist/requests/requestLedger.d.ts.map +1 -1
- package/dist/requests/requestLedger.js +9 -0
- package/dist/requests/requestLedger.js.map +1 -1
- package/dist/requests/topicRequestSettle.d.ts +30 -0
- package/dist/requests/topicRequestSettle.d.ts.map +1 -0
- package/dist/requests/topicRequestSettle.js +23 -0
- package/dist/requests/topicRequestSettle.js.map +1 -0
- package/dist/requests/wakeUpEngine.d.ts +4 -0
- package/dist/requests/wakeUpEngine.d.ts.map +1 -1
- package/dist/requests/wakeUpEngine.js +5 -1
- package/dist/requests/wakeUpEngine.js.map +1 -1
- package/dist/scheduler/delivery.d.ts +2 -0
- package/dist/scheduler/delivery.d.ts.map +1 -1
- package/dist/scheduler/delivery.js +8 -4
- package/dist/scheduler/delivery.js.map +1 -1
- package/dist/scheduler/mcpSurface.d.ts +59 -10
- package/dist/scheduler/mcpSurface.d.ts.map +1 -1
- package/dist/scheduler/mcpSurface.js +276 -79
- package/dist/scheduler/mcpSurface.js.map +1 -1
- package/dist/scheduler/recurrence.d.ts +5 -0
- package/dist/scheduler/recurrence.d.ts.map +1 -1
- package/dist/scheduler/recurrence.js +12 -2
- package/dist/scheduler/recurrence.js.map +1 -1
- package/dist/scheduler/strictToolInput.d.ts +45 -0
- package/dist/scheduler/strictToolInput.d.ts.map +1 -0
- package/dist/scheduler/strictToolInput.js +155 -0
- package/dist/scheduler/strictToolInput.js.map +1 -0
- package/dist/scheduler/types.d.ts +5 -0
- package/dist/scheduler/types.d.ts.map +1 -1
- package/dist/scheduler/workerWatch.d.ts +23 -0
- package/dist/scheduler/workerWatch.d.ts.map +1 -0
- package/dist/scheduler/workerWatch.js +26 -0
- package/dist/scheduler/workerWatch.js.map +1 -0
- package/dist/selfUpdate/emittedSource.d.ts +11 -0
- package/dist/selfUpdate/emittedSource.d.ts.map +1 -0
- package/dist/selfUpdate/emittedSource.js +24 -0
- package/dist/selfUpdate/emittedSource.js.map +1 -0
- package/dist/selfUpdate/gitSource.d.ts +0 -6
- package/dist/selfUpdate/gitSource.d.ts.map +1 -1
- package/dist/selfUpdate/gitSource.js +2 -18
- package/dist/selfUpdate/gitSource.js.map +1 -1
- package/dist/selfUpdate/updatePending.d.ts +1 -1
- package/dist/sendErrorClassifier.d.ts +10 -0
- package/dist/sendErrorClassifier.d.ts.map +1 -1
- package/dist/sendErrorClassifier.js +22 -0
- package/dist/sendErrorClassifier.js.map +1 -1
- package/dist/state.d.ts +78 -7
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +125 -8
- package/dist/state.js.map +1 -1
- package/dist/stateFile.d.ts +9 -0
- package/dist/stateFile.d.ts.map +1 -0
- package/dist/stateFile.js +57 -0
- package/dist/stateFile.js.map +1 -0
- package/dist/stt/sttInstall.d.ts +7 -3
- package/dist/stt/sttInstall.d.ts.map +1 -1
- package/dist/stt/sttInstall.js +8 -4
- package/dist/stt/sttInstall.js.map +1 -1
- package/dist/stt/sttSettings.d.ts +2 -1
- package/dist/stt/sttSettings.d.ts.map +1 -1
- package/dist/stt/sttSettings.js +3 -2
- package/dist/stt/sttSettings.js.map +1 -1
- package/dist/stt/transcribeChain.js +2 -0
- package/dist/stt/transcribeChain.js.map +1 -1
- package/dist/testTimings.d.ts +13 -3
- package/dist/testTimings.d.ts.map +1 -1
- package/dist/testTimings.js +26 -2
- package/dist/testTimings.js.map +1 -1
- package/dist/todos/board.d.ts +83 -0
- package/dist/todos/board.d.ts.map +1 -0
- package/dist/todos/board.js +227 -0
- package/dist/todos/board.js.map +1 -0
- package/dist/todos/format.d.ts +33 -0
- package/dist/todos/format.d.ts.map +1 -0
- package/dist/todos/format.js +90 -0
- package/dist/todos/format.js.map +1 -0
- package/dist/todos/merge.d.ts +46 -0
- package/dist/todos/merge.d.ts.map +1 -0
- package/dist/todos/merge.js +137 -0
- package/dist/todos/merge.js.map +1 -0
- package/dist/todos/midTurn.d.ts +75 -0
- package/dist/todos/midTurn.d.ts.map +1 -0
- package/dist/todos/midTurn.js +214 -0
- package/dist/todos/midTurn.js.map +1 -0
- package/dist/todos/nag.d.ts +85 -0
- package/dist/todos/nag.d.ts.map +1 -0
- package/dist/todos/nag.js +103 -0
- package/dist/todos/nag.js.map +1 -0
- package/dist/todos/order.d.ts +25 -0
- package/dist/todos/order.d.ts.map +1 -0
- package/dist/todos/order.js +61 -0
- package/dist/todos/order.js.map +1 -0
- package/dist/todos/projects.d.ts +102 -0
- package/dist/todos/projects.d.ts.map +1 -0
- package/dist/todos/projects.js +342 -0
- package/dist/todos/projects.js.map +1 -0
- package/dist/todos/registry.d.ts +64 -0
- package/dist/todos/registry.d.ts.map +1 -0
- package/dist/todos/registry.js +116 -0
- package/dist/todos/registry.js.map +1 -0
- package/dist/todos/reminderEngine.d.ts +78 -0
- package/dist/todos/reminderEngine.d.ts.map +1 -0
- package/dist/todos/reminderEngine.js +162 -0
- package/dist/todos/reminderEngine.js.map +1 -0
- package/dist/todos/store.d.ts +34 -0
- package/dist/todos/store.d.ts.map +1 -0
- package/dist/todos/store.js +208 -0
- package/dist/todos/store.js.map +1 -0
- package/dist/todos/tools.d.ts +26 -0
- package/dist/todos/tools.d.ts.map +1 -0
- package/dist/todos/tools.js +25 -0
- package/dist/todos/tools.js.map +1 -0
- package/dist/todos/types.d.ts +85 -0
- package/dist/todos/types.d.ts.map +1 -0
- package/dist/todos/types.js +30 -0
- package/dist/todos/types.js.map +1 -0
- package/dist/trackers/taskAgentLimit.d.ts +58 -0
- package/dist/trackers/taskAgentLimit.d.ts.map +1 -0
- package/dist/trackers/taskAgentLimit.js +62 -0
- package/dist/trackers/taskAgentLimit.js.map +1 -0
- package/dist/trackers/taskAgentSlots.d.ts +109 -0
- package/dist/trackers/taskAgentSlots.d.ts.map +1 -0
- package/dist/trackers/taskAgentSlots.js +227 -0
- package/dist/trackers/taskAgentSlots.js.map +1 -0
- package/dist/trackers/types.d.ts +14 -0
- package/dist/trackers/types.d.ts.map +1 -0
- package/dist/trackers/types.js +3 -0
- package/dist/trackers/types.js.map +1 -0
- package/dist/types.d.ts +80 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/adoptReplayGate.d.ts +16 -0
- package/dist/utils/adoptReplayGate.d.ts.map +1 -0
- package/dist/utils/adoptReplayGate.js +35 -0
- package/dist/utils/adoptReplayGate.js.map +1 -0
- package/dist/utils/agentPlugin.d.ts +113 -0
- package/dist/utils/agentPlugin.d.ts.map +1 -0
- package/dist/utils/agentPlugin.js +192 -0
- package/dist/utils/agentPlugin.js.map +1 -0
- package/dist/utils/autoContinueOnLimit.d.ts +7 -4
- package/dist/utils/autoContinueOnLimit.d.ts.map +1 -1
- package/dist/utils/autoContinueOnLimit.js +8 -5
- package/dist/utils/autoContinueOnLimit.js.map +1 -1
- package/dist/utils/botSkills.d.ts +62 -0
- package/dist/utils/botSkills.d.ts.map +1 -0
- package/dist/utils/botSkills.js +53 -0
- package/dist/utils/botSkills.js.map +1 -0
- package/dist/utils/botSkillsSetting.d.ts +7 -0
- package/dist/utils/botSkillsSetting.d.ts.map +1 -0
- package/dist/utils/botSkillsSetting.js +9 -0
- package/dist/utils/botSkillsSetting.js.map +1 -0
- package/dist/utils/claudeCodeVersion.d.ts +7 -0
- package/dist/utils/claudeCodeVersion.d.ts.map +1 -1
- package/dist/utils/claudeCodeVersion.js +14 -1
- package/dist/utils/claudeCodeVersion.js.map +1 -1
- package/dist/utils/claudeCompactHook.d.ts +33 -15
- package/dist/utils/claudeCompactHook.d.ts.map +1 -1
- package/dist/utils/claudeCompactHook.js +36 -17
- package/dist/utils/claudeCompactHook.js.map +1 -1
- package/dist/utils/claudeCompactSummary.d.ts +15 -0
- package/dist/utils/claudeCompactSummary.d.ts.map +1 -0
- package/dist/utils/claudeCompactSummary.js +96 -0
- package/dist/utils/claudeCompactSummary.js.map +1 -0
- package/dist/utils/claudeLimitSwitch.d.ts +11 -9
- package/dist/utils/claudeLimitSwitch.d.ts.map +1 -1
- package/dist/utils/claudeLimitSwitch.js +18 -16
- package/dist/utils/claudeLimitSwitch.js.map +1 -1
- package/dist/utils/claudeProfiles.d.ts +5 -0
- package/dist/utils/claudeProfiles.d.ts.map +1 -1
- package/dist/utils/claudeProfiles.js +14 -0
- package/dist/utils/claudeProfiles.js.map +1 -1
- package/dist/utils/claudeSessionTitle.d.ts +2 -1
- package/dist/utils/claudeSessionTitle.d.ts.map +1 -1
- package/dist/utils/claudeSessionTitle.js +14 -14
- package/dist/utils/claudeSessionTitle.js.map +1 -1
- package/dist/utils/claudeUsage.d.ts +95 -15
- package/dist/utils/claudeUsage.d.ts.map +1 -1
- package/dist/utils/claudeUsage.js +241 -41
- package/dist/utils/claudeUsage.js.map +1 -1
- package/dist/utils/claudeUsageRefresher.d.ts +10 -0
- package/dist/utils/claudeUsageRefresher.d.ts.map +1 -1
- package/dist/utils/claudeUsageRefresher.js +11 -0
- package/dist/utils/claudeUsageRefresher.js.map +1 -1
- package/dist/utils/claudeUserEntry.d.ts +50 -0
- package/dist/utils/claudeUserEntry.d.ts.map +1 -0
- package/dist/utils/claudeUserEntry.js +120 -0
- package/dist/utils/claudeUserEntry.js.map +1 -0
- package/dist/utils/clearThreadOutputQueues.d.ts +4 -3
- package/dist/utils/clearThreadOutputQueues.d.ts.map +1 -1
- package/dist/utils/clearThreadOutputQueues.js +1 -1
- package/dist/utils/clearThreadOutputQueues.js.map +1 -1
- package/dist/utils/compactOnIdle.d.ts +33 -13
- package/dist/utils/compactOnIdle.d.ts.map +1 -1
- package/dist/utils/compactOnIdle.js +35 -15
- package/dist/utils/compactOnIdle.js.map +1 -1
- package/dist/utils/fallbackBridge.d.ts +5 -0
- package/dist/utils/fallbackBridge.d.ts.map +1 -1
- package/dist/utils/fallbackBridge.js +19 -3
- package/dist/utils/fallbackBridge.js.map +1 -1
- package/dist/utils/fallbackChain.d.ts +9 -3
- package/dist/utils/fallbackChain.d.ts.map +1 -1
- package/dist/utils/fallbackChain.js +21 -3
- package/dist/utils/fallbackChain.js.map +1 -1
- package/dist/utils/fileSendPlan.d.ts +6 -0
- package/dist/utils/fileSendPlan.d.ts.map +1 -1
- package/dist/utils/fileSendPlan.js +11 -0
- package/dist/utils/fileSendPlan.js.map +1 -1
- package/dist/utils/fileSendService.d.ts +7 -1
- package/dist/utils/fileSendService.d.ts.map +1 -1
- package/dist/utils/fileSendService.js +6 -2
- package/dist/utils/fileSendService.js.map +1 -1
- package/dist/utils/groupFinalizePlan.d.ts +8 -10
- package/dist/utils/groupFinalizePlan.d.ts.map +1 -1
- package/dist/utils/groupFinalizePlan.js +8 -12
- package/dist/utils/groupFinalizePlan.js.map +1 -1
- package/dist/utils/jsonStreamHost.d.ts +13 -0
- package/dist/utils/jsonStreamHost.d.ts.map +1 -1
- package/dist/utils/jsonStreamHost.js +13 -0
- package/dist/utils/jsonStreamHost.js.map +1 -1
- package/dist/utils/messageSendService.d.ts +5 -3
- package/dist/utils/messageSendService.d.ts.map +1 -1
- package/dist/utils/messageSendService.js +12 -5
- package/dist/utils/messageSendService.js.map +1 -1
- package/dist/utils/openCodeCompactPlugin.d.ts +39 -14
- package/dist/utils/openCodeCompactPlugin.d.ts.map +1 -1
- package/dist/utils/openCodeCompactPlugin.js +106 -38
- package/dist/utils/openCodeCompactPlugin.js.map +1 -1
- package/dist/utils/promptDelivery.d.ts +29 -12
- package/dist/utils/promptDelivery.d.ts.map +1 -1
- package/dist/utils/promptDelivery.js +13 -5
- package/dist/utils/promptDelivery.js.map +1 -1
- package/dist/utils/sentKindsSummary.d.ts +10 -0
- package/dist/utils/sentKindsSummary.d.ts.map +1 -0
- package/dist/utils/sentKindsSummary.js +18 -0
- package/dist/utils/sentKindsSummary.js.map +1 -0
- package/dist/utils/telegramError.d.ts +6 -0
- package/dist/utils/telegramError.d.ts.map +1 -1
- package/dist/utils/telegramError.js +12 -0
- package/dist/utils/telegramError.js.map +1 -1
- package/dist/utils/topicView.d.ts +1 -1
- package/dist/utils/topicView.js +1 -1
- package/dist/utils/transcribeAudio.d.ts +10 -0
- package/dist/utils/transcribeAudio.d.ts.map +1 -1
- package/dist/utils/transcribeAudio.js +22 -3
- package/dist/utils/transcribeAudio.js.map +1 -1
- package/dist/utils/transcriptErrorTime.d.ts +8 -0
- package/dist/utils/transcriptErrorTime.d.ts.map +1 -0
- package/dist/utils/transcriptErrorTime.js +56 -0
- package/dist/utils/transcriptErrorTime.js.map +1 -0
- package/dist/utils/wedgeRecovery.d.ts +2 -0
- package/dist/utils/wedgeRecovery.d.ts.map +1 -1
- package/dist/utils/wedgeRecovery.js +1 -1
- package/dist/utils/wedgeRecovery.js.map +1 -1
- package/package.json +11 -3
- package/remoteRun/runner.sh +1242 -0
- package/stt/gigaam_sidecar.py +555 -50
package/dist/bot.js
CHANGED
|
@@ -46,7 +46,6 @@ const filters_1 = require("telegraf/filters");
|
|
|
46
46
|
const fs = __importStar(require("fs"));
|
|
47
47
|
const os = __importStar(require("os"));
|
|
48
48
|
const path = __importStar(require("path"));
|
|
49
|
-
const https = __importStar(require("https"));
|
|
50
49
|
const child_process_1 = require("child_process");
|
|
51
50
|
const util_1 = require("util");
|
|
52
51
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
@@ -76,6 +75,7 @@ const startupReadiness_1 = require("./utils/startupReadiness");
|
|
|
76
75
|
const doctorLine_1 = require("./utils/doctorLine");
|
|
77
76
|
const nativeWindows_1 = require("./utils/nativeWindows");
|
|
78
77
|
const state_1 = require("./state");
|
|
78
|
+
const diagLog_1 = require("./diagLog");
|
|
79
79
|
const instanceLock_1 = require("./instanceLock");
|
|
80
80
|
const shutdown_1 = require("./shutdown");
|
|
81
81
|
const updateDispatcher_1 = require("./connectors/telegram/updateDispatcher");
|
|
@@ -87,7 +87,14 @@ const timezonePicker_1 = require("./connectors/telegram/timezonePicker");
|
|
|
87
87
|
const timezoneRecompute_1 = require("./scheduler/timezoneRecompute");
|
|
88
88
|
const validation_1 = require("./validation");
|
|
89
89
|
const bindPicker_1 = require("./connectors/telegram/bindPicker");
|
|
90
|
+
const openBindMessages_1 = require("./connectors/telegram/openBindMessages");
|
|
91
|
+
const bindSweep_1 = require("./connectors/telegram/bindSweep");
|
|
90
92
|
const topicCommand_1 = require("./connectors/telegram/commands/topicCommand");
|
|
93
|
+
const callbackMessage_1 = require("./connectors/telegram/commands/callbackMessage");
|
|
94
|
+
const callbackToast_1 = require("./connectors/telegram/commands/callbackToast");
|
|
95
|
+
const bindingWelcome_1 = require("./connectors/telegram/bindingWelcome");
|
|
96
|
+
const bindPickerClaims_1 = require("./connectors/telegram/bindPickerClaims");
|
|
97
|
+
const pendingTopicName_1 = require("./connectors/telegram/pendingTopicName");
|
|
91
98
|
const forumTopicCreated_1 = require("./connectors/telegram/forumTopicCreated");
|
|
92
99
|
const providerDisconnectPlan_1 = require("./utils/providerDisconnectPlan");
|
|
93
100
|
const folderName_1 = require("./folderName");
|
|
@@ -101,10 +108,13 @@ const outbound_3 = require("./connectors/jira/outbound");
|
|
|
101
108
|
const adapterEventRouting_1 = require("./adapters/adapterEventRouting");
|
|
102
109
|
const connectorSet_1 = require("./platform/connectorSet");
|
|
103
110
|
const telegramCallGuard_1 = require("./connectors/telegram/telegramCallGuard");
|
|
111
|
+
const botApiConnection_1 = require("./connectors/telegram/botApiConnection");
|
|
104
112
|
const unservedStateGuard_1 = require("./platform/unservedStateGuard");
|
|
105
113
|
const contextPreamble_1 = require("./connectors/jira/contextPreamble");
|
|
106
114
|
const postToSession_1 = require("./postToSession");
|
|
107
115
|
const limitHeldPrompts_1 = require("./utils/limitHeldPrompts");
|
|
116
|
+
const adoptReplayGate_1 = require("./utils/adoptReplayGate");
|
|
117
|
+
const topicRequestSettle_1 = require("./requests/topicRequestSettle");
|
|
108
118
|
const promptDelivery_1 = require("./utils/promptDelivery");
|
|
109
119
|
const apiRetryKick_1 = require("./apiRetryKick");
|
|
110
120
|
const limitHeldPromptQueue_1 = require("./utils/limitHeldPromptQueue");
|
|
@@ -164,6 +174,9 @@ const claudeLivenessAction_1 = require("./utils/claudeLivenessAction");
|
|
|
164
174
|
const isoTimestamp_1 = require("./utils/isoTimestamp");
|
|
165
175
|
const effortLevels_1 = require("./effortLevels");
|
|
166
176
|
const instanceSettings_1 = require("./instanceSettings");
|
|
177
|
+
const taskAgentLimit_1 = require("./trackers/taskAgentLimit");
|
|
178
|
+
const taskAgentSlots_1 = require("./trackers/taskAgentSlots");
|
|
179
|
+
const botSkills_1 = require("./utils/botSkills");
|
|
167
180
|
const constants_1 = require("./selfUpdate/constants");
|
|
168
181
|
const launchEnvironment_1 = require("./selfUpdate/launchEnvironment");
|
|
169
182
|
const restart_1 = require("./selfUpdate/restart");
|
|
@@ -183,11 +196,20 @@ const runLedger_1 = require("./scheduler/runLedger");
|
|
|
183
196
|
const delivery_1 = require("./scheduler/delivery");
|
|
184
197
|
const checkRun_1 = require("./scheduler/checkRun");
|
|
185
198
|
const engine_1 = require("./scheduler/engine");
|
|
199
|
+
const recurrence_1 = require("./scheduler/recurrence");
|
|
186
200
|
const mcpSurface_1 = require("./scheduler/mcpSurface");
|
|
187
201
|
const injection_1 = require("./scheduler/injection");
|
|
188
202
|
const mcpBoot_1 = require("./scheduler/mcpBoot");
|
|
189
203
|
const requestLedger_1 = require("./requests/requestLedger");
|
|
190
204
|
const answerRequest_1 = require("./requests/answerRequest");
|
|
205
|
+
const store_1 = require("./todos/store");
|
|
206
|
+
const registry_1 = require("./todos/registry");
|
|
207
|
+
const nag_1 = require("./todos/nag");
|
|
208
|
+
const reminderEngine_1 = require("./todos/reminderEngine");
|
|
209
|
+
const projects_1 = require("./todos/projects");
|
|
210
|
+
const board_1 = require("./todos/board");
|
|
211
|
+
const tools_1 = require("./todos/tools");
|
|
212
|
+
const midTurn_1 = require("./todos/midTurn");
|
|
191
213
|
const answerTail_1 = require("./requests/answerTail");
|
|
192
214
|
const answerSink_1 = require("./connectors/telegram/answerSink");
|
|
193
215
|
const answerSink_2 = require("./platform/answerSink");
|
|
@@ -204,6 +226,7 @@ const apiErrorRetry_1 = require("./apiErrorRetry");
|
|
|
204
226
|
const wedgeRecovery_1 = require("./utils/wedgeRecovery");
|
|
205
227
|
const claudeAuthLogin_1 = require("./utils/claudeAuthLogin");
|
|
206
228
|
const limitEpisodeRecovery_1 = require("./utils/limitEpisodeRecovery");
|
|
229
|
+
const transcriptErrorTime_1 = require("./utils/transcriptErrorTime");
|
|
207
230
|
const autoContinueOnLimit_1 = require("./utils/autoContinueOnLimit");
|
|
208
231
|
const openCodeAuthLogin_1 = require("./utils/openCodeAuthLogin");
|
|
209
232
|
const agentLogin_1 = require("./agentLogin/agentLogin");
|
|
@@ -211,9 +234,11 @@ const questionKeyboards_1 = require("./connectors/telegram/questionKeyboards");
|
|
|
211
234
|
const displayModes_1 = require("./connectors/telegram/commands/displayModes");
|
|
212
235
|
const reminders_1 = require("./connectors/telegram/commands/reminders");
|
|
213
236
|
const modelProviders_1 = require("./connectors/telegram/commands/modelProviders");
|
|
214
|
-
const
|
|
237
|
+
const callbackToast_2 = require("./connectors/telegram/commands/callbackToast");
|
|
215
238
|
const compaction_1 = require("./connectors/telegram/commands/compaction");
|
|
216
239
|
const autoContinueLimits_1 = require("./connectors/telegram/commands/autoContinueLimits");
|
|
240
|
+
const botSkills_2 = require("./connectors/telegram/commands/botSkills");
|
|
241
|
+
const taskAgents_1 = require("./connectors/telegram/commands/taskAgents");
|
|
217
242
|
const claudeAccounts_1 = require("./connectors/telegram/commands/claudeAccounts");
|
|
218
243
|
const claudeProfiles_1 = require("./utils/claudeProfiles");
|
|
219
244
|
const claudeUsage_1 = require("./utils/claudeUsage");
|
|
@@ -229,6 +254,7 @@ const fallbackChain_1 = require("./utils/fallbackChain");
|
|
|
229
254
|
const fallbackBridge_1 = require("./utils/fallbackBridge");
|
|
230
255
|
const fallbackChain_2 = require("./connectors/telegram/commands/fallbackChain");
|
|
231
256
|
const limitsBoard_1 = require("./connectors/telegram/commands/limitsBoard");
|
|
257
|
+
const todo_1 = require("./connectors/telegram/commands/todo");
|
|
232
258
|
const backendSwitch_1 = require("./connectors/telegram/commands/backendSwitch");
|
|
233
259
|
const operatorSwitchPlan_1 = require("./utils/operatorSwitchPlan");
|
|
234
260
|
const backendSwitchPlan_1 = require("./utils/backendSwitchPlan");
|
|
@@ -237,6 +263,7 @@ const stt_1 = require("./connectors/telegram/commands/stt");
|
|
|
237
263
|
const claudeApiSpend_1 = require("./utils/claudeApiSpend");
|
|
238
264
|
const claudeProfileUsageLines_1 = require("./utils/claudeProfileUsageLines");
|
|
239
265
|
const resolveBinary_1 = require("./utils/resolveBinary");
|
|
266
|
+
const runnersConfig_1 = require("./remoteRun/runnersConfig");
|
|
240
267
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
241
268
|
// ENV parsing & fatal validation
|
|
242
269
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -354,6 +381,8 @@ if (ENV.testTimings.telegramSendIntervalMs !== undefined)
|
|
|
354
381
|
(0, rateLimiter_1.configureGlobalSendInterval)(ENV.testTimings.telegramSendIntervalMs);
|
|
355
382
|
if (ENV.testTimings.backgroundHoldMaxMs !== undefined)
|
|
356
383
|
(0, createAdapter_1.registerPendingWorkHoldMs)(ENV.testTimings.backgroundHoldMaxMs);
|
|
384
|
+
if (ENV.testTimings.scheduleMinFireIntervalMs !== undefined)
|
|
385
|
+
(0, recurrence_1.registerMinFireIntervalMs)(ENV.testTimings.scheduleMinFireIntervalMs);
|
|
357
386
|
/** The short retry that probes an unconfirmed usage limit (a test instance may shorten it). */
|
|
358
387
|
const limitProbeDelayMs = ENV.testTimings.limitProbeDelayMs ?? apiErrorRetry_1.unconfirmedLimitProbeDelayMs;
|
|
359
388
|
// The instance default effort (`/effort` in General) is `settings.json`'s `defaultEffort`, re-read when the file changes.
|
|
@@ -446,24 +475,22 @@ function getOutputTransport() {
|
|
|
446
475
|
}
|
|
447
476
|
return outputTransport;
|
|
448
477
|
}
|
|
449
|
-
const telegramAgent =
|
|
450
|
-
keepAlive: true,
|
|
451
|
-
keepAliveMsecs: 10000,
|
|
452
|
-
family: 4,
|
|
453
|
-
});
|
|
478
|
+
const telegramAgent = (0, botApiConnection_1.createBotApiHttpsAgent)();
|
|
454
479
|
// Without the telegram connector there is no token (an empty one), and the call
|
|
455
480
|
// guard below refuses every Bot API call before it is sent (Jira plan J3, D9).
|
|
456
481
|
// `TELEGRAM_API_ROOT` points the client at another Bot API host — the
|
|
457
482
|
// process-level test's fake on loopback; unset is the real `api.telegram.org`.
|
|
458
|
-
// Telegraf drops the https agent itself for an `http://` root.
|
|
459
483
|
const telegramApiRoot = process.env.TELEGRAM_API_ROOT?.trim();
|
|
484
|
+
// Telegraf drops the https agent itself for an `http://` root and node-fetch then rides the global agent, whose
|
|
485
|
+
// pooling is not the bot's. That root gets the same pooling over `http`.
|
|
486
|
+
const telegramApiAgent = telegramApiRoot?.startsWith('http://') ? (0, botApiConnection_1.createBotApiHttpAgent)() : telegramAgent;
|
|
460
487
|
const bot = new telegraf_1.Telegraf(ENV.botToken, {
|
|
461
|
-
telegram: { agent:
|
|
488
|
+
telegram: { agent: telegramApiAgent, ...(telegramApiRoot ? { apiRoot: telegramApiRoot } : {}) },
|
|
462
489
|
});
|
|
463
490
|
// Decouple the long-polling intake/ACK from per-handler latency: wrap
|
|
464
491
|
// `bot.handleUpdate` so each update is enqueued into its thread's serial queue
|
|
465
492
|
// and the loop's `Promise.all` resolves at once (next `getUpdates` = ACK + new
|
|
466
|
-
// batch fires now). The real middleware chain —
|
|
493
|
+
// batch fires now). The real middleware chain — every `bot.use` middleware,
|
|
467
494
|
// every command/`on`/action, `bot.catch` — runs unchanged inside the captured
|
|
468
495
|
// original, just off-loop and per-thread-serial. MUST be installed before
|
|
469
496
|
// `bot.launch`. The graceful-shutdown path drains it (see `updateDrainTimeoutMs`).
|
|
@@ -481,6 +508,10 @@ const updateDrainTimeoutMs = 2000;
|
|
|
481
508
|
// window is not lost on shutdown.
|
|
482
509
|
(0, outputTrace_1.installCallApiTrace)(bot.telegram);
|
|
483
510
|
process.on('exit', outputTrace_1.flushTraceBufferSyncOnExit);
|
|
511
|
+
// The bot's pooling (the agent Telegraf dropped for an `http://` root) and the retry of a request that met a pooled
|
|
512
|
+
// connection the server had closed. AFTER the trace wrap, so the trace records both real HTTP attempts, and below the
|
|
513
|
+
// send queue, pacer and 429 retry. Telegraf builds a NEW client per update: see the first `bot.use` below.
|
|
514
|
+
(0, botApiConnection_1.wireBotApiClient)(bot.telegram, telegramApiAgent);
|
|
484
515
|
// Default every text message/edit to NO link preview: a bare URL in agent
|
|
485
516
|
// output otherwise expanded into a large preview card, one per message, in the
|
|
486
517
|
// muted topic (operator complaint 2026-09-07). Installed OUTSIDE the trace wrap
|
|
@@ -601,7 +632,14 @@ async function checkIsForumAdmin(chatId, userId) {
|
|
|
601
632
|
return false;
|
|
602
633
|
}
|
|
603
634
|
}
|
|
604
|
-
//
|
|
635
|
+
// Telegraf builds a NEW `Telegram` client for every update (`ctx.telegram`, behind every `ctx.reply`, `ctx.answerCbQuery`
|
|
636
|
+
// and `ctx.editMessageText`) from `bot.telegram.options`, so the wiring of `bot.telegram` does not reach it and an
|
|
637
|
+
// `http://` root drops the agent again. Wire it before any handler can use it.
|
|
638
|
+
bot.use((ctx, next) => {
|
|
639
|
+
(0, botApiConnection_1.wireBotApiClient)(ctx.telegram, telegramApiAgent);
|
|
640
|
+
return next();
|
|
641
|
+
});
|
|
642
|
+
// Output-trace `recv` hook — must come before every middleware that can gate or drop an update, so every update is
|
|
605
643
|
// recorded before any gating (access control, group routing) can drop it.
|
|
606
644
|
// Security: while the thread awaits a `/connect` key paste (same
|
|
607
645
|
// `pendingProviderConnects` state the text handler consumes later in this
|
|
@@ -788,7 +826,7 @@ let schedulerEngine = null;
|
|
|
788
826
|
* The request wake-up engine (request/answer core S4), assigned once by
|
|
789
827
|
* `startBot` after the request ledger loads. Module-level like
|
|
790
828
|
* {@link schedulerEngine} because the cancel paths (`/esc`, `/c`, `/quit`,
|
|
791
|
-
* `/
|
|
829
|
+
* `/quitall`, `/new`, `/resume`, leaving the folder, switching the topic to
|
|
792
830
|
* another agent or a shell) are module-level command handlers.
|
|
793
831
|
*/
|
|
794
832
|
let requestWakeUpEngine = null;
|
|
@@ -820,15 +858,19 @@ async function openTopicRequest(key, text, intake) {
|
|
|
820
858
|
return { header: (0, topicRequest_1.buildTopicRequestHeader)(request.id, intake.source, view, request.supersededRequestIds), requestId: request.id };
|
|
821
859
|
}
|
|
822
860
|
/**
|
|
823
|
-
* @description
|
|
824
|
-
*
|
|
825
|
-
*
|
|
861
|
+
* @description Where a topic request goes once its prompt came back from the prompt path (`requests/topicRequestSettle.ts`): the
|
|
862
|
+
* wake-up engine watches the turn of a prompt that reached a session (or waits behind its start), and a prompt that found no
|
|
863
|
+
* session closes the request it opened. Both ports are read at call time: the engine and the ledger are assigned at boot.
|
|
826
864
|
*/
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
865
|
+
const { trackTurn: trackTopicRequestTurn, settleAfterDelivery: settleTopicRequestAfterDelivery } = (0, topicRequestSettle_1.createTopicRequestSettler)({
|
|
866
|
+
trackTurn: async (key, requestId) => {
|
|
867
|
+
await requestWakeUpEngine?.trackForwardedTurn(key, requestId, { isRequestPrompt: true });
|
|
868
|
+
},
|
|
869
|
+
cancelRequest: async (requestId) => {
|
|
870
|
+
await topicRequestLedger?.closeRequest(requestId, 'cancelled');
|
|
871
|
+
},
|
|
872
|
+
warn: (message) => console.warn(message),
|
|
873
|
+
});
|
|
832
874
|
/** What the bot's own limit-wait answer needs (S5); set at boot next to the engine. */
|
|
833
875
|
let requestLimitWaitAnswerDeps = null;
|
|
834
876
|
/**
|
|
@@ -850,6 +892,212 @@ function checkIsAgentEventShownInTopic(key, event) {
|
|
|
850
892
|
function getDataDir() {
|
|
851
893
|
return path.dirname(state.stateFilePath);
|
|
852
894
|
}
|
|
895
|
+
/** The shared todo registry (`todos/`), built on first use: its store lives in `DATA_DIR`, known once the state is. */
|
|
896
|
+
let todoRegistry = null;
|
|
897
|
+
function getTodoRegistry() {
|
|
898
|
+
todoRegistry ??= (0, registry_1.createTodoRegistry)({ store: new store_1.TodoStore(getDataDir()), getNowMs: Date.now });
|
|
899
|
+
return todoRegistry;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* The «Bot skills» switch (`/bot_skills`, `utils/botSkills.ts`): the topic's own choice, else General's value, else `settings.json`'s
|
|
903
|
+
* `botSkills` (re-read when the file changes), else ON. `state` is read when asked, so the reader is registered once it is loaded.
|
|
904
|
+
*/
|
|
905
|
+
const botSkillsSources = {
|
|
906
|
+
getGeneralValue: () => state.getBotSkillsGlobalValue(),
|
|
907
|
+
getTopicOverride: (key) => state.getBotSkillsOverride(key),
|
|
908
|
+
getSettingsValue: (0, instanceSettings_1.createBotSkillsSettingsReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line)),
|
|
909
|
+
};
|
|
910
|
+
const botSkillsReader = (0, botSkills_1.createBotSkillsReader)(botSkillsSources);
|
|
911
|
+
/**
|
|
912
|
+
* The cap on tracker task agents (`trackers/taskAgentLimit.ts`): General's value, else `settings.json`'s
|
|
913
|
+
* `taskAgents.maxConcurrent` (re-read when the file changes), else the built-in default. `state` is read when asked.
|
|
914
|
+
*/
|
|
915
|
+
const taskAgentLimitSources = {
|
|
916
|
+
getGeneralValue: () => state.getTaskAgentsMaxConcurrentGlobalValue(),
|
|
917
|
+
getSettingsValue: (0, instanceSettings_1.createTaskAgentsMaxConcurrentReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line)),
|
|
918
|
+
};
|
|
919
|
+
/** The slots of the tracker task agents and their wait list; built at boot, once the request ledger is loaded. */
|
|
920
|
+
let taskAgentSlots = null;
|
|
921
|
+
/** `settings.json`'s `todo` (on / off, the reminder's interval), re-read when the file changes. */
|
|
922
|
+
const readTodoSettings = (0, instanceSettings_1.createTodoSettingsReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line));
|
|
923
|
+
/** The staleness reminder's interval now; `null` = off (the feature or its reminder). */
|
|
924
|
+
function getTodoReminderIntervalMsNow() {
|
|
925
|
+
return (0, nag_1.getTodoReminderIntervalMs)(readTodoSettings(), ENV.testTimings.todoReminderIntervalMs);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* The reminder line a prompt forwarded now carries (D11 form a), or `null`; taking it counts as delivered. The lists
|
|
929
|
+
* are a courtesy to the agent: a failure here is logged and the prompt goes on without the line.
|
|
930
|
+
*/
|
|
931
|
+
function takeTodoPromptReminder(key) {
|
|
932
|
+
try {
|
|
933
|
+
return getTodoRegistry().takePromptReminder(key, getTodoReminderIntervalMsNow(), Date.now());
|
|
934
|
+
}
|
|
935
|
+
catch (e) {
|
|
936
|
+
console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the reminder line could not be taken:`, e instanceof Error ? e.message : e);
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/** The staleness reminder's poll (`todos/reminderEngine.ts`), built at boot and started once the sessions are restored. */
|
|
941
|
+
let todoReminderEngine = null;
|
|
942
|
+
let todoRepositories = null;
|
|
943
|
+
/** Which folders are one git repository (`todos/projects.ts`), cached; the root commits persist in `DATA_DIR/todos`. */
|
|
944
|
+
function getTodoRepositories() {
|
|
945
|
+
todoRepositories ??= (0, projects_1.createRepositoryIdentityReader)({
|
|
946
|
+
runGit: projects_1.runGitInFolder,
|
|
947
|
+
rootCommitStore: (0, projects_1.createRepositoryRootCommitFile)((0, store_1.getTodosDir)(getDataDir())),
|
|
948
|
+
});
|
|
949
|
+
return todoRepositories;
|
|
950
|
+
}
|
|
951
|
+
/** What the boards need of the Jira connector (D55, D57); set at boot when it is loaded. */
|
|
952
|
+
let todoJiraConversations = null;
|
|
953
|
+
/**
|
|
954
|
+
* A bound conversation as the project grouping sees it before git is asked: complete (a Jira issue, a folder that does
|
|
955
|
+
* not resolve), or still needing its folder's repository.
|
|
956
|
+
*/
|
|
957
|
+
function describeTodoProjectMember({ key, data }) {
|
|
958
|
+
const conversationKey = (0, sessionKey_1.keyToString)(key);
|
|
959
|
+
if (key.platform === 'jira') {
|
|
960
|
+
return { member: { conversationKey, workDir: null, identity: null, jiraRepositoryNames: todoJiraConversations?.getConversationRepositoryNames(key) ?? null } };
|
|
961
|
+
}
|
|
962
|
+
const decision = (0, validation_1.resolveBoundWorkDir)(ENV.workRoot, data);
|
|
963
|
+
return decision.kind === 'proceed' ? { conversationKey, workDir: decision.workDir } : { member: { conversationKey, workDir: null, identity: null } };
|
|
964
|
+
}
|
|
965
|
+
/** The instance's project boards (D15, D16, D57), waiting for git where a folder's read is due. */
|
|
966
|
+
async function listTodoProjects() {
|
|
967
|
+
const repositories = getTodoRepositories();
|
|
968
|
+
const members = await Promise.all(state.listBindings().map(async (binding) => {
|
|
969
|
+
const described = describeTodoProjectMember(binding);
|
|
970
|
+
if ('member' in described)
|
|
971
|
+
return described.member;
|
|
972
|
+
const identity = await repositories.read(described.workDir);
|
|
973
|
+
return identity === undefined ? undefined : { ...described, identity };
|
|
974
|
+
}));
|
|
975
|
+
return (0, projects_1.groupTodoProjects)(members.filter((member) => member !== undefined), state.getTodoProjectOverrides());
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* The project boards as known NOW, never waiting for git and never yielding (a prompt, the mid-turn poll): `null` while a
|
|
979
|
+
* bound folder was never read — its read starts now, with every other one due.
|
|
980
|
+
*/
|
|
981
|
+
function peekTodoProjects() {
|
|
982
|
+
const repositories = getTodoRepositories();
|
|
983
|
+
const members = state.listBindings().map((binding) => {
|
|
984
|
+
const described = describeTodoProjectMember(binding);
|
|
985
|
+
if ('member' in described)
|
|
986
|
+
return described.member;
|
|
987
|
+
const identity = repositories.peek(described.workDir);
|
|
988
|
+
return identity === undefined ? undefined : { ...described, identity };
|
|
989
|
+
});
|
|
990
|
+
return members.every((member) => member !== undefined) ? (0, projects_1.groupTodoProjects)(members, state.getTodoProjectOverrides()) : null;
|
|
991
|
+
}
|
|
992
|
+
/** The bound folders' due repository reads start in the background (boot, a bind), so the next prompts find the grouping known. */
|
|
993
|
+
function warmTodoProjects() {
|
|
994
|
+
if (!readTodoSettings().isEnabled)
|
|
995
|
+
return;
|
|
996
|
+
try {
|
|
997
|
+
peekTodoProjects();
|
|
998
|
+
}
|
|
999
|
+
catch (e) {
|
|
1000
|
+
console.error('[todos] the bound folders\' repositories could not be read:', e instanceof Error ? e.message : e);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/** The project of `key`'s conversation, waiting for git; `null` = in none. */
|
|
1004
|
+
async function getTodoProjectOf(key) {
|
|
1005
|
+
const conversationKey = (0, sessionKey_1.keyToString)(key);
|
|
1006
|
+
return (await listTodoProjects()).find((project) => project.memberKeys.includes(conversationKey)) ?? null;
|
|
1007
|
+
}
|
|
1008
|
+
/** The project of `key`'s conversation as known now; `null` = in none, `undefined` = not known without waiting. */
|
|
1009
|
+
function peekTodoProjectOf(key) {
|
|
1010
|
+
const conversationKey = (0, sessionKey_1.keyToString)(key);
|
|
1011
|
+
const projects = peekTodoProjects();
|
|
1012
|
+
return projects === null ? undefined : (projects.find((project) => project.memberKeys.includes(conversationKey)) ?? null);
|
|
1013
|
+
}
|
|
1014
|
+
/** How a conversation is named on a board and in the picker: `#topic (folder)`, or the issue `PROJ-123 «summary»` (D55). */
|
|
1015
|
+
function getTodoConversationLabel(conversationKey) {
|
|
1016
|
+
const key = (0, sessionKey_1.tryKeyFromString)(conversationKey);
|
|
1017
|
+
if (key === null)
|
|
1018
|
+
return conversationKey;
|
|
1019
|
+
if (key.platform === 'jira') {
|
|
1020
|
+
const title = todoJiraConversations?.getConversationTitle(key) ?? null;
|
|
1021
|
+
return title === null ? key.thread : `${key.thread} «${title}»`;
|
|
1022
|
+
}
|
|
1023
|
+
const binding = state.getBinding(key);
|
|
1024
|
+
return `#${binding?.topicName ?? `topic ${key.thread}`}${binding ? ` (${binding.subdir})` : ''}`;
|
|
1025
|
+
}
|
|
1026
|
+
function getTodoSessionActivity(conversationKey) {
|
|
1027
|
+
const key = (0, sessionKey_1.tryKeyFromString)(conversationKey);
|
|
1028
|
+
if (key === null)
|
|
1029
|
+
return 'asleep';
|
|
1030
|
+
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
1031
|
+
if (!adapter.checkIsActive(key))
|
|
1032
|
+
return 'asleep';
|
|
1033
|
+
return adapter.checkIsBusy?.(key) ? 'working' : 'idle';
|
|
1034
|
+
}
|
|
1035
|
+
let todoBoard = null;
|
|
1036
|
+
/** The project boards (`todos/board.ts`), built on first use like the registry. */
|
|
1037
|
+
function getTodoBoard() {
|
|
1038
|
+
todoBoard ??= (0, board_1.createTodoBoard)({
|
|
1039
|
+
store: getTodoRegistry().getStore(),
|
|
1040
|
+
getProjectOf: getTodoProjectOf,
|
|
1041
|
+
peekProjectOf: peekTodoProjectOf,
|
|
1042
|
+
getPeerLabel: getTodoConversationLabel,
|
|
1043
|
+
getSessionActivity: getTodoSessionActivity,
|
|
1044
|
+
getNowMs: Date.now,
|
|
1045
|
+
});
|
|
1046
|
+
return todoBoard;
|
|
1047
|
+
}
|
|
1048
|
+
/** The agent lost its context: its next prompt carries the whole board (D20). Logged, never thrown, on a lifecycle path. */
|
|
1049
|
+
function noteTodoFreshContext(key) {
|
|
1050
|
+
try {
|
|
1051
|
+
getTodoBoard().noteFreshContext(key);
|
|
1052
|
+
}
|
|
1053
|
+
catch (e) {
|
|
1054
|
+
console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the board's seen state could not be reset:`, e instanceof Error ? e.message : e);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
let todoMidTurn = null;
|
|
1058
|
+
/** Mid-turn delivery of the board (`todos/midTurn.ts`): the Claude inbox hook, the note into a running OpenCode turn. */
|
|
1059
|
+
function getTodoMidTurn() {
|
|
1060
|
+
todoMidTurn ??= (0, midTurn_1.createTodoMidTurn)({
|
|
1061
|
+
board: getTodoBoard(),
|
|
1062
|
+
inboxDir: (0, midTurn_1.getTodoInboxDir)(getDataDir()),
|
|
1063
|
+
getClaudeSessionId: (key) => {
|
|
1064
|
+
const adapterName = (0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name;
|
|
1065
|
+
return adapterName !== undefined && (0, createAdapter_1.checkIsClaudeBackend)(adapterName) ? getResumableSessionId(key) : null;
|
|
1066
|
+
},
|
|
1067
|
+
// Into the running turn only (D21): never a new turn, never through the prompt path that resets the turn.
|
|
1068
|
+
postRunningTurnNote: async (key, text) => (0, createAdapter_1.getThreadAdapter)(key).queueNoteIntoRunningTurn?.(key, text) ?? false,
|
|
1069
|
+
});
|
|
1070
|
+
return todoMidTurn;
|
|
1071
|
+
}
|
|
1072
|
+
let todoTools = null;
|
|
1073
|
+
function getTodoTools() {
|
|
1074
|
+
todoTools ??= (0, tools_1.createTodoTools)({ registry: getTodoRegistry(), board: getTodoBoard(), onBoardShown: discardTodoInbox });
|
|
1075
|
+
return todoTools;
|
|
1076
|
+
}
|
|
1077
|
+
/** A turn start the bot makes carries the board itself: a waiting mid-turn inbox is withdrawn (or counted, if taken). */
|
|
1078
|
+
function discardTodoInbox(key) {
|
|
1079
|
+
try {
|
|
1080
|
+
getTodoMidTurn().discardInbox(key);
|
|
1081
|
+
}
|
|
1082
|
+
catch (e) {
|
|
1083
|
+
console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the mid-turn inbox could not be withdrawn:`, e instanceof Error ? e.message : e);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* The board block a prompt forwarded now carries (D20), or `null`; taking it counts as seen. A failure never stops the prompt.
|
|
1088
|
+
* Synchronous: it sits between the forward's decision and the write (see `TodoBoard.takePromptBoard`).
|
|
1089
|
+
*/
|
|
1090
|
+
function takeTodoPromptBoard(key) {
|
|
1091
|
+
if (!readTodoSettings().isEnabled)
|
|
1092
|
+
return null;
|
|
1093
|
+
try {
|
|
1094
|
+
return getTodoBoard().takePromptBoard(key);
|
|
1095
|
+
}
|
|
1096
|
+
catch (e) {
|
|
1097
|
+
console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the project board could not be built:`, e instanceof Error ? e.message : e);
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
853
1101
|
const threadMessageStates = new Map();
|
|
854
1102
|
const outputQueues = new Map();
|
|
855
1103
|
const pendingQuestions = new Map();
|
|
@@ -1043,10 +1291,12 @@ function onThreadActivityWhileQuestionPending(key) {
|
|
|
1043
1291
|
}
|
|
1044
1292
|
/**
|
|
1045
1293
|
* @description Re-post the current pending question as the newest thread message
|
|
1046
|
-
* (S3):
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1294
|
+
* (S3): send the current question again via {@link postPendingQuestionAt} (which
|
|
1295
|
+
* re-renders with S1 descriptions / S2 current index and re-owns `messageId`), then
|
|
1296
|
+
* end the old message. The ORIGINAL message of the question is its record: it is closed as «asked again
|
|
1297
|
+
* below» (text stays, buttons go) and never deleted. A copy that an earlier re-post made is only a duplicate of that record
|
|
1298
|
+
* and is deleted, so a question that waits through a long stream of output leaves two messages, not a pile. No-op if the
|
|
1299
|
+
* question was answered meanwhile, or if it is already the last message (the loop guard re-checked at
|
|
1050
1300
|
* fire time, since the debounce window may have closed the gap).
|
|
1051
1301
|
*/
|
|
1052
1302
|
async function repostPendingQuestionToBottom(key) {
|
|
@@ -1067,12 +1317,77 @@ async function repostPendingQuestionToBottom(key) {
|
|
|
1067
1317
|
if (s.isPostInFlight)
|
|
1068
1318
|
return;
|
|
1069
1319
|
const oldMessageId = pending.messageId;
|
|
1070
|
-
|
|
1320
|
+
const oldQuestion = pending.data.questions[pending.currentIndex];
|
|
1321
|
+
// The first message of the question is its record. A state that predates the mark has none: the message found is the
|
|
1322
|
+
// original then, which is never deleted.
|
|
1323
|
+
if (oldMessageId !== null)
|
|
1324
|
+
pending.originalMessageId ??= oldMessageId;
|
|
1325
|
+
await postPendingQuestionAt(key);
|
|
1326
|
+
// The old message is touched only once there IS a copy below. A post that failed leaves the question where it was (its
|
|
1327
|
+
// message id unchanged) and still answerable there.
|
|
1328
|
+
const newMessageId = pendingQuestions.get(kStr)?.messageId ?? null;
|
|
1329
|
+
if (oldMessageId === null || !oldQuestion || newMessageId === null || newMessageId === oldMessageId)
|
|
1330
|
+
return;
|
|
1331
|
+
if (oldMessageId === pending.originalMessageId) {
|
|
1332
|
+
// The original: it stays as the record of the question, saying where the live copy is.
|
|
1333
|
+
await closeQuestionMessage(key, oldMessageId, oldQuestion, 'askedAgain');
|
|
1334
|
+
}
|
|
1335
|
+
else {
|
|
1336
|
+
// An earlier copy: the record already tells the question, so this one only duplicates it.
|
|
1071
1337
|
await deleteThreadMessage(key, oldMessageId);
|
|
1072
1338
|
}
|
|
1073
|
-
await postPendingQuestionAt(key);
|
|
1074
1339
|
});
|
|
1075
1340
|
}
|
|
1341
|
+
/**
|
|
1342
|
+
* @description The note a question's message ends with, by how it ended (see {@link QuestionClosing}). An answer's
|
|
1343
|
+
* note is built where the chosen label is known (`agent.question_answered_note`).
|
|
1344
|
+
*/
|
|
1345
|
+
const questionClosingNoteKeys = {
|
|
1346
|
+
unanswered: 'agent.question_unanswered_note',
|
|
1347
|
+
askedAgain: 'agent.question_asked_again_note',
|
|
1348
|
+
};
|
|
1349
|
+
/**
|
|
1350
|
+
* @description End a question's message in the topic WITHOUT deleting it: the buttons are replaced by `note`, and the
|
|
1351
|
+
* header, the question and its options stay, so what the agent asked and how it ended can still be read. Resolves
|
|
1352
|
+
* whether the text edit landed. Every way a question ends goes through this — a delete would take the record away.
|
|
1353
|
+
*
|
|
1354
|
+
* Telegram that cannot parse the Markdown render gets the plain one (no escape marks shown), and a question too long for
|
|
1355
|
+
* the note gets a shortened plain one. An edit that still fails (the message gone, say) leaves the old text, and the
|
|
1356
|
+
* buttons would stay on it dead — so they are taken off by themselves.
|
|
1357
|
+
*/
|
|
1358
|
+
async function editQuestionMessageToClosed(key, messageId, question, note) {
|
|
1359
|
+
const markdownText = (0, openCodeQuestionFlow_1.buildClosedQuestionText)(question, note, escapeMarkdown);
|
|
1360
|
+
const plainText = (0, openCodeQuestionFlow_1.buildClosedQuestionPlainText)(question, note, messageSplit_1.MAX_MESSAGE_LEN);
|
|
1361
|
+
// A question that filled a message when it was asked has no room for the note under it, and Telegram refuses the text:
|
|
1362
|
+
// the note is the result, so the body gives way (descriptions first) and goes out as plain text, which cutting cannot break.
|
|
1363
|
+
const isEdited = markdownText.length <= messageSplit_1.MAX_MESSAGE_LEN
|
|
1364
|
+
? await editThreadMessage(key, messageId, markdownText, { parse_mode: 'Markdown' }, plainText)
|
|
1365
|
+
: await editThreadMessage(key, messageId, plainText);
|
|
1366
|
+
if (!isEdited)
|
|
1367
|
+
await editThreadKeyboard(key, messageId, null, 'questionClose');
|
|
1368
|
+
return isEdited;
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* {@link editQuestionMessageToClosed} for a question that ended some way ({@link QuestionEnd}). The note speaks the topic's
|
|
1372
|
+
* language whatever called it — a boot or a session teardown runs in no request, so no locale is set by one.
|
|
1373
|
+
*/
|
|
1374
|
+
async function closeQuestionMessage(key, messageId, question, ending) {
|
|
1375
|
+
const note = withThreadLocale(key, () => (typeof ending === 'string' ? (0, i18n_1.t)(questionClosingNoteKeys[ending]) : (0, i18n_1.t)('agent.question_answered_note', { answer: ending.answer })));
|
|
1376
|
+
return editQuestionMessageToClosed(key, messageId, question, note);
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* @description End a question message the bot holds no record of — a copy a restart left while its re-ask was being sent, an
|
|
1380
|
+
* older copy whose close Telegram refused — when somebody taps its buttons: its own text stays (`messageText`, as the tap
|
|
1381
|
+
* delivered it) and «not answered» takes the place of the buttons, so the question does not end with no result at all. Without
|
|
1382
|
+
* the text, or when the note does not fit under it, only the buttons come off. Resolves whether the message was changed at all
|
|
1383
|
+
* (the text edit or the removal of the buttons landed): when not, the buttons are still there and the next tap tries again.
|
|
1384
|
+
*/
|
|
1385
|
+
async function closeOrphanQuestionMessage(key, messageId, messageText) {
|
|
1386
|
+
const note = withThreadLocale(key, () => (0, i18n_1.t)(questionClosingNoteKeys.unanswered));
|
|
1387
|
+
const closedText = messageText === null ? null : `${messageText}\n\n${note}`;
|
|
1388
|
+
const isEdited = closedText !== null && closedText.length <= messageSplit_1.MAX_MESSAGE_LEN && await editThreadMessage(key, messageId, closedText);
|
|
1389
|
+
return isEdited || editThreadKeyboard(key, messageId, null, 'reask');
|
|
1390
|
+
}
|
|
1076
1391
|
// ── auto-retry after a provider-side API error (plan S4/S5/S6) ──
|
|
1077
1392
|
/** One minute in ms — used only to render the "in N min" notices. */
|
|
1078
1393
|
const apiRetryMsPerMinute = 60_000;
|
|
@@ -1172,6 +1487,13 @@ const wedgeRecoveriesInFlight = new Set();
|
|
|
1172
1487
|
*/
|
|
1173
1488
|
function handleApiError(key, cls, options = {}) {
|
|
1174
1489
|
const k = (0, sessionKey_1.keyToString)(key);
|
|
1490
|
+
// The copy of a limit error that the adopt's replay reads: it ended a turn BEFORE this boot, and the boot recovery has armed
|
|
1491
|
+
// that very episode (with the error's own time, which the replay cannot know). Handling it again announced a limit for a
|
|
1492
|
+
// window that was over, and resumed the topic a second time.
|
|
1493
|
+
if (cls.isReplayed === true && cls.kind === 'usageLimit' && limitEpisodesOwnedByRecovery.has(k)) {
|
|
1494
|
+
console.log(`[limitRecovery] ${k}: the replayed limit error is the episode the recovery re-armed — not handled twice`);
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1175
1497
|
// A used-up API credit balance: no wait refills it — the account is marked, and the chain or the operator takes over.
|
|
1176
1498
|
if (cls.kind === 'usageLimit' && cls.isBalanceExhausted === true) {
|
|
1177
1499
|
void handleBalanceExhausted(key, cls);
|
|
@@ -1927,8 +2249,45 @@ const awaitingSessionSelection = new Set();
|
|
|
1927
2249
|
* on an invalid name so the user can retry.
|
|
1928
2250
|
*/
|
|
1929
2251
|
const awaitingFolderName = new Set();
|
|
2252
|
+
/**
|
|
2253
|
+
* The `/bind` picker message a «create new folder» tap came from, per thread: once the typed name is bound, that picker is
|
|
2254
|
+
* deleted. Written only together with the arming of {@link awaitingFolderName}, and read only by the bind that arming
|
|
2255
|
+
* leads to — so a leftover entry of an abandoned attempt is overwritten by the next tap.
|
|
2256
|
+
*/
|
|
2257
|
+
const bindPickerMessageIds = new Map();
|
|
2258
|
+
/** The `/bind` picker messages a bind or a leave holds (in flight or done), so a second tap on the same keyboard acts on nothing again. */
|
|
2259
|
+
const bindPickerClaims = new bindPickerClaims_1.BindPickerClaims(ENV.testTimings.bindPickerClaimLimit);
|
|
2260
|
+
/**
|
|
2261
|
+
* The messages open in each topic that offer a bind or ask for a folder's name (every `/bind` picker, the «not bound» replies
|
|
2262
|
+
* that carry one, the «create new folder» prompt): any bind or leave of the topic deletes them all — see
|
|
2263
|
+
* {@link closeOpenBindMessages}. In memory: a picker sent before a restart is not known, and only its own tap removes it.
|
|
2264
|
+
*/
|
|
2265
|
+
const openBindMessages = new openBindMessages_1.OpenBindMessages();
|
|
2266
|
+
/**
|
|
2267
|
+
* The welcome each topic last got (per topic, newest last): its ▶️ Claude / ▶️ OpenCode / 📋 Resume buttons act on the topic's
|
|
2268
|
+
* CURRENT folder whatever the message says, so the next bind or a leave strips the buttons of the ones before. In memory, like
|
|
2269
|
+
* {@link openBindMessages}: a welcome sent before a restart keeps its buttons. The same per-topic, bounded, timestamped list of
|
|
2270
|
+
* message ids as {@link openBindMessages}, reused: these messages are never deleted, only stripped.
|
|
2271
|
+
*/
|
|
2272
|
+
const openWelcomeMessages = new openBindMessages_1.OpenBindMessages();
|
|
1930
2273
|
/** Max sessions shown in a `/sessions` list (Telegram-friendly, plan S3). */
|
|
1931
2274
|
const sessionsDisplayLimit = 10;
|
|
2275
|
+
/**
|
|
2276
|
+
* @description Forum-topic names that arrived via `forum_topic_created`
|
|
2277
|
+
* but couldn't be processed because the creator wasn't an authorised user
|
|
2278
|
+
* (a group admin/creator) (audit S2 / #5). We can't read a thread's title
|
|
2279
|
+
* later via Telegram Bot API in any portable way, so keeping the name here
|
|
2280
|
+
* lets the first message from an authorised user trigger fuzzy auto-bind.
|
|
2281
|
+
*
|
|
2282
|
+
* Also holds the name of a topic an authorised user made (or renamed) that matched
|
|
2283
|
+
* no folder, so the binding made through the picker later can keep it
|
|
2284
|
+
* (`PendingTopicName.isFromAdmin`: only such a name may reach a binding).
|
|
2285
|
+
*
|
|
2286
|
+
* In-memory by design: this is a UX nicety, not a security boundary —
|
|
2287
|
+
* losing the cache on restart just means the user has to bind manually
|
|
2288
|
+
* once. The TTL guards against unbounded growth in a busy group where
|
|
2289
|
+
* non-authorised members keep creating topics.
|
|
2290
|
+
*/
|
|
1932
2291
|
const pendingTopicNames = new Map();
|
|
1933
2292
|
const PENDING_TOPIC_NAME_TTL_MS = 24 * 60 * 60 * 1000;
|
|
1934
2293
|
/**
|
|
@@ -1958,6 +2317,10 @@ const threadContextMarkers = new Map();
|
|
|
1958
2317
|
*/
|
|
1959
2318
|
function clearThreadContextMarker(key) {
|
|
1960
2319
|
threadContextMarkers.delete((0, sessionKey_1.keyToString)(key));
|
|
2320
|
+
// The same boundary for the project board: the next turn start carries it whole (D20), and an inbox no tool call of
|
|
2321
|
+
// this session will take is withdrawn.
|
|
2322
|
+
noteTodoFreshContext(key);
|
|
2323
|
+
discardTodoInbox(key);
|
|
1961
2324
|
}
|
|
1962
2325
|
function getThreadMessageState(key) {
|
|
1963
2326
|
const k = (0, sessionKey_1.keyToString)(key);
|
|
@@ -2004,7 +2367,7 @@ function getOutputQueueState(key) {
|
|
|
2004
2367
|
const k = (0, sessionKey_1.keyToString)(key);
|
|
2005
2368
|
let s = outputQueues.get(k);
|
|
2006
2369
|
if (!s) {
|
|
2007
|
-
s = {
|
|
2370
|
+
s = { pendingSegments: [], isProcessing: false, debounceTimer: null };
|
|
2008
2371
|
outputQueues.set(k, s);
|
|
2009
2372
|
}
|
|
2010
2373
|
return s;
|
|
@@ -2025,7 +2388,7 @@ function checkIsOutputStreaming(key) {
|
|
|
2025
2388
|
const q = outputQueues.get((0, sessionKey_1.keyToString)(key));
|
|
2026
2389
|
if (!q)
|
|
2027
2390
|
return false;
|
|
2028
|
-
return q
|
|
2391
|
+
return (0, outputFlushPlan_1.checkHasPendingOutput)(q) || q.isProcessing || q.debounceTimer !== null;
|
|
2029
2392
|
}
|
|
2030
2393
|
function getStatusCoalesceState(key) {
|
|
2031
2394
|
const k = (0, sessionKey_1.keyToString)(key);
|
|
@@ -2088,11 +2451,15 @@ function clearThreadQueues(key) {
|
|
|
2088
2451
|
// `/quit` release, session `closed`/`stopped`, `/unbind`, and topic-delete —
|
|
2089
2452
|
// all route through here. CRITICAL ORDER: finalize BEFORE
|
|
2090
2453
|
// `clearThreadOutputQueues`, because the group finalize drains
|
|
2091
|
-
//
|
|
2454
|
+
// the pending segments and the clear would otherwise drop them first (S2). Both
|
|
2092
2455
|
// transports capture + reset their in-flight state SYNCHRONOUSLY before any
|
|
2093
2456
|
// await, so the clear below can't race the drain. Fire-and-forget.
|
|
2094
2457
|
void getOutboundFor(key).finalize(key);
|
|
2095
|
-
|
|
2458
|
+
const queue = outputQueues.get(k);
|
|
2459
|
+
// Text that took the new-message mark and is dropped unsent leaves the mark with the thread again.
|
|
2460
|
+
if (queue?.pendingSegments.some((segment) => segment.startsNewMessage))
|
|
2461
|
+
markNeedsNewMessage(key);
|
|
2462
|
+
(0, clearThreadOutputQueues_1.clearThreadOutputQueues)(queue, statusCoalescers.get(k));
|
|
2096
2463
|
// A new session starts with empty context — forget the last-sent outputs so
|
|
2097
2464
|
// the identical-output backstop can't suppress a legitimate repeat across a
|
|
2098
2465
|
// session boundary (the same convergence point that clears the other
|
|
@@ -2365,6 +2732,7 @@ function clearInMemoryThreadState(key) {
|
|
|
2365
2732
|
disconnectProviderLists.delete(pickerKey);
|
|
2366
2733
|
}
|
|
2367
2734
|
awaitingFolderName.delete(k);
|
|
2735
|
+
bindPickerMessageIds.delete(k);
|
|
2368
2736
|
pinnedStatusTextCache.delete(k);
|
|
2369
2737
|
statusCoalescers.delete(k);
|
|
2370
2738
|
// `clearThreadQueues` → `finalizeInFlight` above already finalized any accumulated
|
|
@@ -2764,11 +3132,16 @@ async function replyToThread(key, text, extra = {}, options = {}) {
|
|
|
2764
3132
|
const send = () => bot.telegram.sendMessage((0, sessionKeyCodec_1.getTelegramChatId)(key), text, sendExtra);
|
|
2765
3133
|
return options.unpaced ? (0, rateLimiter_1.sendUnpaced)(key, send) : (0, rateLimiter_1.enqueueSend)(key, send);
|
|
2766
3134
|
};
|
|
2767
|
-
|
|
2768
|
-
|
|
3135
|
+
// A sent message is tracked for `/clear_messages`, and a `/bind` picker is remembered to be closed by the next bind or leave.
|
|
3136
|
+
const trackSent = async (sent) => {
|
|
2769
3137
|
const messageId = sent.message_id;
|
|
2770
3138
|
await state.pushMessageId(key, messageId);
|
|
3139
|
+
if ((0, bindPicker_1.checkIsBindPickerExtra)(extra))
|
|
3140
|
+
openBindMessages.remember((0, sessionKey_1.keyToString)(key), messageId, Date.now());
|
|
2771
3141
|
return messageId;
|
|
3142
|
+
};
|
|
3143
|
+
try {
|
|
3144
|
+
return await trackSent(await sendOnce(buildSendExtra(key, extra)));
|
|
2772
3145
|
}
|
|
2773
3146
|
catch (e) {
|
|
2774
3147
|
// Markdown parse failure fallback. Several `t(...)` templates interpolate
|
|
@@ -2786,10 +3159,7 @@ async function replyToThread(key, text, extra = {}, options = {}) {
|
|
|
2786
3159
|
const plainExtra = { ...extra };
|
|
2787
3160
|
delete plainExtra.parse_mode;
|
|
2788
3161
|
try {
|
|
2789
|
-
|
|
2790
|
-
const messageId = sent.message_id;
|
|
2791
|
-
await state.pushMessageId(key, messageId);
|
|
2792
|
-
return messageId;
|
|
3162
|
+
return await trackSent(await sendOnce(buildSendExtra(key, plainExtra)));
|
|
2793
3163
|
}
|
|
2794
3164
|
catch (e2) {
|
|
2795
3165
|
await handleSendError(key, e2);
|
|
@@ -2811,13 +3181,17 @@ async function replyToThread(key, text, extra = {}, options = {}) {
|
|
|
2811
3181
|
* Returns `true` on success, `false` if the edit failed for any reason —
|
|
2812
3182
|
* caller decides whether to fall back to a new `replyToThread`. We don't
|
|
2813
3183
|
* track edits as new ids (the original was already tracked).
|
|
3184
|
+
*
|
|
3185
|
+
* `plainFallbackText`: the text for the retry without `parse_mode` when Telegram cannot parse the Markdown one — the
|
|
3186
|
+
* same text when absent, which shows a Markdown render's escape marks as they are; a caller that has a real plain
|
|
3187
|
+
* rendering passes it.
|
|
2814
3188
|
*/
|
|
2815
|
-
async function editThreadMessage(key, messageId, text, extra = {}) {
|
|
3189
|
+
async function editThreadMessage(key, messageId, text, extra = {}, plainFallbackText = text) {
|
|
2816
3190
|
if (!checkIsTelegramPrimitiveKey(key, 'editThreadMessage'))
|
|
2817
3191
|
return false;
|
|
2818
|
-
const editOnce = (editExtra) => (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageText((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId, undefined,
|
|
3192
|
+
const editOnce = (editText, editExtra) => (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageText((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId, undefined, editText, editExtra));
|
|
2819
3193
|
try {
|
|
2820
|
-
await editOnce(extra);
|
|
3194
|
+
await editOnce(text, extra);
|
|
2821
3195
|
return true;
|
|
2822
3196
|
}
|
|
2823
3197
|
catch (e) {
|
|
@@ -2837,7 +3211,7 @@ async function editThreadMessage(key, messageId, text, extra = {}) {
|
|
|
2837
3211
|
const plainExtra = { ...extra };
|
|
2838
3212
|
delete plainExtra.parse_mode;
|
|
2839
3213
|
try {
|
|
2840
|
-
await editOnce(plainExtra);
|
|
3214
|
+
await editOnce(plainFallbackText, plainExtra);
|
|
2841
3215
|
return true;
|
|
2842
3216
|
}
|
|
2843
3217
|
catch (e2) {
|
|
@@ -2852,14 +3226,63 @@ async function editThreadMessage(key, messageId, text, extra = {}) {
|
|
|
2852
3226
|
return false;
|
|
2853
3227
|
}
|
|
2854
3228
|
}
|
|
2855
|
-
|
|
3229
|
+
/**
|
|
3230
|
+
* @description Replace the inline keyboard of a message of the thread (`null` removes it) through the paced send queue.
|
|
3231
|
+
* Returns `true` when the keyboard is as asked — a no-change edit («message is not modified») counts — and `false`
|
|
3232
|
+
* when the edit failed, which is logged under `logTag` and left to the caller to act on (a re-render may simply skip it).
|
|
3233
|
+
*/
|
|
3234
|
+
async function editThreadKeyboard(key, messageId, keyboard, logTag) {
|
|
3235
|
+
if (!checkIsTelegramPrimitiveKey(key, 'editThreadKeyboard'))
|
|
3236
|
+
return false;
|
|
3237
|
+
try {
|
|
3238
|
+
await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageReplyMarkup((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId, undefined, keyboard ?? undefined));
|
|
3239
|
+
return true;
|
|
3240
|
+
}
|
|
3241
|
+
catch (e) {
|
|
3242
|
+
const desc = (0, sendErrorClassifier_1.checkIsApiError)(e) ? (0, sendErrorClassifier_1.getErrorDescription)(e) : '';
|
|
3243
|
+
if (/message is not modified/i.test(desc))
|
|
3244
|
+
return true;
|
|
3245
|
+
console.warn(`[${logTag}] keyboard edit failed:`, desc || e);
|
|
3246
|
+
return false;
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
async function deleteThreadMessageDetailed(key, messageId) {
|
|
2856
3250
|
if (!checkIsTelegramPrimitiveKey(key, 'deleteThreadMessage'))
|
|
2857
|
-
return;
|
|
3251
|
+
return 'stayed';
|
|
2858
3252
|
try {
|
|
2859
3253
|
await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.deleteMessage((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId));
|
|
3254
|
+
return 'deleted';
|
|
3255
|
+
}
|
|
3256
|
+
catch (e) {
|
|
3257
|
+
return (0, telegramError_1.checkIsMessageNotFoundError)(e) ? 'gone' : 'stayed';
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
/**
|
|
3261
|
+
* @description Delete a message of the thread. Resolves `false` when it is still there — older than 48 hours, refused, or no
|
|
3262
|
+
* Telegram topic's key — which a caller that needs it gone can act on.
|
|
3263
|
+
*/
|
|
3264
|
+
async function deleteThreadMessage(key, messageId) {
|
|
3265
|
+
return (await deleteThreadMessageDetailed(key, messageId)) === 'deleted';
|
|
3266
|
+
}
|
|
3267
|
+
/** The most message ids one `deleteMessages` call takes (the Bot API documents 1-100). */
|
|
3268
|
+
const deleteMessagesPerCallLimit = 100;
|
|
3269
|
+
/**
|
|
3270
|
+
* @description Delete several messages of the thread in ONE paced send (`deleteMessages`: «if some of the specified messages
|
|
3271
|
+
* can't be found, they are skipped»). Resolves `true` when the call went through; `false` when Telegram refused it — the docs do
|
|
3272
|
+
* not say what it does with a message that is there but may not be deleted — and the caller tries the messages one by one.
|
|
3273
|
+
*/
|
|
3274
|
+
async function deleteThreadMessages(key, messageIds) {
|
|
3275
|
+
if (!checkIsTelegramPrimitiveKey(key, 'deleteThreadMessages'))
|
|
3276
|
+
return false;
|
|
3277
|
+
try {
|
|
3278
|
+
for (let start = 0; start < messageIds.length; start += deleteMessagesPerCallLimit) {
|
|
3279
|
+
const chunk = messageIds.slice(start, start + deleteMessagesPerCallLimit);
|
|
3280
|
+
await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.deleteMessages((0, sessionKeyCodec_1.getTelegramChatId)(key), chunk));
|
|
3281
|
+
}
|
|
3282
|
+
return true;
|
|
2860
3283
|
}
|
|
2861
3284
|
catch {
|
|
2862
|
-
|
|
3285
|
+
return false;
|
|
2863
3286
|
}
|
|
2864
3287
|
}
|
|
2865
3288
|
/**
|
|
@@ -2941,7 +3364,7 @@ function startTypingLoader(key) {
|
|
|
2941
3364
|
const q = outputQueues.get((0, sessionKey_1.keyToString)(key));
|
|
2942
3365
|
console.warn(`[typing] ${(0, sessionKey_1.keyToString)(key)} force-stop: kept alive by leaked output-queue state — ` +
|
|
2943
3366
|
`isOutputStreaming=${checkIsOutputStreaming(key)} isAdapterBusy=${checkIsAdapterBusy(key)} ` +
|
|
2944
|
-
`pendingOutput=${q
|
|
3367
|
+
`pendingOutput=${q !== undefined && (0, outputFlushPlan_1.checkHasPendingOutput)(q)} ` +
|
|
2945
3368
|
`isProcessing=${q?.isProcessing === true} debounceTimer=${q?.debounceTimer !== null && q?.debounceTimer !== undefined}`);
|
|
2946
3369
|
if (q?.debounceTimer) {
|
|
2947
3370
|
clearTimeout(q.debounceTimer);
|
|
@@ -2968,7 +3391,7 @@ function checkIsTypingLoopStuck(key) {
|
|
|
2968
3391
|
isAdapterBusy: checkIsAdapterBusy(key),
|
|
2969
3392
|
isCompacting: checkIsThreadCompacting(key),
|
|
2970
3393
|
isTransportStreaming: getOutboundFor(key).checkIsDelivering(key),
|
|
2971
|
-
hasPendingOutput: q
|
|
3394
|
+
hasPendingOutput: q !== undefined && (0, outputFlushPlan_1.checkHasPendingOutput)(q),
|
|
2972
3395
|
isProcessing: q?.isProcessing === true,
|
|
2973
3396
|
hasDebounceTimer: q?.debounceTimer != null,
|
|
2974
3397
|
});
|
|
@@ -3033,7 +3456,7 @@ function escapeMarkdown(text) {
|
|
|
3033
3456
|
* @description Queue an output text for a thread with debounce.
|
|
3034
3457
|
*
|
|
3035
3458
|
* Multiple `output` events from the adapter are coalesced into one pending
|
|
3036
|
-
* buffer (see {@link
|
|
3459
|
+
* buffer (see {@link addPendingOutput}), not overwritten — every emitted
|
|
3037
3460
|
* chunk reaches Telegram. A continuation tail concatenates as-is (it may be
|
|
3038
3461
|
* cut mid-word); a standalone output joins the buffer with `\n`. The first
|
|
3039
3462
|
* batch's `isContinuation` is recorded so the flush knows whether the buffer
|
|
@@ -3069,11 +3492,9 @@ function getOutputDelay(chatId) {
|
|
|
3069
3492
|
*/
|
|
3070
3493
|
function queueOutput(key, output, isContinuation = false, isFinal = false, isComplete = false, startsNewParagraph = false) {
|
|
3071
3494
|
const q = getOutputQueueState(key);
|
|
3072
|
-
//
|
|
3073
|
-
//
|
|
3074
|
-
|
|
3075
|
-
q.pendingIsContinuation = isContinuation;
|
|
3076
|
-
q.pendingOutput = (0, outputFlushPlan_1.appendPendingOutput)(q.pendingOutput, output, isContinuation, startsNewParagraph);
|
|
3495
|
+
// A batch joins the newest pending segment; one queued after a turn boundary was marked starts a segment of its own, which
|
|
3496
|
+
// opens a new message, and leaves the earlier segment to flush where it was going.
|
|
3497
|
+
(0, outputFlushPlan_1.addPendingOutput)(q, getThreadMessageState(key), { output, isContinuation, startsNewParagraph });
|
|
3077
3498
|
// `isFinal` / `isComplete` still drive the immediate-flush timing below (a
|
|
3078
3499
|
// finished turn must not wait out the debounce) — they no longer mark the
|
|
3079
3500
|
// buffer for redelivery (retired with the global send pacer, S4).
|
|
@@ -3098,7 +3519,8 @@ function queueOutput(key, output, isContinuation = false, isFinal = false, isCom
|
|
|
3098
3519
|
// S3: scale the in-cooldown debounce to the live remaining cooldown.
|
|
3099
3520
|
remainingCooldownMs: (0, rateLimiter_1.getRateLimitRemainingMs)((0, sessionKeyCodec_1.getTelegramChatId)(key)),
|
|
3100
3521
|
});
|
|
3101
|
-
|
|
3522
|
+
// An earlier segment, sealed by a boundary, has nothing more to wait for: its turn is over.
|
|
3523
|
+
if (timing === 'now' || q.pendingSegments.length > 1) {
|
|
3102
3524
|
// Final frame: flush immediately. `processOutputQueue` already guards
|
|
3103
3525
|
// `isProcessing`, so this is idempotent if a flush is mid-flight (the
|
|
3104
3526
|
// pending frame will be picked up by the re-trigger timer it arms).
|
|
@@ -3112,14 +3534,14 @@ function queueOutput(key, output, isContinuation = false, isFinal = false, isCom
|
|
|
3112
3534
|
}
|
|
3113
3535
|
async function processOutputQueue(key) {
|
|
3114
3536
|
const q = getOutputQueueState(key);
|
|
3115
|
-
if (q.isProcessing || !
|
|
3537
|
+
if (q.isProcessing || !(0, outputFlushPlan_1.checkHasPendingOutput)(q))
|
|
3116
3538
|
return;
|
|
3117
3539
|
q.isProcessing = true;
|
|
3118
3540
|
try {
|
|
3119
|
-
const
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
const { unsentRemainder } = await sendOutputImmediate(key,
|
|
3541
|
+
const flush = (0, outputFlushPlan_1.takePendingOutput)(q);
|
|
3542
|
+
if (!flush)
|
|
3543
|
+
return;
|
|
3544
|
+
const { unsentRemainder } = await sendOutputImmediate(key, flush.output, flush.isContinuation, flush.startsNewMessage);
|
|
3123
3545
|
// No silent drop: a chunk dropped on a 429 after the retry-after wait comes
|
|
3124
3546
|
// back as `unsentRemainder`. Put it BACK at the FRONT of the buffer (it is
|
|
3125
3547
|
// older than anything that arrived during the await) so the re-trigger flush
|
|
@@ -3127,17 +3549,12 @@ async function processOutputQueue(key) {
|
|
|
3127
3549
|
// `getUnsentRemainder`, so this never re-sends what already reached Telegram.
|
|
3128
3550
|
// The remainder restarts a fresh message (its predecessors landed / edited in
|
|
3129
3551
|
// place already), so it is NOT a continuation.
|
|
3130
|
-
if (unsentRemainder)
|
|
3131
|
-
q
|
|
3132
|
-
q.pendingOutput === null
|
|
3133
|
-
? unsentRemainder
|
|
3134
|
-
: (0, outputFlushPlan_1.appendPendingOutput)(unsentRemainder, q.pendingOutput, false);
|
|
3135
|
-
q.pendingIsContinuation = false;
|
|
3136
|
-
}
|
|
3552
|
+
if (unsentRemainder)
|
|
3553
|
+
(0, outputFlushPlan_1.requeueUnsentRemainder)(q, unsentRemainder);
|
|
3137
3554
|
}
|
|
3138
3555
|
finally {
|
|
3139
3556
|
q.isProcessing = false;
|
|
3140
|
-
if (
|
|
3557
|
+
if ((0, outputFlushPlan_1.checkHasPendingOutput)(q)) {
|
|
3141
3558
|
// Audit S13 / #30: the re-trigger `setTimeout` used to be a bare
|
|
3142
3559
|
// call whose handle was never stored. A `queueOutput` call between
|
|
3143
3560
|
// here and the timer firing would `clearTimeout(null)` and
|
|
@@ -3149,7 +3566,7 @@ async function processOutputQueue(key) {
|
|
|
3149
3566
|
q.debounceTimer = setTimeout(() => {
|
|
3150
3567
|
q.debounceTimer = null;
|
|
3151
3568
|
processOutputQueue(key);
|
|
3152
|
-
}, getOutputDelay((0, sessionKeyCodec_1.getTelegramChatId)(key)));
|
|
3569
|
+
}, q.pendingSegments.length > 1 ? 0 : getOutputDelay((0, sessionKeyCodec_1.getTelegramChatId)(key)));
|
|
3153
3570
|
}
|
|
3154
3571
|
}
|
|
3155
3572
|
}
|
|
@@ -3158,7 +3575,7 @@ async function processOutputQueue(key) {
|
|
|
3158
3575
|
* coalesced-but-unsent output against what actually landed, and force-send the
|
|
3159
3576
|
* remainder so the agent's FINAL answer is never discarded on teardown (S2).
|
|
3160
3577
|
*
|
|
3161
|
-
* Today the group output queue coalesces into `q.
|
|
3578
|
+
* Today the group output queue coalesces into `q.pendingSegments` and the
|
|
3162
3579
|
* (possibly 429-stretched) debounce flushes it later; a teardown
|
|
3163
3580
|
* (`idle`/`isFinal` → `handleAgentStopped`, `/new`, `/quit`, `/unbind`,
|
|
3164
3581
|
* topic-delete) runs `clearThreadOutputQueues` which DISCARDS that buffer — so
|
|
@@ -3180,20 +3597,23 @@ async function finalizeGroupOutput(key) {
|
|
|
3180
3597
|
const plan = (0, groupFinalizePlan_1.getGroupFinalizePlan)(q);
|
|
3181
3598
|
if (plan.action === 'noop')
|
|
3182
3599
|
return; // fully delivered → no-op, no dupe.
|
|
3183
|
-
// Snapshot is captured by the plan;
|
|
3600
|
+
// Snapshot is captured by the plan; empty the buffer SYNCHRONOUSLY before the
|
|
3184
3601
|
// await so the immediately-following clear / a concurrent flush can't re-send it.
|
|
3185
|
-
q.
|
|
3602
|
+
q.pendingSegments = [];
|
|
3186
3603
|
if (q.debounceTimer) {
|
|
3187
3604
|
clearTimeout(q.debounceTimer);
|
|
3188
3605
|
q.debounceTimer = null;
|
|
3189
3606
|
}
|
|
3190
|
-
const
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3607
|
+
for (const segment of plan.segments) {
|
|
3608
|
+
const { unsentRemainder } = await sendOutputImmediate(key, segment.output, segment.isContinuation, segment.startsNewMessage);
|
|
3609
|
+
// A remainder that still 429'd at teardown is dropped (the buffer is being
|
|
3610
|
+
// torn down anyway — unchanged from the pre-S2 discard), and so is everything behind it. Do NOT re-queue: the
|
|
3611
|
+
// queue is about to be cleared, and re-queuing would race that clear. At 1/2s
|
|
3612
|
+
// global pacing a 429 here is essentially impossible.
|
|
3613
|
+
if (unsentRemainder) {
|
|
3614
|
+
console.warn(`[send] ${(0, sessionKey_1.keyToString)(key)} finalize remainder 429'd and was dropped at teardown`);
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3197
3617
|
}
|
|
3198
3618
|
}
|
|
3199
3619
|
/**
|
|
@@ -3210,12 +3630,17 @@ async function finalizeGroupOutput(key) {
|
|
|
3210
3630
|
* previous text on the same message id, so the user could read only the last
|
|
3211
3631
|
* tail. Editing is now reserved for explicit continuations of the SAME reply.
|
|
3212
3632
|
*
|
|
3633
|
+
* `startsNewMessage`: the text was queued after a turn boundary, so it opens a
|
|
3634
|
+
* message of its own — decided when the text was queued ({@link addPendingOutput}),
|
|
3635
|
+
* not read from the thread here, where a flush of EARLIER text finishing after the
|
|
3636
|
+
* boundary would have spent the mark.
|
|
3637
|
+
*
|
|
3213
3638
|
* On Markdown rejection by Telegram we fall back to plain text (`parse_mode`
|
|
3214
3639
|
* unset) — the message lands either way. If the in-place edit fails (message
|
|
3215
3640
|
* deleted / API hiccup) we send every chunk fresh so the full combined text
|
|
3216
3641
|
* still reaches the user.
|
|
3217
3642
|
*/
|
|
3218
|
-
async function sendOutputImmediate(key, output, isContinuation = false) {
|
|
3643
|
+
async function sendOutputImmediate(key, output, isContinuation = false, startsNewMessage = false) {
|
|
3219
3644
|
// The typing loader is stopped upstream in `handleAgentOutput` (the shared
|
|
3220
3645
|
// onOutput entry for BOTH transports), so it's not repeated here.
|
|
3221
3646
|
await deleteStatusMessage(key);
|
|
@@ -3223,7 +3648,7 @@ async function sendOutputImmediate(key, output, isContinuation = false) {
|
|
|
3223
3648
|
const { chunks: planChunks, shouldEditFirstChunk } = (0, outputFlushPlan_1.getOutputFlushPlan)({
|
|
3224
3649
|
output,
|
|
3225
3650
|
isContinuation,
|
|
3226
|
-
needsNewMessage:
|
|
3651
|
+
needsNewMessage: startsNewMessage,
|
|
3227
3652
|
lastMessageId: msgState.lastMessageId,
|
|
3228
3653
|
lastMessageText: msgState.lastMessageText,
|
|
3229
3654
|
});
|
|
@@ -3261,7 +3686,6 @@ async function sendOutputImmediate(key, output, isContinuation = false) {
|
|
|
3261
3686
|
break;
|
|
3262
3687
|
msgState.lastMessageId = id;
|
|
3263
3688
|
msgState.lastMessageText = chunks[i];
|
|
3264
|
-
msgState.needsNewMessage = false;
|
|
3265
3689
|
sentCount = i + 1;
|
|
3266
3690
|
}
|
|
3267
3691
|
// S3: output just landed below any pending question — bring the question back
|
|
@@ -3399,7 +3823,7 @@ const shutdownOutputFlushMaxMs = rateLimiter_1.shutdownDrainMaxMs + 1000;
|
|
|
3399
3823
|
* already-emitted answer chunks died in the send pipeline with the
|
|
3400
3824
|
* process and were never re-sent). Two buffers drain, in order:
|
|
3401
3825
|
*
|
|
3402
|
-
* [A] per-thread COALESCE state — the group/baseline `q.
|
|
3826
|
+
* [A] per-thread COALESCE state — the group/baseline `q.pendingSegments`
|
|
3403
3827
|
* buffers (enumerated here from {@link outputQueues}) and the DM
|
|
3404
3828
|
* transport's active drafts (enumerated via `getInFlightThreadKeys`) —
|
|
3405
3829
|
* each finalized to a permanent message via the transport's
|
|
@@ -3429,7 +3853,7 @@ async function finalizePendingOutputOnShutdown() {
|
|
|
3429
3853
|
// queues + the connector's own unfinalized content (deduped by serialised key).
|
|
3430
3854
|
const pendingKeys = new Map();
|
|
3431
3855
|
for (const [keyStr, q] of outputQueues) {
|
|
3432
|
-
if (
|
|
3856
|
+
if (!(0, outputFlushPlan_1.checkHasPendingOutput)(q))
|
|
3433
3857
|
continue;
|
|
3434
3858
|
try {
|
|
3435
3859
|
pendingKeys.set(keyStr, (0, sessionKey_1.keyFromString)(keyStr));
|
|
@@ -3447,11 +3871,11 @@ async function finalizePendingOutputOnShutdown() {
|
|
|
3447
3871
|
// A DM-surface thread on a non-draft adapter (e.g. terminal) coalesces via
|
|
3448
3872
|
// the group `queueOutput` path, but its transport finalize handled only the
|
|
3449
3873
|
// draft — drain any such leftover buffer directly. Group keys were already
|
|
3450
|
-
// drained above (their finalize
|
|
3874
|
+
// drained above (their finalize empties the pending segments synchronously), so
|
|
3451
3875
|
// this is a no-op for them.
|
|
3452
3876
|
const leftoverDrains = [];
|
|
3453
3877
|
for (const [keyStr, q] of outputQueues) {
|
|
3454
|
-
if (
|
|
3878
|
+
if (!(0, outputFlushPlan_1.checkHasPendingOutput)(q))
|
|
3455
3879
|
continue;
|
|
3456
3880
|
try {
|
|
3457
3881
|
leftoverDrains.push(finalizeGroupOutput((0, sessionKey_1.keyFromString)(keyStr)));
|
|
@@ -3687,9 +4111,18 @@ async function releaseThreadSession(key) {
|
|
|
3687
4111
|
// Claude / no pending question.
|
|
3688
4112
|
(0, createAdapter_1.getThreadAdapter)(key).rejectQuestion?.(key);
|
|
3689
4113
|
const result = stopAllAdaptersFor(key);
|
|
3690
|
-
await
|
|
4114
|
+
await releaseConversationIds(key);
|
|
3691
4115
|
return result;
|
|
3692
4116
|
}
|
|
4117
|
+
/**
|
|
4118
|
+
* @description Release a conversation for good: its saved session ids go, so nothing resumes it, and the question the idle
|
|
4119
|
+
* compaction re-asked for it is closed (its message stays, without its buttons). A conversation that is asleep has no process
|
|
4120
|
+
* whose stop would say so, so the release itself has to — every release of a conversation that may be asleep goes through here.
|
|
4121
|
+
*/
|
|
4122
|
+
async function releaseConversationIds(key) {
|
|
4123
|
+
await state.clearAgentSessionIds(key);
|
|
4124
|
+
clearThreadCompaction(key);
|
|
4125
|
+
}
|
|
3693
4126
|
/**
|
|
3694
4127
|
* @description Switch a thread's adapter and clear the previous adapter's
|
|
3695
4128
|
* persisted session id, so a later restart can't re-attach to the wrong
|
|
@@ -3712,8 +4145,11 @@ async function switchThreadAdapter(key, newName) {
|
|
|
3712
4145
|
// Another agent or a shell takes the topic over: the open request belonged to
|
|
3713
4146
|
// the old conversation, so it closes silently instead of a later wake-up
|
|
3714
4147
|
// reminding (or, in a terminal, TYPING into) a session that never saw it.
|
|
3715
|
-
if (prevName && !(0, createAdapter_1.checkIsSameConversationSwitch)(prevName, newName))
|
|
4148
|
+
if (prevName && !(0, createAdapter_1.checkIsSameConversationSwitch)(prevName, newName)) {
|
|
3716
4149
|
cancelConversationRequest(key);
|
|
4150
|
+
// Nor does the conversation left behind get its re-asked question answered: its buttons would send the label into the new one.
|
|
4151
|
+
closeReAskedQuestion(key);
|
|
4152
|
+
}
|
|
3717
4153
|
let switchCutOffWork = null;
|
|
3718
4154
|
if (prevName && prevName !== newName) {
|
|
3719
4155
|
try {
|
|
@@ -3848,8 +4284,8 @@ function getStartReadyMessage(adapter, subdir, args, model, effort) {
|
|
|
3848
4284
|
return (0, i18n_1.t)('agent.ready', { ...vars, infoBlock: getStartReadyInfoBlock(model, effort) });
|
|
3849
4285
|
}
|
|
3850
4286
|
/**
|
|
3851
|
-
* The bot-MCP tool digest
|
|
3852
|
-
* at boot; `null` before that (no digest is persisted, which reads as stale).
|
|
4287
|
+
* The bot-MCP tool digest of a conversation (its platform's tools, its «Bot skills» instructions), from the scheduler handle
|
|
4288
|
+
* once it is wired at boot; `null` before that (no digest is persisted, which reads as stale).
|
|
3853
4289
|
*/
|
|
3854
4290
|
let botMcpToolDigestReader = null;
|
|
3855
4291
|
/**
|
|
@@ -3889,7 +4325,7 @@ function getPerTurnRefusal(key) {
|
|
|
3889
4325
|
async function persistSessionStart(key, adapter) {
|
|
3890
4326
|
await (0, persistAdapterSessionIds_1.persistAdapterSessionIds)(key, adapter, state);
|
|
3891
4327
|
if ((0, adapterNames_1.checkIsJsonStreamBackend)(adapter.name) && botMcpToolDigestReader) {
|
|
3892
|
-
await state.setAgentMcpToolDigest(key, botMcpToolDigestReader(key
|
|
4328
|
+
await state.setAgentMcpToolDigest(key, botMcpToolDigestReader(key));
|
|
3893
4329
|
}
|
|
3894
4330
|
}
|
|
3895
4331
|
/**
|
|
@@ -4231,6 +4667,11 @@ const autoStopUnsupportedLoggedKeys = new Set();
|
|
|
4231
4667
|
* and that turn ends here too) or the process still works (a background task,
|
|
4232
4668
|
* input not yet taken in). The session sleeps; the next trigger resumes it with
|
|
4233
4669
|
* the prompt cache warm.
|
|
4670
|
+
*
|
|
4671
|
+
* The stop is QUEUED here, in the same tick the adapter marks the turn done (nothing awaits in between): a
|
|
4672
|
+
* `postToSession` that polled the session idle and ensures it again (`postToSession.ts`) therefore always finds
|
|
4673
|
+
* the stop already on the transition queue and runs after it. Keep it that way — an await ahead of the queueing
|
|
4674
|
+
* would let that ensure run beside the stop and forward to the process it ends.
|
|
4234
4675
|
*/
|
|
4235
4676
|
function handleAgentTurnEnded(key) {
|
|
4236
4677
|
noteThreadActivity(key);
|
|
@@ -4273,7 +4714,7 @@ async function replayBufferedPrompts(key) {
|
|
|
4273
4714
|
// like the API-error retry's nudge, hears whether it reached the session).
|
|
4274
4715
|
await startupPromptBuffer.replayPrompts((0, sessionKey_1.keyToString)(key), {
|
|
4275
4716
|
isSessionActive,
|
|
4276
|
-
forward: (prompt) => forwardPromptToAgent(key, adapter, prompt),
|
|
4717
|
+
forward: async (prompt) => { await forwardPromptToAgent(key, adapter, prompt); },
|
|
4277
4718
|
});
|
|
4278
4719
|
if (!isSessionActive)
|
|
4279
4720
|
return;
|
|
@@ -4307,17 +4748,61 @@ async function replayBufferedPrompts(key) {
|
|
|
4307
4748
|
* `session.status=retry` will not read queued prompts until the provider retry
|
|
4308
4749
|
* time, so a new prompt aborts that stale wait before using the current model.
|
|
4309
4750
|
* An adapter without the method forwards directly.
|
|
4751
|
+
*
|
|
4752
|
+
* Resolves where the prompt went (`LatePromptOutcome`): `forwarded` to the session, `buffered` behind a stop window, `noSession`
|
|
4753
|
+
* (nothing to take it; the person was told), `dropped` (a technical prompt found no live idle session). Only a prompt that waited
|
|
4754
|
+
* for an adopt replay can come out as anything but `forwarded`; most callers have no use for the answer.
|
|
4310
4755
|
*/
|
|
4311
4756
|
async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {}) {
|
|
4757
|
+
// An adopted session replays what its process wrote during the downtime first: a prompt written meanwhile would reset
|
|
4758
|
+
// the answer and error state those frames still use, and the replayed text would join the new reply's message (the
|
|
4759
|
+
// «new message» mark would go to the replayed text). Nothing is awaited when no replay is pending: the forward then runs
|
|
4760
|
+
// in the caller's tick, as it always did — an await ahead of it would let a stop queued meanwhile begin first.
|
|
4761
|
+
if (!adapter.checkIsAdoptReplayPending?.(key)) {
|
|
4762
|
+
await forwardPromptToLiveAgent(key, adapter, text, sentAtMs, options);
|
|
4763
|
+
return 'forwarded';
|
|
4764
|
+
}
|
|
4765
|
+
await (0, adoptReplayGate_1.waitForAdoptedReplay)(adapter, key);
|
|
4766
|
+
// The wait let the replay run: the replayed turn's end may have queued the per-turn stop, which holds the window open and
|
|
4767
|
+
// ends the process — a prompt written now would go to a process being killed and be lost. Routed again, as a late prompt is.
|
|
4768
|
+
// A technical prompt (the standalone todo reminder) goes only into a session that is live and idle now, and is otherwise
|
|
4769
|
+
// dropped without a word: never buffered, never a resume, never a "no session" line to the person.
|
|
4770
|
+
const isTechnicalPrompt = options.isTechnicalPrompt === true;
|
|
4771
|
+
const outcome = await (0, promptDelivery_1.deliverLatePrompt)({
|
|
4772
|
+
isTechnical: isTechnicalPrompt,
|
|
4773
|
+
checkIsWindowOpen: () => startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key)),
|
|
4774
|
+
checkIsSessionActive: () => adapter.checkIsActive(key) && !(isTechnicalPrompt && (adapter.checkIsBusy?.(key) ?? false)),
|
|
4775
|
+
buffer: async () => {
|
|
4776
|
+
await deliverPromptOrBuffer(key, `${options.requestHeader ?? ''}${getPromptWithThreadReplyQuote(key, text, options.replyContext)}`, true);
|
|
4777
|
+
},
|
|
4778
|
+
wake: () => wakeSleepingSessionForPrompt(key),
|
|
4779
|
+
forward: async () => {
|
|
4780
|
+
await forwardPromptToLiveAgent(key, (0, createAdapter_1.getThreadAdapter)(key), text, sentAtMs, options);
|
|
4781
|
+
return 'forwarded';
|
|
4782
|
+
},
|
|
4783
|
+
reportNoSession: async (isAlreadyTold) => {
|
|
4784
|
+
console.warn(`[adopt] ${(0, sessionKey_1.keyToString)(key)}: no session is left to take the prompt that waited for the replay; it is dropped`);
|
|
4785
|
+
if (!isAlreadyTold)
|
|
4786
|
+
await replyToThread(key, (0, i18n_1.t)('agent.no_session'));
|
|
4787
|
+
},
|
|
4788
|
+
});
|
|
4789
|
+
if (outcome === 'dropped')
|
|
4790
|
+
console.log(`[adopt] ${(0, sessionKey_1.keyToString)(key)}: the technical prompt found no live, idle session after the replay wait; it is dropped`);
|
|
4791
|
+
return outcome;
|
|
4792
|
+
}
|
|
4793
|
+
/** @description The forward itself, for a session that is ready to take the prompt: {@link forwardPromptToAgent} minus the replay wait. */
|
|
4794
|
+
async function forwardPromptToLiveAgent(key, adapter, text, sentAtMs, options = {}) {
|
|
4312
4795
|
// A forwarded prompt is thread activity — reset the compact-on-idle watchdog (F2).
|
|
4313
4796
|
noteThreadActivity(key);
|
|
4797
|
+
const isTechnicalPrompt = options.isTechnicalPrompt ?? false;
|
|
4314
4798
|
// Skip slash commands (not worth replaying), the recovery replay itself (keeps
|
|
4315
|
-
// the once-per-episode guard intact)
|
|
4316
|
-
// prompt stays the one to replay).
|
|
4799
|
+
// the once-per-episode guard intact), a request reminder (the request's own
|
|
4800
|
+
// prompt stays the one to replay) and a bot-made technical prompt.
|
|
4317
4801
|
const isReplayable = (0, wedgeRecovery_1.checkIsReplayablePrompt)({
|
|
4318
4802
|
isSlashCommand: (0, threadContextPreamble_1.checkShouldSkipPreambleForText)(text),
|
|
4319
4803
|
isRecoveryReplay: options.isRecoveryReplay ?? false,
|
|
4320
4804
|
isRequestReminder: options.isRequestReminder ?? false,
|
|
4805
|
+
isTechnicalPrompt,
|
|
4321
4806
|
});
|
|
4322
4807
|
// R23: prompts held during a usage-limit wait ride the first such prompt
|
|
4323
4808
|
// forwarded to a live session once no wait holds them — after it, in arrival
|
|
@@ -4366,6 +4851,18 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
|
|
|
4366
4851
|
if (state.checkIsTimestampsEnabled(key) && !(0, threadContextPreamble_1.checkShouldSkipPreambleForText)(text)) {
|
|
4367
4852
|
promptText = `${(0, isoTimestamp_1.formatIsoLocalOffset)(sentAtMs ?? Date.now())}\n\n${promptText}`;
|
|
4368
4853
|
}
|
|
4854
|
+
// The project board rides every turn start the bot makes (D20: whole on a fresh context, else only what changed),
|
|
4855
|
+
// then the todo list's staleness reminder rides the next real prompt from any source (D11 form a) — never a slash
|
|
4856
|
+
// command or a shell line; the reminder never rides the standalone reminder itself.
|
|
4857
|
+
const isTodoDecorated = !(0, threadContextPreamble_1.checkShouldSkipPreambleForText)(text) && adapter.name !== 'terminal';
|
|
4858
|
+
if (isTodoDecorated)
|
|
4859
|
+
discardTodoInbox(key);
|
|
4860
|
+
const todoBoardBlock = isTodoDecorated ? takeTodoPromptBoard(key) : null;
|
|
4861
|
+
if (todoBoardBlock !== null)
|
|
4862
|
+
promptText = `${promptText}\n\n${todoBoardBlock}`;
|
|
4863
|
+
const todoReminderLine = isTodoDecorated && !isTechnicalPrompt ? takeTodoPromptReminder(key) : null;
|
|
4864
|
+
if (todoReminderLine !== null)
|
|
4865
|
+
promptText = `${promptText}\n\n${todoReminderLine}`;
|
|
4369
4866
|
markNeedsNewMessage(key);
|
|
4370
4867
|
// A new prompt is a new turn: lift the S2 idle-suppress latch and re-base the
|
|
4371
4868
|
// working-status elapsed so the next busy period shows a fresh 0:00 counter
|
|
@@ -4380,7 +4877,8 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
|
|
|
4380
4877
|
// persists while output is streaming OR the agent is busy — self-stopping only
|
|
4381
4878
|
// when the topic drains + idles.
|
|
4382
4879
|
getOutboundFor(key).setActivity(key, 'working');
|
|
4383
|
-
|
|
4880
|
+
// A technical prompt is posted only into an idle session: there is no turn to interrupt or sub-agent to detach.
|
|
4881
|
+
if (adapter.interruptAndWaitIdle && !isTechnicalPrompt) {
|
|
4384
4882
|
await adapter.interruptAndWaitIdle(key);
|
|
4385
4883
|
}
|
|
4386
4884
|
// OpenCode: if a sub-agent is synchronously blocking this session, detach it to
|
|
@@ -4388,7 +4886,7 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
|
|
|
4388
4886
|
// the whole (possibly long) sub-agent run — the sub-agent keeps working and its
|
|
4389
4887
|
// result is injected back when it finishes. No-op for other adapters and when
|
|
4390
4888
|
// no sub-agent is running. Best-effort; never blocks the forward on failure.
|
|
4391
|
-
if (adapter.detachRunningSubagents) {
|
|
4889
|
+
if (adapter.detachRunningSubagents && !isTechnicalPrompt) {
|
|
4392
4890
|
await adapter.detachRunningSubagents(key);
|
|
4393
4891
|
}
|
|
4394
4892
|
adapter.sendInput(key, promptText);
|
|
@@ -4567,6 +5065,7 @@ const botCore = {
|
|
|
4567
5065
|
getState: () => state,
|
|
4568
5066
|
replyToThread,
|
|
4569
5067
|
editThreadMessage,
|
|
5068
|
+
editThreadKeyboard,
|
|
4570
5069
|
deleteThreadMessage,
|
|
4571
5070
|
authoriseContext,
|
|
4572
5071
|
withThreadLocale,
|
|
@@ -4575,17 +5074,25 @@ const botCore = {
|
|
|
4575
5074
|
cancelConversationRequest,
|
|
4576
5075
|
wakeSleepingSession,
|
|
4577
5076
|
};
|
|
4578
|
-
const { checkIsThreadCompacting, noteThreadActivity, noteThreadUserActivity, markThreadTurnProducedOutput, clearThreadCompaction, rearmThreadIdleTimer, armDeferredCompaction, checkIsDeferredCompactionArmed, threadsCompacting,
|
|
5077
|
+
const { checkIsThreadCompacting, noteThreadActivity, noteThreadUserActivity, markThreadTurnProducedOutput, clearThreadCompaction, rearmThreadIdleTimer, armDeferredCompaction, checkIsDeferredCompactionArmed, threadsCompacting, answerReAskedQuestionByNumber, reAskInterruptedQuestion, restoreReAskedQuestion, closeSavedReAskedQuestion, closeReAskedQuestion, registerCompactionCommands, registerCompactionCallbacks, } = (0, compaction_1.createCompaction)({
|
|
4579
5078
|
...botCore,
|
|
4580
5079
|
startTypingLoader,
|
|
4581
|
-
forwardPromptToAgent,
|
|
5080
|
+
forwardPromptToAgent: async (key, adapter, text) => { await forwardPromptToAgent(key, adapter, text); },
|
|
4582
5081
|
pendingQuestions,
|
|
4583
5082
|
clearPendingQuestion,
|
|
5083
|
+
closeQuestionMessage,
|
|
4584
5084
|
idleWindowMs: (0, compactOnIdle_1.getAgentIdleMs)(process.env.AGENT_IDLE_MINUTES),
|
|
4585
5085
|
checkIsLimitWaitArmed: (key) => getArmedApiRetry(key)?.kind === 'usageLimit',
|
|
4586
5086
|
suspendThreadSession,
|
|
4587
5087
|
// L-D7: a sleeping per-turn session is woken for its idle compaction — a resume only, never a fresh start.
|
|
4588
5088
|
resumeSleepingSessionForCompaction: async (key) => (await ensureAgentSession(key, { isResumeOnly: true })).ok && (0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key),
|
|
5089
|
+
getThreadAdapter: createAdapter_1.getThreadAdapter,
|
|
5090
|
+
// An answer to the agent's own question is never a request (S7), even delivered as a prompt.
|
|
5091
|
+
deliverLabel: (key, label) => withThreadLocale(key, () => deliverActivePrompt(key, (0, createAdapter_1.getThreadAdapter)(key), label, null)),
|
|
5092
|
+
resumeSleepingSessionForPrompt: (key) => resumeSleepingSessionForPrompt(key),
|
|
5093
|
+
checkIsStarting: (key) => startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key)),
|
|
5094
|
+
closeOrphanQuestionMessage,
|
|
5095
|
+
notePersonActivity,
|
|
4589
5096
|
});
|
|
4590
5097
|
/**
|
|
4591
5098
|
* @description True iff the neutral router owns `name`, i.e. a `/name` message
|
|
@@ -4703,6 +5210,11 @@ command('start', async (_ctx, key) => {
|
|
|
4703
5210
|
'/quit — quit current agent\n' +
|
|
4704
5211
|
'/status — show status');
|
|
4705
5212
|
});
|
|
5213
|
+
/** The `/status` agent line (`Claude Code (claude)`), flagged when the backend is the deprecated tmux one. */
|
|
5214
|
+
function formatStatusAgentLine(label, adapterName) {
|
|
5215
|
+
const agentLine = `${label} (${adapterName})`;
|
|
5216
|
+
return adapterName === adapterNames_1.claudeTmuxAdapterName ? (0, i18n_1.t)('status.thread_agentTmuxDeprecated', { agent: agentLine }) : agentLine;
|
|
5217
|
+
}
|
|
4706
5218
|
command('status', async (_ctx, key) => {
|
|
4707
5219
|
// In General the per-thread status is meaningless (no binding ever) — so
|
|
4708
5220
|
// we promote /status to a workspace-wide overview there. Topical threads
|
|
@@ -4725,7 +5237,7 @@ command('status', async (_ctx, key) => {
|
|
|
4725
5237
|
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
4726
5238
|
const isActive = adapter.checkIsActive(key);
|
|
4727
5239
|
const adapterName = (0, createAdapter_1.getThreadAdapterNameRaw)(key);
|
|
4728
|
-
const agentLine = adapterName ?
|
|
5240
|
+
const agentLine = adapterName ? formatStatusAgentLine(adapter.label, adapterName) : (0, i18n_1.t)('status.thread_no_agent');
|
|
4729
5241
|
const binding = state.getBinding(key);
|
|
4730
5242
|
const subdir = binding?.subdir ?? (0, i18n_1.t)('status.thread_no_binding');
|
|
4731
5243
|
let workDir = null;
|
|
@@ -4901,6 +5413,19 @@ command('timezone', async (_ctx, key, parsed) => {
|
|
|
4901
5413
|
const view = buildTimezoneRegionView();
|
|
4902
5414
|
await replyToThread(key, view.text, view.keyboard);
|
|
4903
5415
|
});
|
|
5416
|
+
/**
|
|
5417
|
+
* @description How the welcome's shared-folder row writes `peerKey`, a topic of the SAME chat as the one being bound: by the
|
|
5418
|
+
* name stored on its binding, else by the link that opens it (a person can follow it; a session key means nothing to them).
|
|
5419
|
+
* The link is the numeric `t.me/c/…` form, which opens a topic for any member of the group. `null` for a topic that can be
|
|
5420
|
+
* neither named nor linked (a thread of a private chat, say) — it is only counted, never shown as an id.
|
|
5421
|
+
*/
|
|
5422
|
+
function describeSharedTopic(peerKey) {
|
|
5423
|
+
const name = state.getBinding(peerKey)?.topicName;
|
|
5424
|
+
const link = (0, topicCommand_1.buildTopicLink)({ chatId: (0, sessionKeyCodec_1.getTelegramChatId)(peerKey), username: null }, (0, sessionKeyCodec_1.getTelegramThreadId)(peerKey));
|
|
5425
|
+
if (name === undefined && link === null)
|
|
5426
|
+
return null;
|
|
5427
|
+
return { name, link: link ?? '' };
|
|
5428
|
+
}
|
|
4904
5429
|
async function applyBinding(key, rawSubdir, options = {}) {
|
|
4905
5430
|
let subdir;
|
|
4906
5431
|
try {
|
|
@@ -4920,18 +5445,88 @@ async function applyBinding(key, rawSubdir, options = {}) {
|
|
|
4920
5445
|
// user should know they're about to join an existing workspace rather
|
|
4921
5446
|
// than start a fresh one.
|
|
4922
5447
|
const peers = state.listKeysForSubdir(subdir).filter(k => (0, sessionKeyCodec_1.checkIsTelegramKey)(k) && (0, sessionKey_1.keyToString)(k) !== (0, sessionKey_1.keyToString)(key));
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
5448
|
+
const topicName = options.topicName ?? (0, pendingTopicName_1.getTrustedPendingTopicName)(pendingTopicNames.get((0, sessionKey_1.keyToString)(key)), Date.now(), PENDING_TOPIC_NAME_TTL_MS);
|
|
5449
|
+
await state.setBinding(key, subdir, topicName !== undefined ? { topicName } : {});
|
|
5450
|
+
// Bound: the set-aside name has done its part (an unbound topic is the only reader of it), and a «create new folder» wait
|
|
5451
|
+
// armed on a picker is over — the next plain message is nobody's folder name any more.
|
|
5452
|
+
const boundKey = (0, sessionKey_1.keyToString)(key);
|
|
5453
|
+
pendingTopicNames.delete(boundKey);
|
|
5454
|
+
awaitingFolderName.delete(boundKey);
|
|
5455
|
+
bindPickerMessageIds.delete(boundKey);
|
|
5456
|
+
warmTodoProjects();
|
|
5457
|
+
// Only topics of THIS chat are named: a member of one group is not told what another group (or a private chat) calls its
|
|
5458
|
+
// topics. The rest — and a topic of this chat that can be neither named nor linked — are counted.
|
|
5459
|
+
const sharedWith = [];
|
|
5460
|
+
let sharedMore = 0;
|
|
5461
|
+
for (const peerKey of peers) {
|
|
5462
|
+
const described = peerKey.space === key.space ? describeSharedTopic(peerKey) : null;
|
|
5463
|
+
if (described === null)
|
|
5464
|
+
sharedMore += 1;
|
|
5465
|
+
else
|
|
5466
|
+
sharedWith.push(described);
|
|
5467
|
+
}
|
|
4930
5468
|
// Rebind resume (S8): a thread that was unbound paused its schedules; binding
|
|
4931
5469
|
// it again (this thread, possibly a different folder) resumes them. Best
|
|
4932
5470
|
// effort — a folder pick must succeed even if the resume bookkeeping hiccups.
|
|
4933
5471
|
await resumeThreadSchedulesOnRebind(key).catch((e) => console.warn(`[scheduler] resume-on-rebind failed for ${(0, sessionKey_1.keyToString)(key)}:`, e));
|
|
4934
|
-
return { ok: true,
|
|
5472
|
+
return { ok: true, subdir, sharedWith, sharedMore };
|
|
5473
|
+
}
|
|
5474
|
+
/**
|
|
5475
|
+
* @description Answer a bind attempt in the topic: the refusal's message, or — once bound — the rich welcome, the one
|
|
5476
|
+
* message announcing the binding (with the shared-folder row when other topics use the folder, and whatever else the
|
|
5477
|
+
* caller knows of this bind: `notes`). Shared by `/bind <subdir>`, the `bind_<subdir>` picker button and the picker's
|
|
5478
|
+
* «create new folder».
|
|
5479
|
+
*/
|
|
5480
|
+
async function replyToBindResult(key, result, notes = {}) {
|
|
5481
|
+
if (!result.ok) {
|
|
5482
|
+
await replyToThread(key, result.message);
|
|
5483
|
+
return;
|
|
5484
|
+
}
|
|
5485
|
+
await sendBindingWelcome(key, result.subdir, { ...notes, sharedWith: result.sharedWith, sharedMore: result.sharedMore });
|
|
5486
|
+
}
|
|
5487
|
+
/**
|
|
5488
|
+
* @description The `/bind` picker is one-shot (§ Inline keyboards): once a folder is bound through it — or the topic left its
|
|
5489
|
+
* folder from it — the message is deleted, since its text («not bound, pick from the list», «currently bound: …») is no
|
|
5490
|
+
* longer true and the welcome replaces it; a stale tap cannot rebind the topic either. A pick that bound nothing never gets
|
|
5491
|
+
* here, and paging or the create-folder prompt leave the picker alone. A message that stayed ({@link deleteThreadMessage}
|
|
5492
|
+
* resolved `false`: older than 48 hours, say) loses at least its buttons.
|
|
5493
|
+
*/
|
|
5494
|
+
async function removeBindPicker(key, pickerMessageId) {
|
|
5495
|
+
openBindMessages.forget((0, sessionKey_1.keyToString)(key), pickerMessageId);
|
|
5496
|
+
// A message that is gone has no buttons to strip (and Telegram would only answer «not found»).
|
|
5497
|
+
if (await deleteThreadMessageDetailed(key, pickerMessageId) === 'stayed')
|
|
5498
|
+
await editThreadKeyboard(key, pickerMessageId, null, 'bind');
|
|
5499
|
+
}
|
|
5500
|
+
/**
|
|
5501
|
+
* @description Delete every message still open in the topic that offers a bind or asks for a folder's name — each `/bind`
|
|
5502
|
+
* picker (every plain message in an unbound topic posted another), the «not bound» replies, the «send the folder name»
|
|
5503
|
+
* prompt — once the topic is bound or has left its folder, whichever way that happened (a tap, a typed `/bind <folder>`, the
|
|
5504
|
+
* bot's own auto-bind, a leave): their text is no longer true, and a tap on a stale one would rebind the topic. A message
|
|
5505
|
+
* the bot has no record of (sent before a restart) is not touched.
|
|
5506
|
+
*
|
|
5507
|
+
* Every one of them is held first ({@link BindPickerClaims.retire}, outside the limited claim list), so a tap is refused
|
|
5508
|
+
* whatever becomes of its message; the hold is let go of only for the ones with nothing left to tap. ONE bulk call deletes them
|
|
5509
|
+
* (each single delete is a paced send, and there can be dozens). A message too old for Telegram to delete loses its buttons
|
|
5510
|
+
* instead, and so does every one of a bulk call Telegram refused — no delete per picker — but only the newest `keyboardStripsPerSweepLimit` of them; the
|
|
5511
|
+
* older rest keeps its buttons, refused by the hold (`sweepOpenBindMessages`). The hold lives in memory: after a restart such a
|
|
5512
|
+
* picker works again. Never rejects: the callers that do not wait for it ({@link sendBindingWelcome}) have nothing to catch.
|
|
5513
|
+
*/
|
|
5514
|
+
async function closeOpenBindMessages(key) {
|
|
5515
|
+
try {
|
|
5516
|
+
await (0, bindSweep_1.sweepOpenBindMessages)(openBindMessages.take((0, sessionKey_1.keyToString)(key)), Date.now(), {
|
|
5517
|
+
retire: (messageId) => bindPickerClaims.retire(key, messageId),
|
|
5518
|
+
unretire: (messageId) => bindPickerClaims.unretire(key, messageId),
|
|
5519
|
+
deleteMessages: (messageIds) => deleteThreadMessages(key, messageIds),
|
|
5520
|
+
removeKeyboard: (messageId) => editThreadKeyboard(key, messageId, null, 'bind'),
|
|
5521
|
+
});
|
|
5522
|
+
}
|
|
5523
|
+
catch (e) {
|
|
5524
|
+
console.warn(`[bind] closing the open pickers of ${(0, sessionKey_1.keyToString)(key)} failed:`, e);
|
|
5525
|
+
}
|
|
5526
|
+
}
|
|
5527
|
+
/** Remove the buttons of the topic's earlier welcomes (the newest few; see `stripStaleKeyboards`). Never rejects. */
|
|
5528
|
+
async function stripWelcomeKeyboards(key, messageIds) {
|
|
5529
|
+
await (0, bindSweep_1.stripStaleKeyboards)(messageIds, (messageId) => editThreadKeyboard(key, messageId, null, 'welcome'));
|
|
4935
5530
|
}
|
|
4936
5531
|
/**
|
|
4937
5532
|
* @description Pause the AGENT-PROMPT schedules owned by `key` because its topic
|
|
@@ -5002,16 +5597,18 @@ async function resumeThreadSchedulesOnRebind(key) {
|
|
|
5002
5597
|
* armed-mode branches in the text handler never overlap (only one armed
|
|
5003
5598
|
* mode is active per thread at a time).
|
|
5004
5599
|
*/
|
|
5005
|
-
function armFolderCreation(key) {
|
|
5600
|
+
function armFolderCreation(key, pickerMessageId) {
|
|
5006
5601
|
const kStr = (0, sessionKey_1.keyToString)(key);
|
|
5007
5602
|
awaitingModelSelection.delete(kStr);
|
|
5008
5603
|
awaitingSessionSelection.delete(kStr);
|
|
5009
5604
|
// The `/topic` prompts are other readers of the next plain message.
|
|
5010
5605
|
cancelTopicInput(key);
|
|
5011
5606
|
// A stale pending question would otherwise consume the message AFTER the
|
|
5012
|
-
// folder name as an answer to the old (pre-rebind) agent question.
|
|
5013
|
-
|
|
5607
|
+
// folder name as an answer to the old (pre-rebind) agent question. Its message stays, closed as unanswered.
|
|
5608
|
+
retirePendingQuestionInBackground(key);
|
|
5014
5609
|
awaitingFolderName.add(kStr);
|
|
5610
|
+
if (pickerMessageId !== null)
|
|
5611
|
+
bindPickerMessageIds.set(kStr, pickerMessageId);
|
|
5015
5612
|
}
|
|
5016
5613
|
/** Map a `validateNewFolderName` rejection to its localised reply. */
|
|
5017
5614
|
function mapNewFolderError(reason) {
|
|
@@ -5027,10 +5624,10 @@ function mapNewFolderError(reason) {
|
|
|
5027
5624
|
/**
|
|
5028
5625
|
* @description Create a new folder under WORK_ROOT from a user-typed name:
|
|
5029
5626
|
* validate the name first (no traversal/slashes/dots), then `mkdir` (recursive:
|
|
5030
|
-
* an already-existing folder is fine — the caller just uses it, and
|
|
5031
|
-
*
|
|
5032
|
-
* the outcome says which, so the caller knows whether a retry
|
|
5033
|
-
* (`invalid`) or not (`failed`).
|
|
5627
|
+
* an already-existing folder is fine — the caller just uses it, and says so in its
|
|
5628
|
+
* own way: `alreadyExisted`). A refusal or a filesystem failure is reported to
|
|
5629
|
+
* `key`'s topic here; the outcome says which, so the caller knows whether a retry
|
|
5630
|
+
* is still wanted (`invalid`) or not (`failed`).
|
|
5034
5631
|
*/
|
|
5035
5632
|
async function createWorkFolder(key, rawName) {
|
|
5036
5633
|
const validated = (0, folderName_1.validateNewFolderName)(rawName);
|
|
@@ -5052,10 +5649,7 @@ async function createWorkFolder(key, rawName) {
|
|
|
5052
5649
|
// Folder freshly on disk → the cached subdir list is stale; drop it so the
|
|
5053
5650
|
// next picker render includes the new folder.
|
|
5054
5651
|
subdirCache.clear();
|
|
5055
|
-
|
|
5056
|
-
await replyToThread(key, (0, i18n_1.t)('bind.create_exists', { subdir: validated.name }));
|
|
5057
|
-
}
|
|
5058
|
-
return { outcome: 'ready', subdir: validated.name };
|
|
5652
|
+
return { outcome: 'ready', subdir: validated.name, alreadyExisted };
|
|
5059
5653
|
}
|
|
5060
5654
|
/**
|
|
5061
5655
|
* @description Create a new folder under WORK_ROOT from a user-typed name and
|
|
@@ -5068,21 +5662,36 @@ async function createWorkFolder(key, rawName) {
|
|
|
5068
5662
|
* filesystem failure disarms it.
|
|
5069
5663
|
*/
|
|
5070
5664
|
async function createAndBindFolder(key, rawName) {
|
|
5071
|
-
const
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
await
|
|
5080
|
-
|
|
5665
|
+
const kStr = (0, sessionKey_1.keyToString)(key);
|
|
5666
|
+
// Read before the bind: a bind drops it (and the wait) itself; deleted below for a refused one, which does not.
|
|
5667
|
+
const pickerMessageId = bindPickerMessageIds.get(kStr) ?? null;
|
|
5668
|
+
let isNameRefused = false;
|
|
5669
|
+
// The picker this name was asked on is held from BEFORE the folder is made, for the whole bind, like a tap on a folder holds
|
|
5670
|
+
// it: a tap on it that lands behind the typed name is told the picker was used instead of binding the topic a second time, and
|
|
5671
|
+
// a name that arrives when a tap already holds the picker makes no (empty) folder — it is told so, not dropped in silence.
|
|
5672
|
+
const run = await bindPickerClaims.runClaimed(key, pickerMessageId, async () => {
|
|
5673
|
+
const folder = await createWorkFolder(key, rawName);
|
|
5674
|
+
if (folder.outcome === 'invalid') {
|
|
5675
|
+
isNameRefused = true;
|
|
5676
|
+
return false;
|
|
5677
|
+
}
|
|
5678
|
+
if (folder.outcome === 'failed')
|
|
5679
|
+
return false;
|
|
5680
|
+
const result = await applyBinding(key, folder.subdir);
|
|
5681
|
+
bindPickerMessageIds.delete(kStr);
|
|
5682
|
+
if (result.ok && pickerMessageId !== null)
|
|
5683
|
+
await removeBindPicker(key, pickerMessageId);
|
|
5684
|
+
await replyToBindResult(key, result, { isExistingFolder: folder.alreadyExisted });
|
|
5685
|
+
return result.ok;
|
|
5686
|
+
});
|
|
5687
|
+
if (run === 'busy')
|
|
5688
|
+
await replyToThread(key, (0, i18n_1.t)('cb.bind_picker_used'));
|
|
5689
|
+
return { ok: !isNameRefused };
|
|
5081
5690
|
}
|
|
5082
5691
|
/**
|
|
5083
5692
|
* @description Bind a topic the bot has just created (`/topic`) to `subdir` and greet it with the usual binding welcome:
|
|
5084
|
-
* the folder's stats and the start buttons. No agent starts by itself: it is started there as in any bound topic. The
|
|
5085
|
-
* is left out
|
|
5693
|
+
* the folder's stats and the start buttons. No agent starts by itself: it is started there as in any bound topic. The welcome's
|
|
5694
|
+
* shared-folder row is left out (`sharedWith` is not passed on): it would warn of what the person just asked for. The topic's name
|
|
5086
5695
|
* rides on the binding for the thread-context preamble. Resolves why it could not bind, `null` once it is bound.
|
|
5087
5696
|
*/
|
|
5088
5697
|
async function bindNewTopic(key, subdir, topicName) {
|
|
@@ -5092,6 +5701,22 @@ async function bindNewTopic(key, subdir, topicName) {
|
|
|
5092
5701
|
await sendBindingWelcome(key, result.subdir);
|
|
5093
5702
|
return null;
|
|
5094
5703
|
}
|
|
5704
|
+
/**
|
|
5705
|
+
* @description Bind a topic the bot matched to the folder `subdirName` by the topic's own name, and welcome it. It goes
|
|
5706
|
+
* through {@link applyBinding} like every other bind (validation, the schedules a rebind resumes, the collision lookup), so
|
|
5707
|
+
* the welcome says why the bot did it AND names the topics already on the folder. The topic's name rides on the binding for
|
|
5708
|
+
* the thread-context preamble. Resolves whether the topic is bound: a refused folder is only logged, and the caller falls
|
|
5709
|
+
* back to the folder picker.
|
|
5710
|
+
*/
|
|
5711
|
+
async function autoBindTopicByName(key, subdirName, topicName) {
|
|
5712
|
+
const result = await applyBinding(key, subdirName, topicName ? { topicName } : {});
|
|
5713
|
+
if (!result.ok) {
|
|
5714
|
+
console.warn(`[auto-bind] "${subdirName}" refused for ${(0, sessionKey_1.keyToString)(key)}: ${result.message}`);
|
|
5715
|
+
return false;
|
|
5716
|
+
}
|
|
5717
|
+
await sendBindingWelcome(key, result.subdir, { isAutoBound: true, sharedWith: result.sharedWith, sharedMore: result.sharedMore });
|
|
5718
|
+
return true;
|
|
5719
|
+
}
|
|
5095
5720
|
command('bind', async (_ctx, key, parsed) => {
|
|
5096
5721
|
if (checkIsGeneral(key)) {
|
|
5097
5722
|
await replyToThread(key, (0, i18n_1.t)('bind.in_general'));
|
|
@@ -5118,10 +5743,7 @@ command('bind', async (_ctx, key, parsed) => {
|
|
|
5118
5743
|
await replyToThread(key, usage, (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!binding));
|
|
5119
5744
|
return;
|
|
5120
5745
|
}
|
|
5121
|
-
|
|
5122
|
-
await replyToThread(key, result.message);
|
|
5123
|
-
if (result.ok)
|
|
5124
|
-
await sendBindingWelcome(key, result.subdir);
|
|
5746
|
+
await replyToBindResult(key, await applyBinding(key, arg));
|
|
5125
5747
|
});
|
|
5126
5748
|
/**
|
|
5127
5749
|
* @description Unbind a thread from its folder: stop any live session, drop the
|
|
@@ -5142,6 +5764,11 @@ async function unbindThread(key) {
|
|
|
5142
5764
|
// Order matters: drop the pin FIRST (while binding.pinnedStatusMessageId
|
|
5143
5765
|
// is still readable), then stop the session, then wipe the binding.
|
|
5144
5766
|
await clearPinnedStatus(key);
|
|
5767
|
+
// A question still on screen ends here with its «not answered» note: the stop below is reported only after the process
|
|
5768
|
+
// is gone (json-stream stops in the background), by which time the teardown has cleared the question's state and the
|
|
5769
|
+
// report finds nothing to close — the buttons would stay, dead, with no note.
|
|
5770
|
+
if (pendingQuestions.has(kStr))
|
|
5771
|
+
await retirePendingQuestion(key);
|
|
5145
5772
|
// Stop any running session before discarding the binding so we don't leave
|
|
5146
5773
|
// an orphan tmux/SSE stream pointing at a directory we no longer track.
|
|
5147
5774
|
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
@@ -5160,7 +5787,7 @@ async function unbindThread(key) {
|
|
|
5160
5787
|
}
|
|
5161
5788
|
// Release persisted session ids too — otherwise re-binding this thread
|
|
5162
5789
|
// later + a bot restart would resurrect the old session the user unbound.
|
|
5163
|
-
await
|
|
5790
|
+
await releaseConversationIds(key);
|
|
5164
5791
|
// Pause this thread's schedules BEFORE dropping the binding: a fire against
|
|
5165
5792
|
// an unbound topic can't deliver, so the jobs park (paused + disarmed) and
|
|
5166
5793
|
// the next /bind resumes them. `removeBinding` only touches bindings/agents/
|
|
@@ -5480,11 +6107,15 @@ function buildStartAgentKeyboard() {
|
|
|
5480
6107
|
}
|
|
5481
6108
|
/**
|
|
5482
6109
|
* @description Compose and send the rich post-bind welcome described in
|
|
5483
|
-
* plan §20.5: header with the bound subdir,
|
|
5484
|
-
*
|
|
5485
|
-
*
|
|
5486
|
-
|
|
5487
|
-
|
|
6110
|
+
* plan §20.5: header with the bound subdir, the optional rows (why it was bound
|
|
6111
|
+
* when the bot did it by itself, that the folder was there already, CLAUDE.md /
|
|
6112
|
+
* .mcp.json / git, which other topics use the folder), and an inline keyboard to start an agent. It is the ONE
|
|
6113
|
+
* message every way of binding leaves in the topic. Falls back gracefully when
|
|
6114
|
+
* stats can't be probed.
|
|
6115
|
+
*/
|
|
6116
|
+
async function sendBindingWelcome(key, subdir, notes = {}) {
|
|
6117
|
+
// The welcomes this topic got before are outdated the moment this one goes out: their buttons would act on the CURRENT folder.
|
|
6118
|
+
const outdatedWelcomeIds = openWelcomeMessages.take((0, sessionKey_1.keyToString)(key)).map((entry) => entry.messageId);
|
|
5488
6119
|
// Pin the status banner first so the user sees it land near the top of
|
|
5489
6120
|
// the thread before the slower stats probe finishes. Fire-and-forget —
|
|
5490
6121
|
// a failed pin (missing permissions, closed topic) just logs a warning
|
|
@@ -5499,6 +6130,10 @@ async function sendBindingWelcome(key, subdir) {
|
|
|
5499
6130
|
stats = { claudeMdSize: null, mcpServerCount: null, gitBranch: null, gitClean: null };
|
|
5500
6131
|
}
|
|
5501
6132
|
const lines = [(0, i18n_1.t)('binding.welcome.header', { subdir })];
|
|
6133
|
+
if (notes.isAutoBound)
|
|
6134
|
+
lines.push((0, i18n_1.t)('binding.welcome.auto_bound'));
|
|
6135
|
+
if (notes.isExistingFolder)
|
|
6136
|
+
lines.push((0, i18n_1.t)('binding.welcome.existed'));
|
|
5502
6137
|
if (stats.claudeMdSize)
|
|
5503
6138
|
lines.push((0, i18n_1.t)('binding.welcome.claude_md', { size: stats.claudeMdSize }));
|
|
5504
6139
|
if (stats.mcpServerCount !== null) {
|
|
@@ -5513,12 +6148,23 @@ async function sendBindingWelcome(key, subdir) {
|
|
|
5513
6148
|
else if (fs.existsSync(path.join(workDir, '.git')) === false) {
|
|
5514
6149
|
lines.push((0, i18n_1.t)('binding.welcome.git_none'));
|
|
5515
6150
|
}
|
|
6151
|
+
const sharedMore = notes.sharedMore ?? 0;
|
|
6152
|
+
if ((notes.sharedWith && notes.sharedWith.length > 0) || sharedMore > 0) {
|
|
6153
|
+
const moreLabel = sharedMore > 0 ? (0, i18n_1.t)('binding.welcome.sharedMore', { count: sharedMore }) : null;
|
|
6154
|
+
lines.push((0, i18n_1.t)('binding.welcome.shared', { threads: (0, bindingWelcome_1.formatSharedTopicList)(notes.sharedWith ?? [], moreLabel) }));
|
|
6155
|
+
}
|
|
5516
6156
|
lines.push('');
|
|
5517
6157
|
lines.push((0, i18n_1.t)('binding.welcome.start_prompt'));
|
|
5518
|
-
await replyToThread(key, lines.join('\n'), {
|
|
6158
|
+
const welcomeMessageId = await replyToThread(key, lines.join('\n'), {
|
|
5519
6159
|
parse_mode: 'Markdown',
|
|
5520
6160
|
...buildStartAgentKeyboard(),
|
|
5521
6161
|
});
|
|
6162
|
+
if (welcomeMessageId !== null)
|
|
6163
|
+
openWelcomeMessages.remember((0, sessionKey_1.keyToString)(key), welcomeMessageId, Date.now());
|
|
6164
|
+
// Every bind ends in this welcome, so every bind sweeps here — after the welcome, and NOT waited for: what follows a bind
|
|
6165
|
+
// (an agent start, when the first message was a start phrase) must not hang on the deletions and edits, which are paced sends.
|
|
6166
|
+
void stripWelcomeKeyboards(key, outdatedWelcomeIds);
|
|
6167
|
+
void closeOpenBindMessages(key);
|
|
5522
6168
|
}
|
|
5523
6169
|
// `open_sessions` callback wires the [📋 Resume…] button to the `/sessions`
|
|
5524
6170
|
// flow — same `handleSessionsList` helper the slash command calls into, so
|
|
@@ -5740,10 +6386,9 @@ async function handleAgentStart(key, adapterName, args) {
|
|
|
5740
6386
|
await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
|
|
5741
6387
|
return;
|
|
5742
6388
|
}
|
|
5743
|
-
// "Claude Code" is one user-facing choice with
|
|
5744
|
-
//
|
|
5745
|
-
|
|
5746
|
-
adapterName = (0, createAdapter_1.resolveClaudeBackendName)(key);
|
|
6389
|
+
// "Claude Code" is one user-facing choice with several backends (json-stream,
|
|
6390
|
+
// per-turn, the deprecated tmux-scrape); open the thread's picked backend, else the default.
|
|
6391
|
+
adapterName = (0, createAdapter_1.resolveStartAdapterName)(key, adapterName);
|
|
5747
6392
|
// The other backend, with a conversation to hand over: ask first; the answer makes the switch.
|
|
5748
6393
|
const targetFamily = (0, backendSwitchPlan_1.getBackendFamily)(adapterName);
|
|
5749
6394
|
if (targetFamily !== null && await askBeforeCrossBackendSwitch(key, targetFamily, args))
|
|
@@ -5785,19 +6430,32 @@ command('claude', (_ctx, key, parsed) => handleStartCommand(parsed, key, 'claude
|
|
|
5785
6430
|
command(['opencode', 'oc'], (_ctx, key, parsed) => handleStartCommand(parsed, key, 'opencode'));
|
|
5786
6431
|
command('terminal', (_ctx, key, parsed) => handleStartCommand(parsed, key, 'terminal'));
|
|
5787
6432
|
registerProviderCommands();
|
|
5788
|
-
/** Human label for a Claude backend name (the
|
|
6433
|
+
/** Human label for a Claude backend name (the adapters share `label`
|
|
5789
6434
|
* "Claude Code", so the picker/notices need a distinguishing name). */
|
|
5790
6435
|
function getClaudeBackendLabel(name) {
|
|
5791
6436
|
if (name === adapterNames_1.claudeJsonStreamAdapterName)
|
|
5792
6437
|
return '⚡ JSON-stream';
|
|
5793
6438
|
if (name === adapterNames_1.claudePerTurnAdapterName)
|
|
5794
6439
|
return '🔁 Per-turn';
|
|
5795
|
-
return '🖥
|
|
6440
|
+
return '🖥 tmux';
|
|
6441
|
+
}
|
|
6442
|
+
/** The `/claude_mode` button text of a backend: its label, the deprecated tmux backend marked as such. A notice
|
|
6443
|
+
* keeps the plain label instead — the deprecation hint under it already says it. */
|
|
6444
|
+
function getClaudeBackendButtonLabel(name) {
|
|
6445
|
+
return name === adapterNames_1.claudeTmuxAdapterName ? (0, i18n_1.t)('claudeMode.tmuxButtonLabel') : getClaudeBackendLabel(name);
|
|
6446
|
+
}
|
|
6447
|
+
/** The one-line hint that the tmux backend is deprecated and which backend to use instead. */
|
|
6448
|
+
function getTmuxDeprecatedHint() {
|
|
6449
|
+
return (0, i18n_1.t)('claudeMode.tmuxDeprecatedHint', { recommended: getClaudeBackendLabel((0, createAdapter_1.getDefaultClaudeBackendName)()) });
|
|
6450
|
+
}
|
|
6451
|
+
/** `text` with the tmux deprecation hint under it when `backendName` is the tmux backend; unchanged otherwise. */
|
|
6452
|
+
function appendTmuxDeprecatedHint(backendName, text) {
|
|
6453
|
+
return backendName === adapterNames_1.claudeTmuxAdapterName ? `${text}\n${getTmuxDeprecatedHint()}` : text;
|
|
5796
6454
|
}
|
|
5797
6455
|
/** Build the `/claude_mode` picker: one button per Claude backend, `✓` on the
|
|
5798
6456
|
* current one. Callback data `ccmode_<adapterName>`. */
|
|
5799
6457
|
function buildClaudeModeKeyboard(current) {
|
|
5800
|
-
const buttons = [adapterNames_1.claudeJsonStreamAdapterName, adapterNames_1.claudePerTurnAdapterName,
|
|
6458
|
+
const buttons = [adapterNames_1.claudeJsonStreamAdapterName, adapterNames_1.claudePerTurnAdapterName, adapterNames_1.claudeTmuxAdapterName].map((name) => telegraf_1.Markup.button.callback(name === current ? `${getClaudeBackendButtonLabel(name)} ✓` : getClaudeBackendButtonLabel(name), `ccmode_${name}`));
|
|
5801
6459
|
return telegraf_1.Markup.inlineKeyboard(buttons, { columns: 1 });
|
|
5802
6460
|
}
|
|
5803
6461
|
/**
|
|
@@ -5864,14 +6522,14 @@ command('claude_mode', async (_ctx, key, parsed) => {
|
|
|
5864
6522
|
return;
|
|
5865
6523
|
}
|
|
5866
6524
|
if (action.kind === 'already') {
|
|
5867
|
-
await replyToThread(key, (0, i18n_1.t)('claudeMode.already', { label: getClaudeBackendLabel(action.backendName) }));
|
|
6525
|
+
await replyToThread(key, appendTmuxDeprecatedHint(action.backendName, (0, i18n_1.t)('claudeMode.already', { label: getClaudeBackendLabel(action.backendName) })));
|
|
5868
6526
|
return;
|
|
5869
6527
|
}
|
|
5870
6528
|
if (action.kind === 'switch') {
|
|
5871
|
-
await replyToThread(key, await applyClaudeBackendSwitch(key, action.backendName));
|
|
6529
|
+
await replyToThread(key, appendTmuxDeprecatedHint(action.backendName, await applyClaudeBackendSwitch(key, action.backendName)));
|
|
5872
6530
|
return;
|
|
5873
6531
|
}
|
|
5874
|
-
await replyToThread(key, (0, i18n_1.t)('claudeMode.pick', { label: getClaudeBackendLabel(action.currentBackendName) }), buildClaudeModeKeyboard(action.currentBackendName));
|
|
6532
|
+
await replyToThread(key, (0, i18n_1.t)('claudeMode.pick', { label: getClaudeBackendLabel(action.currentBackendName), hint: getTmuxDeprecatedHint() }), buildClaudeModeKeyboard(action.currentBackendName));
|
|
5875
6533
|
});
|
|
5876
6534
|
registerModelCommands();
|
|
5877
6535
|
const { registerDisplayModeCommands, registerDisplayModeCallbacks, } = (0, displayModes_1.createDisplayModes)(botCore);
|
|
@@ -5909,6 +6567,24 @@ const { applyAutoContinueLimits, consumeAutoContinueLimitsPicker, registerAutoCo
|
|
|
5909
6567
|
getRequestWakeUpEngine: () => requestWakeUpEngine,
|
|
5910
6568
|
});
|
|
5911
6569
|
registerAutoContinueLimitsCommands();
|
|
6570
|
+
// ── «Bot skills» switch (`/bot_skills`): `connectors/telegram/commands/botSkills.ts`, `utils/botSkills.ts` ──
|
|
6571
|
+
const { registerBotSkillsCommands, registerBotSkillsCallbacks } = (0, botSkills_2.createBotSkills)({
|
|
6572
|
+
...botCore,
|
|
6573
|
+
readBotSkills: botSkillsReader,
|
|
6574
|
+
readBotSkillsInstanceValue: () => (0, botSkills_1.readBotSkillsInstanceValue)(botSkillsSources),
|
|
6575
|
+
checkIsOpenCodeTopic: (key) => ((0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name) === adapterNames_1.openCodeAdapterName,
|
|
6576
|
+
});
|
|
6577
|
+
registerBotSkillsCommands();
|
|
6578
|
+
// ── The cap on tracker task agents (`/task_agents`): `connectors/telegram/commands/taskAgents.ts`, `trackers/` ──
|
|
6579
|
+
const { registerTaskAgentsCommands, registerTaskAgentsCallbacks } = (0, taskAgents_1.createTaskAgents)({
|
|
6580
|
+
...botCore,
|
|
6581
|
+
readTaskAgentLimit: () => (0, taskAgentLimit_1.readTaskAgentLimit)(taskAgentLimitSources),
|
|
6582
|
+
// A General value just written may raise the cap: the waiting issues start at once.
|
|
6583
|
+
noteTaskAgentLimitChanged: () => taskAgentSlots?.noteLimitMayHaveChanged(),
|
|
6584
|
+
// The slots decide nothing on an instance that serves no tracker: its screen says nothing of them.
|
|
6585
|
+
readTaskAgentStatus: () => ([...ENV.servedPlatforms].some((platform) => platform !== 'telegram') ? taskAgentSlots?.getStatus() ?? null : null),
|
|
6586
|
+
});
|
|
6587
|
+
registerTaskAgentsCommands();
|
|
5912
6588
|
// ── Claude account profiles (`/account`): `utils/claudeProfiles.ts`, `commands/claudeAccounts.ts` ──
|
|
5913
6589
|
/**
|
|
5914
6590
|
* @description The Claude config directory a conversation's NEXT process starts with: its resolved account
|
|
@@ -5941,7 +6617,13 @@ async function readClaudeAuthStatusUnder(configDir) {
|
|
|
5941
6617
|
}
|
|
5942
6618
|
catch (e) {
|
|
5943
6619
|
// A logged-out directory makes the CLI exit non-zero WITH its JSON on stdout: that is the answer.
|
|
5944
|
-
|
|
6620
|
+
const status = (0, claudeProfiles_1.parseClaudeAuthStatus)((0, claudeProfiles_1.readProcessErrorStdout)(e));
|
|
6621
|
+
// No answer at all (a probe killed by its timeout on a loaded host, a CLI that did not start): one line, so a
|
|
6622
|
+
// missing status can be told from a signed-out one.
|
|
6623
|
+
if (status === null) {
|
|
6624
|
+
console.log(`[accounts] the auth status probe under ${configDir ?? 'the default directory'} gave no status: ${(0, claudeProfiles_1.getProcessFailureReason)(e, claudeAuthStatusProbeTimeoutMs)}`);
|
|
6625
|
+
}
|
|
6626
|
+
return status;
|
|
5945
6627
|
}
|
|
5946
6628
|
}
|
|
5947
6629
|
const claudeAuthStatusProbeTimeoutMs = 5000;
|
|
@@ -6153,12 +6835,39 @@ function getTopicFallbackPosition(key) {
|
|
|
6153
6835
|
const parsed = label ? (0, fallbackChain_1.parseOpenCodeModelLabel)(label) : null;
|
|
6154
6836
|
return parsed ? { backend: 'opencode', ...parsed } : null;
|
|
6155
6837
|
}
|
|
6838
|
+
/**
|
|
6839
|
+
* @description Whether the account or model `key` runs on is marked limited now, and whether it is billed to API
|
|
6840
|
+
* credits — what the todo list's standalone reminder never fires into (D12).
|
|
6841
|
+
*/
|
|
6842
|
+
function getConversationAccountState(key) {
|
|
6843
|
+
const position = getTopicFallbackPosition(key);
|
|
6844
|
+
if (position === null)
|
|
6845
|
+
return { isLimited: false, isApiBilled: false };
|
|
6846
|
+
const nowMs = Date.now();
|
|
6847
|
+
const checkIsFuture = (instant) => instant !== undefined && Date.parse(instant) > nowMs;
|
|
6848
|
+
if (position.backend === 'opencode')
|
|
6849
|
+
return { isLimited: checkIsFuture(state.getOpenCodeModelLimitedUntil((0, fallbackChain_1.buildOpenCodeModelLabel)(position))), isApiBilled: false };
|
|
6850
|
+
const info = state.getClaudeProfileInfo(position.profile);
|
|
6851
|
+
return { isLimited: checkIsFuture(info?.limitedUntil) || info?.creditExhaustedAt !== undefined, isApiBilled: info?.billing === 'api' };
|
|
6852
|
+
}
|
|
6853
|
+
/**
|
|
6854
|
+
* @description A person wrote in the conversation (a message, voice, file, album; an issue's request): the
|
|
6855
|
+
* compact-on-idle latch clears (D2), the todo reminder's silence after ignored reminders ends (D13), and a question the
|
|
6856
|
+
* idle compaction re-asked is closed as unanswered — they wrote something else instead of tapping it, like a live
|
|
6857
|
+
* question a message cancels, and its buttons must not be tapped later onto whatever the agent asks next. A tap on the
|
|
6858
|
+
* re-asked question takes its record before it gets here, so it is not closed by its own answer.
|
|
6859
|
+
*/
|
|
6860
|
+
function notePersonActivity(key) {
|
|
6861
|
+
noteThreadUserActivity(key);
|
|
6862
|
+
getTodoRegistry().notePersonActivity(key);
|
|
6863
|
+
closeReAskedQuestion(key);
|
|
6864
|
+
}
|
|
6156
6865
|
/**
|
|
6157
6866
|
* @description What a usage limit on `key` leads to (`utils/fallbackChain.ts`): the rung the topic is on is
|
|
6158
6867
|
* marked limited until the reset the error named (a Claude profile: else its session window, else the retry's
|
|
6159
6868
|
* blind delay; an OpenCode model: else the blind delay), the usage snapshots are brought up to date, and the
|
|
6160
6869
|
* chain's AVAILABLE rungs are listed in priority order. Without a configured chain a Claude topic falls back to
|
|
6161
|
-
* the within-Claude
|
|
6870
|
+
* the within-Claude ranking by the soonest weekly reset (`utils/claudeLimitSwitch.ts`) — the behaviour before the chain existed. An
|
|
6162
6871
|
* offer is remembered per thread under its episode so only ITS buttons can act on it. `none` for a non-Telegram
|
|
6163
6872
|
* conversation, a topic with no account to switch from, or when no rung can take over.
|
|
6164
6873
|
*/
|
|
@@ -6261,35 +6970,58 @@ function getBalanceSpentLine(from) {
|
|
|
6261
6970
|
function readRunningModelBeforeMove(key, from, to) {
|
|
6262
6971
|
return from?.backend === 'claude' && to.backend === 'claude' ? getThreadClaudeModel(key) : null;
|
|
6263
6972
|
}
|
|
6973
|
+
/** Where the topic's agent keeps its conversation: its adapter, the bound folder (`null` when unbound) and the persisted session id (`null` when it holds none yet). */
|
|
6974
|
+
function getConversationSource(key) {
|
|
6975
|
+
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
6976
|
+
const decision = getWorkDirStartDecision(key);
|
|
6977
|
+
const sessionId = (0, ensureSessionPlan_1.getPersistedSessionIdForAdapter)(state.getAgent(key) ?? null, adapter.name, (0, createAdapter_1.checkIsClaudeBackend)(adapter.name));
|
|
6978
|
+
return { adapter, workDir: decision.ok ? decision.workDir : null, sessionId };
|
|
6979
|
+
}
|
|
6264
6980
|
/**
|
|
6265
6981
|
* @description The last `limit` turns (oldest first) of the conversation the topic's agent holds, off its own
|
|
6266
6982
|
* transcript. `[]` when it has none yet (no session id persisted); `null` when they cannot be read (no bound
|
|
6267
6983
|
* folder, an adapter without a transcript reader, a read that failed).
|
|
6268
6984
|
*/
|
|
6269
6985
|
async function readConversationTurns(key, limit) {
|
|
6270
|
-
const adapter = (
|
|
6271
|
-
const decision = getWorkDirStartDecision(key);
|
|
6272
|
-
const sessionId = (0, ensureSessionPlan_1.getPersistedSessionIdForAdapter)(state.getAgent(key) ?? null, adapter.name, (0, createAdapter_1.checkIsClaudeBackend)(adapter.name));
|
|
6986
|
+
const { adapter, workDir, sessionId } = getConversationSource(key);
|
|
6273
6987
|
if (!sessionId)
|
|
6274
6988
|
return [];
|
|
6275
|
-
if (
|
|
6989
|
+
if (workDir === null || !adapter.getRecentTurns)
|
|
6276
6990
|
return null;
|
|
6277
6991
|
try {
|
|
6278
|
-
return await adapter.getRecentTurns(key,
|
|
6992
|
+
return await adapter.getRecentTurns(key, workDir, sessionId, limit);
|
|
6279
6993
|
}
|
|
6280
6994
|
catch (e) {
|
|
6281
6995
|
console.warn(`[fallback] ${(0, sessionKey_1.keyToString)(key)}: the previous agent's turns are unreadable:`, e instanceof Error ? e.message : e);
|
|
6282
6996
|
return null;
|
|
6283
6997
|
}
|
|
6284
6998
|
}
|
|
6999
|
+
/**
|
|
7000
|
+
* @description The summary the topic's agent's own compaction left in its conversation (`AgentAdapter.getSessionCompactionSummary`),
|
|
7001
|
+
* for the context bridge to hand on beside the turns; `null` when it has none, the backend keeps none, or it cannot be read.
|
|
7002
|
+
*/
|
|
7003
|
+
async function readConversationCompactionSummary(key) {
|
|
7004
|
+
const { adapter, workDir, sessionId } = getConversationSource(key);
|
|
7005
|
+
if (!sessionId || workDir === null || !adapter.getSessionCompactionSummary)
|
|
7006
|
+
return null;
|
|
7007
|
+
try {
|
|
7008
|
+
return await adapter.getSessionCompactionSummary(key, workDir, sessionId);
|
|
7009
|
+
}
|
|
7010
|
+
catch (e) {
|
|
7011
|
+
console.warn(`[fallback] ${(0, sessionKey_1.keyToString)(key)}: the previous agent's compaction summary is unreadable:`, e instanceof Error ? e.message : e);
|
|
7012
|
+
return null;
|
|
7013
|
+
}
|
|
7014
|
+
}
|
|
6285
7015
|
/**
|
|
6286
7016
|
* @description The context bridge for a cross-backend switch (`utils/fallbackBridge.ts`), built BEFORE the old
|
|
6287
|
-
* adapter is stopped: its last turns off its own transcript (an unreadable one yields a bridge without turns)
|
|
7017
|
+
* adapter is stopped: its last turns off its own transcript (an unreadable one yields a bridge without turns) and the
|
|
7018
|
+
* summary of its own earlier compaction, if any.
|
|
6288
7019
|
* `reason` is why the agent changes — a usage limit or the operator's own switch.
|
|
6289
7020
|
*/
|
|
6290
7021
|
async function buildFallbackBridge(key, input) {
|
|
6291
7022
|
const turns = await readConversationTurns(key, fallbackBridge_1.fallbackBridgeTurnLimit) ?? [];
|
|
6292
|
-
|
|
7023
|
+
const compactionSummary = await readConversationCompactionSummary(key);
|
|
7024
|
+
return (0, fallbackBridge_1.buildFallbackBridgePrompt)({ ...input, turns, compactionSummary, replyLanguage: (0, i18n_1.getLocaleEndonym)((0, i18n_1.getActiveLang)()) });
|
|
6293
7025
|
}
|
|
6294
7026
|
/** The backend the topic's agent runs on, by name (`Claude` / `OpenCode`), for a label whose rung is unknown. */
|
|
6295
7027
|
function formatThreadBackend(key) {
|
|
@@ -6372,19 +7104,31 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
|
|
|
6372
7104
|
}
|
|
6373
7105
|
firstPrompt = { text: bridge, isRequestPrompt: false };
|
|
6374
7106
|
}
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
6381
|
-
|
|
6382
|
-
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
7107
|
+
// From the end of the wait until the continue prompt is forwarded, the idle the limit left behind is not a turn end (the
|
|
7108
|
+
// same hold a retry kick has): the model pin below re-spawns the session for seconds, and a wake-up engine that polled
|
|
7109
|
+
// then would remind the agent on top of the prompt this move is about to send. A kick that moved the topic holds it already.
|
|
7110
|
+
const isHoldingTurnEnd = !apiRetryKicksInFlight.has(k);
|
|
7111
|
+
if (isHoldingTurnEnd)
|
|
7112
|
+
apiRetryKicksInFlight.add(k);
|
|
7113
|
+
try {
|
|
7114
|
+
cancelApiRetry(key); // retires the offer too: the switch consumed it
|
|
7115
|
+
const notice = `${getFallbackSwitchedNotice(from, to, runningModel)}${isStoppingBackground ? `\n${(0, i18n_1.t)('fallback.backgroundStopped')}` : ''}`;
|
|
7116
|
+
console.log(`[fallback] ${k}: switched from ${fromLabel} to ${toLabel} after a usage limit${firstPrompt ? ' (cross-backend, bridged)' : ''}; resuming`);
|
|
7117
|
+
await say(notice);
|
|
7118
|
+
await resumeTopicOnNewSession(key, {
|
|
7119
|
+
modelToPin: (0, fallbackChain_1.getFallbackModelToPin)(to, runningModel),
|
|
7120
|
+
// Read after the session is up: the open requests may have changed while it started.
|
|
7121
|
+
getFirstPrompt: () => firstPrompt ?? (0, limitHeldPrompts_1.getLimitResumeMessage)({
|
|
7122
|
+
continueNudge: (0, i18n_1.t)('apiRetry.continueNudge'),
|
|
7123
|
+
untakenRequestPrompt: (0, requestHeader_1.joinPromptsNotTakenIn)(requestLimitWaitAnswerDeps?.ledger.listOpenRequestsOf(key) ?? []),
|
|
7124
|
+
}),
|
|
7125
|
+
});
|
|
7126
|
+
return { isSwitched: true, notice };
|
|
7127
|
+
}
|
|
7128
|
+
finally {
|
|
7129
|
+
if (isHoldingTurnEnd)
|
|
7130
|
+
apiRetryKicksInFlight.delete(k);
|
|
7131
|
+
}
|
|
6388
7132
|
}
|
|
6389
7133
|
/**
|
|
6390
7134
|
* @description The move ACROSS backends both the usage-limit switch and the operator's manual switch make, first
|
|
@@ -6611,10 +7355,14 @@ async function takeFallbackSwitchOffer(key, episodeId, index) {
|
|
|
6611
7355
|
const { notice } = await switchTopicToFallbackEntry(key, offer.from, decision.to, { isAnnounced: false });
|
|
6612
7356
|
return notice;
|
|
6613
7357
|
}
|
|
6614
|
-
/**
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
7358
|
+
/**
|
|
7359
|
+
* The `claude_profiles` MCP tool's answer: snapshots refreshed on demand (a minute's cache; the account a resumed session
|
|
7360
|
+
* was stopped on is re-read at once), then the launch pick — a new agent, or the session's own account when it is warm.
|
|
7361
|
+
* The accounts the operator switched off in the `/limits` list are never picked for a new agent.
|
|
7362
|
+
*/
|
|
7363
|
+
async function listClaudeProfilesForAgent(query) {
|
|
7364
|
+
await (0, claudeUsageRefresher_1.refreshClaudeUsageForLaunch)(claudeUsageRefresher, { currentProfile: query.currentProfile, isStoppedByLimit: query.isStoppedByLimit, maxAgeMs: claudeUsageRefresher_1.claudeUsageOnDemandMaxAgeMs });
|
|
7365
|
+
return (0, claudeUsage_1.buildClaudeProfilesReport)((0, claudeUsage_1.buildClaudeProfileUsageViews)({ dataDir: (0, state_1.resolveDataDir)(), records: state.getAllClaudeProfileInfos() }), query.model, Date.now(), { currentProfile: query.currentProfile, isStoppedByLimit: query.isStoppedByLimit, excludedNames: (0, fallbackChain_1.getDisabledClaudeProfileNames)(state.getFallbackChain()) });
|
|
6618
7366
|
}
|
|
6619
7367
|
const { registerFallbackChainCommands, registerFallbackChainCallbacks, openAutoSwitchView, runAutoSwitchCommand, applyAutoSwitch } = (0, fallbackChain_2.createFallbackChain)({
|
|
6620
7368
|
...botCore,
|
|
@@ -6626,6 +7374,15 @@ const { registerFallbackChainCommands, registerFallbackChainCallbacks, openAutoS
|
|
|
6626
7374
|
getTopicPosition: getTopicFallbackPosition,
|
|
6627
7375
|
takeSwitchOffer: takeFallbackSwitchOffer,
|
|
6628
7376
|
});
|
|
7377
|
+
const { registerTodoCommands, registerTodoCallbacks } = (0, todo_1.createTodo)({
|
|
7378
|
+
...botCore,
|
|
7379
|
+
getTodoRegistry,
|
|
7380
|
+
readTodoSettings,
|
|
7381
|
+
getSettingsPath: () => (0, instanceSettings_1.getInstanceSettingsPath)(),
|
|
7382
|
+
listTodoProjects,
|
|
7383
|
+
getConversationLabel: getTodoConversationLabel,
|
|
7384
|
+
checkIsConversationAsleep: (conversationKey) => getTodoSessionActivity(conversationKey) === 'asleep',
|
|
7385
|
+
});
|
|
6629
7386
|
const { registerLimitsBoardCommands, registerLimitsBoardCallbacks } = (0, limitsBoard_1.createLimitsBoard)({
|
|
6630
7387
|
...botCore,
|
|
6631
7388
|
getInstanceTimezone: getCurrentTimezone,
|
|
@@ -6685,6 +7442,7 @@ registerClaudeAccountCommands();
|
|
|
6685
7442
|
registerTopicCommand();
|
|
6686
7443
|
registerFallbackChainCommands();
|
|
6687
7444
|
registerLimitsBoardCommands();
|
|
7445
|
+
registerTodoCommands();
|
|
6688
7446
|
registerSttCommands();
|
|
6689
7447
|
const { registerSelfUpdateCommands, registerSelfUpdateCallbacks } = (0, selfUpdate_1.createSelfUpdate)({
|
|
6690
7448
|
...botCore,
|
|
@@ -6801,8 +7559,10 @@ async function resumeSessionByIndex(key, idx, onExpired) {
|
|
|
6801
7559
|
// The ONLY resume path that posts the "last N messages" context block —
|
|
6802
7560
|
// silent re-attach (bot restart) and crash recovery must stay quiet.
|
|
6803
7561
|
await adapter.resumeSession(key, workDir, sessionId, { isWithRecentContext: true });
|
|
6804
|
-
// Another session took over this topic; it does not know the open request
|
|
7562
|
+
// Another session took over this topic; it does not know the open request, and the question the idle compaction re-asked
|
|
7563
|
+
// for the conversation it replaces would send its label into the picked one.
|
|
6805
7564
|
cancelConversationRequest(key);
|
|
7565
|
+
closeReAskedQuestion(key);
|
|
6806
7566
|
// Persist the PICKED id — without this the next restart re-attaches to
|
|
6807
7567
|
// whatever id the last fresh start wrote, silently dropping the user's
|
|
6808
7568
|
// pick (live incident 2026-06-10).
|
|
@@ -6819,7 +7579,7 @@ async function resumeSessionByIndex(key, idx, onExpired) {
|
|
|
6819
7579
|
}
|
|
6820
7580
|
command(['sessions', 'resume'], (_ctx, key) => handleSessionsList(key));
|
|
6821
7581
|
/**
|
|
6822
|
-
* @description `/
|
|
7582
|
+
* @description `/quitall` — kill every active agent across every thread.
|
|
6823
7583
|
*
|
|
6824
7584
|
* General-only because it's a workspace-wide operation that's easy to
|
|
6825
7585
|
* fire by mistake; restricting to General + needing an explicit command
|
|
@@ -6832,7 +7592,7 @@ command(['sessions', 'resume'], (_ctx, key) => handleSessionsList(key));
|
|
|
6832
7592
|
* adapter that thread is configured for and stop only if it's active —
|
|
6833
7593
|
* idempotent, so re-running is safe.
|
|
6834
7594
|
*/
|
|
6835
|
-
command(
|
|
7595
|
+
command('quitall', async (_ctx, key) => {
|
|
6836
7596
|
if (!checkIsGeneral(key)) {
|
|
6837
7597
|
await replyToThread(key, (0, i18n_1.t)('quit_all.general_only'));
|
|
6838
7598
|
return;
|
|
@@ -6852,7 +7612,7 @@ command(['quit-all', 'quitall'], async (_ctx, key) => {
|
|
|
6852
7612
|
active += result.attempted;
|
|
6853
7613
|
stopped += result.stopped.length;
|
|
6854
7614
|
// Same release-for-good semantics as `/quit`, per swept thread.
|
|
6855
|
-
await
|
|
7615
|
+
await releaseConversationIds(bKey);
|
|
6856
7616
|
}
|
|
6857
7617
|
if (active === 0) {
|
|
6858
7618
|
await replyToThread(key, (0, i18n_1.t)('quit_all.none_active'));
|
|
@@ -6907,7 +7667,7 @@ command(['quit', 'q'], async (_ctx, key) => {
|
|
|
6907
7667
|
if (!primaryActive) {
|
|
6908
7668
|
// A sleeping conversation is released for good: the next message starts a fresh one.
|
|
6909
7669
|
if (getResumableSessionId(key) !== null) {
|
|
6910
|
-
await
|
|
7670
|
+
await releaseConversationIds(key);
|
|
6911
7671
|
await replyToThread(key, (0, i18n_1.t)('agent.sleeping_released'));
|
|
6912
7672
|
return;
|
|
6913
7673
|
}
|
|
@@ -6928,8 +7688,14 @@ command(['quit', 'q'], async (_ctx, key) => {
|
|
|
6928
7688
|
await replyToThread(key, (0, i18n_1.t)('agent.exit_signal_sent', { label: adapter.label }));
|
|
6929
7689
|
return;
|
|
6930
7690
|
}
|
|
6931
|
-
// OpenCode, terminal, any future adapter: tear down via stopSession.
|
|
6932
|
-
|
|
7691
|
+
// OpenCode, terminal, any future adapter: tear down via stopSession. «Stopped» is said once it is: a `/claude` sent on the
|
|
7692
|
+
// reply must find the session gone, not «already running» — as one transition, so a start or resume waits behind it.
|
|
7693
|
+
await sessionTransitions.run((0, sessionKey_1.keyToString)(key), async () => {
|
|
7694
|
+
if (adapter.stopSessionAndWait)
|
|
7695
|
+
await adapter.stopSessionAndWait(key);
|
|
7696
|
+
else
|
|
7697
|
+
adapter.stopSession(key);
|
|
7698
|
+
});
|
|
6933
7699
|
// Explicit quit releases the session for good — no auto-reattach later.
|
|
6934
7700
|
await state.clearAgentSessionIds(key);
|
|
6935
7701
|
await replyToThread(key, (0, i18n_1.t)('agent.stopped', { label: adapter.label }));
|
|
@@ -7198,8 +7964,8 @@ command('schedule', async (ctx, key, parsed) => {
|
|
|
7198
7964
|
await replyToThread(key, result.message);
|
|
7199
7965
|
const isStarting = startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key));
|
|
7200
7966
|
const opening = await openTopicRequest(key, wrappedPrompt, { source: 'schedule', requesterId: (0, topicRequest_1.getTopicRequesterId)(ctx.from) });
|
|
7201
|
-
await deliverPromptOrBuffer(key, `${opening.header}${wrappedPrompt}`, isStarting);
|
|
7202
|
-
|
|
7967
|
+
const delivery = await deliverPromptOrBuffer(key, `${opening.header}${wrappedPrompt}`, isStarting);
|
|
7968
|
+
await settleTopicRequestAfterDelivery(key, opening, delivery);
|
|
7203
7969
|
});
|
|
7204
7970
|
const reminderWizards = new Map();
|
|
7205
7971
|
const { cancelReminderWizard, claimReminderTextCapture, expireReminderWizard, finishReminderWizard, registerReminderCommands, registerReminderCallbacks, } = (0, reminders_1.createReminders)({
|
|
@@ -7278,16 +8044,18 @@ command('clear_messages', async (ctx, key) => {
|
|
|
7278
8044
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
7279
8045
|
// Slash tokens the text handler must NOT forward to the agent. Covers every
|
|
7280
8046
|
// bot-owned command (incl. the raw-key controls c/y/n/enter/up/down/tab/esc)
|
|
7281
|
-
// PLUS the retired commands (agent/cancel/unbind/where/stop/stopall/stop-all)
|
|
7282
|
-
// their handlers were removed
|
|
7283
|
-
//
|
|
7284
|
-
//
|
|
8047
|
+
// PLUS the retired commands (agent/cancel/unbind/where/stop/stopall/stop-all)
|
|
8048
|
+
// and the spelling `quit-all`: their handlers were removed (`quit-all` never
|
|
8049
|
+
// worked — Telegram ends a command at the hyphen, so a typed `/quit-all` is
|
|
8050
|
+
// routed as `/quit`), but keeping the tokens here makes a stray `/where` (or
|
|
8051
|
+
// `/stop`, or an entity-less `/quit-all`) inert instead of typing a meaningless
|
|
8052
|
+
// prompt into the agent.
|
|
7285
8053
|
const botCommands = new Set([
|
|
7286
8054
|
'start', 'claude', 'opencode', 'oc', 'terminal', 'agent', 'sessions', 'resume', 'cancel', 'model', 'connect',
|
|
7287
|
-
'disconnect', 'stop', 'stopall', 'stop-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'interrupt', 'output', 'clear_messages',
|
|
8055
|
+
'disconnect', 'stop', 'stopall', 'stop-all', 'quit-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'interrupt', 'output', 'clear_messages',
|
|
7288
8056
|
'bind', 'topic', 'unbind', 'where', 'ls', 'list', 'new', 'clear_session', 'whoami', 'version', 'help', 'language', 'lang',
|
|
7289
|
-
'doctor', 'mcp', 'rename_session', 'compact', 'compact_on_idle', 'compact_summary', 'auto_continue_limits', 'limits', 'limits_autoswitch', 'trace', 'timestamps', 'timezone', 'schedule', 'reminders', 'thinking', 'tool_results',
|
|
7290
|
-
'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', '
|
|
8057
|
+
'doctor', 'mcp', 'rename_session', 'compact', 'compact_on_idle', 'compact_summary', 'bot_skills', 'task_agents', 'auto_continue_limits', 'limits', 'limits_autoswitch', 'trace', 'timestamps', 'timezone', 'schedule', 'reminders', 'todo', 'thinking', 'tool_results',
|
|
8058
|
+
'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quitall', 'pair', 'account', 'accounts', 'fallback', 'stt', 'voice', 'speech', 'update',
|
|
7291
8059
|
]);
|
|
7292
8060
|
/**
|
|
7293
8061
|
* @description The bare `/clear` is no longer bot-owned (it was renamed to
|
|
@@ -7432,6 +8200,9 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
|
|
|
7432
8200
|
await bufferPromptDuringStartup(key, text, { source: 'text', requesterId: (0, topicRequest_1.getTopicRequesterId)(ctx.from) }, getReplyQuoteBlock(key, ctx.message));
|
|
7433
8201
|
return;
|
|
7434
8202
|
}
|
|
8203
|
+
// Set when this very message bound the topic: its welcome is already the answer, so the idle-thread hint at the end
|
|
8204
|
+
// stays out — but the message itself is still read as what it is (a start phrase starts the agent).
|
|
8205
|
+
let isJustAutoBound = false;
|
|
7435
8206
|
// Audit S2 / #5: if `forum_topic_created` was emitted by a non-allowed
|
|
7436
8207
|
// admin, we stashed the topic name in `pendingTopicNames` without
|
|
7437
8208
|
// binding. Now that an allowed user is engaging this thread, retry the
|
|
@@ -7441,29 +8212,28 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
|
|
|
7441
8212
|
// explicitly chose to create a new folder, so this message is the name —
|
|
7442
8213
|
// don't let a stale topic-name cache hijack it into a different bind.
|
|
7443
8214
|
if (!checkIsGeneral(key) && !state.getBinding(key) && !awaitingFolderName.has(kStr)) {
|
|
7444
|
-
const
|
|
7445
|
-
|
|
8215
|
+
const pendingEntry = pendingTopicNames.get(kStr);
|
|
8216
|
+
const pending = (0, pendingTopicName_1.getFreshPendingTopicName)(pendingEntry, Date.now(), PENDING_TOPIC_NAME_TTL_MS);
|
|
8217
|
+
if (pending) {
|
|
7446
8218
|
const match = (0, validation_1.findAutobindSubdir)(pending.name, listAvailableSubdirs(ENV.workRoot));
|
|
7447
8219
|
if (match) {
|
|
7448
8220
|
try {
|
|
7449
|
-
const subdir = (0, validation_1.validateSubdir)(ENV.workRoot, match);
|
|
7450
|
-
// The pending entry carries the topic name (cached at creation) —
|
|
7451
|
-
// copy it onto the binding for the thread-context preamble (S1).
|
|
7452
|
-
await state.setBinding(key, subdir, pending.name ? { topicName: pending.name } : {});
|
|
7453
8221
|
pendingTopicNames.delete(kStr);
|
|
7454
|
-
|
|
7455
|
-
|
|
8222
|
+
// The name rides on the binding for the thread-context preamble (S1): an authorised user's as given, a member's
|
|
8223
|
+
// replaced by the folder's own name — the match is all the bot vets of it.
|
|
8224
|
+
isJustAutoBound = await autoBindTopicByName(key, match, (0, pendingTopicName_1.getAutoBindTopicName)(pending, match));
|
|
7456
8225
|
}
|
|
7457
8226
|
catch (e) {
|
|
7458
8227
|
console.warn(`[deferred-autobind] failed for "${match}":`, e);
|
|
7459
|
-
pendingTopicNames.delete(kStr);
|
|
7460
8228
|
}
|
|
7461
8229
|
}
|
|
7462
|
-
else {
|
|
8230
|
+
else if (!pending.isFromAdmin) {
|
|
8231
|
+
// No folder matches: a name only a non-admin gave has nothing else to do. An authorised user's stays for the
|
|
8232
|
+
// bind the picker leads to, which keeps it (until it is bound or the TTL runs out).
|
|
7463
8233
|
pendingTopicNames.delete(kStr);
|
|
7464
8234
|
}
|
|
7465
8235
|
}
|
|
7466
|
-
else if (
|
|
8236
|
+
else if (pendingEntry) {
|
|
7467
8237
|
// TTL expired — clean the entry.
|
|
7468
8238
|
pendingTopicNames.delete(kStr);
|
|
7469
8239
|
}
|
|
@@ -7533,12 +8303,13 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
|
|
|
7533
8303
|
if (requestedFamily !== null && state.getBinding(key) && await askBeforeCrossBackendSwitch(key, requestedFamily, startMatch.args))
|
|
7534
8304
|
return;
|
|
7535
8305
|
// The shared `ensureAgentSession` does the bind-check + switch + start.
|
|
7536
|
-
// The explicit matched adapter wins (`preferredAdapterName`)
|
|
7537
|
-
//
|
|
7538
|
-
//
|
|
7539
|
-
//
|
|
8306
|
+
// The explicit matched adapter wins (`preferredAdapterName`), named the way
|
|
8307
|
+
// `/claude` names it: `claude` is the topic's Claude backend, never the bare
|
|
8308
|
+
// tmux one. The phrase's trailing args ride along. On `unbound` we keep this
|
|
8309
|
+
// path's own reply: the folder picker (plan §11 Этап 4 — starting without a
|
|
8310
|
+
// binding would silently launch against WORK_ROOT, which is almost never wanted).
|
|
7540
8311
|
const result = await ensureAgentSession(key, {
|
|
7541
|
-
preferredAdapterName: startMatch.adapterName,
|
|
8312
|
+
preferredAdapterName: (0, createAdapter_1.resolveStartAdapterName)(key, startMatch.adapterName),
|
|
7542
8313
|
args: startMatch.args,
|
|
7543
8314
|
});
|
|
7544
8315
|
if (!result.ok && result.reason === 'unbound') {
|
|
@@ -7628,6 +8399,8 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
|
|
|
7628
8399
|
await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
|
|
7629
8400
|
return;
|
|
7630
8401
|
}
|
|
8402
|
+
if (isJustAutoBound)
|
|
8403
|
+
return;
|
|
7631
8404
|
await replyToThread(key, (0, i18n_1.t)('thread.no_agent_with_binding', { subdir: binding.subdir }));
|
|
7632
8405
|
});
|
|
7633
8406
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -7750,7 +8523,7 @@ async function processVoiceJob(key, fileId, durationSeconds, requesterId, sentAt
|
|
|
7750
8523
|
// Same choke point as the text trigger (L-D4): a sleeping conversation is
|
|
7751
8524
|
// woken, never replaced by a fresh start; `unbound` keeps the folder picker.
|
|
7752
8525
|
const result = await ensureAgentSession(key, {
|
|
7753
|
-
preferredAdapterName: startMatch.adapterName,
|
|
8526
|
+
preferredAdapterName: (0, createAdapter_1.resolveStartAdapterName)(key, startMatch.adapterName),
|
|
7754
8527
|
args: startMatch.args,
|
|
7755
8528
|
});
|
|
7756
8529
|
if (!result.ok && result.reason === 'unbound') {
|
|
@@ -7946,12 +8719,12 @@ const albumCollector = (0, mediaGroupCollector_1.createMediaGroupCollector)({
|
|
|
7946
8719
|
// cancel it silently, like the text/voice handlers.
|
|
7947
8720
|
cancelApiRetry(key);
|
|
7948
8721
|
// A user album is genuine user activity → clear the compact-on-idle latch (D2).
|
|
7949
|
-
|
|
8722
|
+
notePersonActivity(key);
|
|
7950
8723
|
try {
|
|
7951
8724
|
const opening = await openTopicRequest(key, promptText, { source: 'album', requesterId: orderedItems[0].requesterId });
|
|
7952
8725
|
const quotedPrompt = getPromptWithThreadReplyQuote(key, promptText, replyContext);
|
|
7953
|
-
await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
|
|
7954
|
-
|
|
8726
|
+
const delivery = await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
|
|
8727
|
+
await settleTopicRequestAfterDelivery(key, opening, delivery);
|
|
7955
8728
|
}
|
|
7956
8729
|
catch (err) {
|
|
7957
8730
|
console.error('[Bot] album flush failed:', err);
|
|
@@ -8030,12 +8803,12 @@ async function handleIncomingFile(ctx, key) {
|
|
|
8030
8803
|
// cancel it silently, like the text/voice handlers.
|
|
8031
8804
|
cancelApiRetry(key);
|
|
8032
8805
|
// A user file is genuine user activity → clear the compact-on-idle latch (D2).
|
|
8033
|
-
|
|
8806
|
+
notePersonActivity(key);
|
|
8034
8807
|
const opening = await openTopicRequest(key, promptText, { source: 'file', requesterId: (0, topicRequest_1.getTopicRequesterId)(ctx.from) });
|
|
8035
8808
|
// A file sent as a REPLY carries the message it answers, as a typed or spoken reply does.
|
|
8036
8809
|
const quotedPrompt = getPromptWithThreadReplyQuote(key, promptText, getReplyQuoteBlock(key, ctx.message));
|
|
8037
|
-
await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
|
|
8038
|
-
|
|
8810
|
+
const delivery = await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
|
|
8811
|
+
await settleTopicRequestAfterDelivery(key, opening, delivery);
|
|
8039
8812
|
}
|
|
8040
8813
|
bot.on(
|
|
8041
8814
|
// anyOf-composed per-kind filter — `message('photo', 'document', …)` would
|
|
@@ -8139,7 +8912,7 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
|
|
|
8139
8912
|
const existing = state.getBinding(key);
|
|
8140
8913
|
// Already bound — service event arrived after the user's first message
|
|
8141
8914
|
// (or on restart). Don't overwrite; quietly skip the welcome too, the
|
|
8142
|
-
// user has already seen the
|
|
8915
|
+
// user has already seen the welcome.
|
|
8143
8916
|
if (existing)
|
|
8144
8917
|
return;
|
|
8145
8918
|
// Audit S2 / #5: topic creation may be open to all members (group setting),
|
|
@@ -8156,7 +8929,7 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
|
|
|
8156
8929
|
return;
|
|
8157
8930
|
if (action === 'cacheNameOnly') {
|
|
8158
8931
|
if (topicName) {
|
|
8159
|
-
pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now() });
|
|
8932
|
+
pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now(), isFromAdmin: false });
|
|
8160
8933
|
}
|
|
8161
8934
|
console.warn(`[security] forum_topic_created in chat ${ctx.chat.id} by user ${userId ?? '?'} (not a group admin) — name cached, no auto-bind`);
|
|
8162
8935
|
return;
|
|
@@ -8169,19 +8942,20 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
|
|
|
8169
8942
|
const match = (0, validation_1.findAutobindSubdir)(topicName, subdirs);
|
|
8170
8943
|
if (match) {
|
|
8171
8944
|
try {
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
await state.setBinding(key, subdir, topicName ? { topicName } : {});
|
|
8176
|
-
await replyToThread(key, (0, i18n_1.t)('thread.welcome_bound', { subdir }));
|
|
8177
|
-
await sendBindingWelcome(key, subdir);
|
|
8178
|
-
return;
|
|
8945
|
+
// The creation event carries the topic name — it rides on the binding for the thread-context preamble (S1).
|
|
8946
|
+
if (await autoBindTopicByName(key, match, topicName))
|
|
8947
|
+
return;
|
|
8179
8948
|
}
|
|
8180
8949
|
catch (e) {
|
|
8181
8950
|
console.warn(`[forum_topic_created] auto-bind failed for "${match}":`, e);
|
|
8182
8951
|
// Fall through to picker.
|
|
8183
8952
|
}
|
|
8184
8953
|
}
|
|
8954
|
+
// The picker is the way on, and the bind through it is the topic's first: set the name an authorised user gave aside, so
|
|
8955
|
+
// that bind keeps it (the shared-folder row of a later topic names this one by it).
|
|
8956
|
+
if (topicName && !state.getBinding(key)) {
|
|
8957
|
+
pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now(), isFromAdmin: true });
|
|
8958
|
+
}
|
|
8185
8959
|
const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
8186
8960
|
await replyToThread(key, (0, i18n_1.t)('thread.welcome_pick'), extra);
|
|
8187
8961
|
});
|
|
@@ -8213,7 +8987,7 @@ bot.on((0, filters_1.message)('forum_topic_edited'), async (ctx) => {
|
|
|
8213
8987
|
}
|
|
8214
8988
|
else {
|
|
8215
8989
|
// No binding yet — keep the freshest name for a later fuzzy auto-bind.
|
|
8216
|
-
pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: newName, ts: Date.now() });
|
|
8990
|
+
pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: newName, ts: Date.now(), isFromAdmin: true });
|
|
8217
8991
|
}
|
|
8218
8992
|
});
|
|
8219
8993
|
// Audit S2 / #5: closed/reopened events can come from a member who isn't an
|
|
@@ -8297,16 +9071,11 @@ bot.action(/^bind_page_(\d+)$/, async (ctx) => {
|
|
|
8297
9071
|
const page = parseInt(ctx.match[1], 10);
|
|
8298
9072
|
const subdirs = listAvailableSubdirs(ENV.workRoot);
|
|
8299
9073
|
const keyboard = (0, bindPicker_1.buildBindKeyboard)(subdirs, page, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
|
|
8300
|
-
|
|
8301
|
-
await ctx.editMessageReplyMarkup(keyboard.reply_markup);
|
|
8302
|
-
}
|
|
8303
|
-
catch (e) {
|
|
8304
|
-
const desc = (0, sendErrorClassifier_1.checkIsApiError)(e) ? (0, sendErrorClassifier_1.getErrorDescription)(e) : '';
|
|
8305
|
-
if (!/message is not modified/i.test(desc)) {
|
|
8306
|
-
console.warn(`[bind_page] edit failed:`, desc || e);
|
|
8307
|
-
}
|
|
8308
|
-
}
|
|
9074
|
+
// Answered BEFORE the edit: the edit waits in the paced send queue, which can outlive the callback.
|
|
8309
9075
|
await ctx.answerCbQuery();
|
|
9076
|
+
const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
|
|
9077
|
+
if (pickerMessageId !== null)
|
|
9078
|
+
await editThreadKeyboard(key, pickerMessageId, keyboard.reply_markup, 'bind_page');
|
|
8310
9079
|
});
|
|
8311
9080
|
// Middle "N/M" pill in the nav row — pure UI, no state change.
|
|
8312
9081
|
bot.action(bindPicker_1.bindPageNoopCallback, async (ctx) => {
|
|
@@ -8326,9 +9095,18 @@ bot.action(bindPicker_1.bindCreateFolderCallback, async (ctx) => {
|
|
|
8326
9095
|
await ctx.answerCbQuery((0, i18n_1.t)('cb.bind_only_topical'));
|
|
8327
9096
|
return;
|
|
8328
9097
|
}
|
|
9098
|
+
const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
|
|
9099
|
+
// A picker a bind (or a leave) holds is spent: a name asked for now would bind a second time.
|
|
9100
|
+
if (pickerMessageId !== null && bindPickerClaims.has(key, pickerMessageId)) {
|
|
9101
|
+
await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.bind_picker_used'));
|
|
9102
|
+
return;
|
|
9103
|
+
}
|
|
8329
9104
|
await ctx.answerCbQuery((0, i18n_1.t)('bind.create_cb'));
|
|
8330
|
-
armFolderCreation(key);
|
|
8331
|
-
|
|
9105
|
+
armFolderCreation(key, pickerMessageId);
|
|
9106
|
+
// Remembered like a picker: the bind this question leads to deletes it (a refused name keeps it, the question stays open).
|
|
9107
|
+
const promptMessageId = await replyToThread(key, (0, i18n_1.t)('bind.create_prompt'));
|
|
9108
|
+
if (promptMessageId !== null)
|
|
9109
|
+
openBindMessages.remember((0, sessionKey_1.keyToString)(key), promptMessageId, Date.now());
|
|
8332
9110
|
});
|
|
8333
9111
|
// «Leave current dir» — the folded-in `/unbind`, shown only in a bound topic.
|
|
8334
9112
|
// Callback id deliberately avoids the `bind_` prefix so it can't be matched by
|
|
@@ -8343,8 +9121,20 @@ bot.action(bindPicker_1.bindLeaveCurrentCallback, async (ctx) => {
|
|
|
8343
9121
|
await ctx.answerCbQuery((0, i18n_1.t)('cb.bind_only_topical'));
|
|
8344
9122
|
return;
|
|
8345
9123
|
}
|
|
8346
|
-
|
|
8347
|
-
|
|
9124
|
+
// Claimed like a bind: two taps in a row unbind once, and a picker a bind holds is no more a place to leave from.
|
|
9125
|
+
const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
|
|
9126
|
+
const run = await bindPickerClaims.runClaimed(key, pickerMessageId, async () => {
|
|
9127
|
+
await (0, callbackToast_1.answerCallbackQuerySafely)(ctx);
|
|
9128
|
+
await unbindThread(key);
|
|
9129
|
+
if (pickerMessageId !== null)
|
|
9130
|
+
await removeBindPicker(key, pickerMessageId);
|
|
9131
|
+
await closeOpenBindMessages(key);
|
|
9132
|
+
// The welcome's buttons have no folder to act on now.
|
|
9133
|
+
await stripWelcomeKeyboards(key, openWelcomeMessages.take((0, sessionKey_1.keyToString)(key)).map((entry) => entry.messageId));
|
|
9134
|
+
return true;
|
|
9135
|
+
});
|
|
9136
|
+
if (run === 'busy')
|
|
9137
|
+
await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.bind_picker_used'));
|
|
8348
9138
|
});
|
|
8349
9139
|
// `/topic`'s buttons: patterns of their own (`topic_…`), disjoint from the `bind_<subdir>` regex below.
|
|
8350
9140
|
registerTopicCallbacks();
|
|
@@ -8358,12 +9148,26 @@ bot.action(/^bind_(.+)$/, async (ctx) => {
|
|
|
8358
9148
|
await ctx.answerCbQuery((0, i18n_1.t)('cb.bind_only_topical'));
|
|
8359
9149
|
return;
|
|
8360
9150
|
}
|
|
9151
|
+
// The picker is claimed before any await: a bind takes seconds (the folder's facts are probed, the sends are paced), and a
|
|
9152
|
+
// second tap landing meanwhile must not bind — or welcome — a second time. A bind that does not happen (a refused folder,
|
|
9153
|
+
// a failure on the way) gives the picker back.
|
|
8361
9154
|
const subdir = ctx.match[1];
|
|
8362
|
-
|
|
8363
|
-
const
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
await
|
|
9155
|
+
const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
|
|
9156
|
+
const run = await bindPickerClaims.runClaimed(key, pickerMessageId, async () => {
|
|
9157
|
+
// A toast that comes too late (the query expired) changes nothing about the bind the person asked for.
|
|
9158
|
+
await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.binding_to', { subdir }));
|
|
9159
|
+
const result = await applyBinding(key, subdir);
|
|
9160
|
+
if (!result.ok) {
|
|
9161
|
+
await replyToBindResult(key, result);
|
|
9162
|
+
return false;
|
|
9163
|
+
}
|
|
9164
|
+
if (pickerMessageId !== null)
|
|
9165
|
+
await removeBindPicker(key, pickerMessageId);
|
|
9166
|
+
await replyToBindResult(key, result);
|
|
9167
|
+
return true;
|
|
9168
|
+
});
|
|
9169
|
+
if (run === 'busy')
|
|
9170
|
+
await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.bind_picker_used'));
|
|
8367
9171
|
});
|
|
8368
9172
|
// ─── /language inline picker callbacks ───────────────────────────────────────
|
|
8369
9173
|
//
|
|
@@ -8501,10 +9305,13 @@ bot.action(timezonePicker_1.zonePickCallbackRe, async (ctx) => {
|
|
|
8501
9305
|
registerModelCallbacks();
|
|
8502
9306
|
registerCompactionCallbacks();
|
|
8503
9307
|
registerAutoContinueLimitsCallbacks();
|
|
9308
|
+
registerBotSkillsCallbacks();
|
|
9309
|
+
registerTaskAgentsCallbacks();
|
|
8504
9310
|
registerBackendSwitchCallbacks();
|
|
8505
9311
|
registerClaudeAccountCallbacks();
|
|
8506
9312
|
registerFallbackChainCallbacks();
|
|
8507
9313
|
registerLimitsBoardCallbacks();
|
|
9314
|
+
registerTodoCallbacks();
|
|
8508
9315
|
registerSttCallbacks();
|
|
8509
9316
|
registerSelfUpdateCallbacks();
|
|
8510
9317
|
// «⏭ Skip once» on the `/limits` board and the `/limits_autoswitch` screen: drop THIS armed resume and
|
|
@@ -8535,43 +9342,6 @@ bot.action(new RegExp(`^${autoContinueOnLimit_1.skipArmedRetryCallbackPrefix}(\\
|
|
|
8535
9342
|
await consumeAutoContinueLimitsPicker(key, ctx.callbackQuery?.message, (0, i18n_1.t)('autoContinueLimits.skippedNotice'));
|
|
8536
9343
|
});
|
|
8537
9344
|
});
|
|
8538
|
-
// D1: a tap on an idle-compaction RE-ASKED question button. The original request
|
|
8539
|
-
// was rejected server-side during the compaction, so the chosen option label is
|
|
8540
|
-
// delivered to the (now compacted) session as a FRESH prompt, not answered.
|
|
8541
|
-
bot.action(/^reask_(\d+)$/, async (ctx) => {
|
|
8542
|
-
const key = await authoriseContext(ctx);
|
|
8543
|
-
if (!key) {
|
|
8544
|
-
await ctx.answerCbQuery((0, i18n_1.t)('cb.access_denied'));
|
|
8545
|
-
return;
|
|
8546
|
-
}
|
|
8547
|
-
const optIdx = parseInt(ctx.match[1], 10);
|
|
8548
|
-
const labels = reAskedQuestionOptions.get((0, sessionKey_1.keyToString)(key));
|
|
8549
|
-
const label = labels?.[optIdx];
|
|
8550
|
-
if (!label) {
|
|
8551
|
-
await ctx.answerCbQuery((0, i18n_1.t)('cb.no_pending_question'));
|
|
8552
|
-
return;
|
|
8553
|
-
}
|
|
8554
|
-
// The idle fire posts the re-asked question just BEFORE it stops the process, and a stopping process still reads as
|
|
8555
|
-
// active: a tap inside that window (or inside a start) is buffered like a typed prompt and replayed once a session is up.
|
|
8556
|
-
const isStarting = startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key));
|
|
8557
|
-
// The answer to a re-asked question wakes a sleeping conversation (L-D4) — the question is its own.
|
|
8558
|
-
if (!isStarting && !(0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key) && !(await resumeSleepingSessionForPrompt(key))) {
|
|
8559
|
-
await ctx.answerCbQuery((0, i18n_1.t)('agent.no_session'));
|
|
8560
|
-
return;
|
|
8561
|
-
}
|
|
8562
|
-
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
8563
|
-
reAskedQuestionOptions.delete((0, sessionKey_1.keyToString)(key));
|
|
8564
|
-
// The label goes to the agent as a prompt and nothing else in the topic shows it whole (the buttons clip long
|
|
8565
|
-
// labels): a label too long for the toast is posted in full, as the record of the answer that was sent.
|
|
8566
|
-
await (0, callbackToast_1.answerCallbackWithWholeText)(ctx, label, (wholeLabel) => replyToThread(key, wholeLabel));
|
|
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.
|
|
8570
|
-
const outcome = await withThreadLocale(key, () => deliverActivePrompt(key, adapter, label, null));
|
|
8571
|
-
// The label reached no session (the topic was told why): the question is still open, so are its buttons.
|
|
8572
|
-
if (outcome === 'noSession' && labels && !reAskedQuestionOptions.has((0, sessionKey_1.keyToString)(key)))
|
|
8573
|
-
reAskedQuestionOptions.set((0, sessionKey_1.keyToString)(key), labels);
|
|
8574
|
-
});
|
|
8575
9345
|
registerReminderCallbacks();
|
|
8576
9346
|
bot.action(/^ccmode_(.+)$/, async (ctx) => {
|
|
8577
9347
|
const key = await authoriseContext(ctx);
|
|
@@ -8593,18 +9363,11 @@ bot.action(/^ccmode_(.+)$/, async (ctx) => {
|
|
|
8593
9363
|
return;
|
|
8594
9364
|
}
|
|
8595
9365
|
await ctx.answerCbQuery((0, i18n_1.t)('cb.claudeMode_switching'));
|
|
8596
|
-
await replyToThread(key, await applyClaudeBackendSwitch(key, target));
|
|
9366
|
+
await replyToThread(key, appendTmuxDeprecatedHint(target, await applyClaudeBackendSwitch(key, target)));
|
|
8597
9367
|
// Re-render the picker so the `✓` follows the new backend (mirrors effort_cb).
|
|
8598
|
-
const
|
|
8599
|
-
if (
|
|
8600
|
-
|
|
8601
|
-
await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageReplyMarkup((0, sessionKeyCodec_1.getTelegramChatId)(key), cbMsg.message_id, undefined, buildClaudeModeKeyboard((0, createAdapter_1.resolveClaudeBackendName)(key)).reply_markup));
|
|
8602
|
-
}
|
|
8603
|
-
catch (e) {
|
|
8604
|
-
const desc = (0, sendErrorClassifier_1.checkIsApiError)(e) ? (0, sendErrorClassifier_1.getErrorDescription)(e) : '';
|
|
8605
|
-
if (!/message is not modified/i.test(desc))
|
|
8606
|
-
console.warn('[ccmode_cb] re-render:', desc || e);
|
|
8607
|
-
}
|
|
9368
|
+
const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
|
|
9369
|
+
if (pickerMessageId !== null) {
|
|
9370
|
+
await editThreadKeyboard(key, pickerMessageId, buildClaudeModeKeyboard((0, createAdapter_1.resolveClaudeBackendName)(key)).reply_markup, 'ccmode_cb');
|
|
8608
9371
|
}
|
|
8609
9372
|
});
|
|
8610
9373
|
registerDisplayModeCallbacks();
|
|
@@ -8632,18 +9395,9 @@ bot.action(/^agent_(.+)$/, async (ctx) => {
|
|
|
8632
9395
|
// instead of staying stuck on the previous one (B16, mirrors B12). The
|
|
8633
9396
|
// start keeps the picker message visible (it is neither edited nor deleted),
|
|
8634
9397
|
// so the stale marker would otherwise persist.
|
|
8635
|
-
const
|
|
8636
|
-
if (
|
|
8637
|
-
|
|
8638
|
-
try {
|
|
8639
|
-
await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageReplyMarkup((0, sessionKeyCodec_1.getTelegramChatId)(key), cbMsg.message_id, undefined, keyboard.reply_markup));
|
|
8640
|
-
}
|
|
8641
|
-
catch (e) {
|
|
8642
|
-
const desc = (0, sendErrorClassifier_1.checkIsApiError)(e) ? (0, sendErrorClassifier_1.getErrorDescription)(e) : '';
|
|
8643
|
-
if (!/message is not modified/i.test(desc)) {
|
|
8644
|
-
console.warn('[agent_cb] keyboard re-render failed:', desc || e);
|
|
8645
|
-
}
|
|
8646
|
-
}
|
|
9398
|
+
const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
|
|
9399
|
+
if (pickerMessageId !== null) {
|
|
9400
|
+
await editThreadKeyboard(key, pickerMessageId, buildAgentKeyboard((0, createAdapter_1.getAvailableAdapters)(), adapterName).reply_markup, 'agent_cb');
|
|
8647
9401
|
}
|
|
8648
9402
|
}
|
|
8649
9403
|
catch {
|
|
@@ -8716,14 +9470,14 @@ bot.action(/^qa_(\d+)_(\d+)$/, async (ctx) => {
|
|
|
8716
9470
|
const selectedLabel = question.options[optIdx].label;
|
|
8717
9471
|
const isAnswerShown = await applyQuestionAnswer(key, [selectedLabel]);
|
|
8718
9472
|
if (isAnswerShown) {
|
|
8719
|
-
// The question message was just edited to
|
|
9473
|
+
// The question message was just edited to carry the whole answer under the question: the toast only
|
|
8720
9474
|
// confirms the tap, so a label too long for it is cut there and not posted a second time.
|
|
8721
|
-
await (0,
|
|
9475
|
+
await (0, callbackToast_2.answerCallbackWithFittedText)(ctx, selectedLabel);
|
|
8722
9476
|
}
|
|
8723
9477
|
else {
|
|
8724
9478
|
// The message could not be edited (it is gone, or the edit failed): nothing in the topic shows the answer, so a
|
|
8725
9479
|
// label too long for the toast is posted in full.
|
|
8726
|
-
await (0,
|
|
9480
|
+
await (0, callbackToast_2.answerCallbackWithWholeText)(ctx, selectedLabel, (wholeLabel) => replyToThread(key, wholeLabel));
|
|
8727
9481
|
}
|
|
8728
9482
|
});
|
|
8729
9483
|
/**
|
|
@@ -8735,6 +9489,13 @@ bot.action(/^qa_(\d+)_(\d+)$/, async (ctx) => {
|
|
|
8735
9489
|
* the agent without the message it points at.
|
|
8736
9490
|
*/
|
|
8737
9491
|
async function bufferPromptDuringStartup(key, text, intake, replyContext) {
|
|
9492
|
+
// The same for a bare option number typed in that window: it answers the question asked again.
|
|
9493
|
+
if (await answerReAskedQuestionByNumber(key, text))
|
|
9494
|
+
return;
|
|
9495
|
+
// A person wrote, in a window where the session is starting or being stopped: it counts like a message that reaches
|
|
9496
|
+
// `deliverActivePrompt` — the idle latch clears, and a question the idle compaction re-asked is closed (the idle fire
|
|
9497
|
+
// posts it just before the stop, so this window is where a message instead of a tap most often lands).
|
|
9498
|
+
notePersonActivity(key);
|
|
8738
9499
|
const opening = await openTopicRequest(key, text, intake);
|
|
8739
9500
|
const quotedText = getPromptWithThreadReplyQuote(key, text, replyContext);
|
|
8740
9501
|
await deliverPromptOrBuffer(key, `${opening.header}${quotedText}`, true);
|
|
@@ -8753,15 +9514,18 @@ async function bufferPromptDuringStartup(key, text, intake, replyContext) {
|
|
|
8753
9514
|
* user before forwarding so the prompt doesn't queue behind it forever.
|
|
8754
9515
|
* 3. Forward the prompt (a bare `/clear` first purges thread context + files).
|
|
8755
9516
|
*
|
|
8756
|
-
*
|
|
8757
|
-
*
|
|
9517
|
+
* Step 1 is the OpenCode AND the Claude stream-json backends (both raise the `question` event and implement
|
|
9518
|
+
* `answerQuestion`). The tmux-scrape Claude and the terminal never reach it (no `pendingQuestions` entry): for them
|
|
8758
9519
|
* `forwardPromptToAgent` interrupts the TUI selector itself.
|
|
8759
9520
|
*/
|
|
8760
9521
|
async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyContext) {
|
|
9522
|
+
// A bare option number answers the question the idle compaction asked again (a tap's own label comes in with no intake).
|
|
9523
|
+
if (intake !== null && await answerReAskedQuestionByNumber(key, text))
|
|
9524
|
+
return null;
|
|
8761
9525
|
// A genuine USER message (text or voice) — clear the compact-on-idle latch so
|
|
8762
9526
|
// the next idle period can compact again (D2), whether it answers a pending
|
|
8763
9527
|
// question or is a fresh prompt.
|
|
8764
|
-
|
|
9528
|
+
notePersonActivity(key);
|
|
8765
9529
|
const pending = pendingQuestions.get((0, sessionKey_1.keyToString)(key));
|
|
8766
9530
|
if (pending && adapter.answerQuestion) {
|
|
8767
9531
|
const currentQuestion = pending.data.questions[pending.currentIndex];
|
|
@@ -8775,8 +9539,8 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
|
|
|
8775
9539
|
}
|
|
8776
9540
|
// Free-form text cancels the question and is a fresh prompt — a request (S7).
|
|
8777
9541
|
const opening = await openTopicRequest(key, text, intake);
|
|
8778
|
-
await cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, opening.header);
|
|
8779
|
-
|
|
9542
|
+
const delivery = await cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, opening.header);
|
|
9543
|
+
await settleTopicRequestAfterDelivery(key, opening, delivery);
|
|
8780
9544
|
return null;
|
|
8781
9545
|
}
|
|
8782
9546
|
// A bare `/clear` forwarded to the agent wipes its context (Claude TUI), so
|
|
@@ -8805,6 +9569,8 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
|
|
|
8805
9569
|
// durable by the time the agent reads it.
|
|
8806
9570
|
const opening = await openTopicRequest(key, text, intake);
|
|
8807
9571
|
// Routed now, not when the message arrived: the idle stop may have begun or ended during the awaits since.
|
|
9572
|
+
// The forward can itself end in no session — a prompt that waited for an adopt replay found the session gone and nothing to wake;
|
|
9573
|
+
// it has told the person already (`forwardPromptToAgent`), so what is left here is the request this prompt opened.
|
|
8808
9574
|
const outcome = await (0, promptDelivery_1.deliverLatePrompt)({
|
|
8809
9575
|
checkIsWindowOpen: () => startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key)),
|
|
8810
9576
|
checkIsSessionActive: () => adapter.checkIsActive(key),
|
|
@@ -8814,36 +9580,44 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
|
|
|
8814
9580
|
wake: () => wakeSleepingSessionForPrompt(key),
|
|
8815
9581
|
forward: () => forwardPromptToAgent(key, (0, createAdapter_1.getThreadAdapter)(key), text, sentAtMs, { replyContext, requestHeader: opening.header }),
|
|
8816
9582
|
reportNoSession: async (isAlreadyTold) => {
|
|
8817
|
-
// The prompt reaches no agent: the request it opened would only wait for a turn that never comes.
|
|
8818
|
-
if (opening.requestId !== null)
|
|
8819
|
-
await topicRequestLedger?.closeRequest(opening.requestId, 'cancelled');
|
|
8820
9583
|
if (!isAlreadyTold)
|
|
8821
9584
|
await replyToThread(key, (0, i18n_1.t)('agent.no_session'));
|
|
8822
9585
|
},
|
|
8823
9586
|
});
|
|
8824
|
-
|
|
8825
|
-
trackTopicRequestTurn(key, opening);
|
|
9587
|
+
await settleTopicRequestAfterDelivery(key, opening, outcome);
|
|
8826
9588
|
return outcome;
|
|
8827
9589
|
}
|
|
8828
9590
|
/**
|
|
8829
|
-
* @description
|
|
8830
|
-
* pending-question state and
|
|
8831
|
-
*
|
|
8832
|
-
*
|
|
9591
|
+
* @description Close the on-screen state of a pending question that ends without an answer: clear the bot's
|
|
9592
|
+
* pending-question state (which unpins it) and edit the question's message so its buttons are gone and a note says that
|
|
9593
|
+
* nobody answered it. The message is never deleted — the question and its options
|
|
9594
|
+
* stay readable. Closing the question on the agent is the caller's, before it stops or aborts the turn.
|
|
9595
|
+
* Returns whether the message itself was edited — it IS the notice then.
|
|
8833
9596
|
*
|
|
8834
9597
|
* The edit runs through `runQuestionLifecycleOp` (same serializer as the answer path) so it can't race a concurrent
|
|
8835
|
-
* re-post-to-bottom. The `messageId` +
|
|
9598
|
+
* re-post-to-bottom. The `messageId` + question are captured BEFORE clearing, since `clearPendingQuestion` drops the state.
|
|
8836
9599
|
*/
|
|
8837
9600
|
async function retirePendingQuestion(key) {
|
|
8838
9601
|
const pending = pendingQuestions.get((0, sessionKey_1.keyToString)(key));
|
|
8839
|
-
const cancelledMessageId = pending?.messageId ?? null;
|
|
8840
|
-
const cancelledQuestion = pending?.data.questions[pending.currentIndex];
|
|
8841
9602
|
clearPendingQuestion(key);
|
|
8842
|
-
|
|
9603
|
+
return closeCapturedQuestionMessage(key, pending?.messageId ?? null, pending?.data.questions[pending.currentIndex]);
|
|
9604
|
+
}
|
|
9605
|
+
/**
|
|
9606
|
+
* @description The edit half of {@link retirePendingQuestion}, for a question whose id and text were captured before its
|
|
9607
|
+
* state was cleared (also: read from the saved state at boot, where no memory entry ever existed). `false` when there is no
|
|
9608
|
+
* message to edit, else whether the edit landed.
|
|
9609
|
+
*/
|
|
9610
|
+
async function closeCapturedQuestionMessage(key, messageId, question) {
|
|
9611
|
+
if (messageId === null || !question)
|
|
8843
9612
|
return false;
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
9613
|
+
return runQuestionLifecycleOp(key, () => closeQuestionMessage(key, messageId, question, 'unanswered'));
|
|
9614
|
+
}
|
|
9615
|
+
/**
|
|
9616
|
+
* @description {@link retirePendingQuestion} for the teardown and boot paths that do not wait for the edit (it goes through
|
|
9617
|
+
* the send pacer): a failure is logged, never an unhandled rejection.
|
|
9618
|
+
*/
|
|
9619
|
+
function retirePendingQuestionInBackground(key) {
|
|
9620
|
+
retirePendingQuestion(key).catch((e) => console.error('[pendingQuestion] retire failed:', e));
|
|
8847
9621
|
}
|
|
8848
9622
|
/**
|
|
8849
9623
|
* @description Cancel a pending OpenCode question because the user sent free-form
|
|
@@ -8853,7 +9627,7 @@ async function retirePendingQuestion(key) {
|
|
|
8853
9627
|
* abort`), post the "previous question cancelled" notice, then forward.
|
|
8854
9628
|
*/
|
|
8855
9629
|
async function cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, requestHeader) {
|
|
8856
|
-
// When the question bubble itself was
|
|
9630
|
+
// When the question bubble itself was closed with its «not answered» note, that IS the
|
|
8857
9631
|
// cancellation notice — a second standalone "previous question cancelled" line is
|
|
8858
9632
|
// pure noise (the reported triple message). The standalone notice is kept only
|
|
8859
9633
|
// for the no-bubble case.
|
|
@@ -8871,7 +9645,7 @@ async function cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, rep
|
|
|
8871
9645
|
if (!didLabelQuestionMessage) {
|
|
8872
9646
|
await replyToThread(key, (0, i18n_1.t)('agent.question_cancelled_for_prompt'));
|
|
8873
9647
|
}
|
|
8874
|
-
|
|
9648
|
+
return forwardPromptToAgent(key, adapter, text, sentAtMs, { replyContext, requestHeader });
|
|
8875
9649
|
}
|
|
8876
9650
|
/**
|
|
8877
9651
|
* @description Record one answer for the CURRENTLY shown OpenCode question and
|
|
@@ -8895,8 +9669,9 @@ async function applyQuestionAnswerInner(key, answerForCurrent) {
|
|
|
8895
9669
|
return false;
|
|
8896
9670
|
const answeredQuestion = pending.data.questions[pending.currentIndex];
|
|
8897
9671
|
const { nextState, action } = (0, openCodeQuestionFlow_1.recordAnswerAndAdvance)(pending, answerForCurrent);
|
|
8898
|
-
// Mark the answered question's message as done
|
|
8899
|
-
|
|
9672
|
+
// Mark the answered question's message as done: the question stays as asked, the chosen answer takes the place of
|
|
9673
|
+
// its buttons.
|
|
9674
|
+
const isAnswerShown = pending.messageId !== null && answeredQuestion !== undefined && await editQuestionMessageToClosed(key, pending.messageId, answeredQuestion, (0, i18n_1.t)('agent.question_answered_note', { answer: answerForCurrent.join(', ') }));
|
|
8900
9675
|
if (action.kind === 'submit') {
|
|
8901
9676
|
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
8902
9677
|
clearPendingQuestion(key);
|
|
@@ -8910,7 +9685,8 @@ async function applyQuestionAnswerInner(key, answerForCurrent) {
|
|
|
8910
9685
|
}
|
|
8911
9686
|
// More questions remain: persist the advanced state, then post the next one
|
|
8912
9687
|
// (which re-owns `messageId`). DO NOT reply to the agent yet.
|
|
8913
|
-
|
|
9688
|
+
// The next question is a new message of its own: it has no original yet, the one of the question answered is not its record.
|
|
9689
|
+
setPendingQuestion(key, { ...nextState, originalMessageId: null });
|
|
8914
9690
|
await postPendingQuestionAt(key);
|
|
8915
9691
|
return isAnswerShown;
|
|
8916
9692
|
}
|
|
@@ -9922,6 +10698,9 @@ function handleAgentQuestion(key, questionData) {
|
|
|
9922
10698
|
if (persistedQuestion && persistedQuestion.data.requestId === questionData.requestId) {
|
|
9923
10699
|
return;
|
|
9924
10700
|
}
|
|
10701
|
+
// The agent asks anew: a question the idle compaction re-asked earlier is superseded, and its buttons would now answer
|
|
10702
|
+
// the wrong question — it is closed as unanswered.
|
|
10703
|
+
closeReAskedQuestion(key);
|
|
9925
10704
|
// A pending status frame is now stale — the question UI replaces it. Stop the
|
|
9926
10705
|
// liveness loop first so it can't recreate a frame under the question prompt
|
|
9927
10706
|
// (Claude-only; a no-op for OpenCode threads, which never arm it).
|
|
@@ -10037,6 +10816,7 @@ async function postPendingQuestionAt(key) {
|
|
|
10037
10816
|
const existing = pendingQuestions.get(kStr);
|
|
10038
10817
|
if (existing && existing.data === pending.data && existing.currentIndex === qIdx) {
|
|
10039
10818
|
existing.messageId = messageId;
|
|
10819
|
+
existing.originalMessageId ??= messageId;
|
|
10040
10820
|
setPendingQuestion(key, existing);
|
|
10041
10821
|
markQuestionMessageSent(key, messageId);
|
|
10042
10822
|
// Pin the question so the muted topic fires a notification (S2). The
|
|
@@ -10048,11 +10828,12 @@ async function postPendingQuestionAt(key) {
|
|
|
10048
10828
|
void pinThreadQuestion(key, messageId);
|
|
10049
10829
|
}
|
|
10050
10830
|
else {
|
|
10051
|
-
// The question advanced / was answered while our send sat in the
|
|
10831
|
+
// The question advanced / was answered / was closed while our send sat in the
|
|
10052
10832
|
// queue (a fast digit reply can beat the post — seen live 2026-06-10:
|
|
10053
10833
|
// a stale unanswered-looking "❓" landed and lingered). The message we
|
|
10054
|
-
// just sent describes a question that no longer exists
|
|
10055
|
-
|
|
10834
|
+
// just sent describes a question that no longer exists: it is closed as
|
|
10835
|
+
// unanswered (its buttons would be dead), never deleted.
|
|
10836
|
+
await closeQuestionMessage(key, messageId, question, 'unanswered');
|
|
10056
10837
|
}
|
|
10057
10838
|
}
|
|
10058
10839
|
}
|
|
@@ -10090,7 +10871,7 @@ function handleAgentClosed(key) {
|
|
|
10090
10871
|
clearThinkingMessage(key);
|
|
10091
10872
|
// A closed session has no running delegation — remove the sub-agent status.
|
|
10092
10873
|
clearSubagentStatus(key);
|
|
10093
|
-
|
|
10874
|
+
retirePendingQuestionInBackground(key);
|
|
10094
10875
|
// A closed session has nothing to resume — drop any armed retry silently.
|
|
10095
10876
|
cancelApiRetry(key);
|
|
10096
10877
|
clearAuthNotice(key); // session closed → retire any pinned logged-out notice
|
|
@@ -10197,7 +10978,7 @@ function handleAgentError(key, error) {
|
|
|
10197
10978
|
deleteStatusMessage(key).catch(() => { });
|
|
10198
10979
|
clearThinkingMessage(key);
|
|
10199
10980
|
clearSubagentStatus(key);
|
|
10200
|
-
|
|
10981
|
+
retirePendingQuestionInBackground(key);
|
|
10201
10982
|
replyToThread(key, `Error: ${error.message}`).catch(() => { });
|
|
10202
10983
|
}
|
|
10203
10984
|
/**
|
|
@@ -10245,8 +11026,8 @@ function handleAgentStarted(key) {
|
|
|
10245
11026
|
* the delete); the "thinking …" frame and the sub-agent status for the same reason;
|
|
10246
11027
|
* and the pending question (F1): no stop path fires the adapter's `questionGone`
|
|
10247
11028
|
* (Claude's pane is killed; OpenCode emits no resolve), so a pinned question would
|
|
10248
|
-
* leak forever —
|
|
10249
|
-
* `handleAgentClosed` / `handleAgentError`.
|
|
11029
|
+
* leak forever — retiring it chains `unpinThreadQuestion` and closes its message as unanswered (the question stays
|
|
11030
|
+
* in the topic, its buttons would be dead), matching `handleAgentClosed` / `handleAgentError`.
|
|
10250
11031
|
*/
|
|
10251
11032
|
function clearGoneProcessFrames(key) {
|
|
10252
11033
|
clearThreadQueues(key);
|
|
@@ -10254,11 +11035,11 @@ function clearGoneProcessFrames(key) {
|
|
|
10254
11035
|
deleteStatusMessage(key).catch(() => { });
|
|
10255
11036
|
clearThinkingMessage(key);
|
|
10256
11037
|
clearSubagentStatus(key);
|
|
10257
|
-
|
|
11038
|
+
retirePendingQuestionInBackground(key);
|
|
10258
11039
|
}
|
|
10259
11040
|
function handleAgentStopped(key) {
|
|
10260
11041
|
// Single convergence point for every `stopSession`-driven stop path —
|
|
10261
|
-
// `/quit` (OpenCode/terminal), `/
|
|
11042
|
+
// `/quit` (OpenCode/terminal), `/quitall`, `/new`, `/unbind`, and adapter
|
|
10262
11043
|
// switch all emit `stopped`.
|
|
10263
11044
|
clearGoneProcessFrames(key);
|
|
10264
11045
|
// Session ended — a future session starts with empty context, so forget the
|
|
@@ -10321,7 +11102,7 @@ exports.COMMANDS_MENU = [
|
|
|
10321
11102
|
{ command: 'claude', description: '▶️ Start Claude Code' },
|
|
10322
11103
|
{ command: 'opencode', description: '▶️ Start OpenCode' },
|
|
10323
11104
|
{ command: 'terminal', description: '🖥 Open a raw shell in the bound folder' },
|
|
10324
|
-
{ command: 'claude_mode', description: '🔀 Claude backend: json-stream ⇄ per-turn ⇄ tmux
|
|
11105
|
+
{ command: 'claude_mode', description: '🔀 Claude backend: json-stream ⇄ per-turn ⇄ tmux (deprecated)' },
|
|
10325
11106
|
{ command: 'new', description: '🆕 Restart session (alias /clear_session)' },
|
|
10326
11107
|
{ command: 'clear_session', description: '🆕 Restart session (alias /new)' },
|
|
10327
11108
|
{ command: 'model', description: '🧠 Switch model' },
|
|
@@ -10338,6 +11119,8 @@ exports.COMMANDS_MENU = [
|
|
|
10338
11119
|
{ command: 'compact', description: '🧹 Compact agent context' },
|
|
10339
11120
|
{ command: 'compact_on_idle', description: '🧹 Auto-compact after idle (toggle)' },
|
|
10340
11121
|
{ command: 'compact_summary', description: '📄 Post the compaction summary (toggle)' },
|
|
11122
|
+
{ command: 'bot_skills', description: '🧰 The bot\'s own skills for agents (on/off)' },
|
|
11123
|
+
{ command: 'task_agents', description: '🧵 How many tracker task agents work at once' },
|
|
10341
11124
|
{ command: 'accounts', description: '👤 Claude accounts and OpenCode providers (switch, add, re-login, remove, connect, disconnect)' },
|
|
10342
11125
|
{ command: 'limits', description: '🚧 Usage limits — switch this topic' },
|
|
10343
11126
|
{ command: 'limits_autoswitch', description: '🔁 Automatic switching on a usage limit' },
|
|
@@ -10345,6 +11128,7 @@ exports.COMMANDS_MENU = [
|
|
|
10345
11128
|
{ command: 'update', description: '🔄 Bot updates: automatic, notify only, off' },
|
|
10346
11129
|
{ command: 'schedule', description: '⏰ Schedule a prompt (agent does the work)' },
|
|
10347
11130
|
{ command: 'reminders', description: '🔔 Reminders — add, list, delete (no agent)' },
|
|
11131
|
+
{ command: 'todo', description: '📋 Agents\' published todo lists' },
|
|
10348
11132
|
{ command: 'status', description: '📊 Show status' },
|
|
10349
11133
|
{ command: 'output', description: '📜 Last 500 lines' },
|
|
10350
11134
|
{ command: 'trace', description: '🛰 Output-trace recorder on/off' },
|
|
@@ -10650,7 +11434,7 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
|
|
|
10650
11434
|
// A per-turn process that outlived the bot is stopped as soon as its turn (if any) ends (L5).
|
|
10651
11435
|
stopAdoptedPerTurnSessionWhenIdle(key, ownerAdapter);
|
|
10652
11436
|
}
|
|
10653
|
-
if ((0, adoptedToolList_1.checkIsAdoptedToolListStale)({ persistedDigest: agent.mcpToolDigest, currentDigest: botMcpToolDigestReader?.(key
|
|
11437
|
+
if ((0, adoptedToolList_1.checkIsAdoptedToolListStale)({ persistedDigest: agent.mcpToolDigest, currentDigest: botMcpToolDigestReader?.(key) ?? '' })) {
|
|
10654
11438
|
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`);
|
|
10655
11439
|
}
|
|
10656
11440
|
// The surviving process keeps the account it was spawned with. A switch the user picked while it worked is
|
|
@@ -10797,7 +11581,8 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
|
|
|
10797
11581
|
* A persisted question is restored ONLY when its thread's session reattached
|
|
10798
11582
|
* and is currently active (`adapter.checkIsActive`). Otherwise the question is
|
|
10799
11583
|
* unreachable — the agent that asked it is gone — so it is dropped from the
|
|
10800
|
-
* store, preventing a stale entry from lingering across boots
|
|
11584
|
+
* store, preventing a stale entry from lingering across boots; its message is
|
|
11585
|
+
* kept, closed as unanswered, and its pin released. The persisted
|
|
10801
11586
|
* `messageId` is preserved, so the OLD buttons resolve correctly; the question
|
|
10802
11587
|
* is never re-posted.
|
|
10803
11588
|
*
|
|
@@ -10830,12 +11615,62 @@ function restorePendingQuestions() {
|
|
|
10830
11615
|
restored += 1;
|
|
10831
11616
|
}
|
|
10832
11617
|
else {
|
|
11618
|
+
// The agent that asked it is gone, so nobody can answer it: it is closed like any question that ends unanswered —
|
|
11619
|
+
// its message stays with the «not answered» note in place of the buttons and its pin is released. The pin map is
|
|
11620
|
+
// lost with the restart, so it is seeded from the saved message id for the clear to unpin that message.
|
|
11621
|
+
const orphaned = (0, openCodeQuestionFlow_1.migratePendingQuestionState)(value);
|
|
11622
|
+
if (orphaned.messageId !== null)
|
|
11623
|
+
questionPinnedMessageId.set(keyStr, orphaned.messageId);
|
|
10833
11624
|
clearPendingQuestion(key);
|
|
11625
|
+
closeCapturedQuestionMessage(key, orphaned.messageId, orphaned.data.questions[orphaned.currentIndex])
|
|
11626
|
+
.catch((e) => console.error('[pendingQuestion] closing the dropped question failed:', e));
|
|
10834
11627
|
dropped += 1;
|
|
10835
11628
|
}
|
|
10836
11629
|
}
|
|
10837
11630
|
console.log(`[reattach] pending questions: restored ${restored}, dropped ${dropped}`);
|
|
10838
11631
|
}
|
|
11632
|
+
/**
|
|
11633
|
+
* @description Bring back the questions the idle compaction re-asked, at boot. One whose conversation can still be
|
|
11634
|
+
* resumed (a live adopted process, or a sleeping conversation whose id is kept) gets its buttons working again — a tap
|
|
11635
|
+
* wakes it, as before the restart. One whose conversation is gone (released while the bot was down), or beside which a newer
|
|
11636
|
+
* question waits already, cannot be answered / must not be: its message is closed as unanswered, never left with buttons that
|
|
11637
|
+
* do nothing or that would answer the wrong question, and the saved record is dropped.
|
|
11638
|
+
* MUST run AFTER {@link reattachExistingSessions} so session activity is known. A platform this instance does not serve
|
|
11639
|
+
* is left alone, like {@link restorePendingQuestions}. A record saved BEFORE its re-ask was posted (`isPosted: false`: the bot
|
|
11640
|
+
* stopped during the idle compaction) names the message the question waited in: when the conversation can be resumed the
|
|
11641
|
+
* question is asked again now (nobody has had the chance to answer it) and that message says so; otherwise it is closed as
|
|
11642
|
+
* unanswered like any other.
|
|
11643
|
+
*/
|
|
11644
|
+
function restoreReAskedQuestions() {
|
|
11645
|
+
for (const [keyStr, saved] of Object.entries(state.getReAskedQuestions())) {
|
|
11646
|
+
let key;
|
|
11647
|
+
try {
|
|
11648
|
+
key = (0, sessionKey_1.keyFromString)(keyStr);
|
|
11649
|
+
}
|
|
11650
|
+
catch {
|
|
11651
|
+
continue;
|
|
11652
|
+
}
|
|
11653
|
+
if (!(0, connectorSet_1.checkIsServedConversation)(key, ENV.servedPlatforms))
|
|
11654
|
+
continue;
|
|
11655
|
+
// A question waits already (restored a moment ago, or asked anew by the agent that reattached): the copy's buttons would
|
|
11656
|
+
// deliver their label into IT, and a question asked again now would sit beside it. Nor is there anything to deliver a label
|
|
11657
|
+
// to once the conversation is gone.
|
|
11658
|
+
const isConversationResumable = !pendingQuestions.has(keyStr) && ((0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key) || getResumableSessionId(key) !== null);
|
|
11659
|
+
if (isConversationResumable) {
|
|
11660
|
+
if (saved.isPosted === false) {
|
|
11661
|
+
// The bot stopped between the start of an idle compaction and its re-ask: the question was rejected on the agent's side
|
|
11662
|
+
// and nobody has answered it — it is asked again now, and the message it waited in (what is saved) says so.
|
|
11663
|
+
reAskInterruptedQuestion(key, saved).catch((e) => console.error('[reAskedQuestion] asking the interrupted question again failed:', e));
|
|
11664
|
+
}
|
|
11665
|
+
else {
|
|
11666
|
+
restoreReAskedQuestion(key, saved);
|
|
11667
|
+
}
|
|
11668
|
+
continue;
|
|
11669
|
+
}
|
|
11670
|
+
// The copy is closed, never brought back; an interrupted question's message ends as unanswered, the one thing known of it.
|
|
11671
|
+
closeSavedReAskedQuestion(key, saved);
|
|
11672
|
+
}
|
|
11673
|
+
}
|
|
10839
11674
|
/**
|
|
10840
11675
|
* @description Re-arm persisted API-error retries (S6) so a pending kick —
|
|
10841
11676
|
* especially a multi-hour usage-limit wait — survives a bot restart. MUST run
|
|
@@ -10905,23 +11740,20 @@ function markLimitEpisodeSettled(key) {
|
|
|
10905
11740
|
}
|
|
10906
11741
|
/**
|
|
10907
11742
|
* @description When the parked `errorText` of a json-stream conversation was written, from its Claude transcript's
|
|
10908
|
-
* API-error entry ({@link
|
|
11743
|
+
* API-error entry ({@link readTranscriptErrorWrittenAtMs}); `null` when the transcript tail cannot be read or holds no such
|
|
10909
11744
|
* entry — the recovery then goes by the stdout log's mtime.
|
|
10910
11745
|
*/
|
|
10911
11746
|
function readParkedErrorWrittenAtMs(key, claudeSessionId, errorText) {
|
|
10912
11747
|
const workDirDecision = getWorkDirStartDecision(key);
|
|
10913
11748
|
if (!workDirDecision.ok)
|
|
10914
11749
|
return null;
|
|
10915
|
-
|
|
10916
|
-
try {
|
|
10917
|
-
const sizeBytes = fs.statSync(transcriptPath).size;
|
|
10918
|
-
const tail = (0, jsonStreamHost_1.readFileByteRange)(transcriptPath, Math.max(0, sizeBytes - limitEpisodeRecovery_1.limitEpisodeTailMaxBytes), sizeBytes);
|
|
10919
|
-
return (0, limitEpisodeRecovery_1.getParkedErrorWrittenAtMs)(tail.toString('utf8'), errorText);
|
|
10920
|
-
}
|
|
10921
|
-
catch {
|
|
10922
|
-
return null; // no transcript on disk — the mtime decides
|
|
10923
|
-
}
|
|
11750
|
+
return (0, transcriptErrorTime_1.readTranscriptErrorWrittenAtMs)((0, claudeCliAdapter_1.getClaudeTranscriptPath)(workDirDecision.workDir, claudeSessionId), errorText);
|
|
10924
11751
|
}
|
|
11752
|
+
/**
|
|
11753
|
+
* @description The threads whose limit episode the boot recovery armed and whose adopt is still replaying the downtime tail
|
|
11754
|
+
* ({@link handleApiError} leaves the replay's copy of that error alone). An entry lives until the replay is over.
|
|
11755
|
+
*/
|
|
11756
|
+
const limitEpisodesOwnedByRecovery = new Set();
|
|
10925
11757
|
/**
|
|
10926
11758
|
* @description Recover a usage/session-limit episode that ENDED BEFORE this boot
|
|
10927
11759
|
* and left no armed retry behind — the case {@link restoreApiRetries} cannot help
|
|
@@ -11005,6 +11837,10 @@ function recoverLimitEpisodesFromDisk() {
|
|
|
11005
11837
|
void state
|
|
11006
11838
|
.setLimitEpisodeRecovered(key, log)
|
|
11007
11839
|
.catch(e => console.error('[limitRecovery] marker persist failed:', e));
|
|
11840
|
+
// This episode is the recovery's alone: the adopt replays the same error a moment later (the tail was read from the
|
|
11841
|
+
// saved offset), and `handleApiError` leaves that copy out until the replay is over.
|
|
11842
|
+
limitEpisodesOwnedByRecovery.add(keyStr);
|
|
11843
|
+
void ((0, createAdapter_1.getAdapter)(agent.name).whenAdoptReplayed?.(key) ?? Promise.resolve()).then(() => limitEpisodesOwnedByRecovery.delete(keyStr));
|
|
11008
11844
|
void withThreadLocale(key, () => handleApiError(key, decision.cls, { isRecoveredEpisode: true }));
|
|
11009
11845
|
recovered += 1;
|
|
11010
11846
|
}
|
|
@@ -11204,6 +12040,8 @@ let connectorConversationHooks = new Map();
|
|
|
11204
12040
|
* {@link clearThreadContextMarker}, which a stop clears too.
|
|
11205
12041
|
*/
|
|
11206
12042
|
function noteConversationContextReset(key, reason) {
|
|
12043
|
+
// A completed compaction or a fresh session: the agent forgot the board too (D20).
|
|
12044
|
+
noteTodoFreshContext(key);
|
|
11207
12045
|
connectorConversationHooks.get(key.platform)?.onContextReset(key, reason);
|
|
11208
12046
|
}
|
|
11209
12047
|
/**
|
|
@@ -11267,6 +12105,36 @@ async function remindAnswerTail(key, text) {
|
|
|
11267
12105
|
await forwardPromptToAgent(key, (0, createAdapter_1.getThreadAdapter)(key), text, undefined, { isRequestReminder: true });
|
|
11268
12106
|
return true;
|
|
11269
12107
|
}
|
|
12108
|
+
/**
|
|
12109
|
+
* @description How a conversation's session stands right now (`requests/sessionTurnProbe.ts`) — the one probe the
|
|
12110
|
+
* wake-up engine and the todo reminder both poll.
|
|
12111
|
+
*/
|
|
12112
|
+
const probeThreadTurn = (0, sessionTurnProbe_1.createSessionTurnProbe)({
|
|
12113
|
+
getAdapter: createAdapter_1.getThreadAdapter,
|
|
12114
|
+
checkHasPendingQuestion: (keyString) => pendingQuestions.has(keyString),
|
|
12115
|
+
checkHasQuestionPin: (keyString) => questionPinnedMessageId.has(keyString),
|
|
12116
|
+
checkIsCompacting: (keyString) => threadsCompacting.has(keyString),
|
|
12117
|
+
getApiRetryTimer: (keyString) => apiRetryTimers.get(keyString)?.timer,
|
|
12118
|
+
checkIsWedgeRecoveryInFlight: (keyString) => wedgeRecoveriesInFlight.has(keyString),
|
|
12119
|
+
checkIsRetryKickInFlight: (keyString) => apiRetryKicksInFlight.has(keyString),
|
|
12120
|
+
checkIsSessionStarting: (keyString) => startupPromptBuffer.checkIsStarting(keyString),
|
|
12121
|
+
serializeKey: sessionKey_1.keyToString,
|
|
12122
|
+
});
|
|
12123
|
+
/**
|
|
12124
|
+
* @description The todo list's standalone reminder (D11 form b): a technical prompt, posted only into a live session
|
|
12125
|
+
* that is idle right now — never a resume, never an interrupt (`postToIdleSession`). It opens no request and is never
|
|
12126
|
+
* replayed; in a Jira conversation it is no hand-over and nothing of its turn reaches the issue (D58).
|
|
12127
|
+
*/
|
|
12128
|
+
async function postTodoReminder(key, text) {
|
|
12129
|
+
const adapter = (0, createAdapter_1.getThreadAdapter)(key);
|
|
12130
|
+
// A reminder dropped after an adopt replay (the session was no longer live and idle) was not posted.
|
|
12131
|
+
let isDropped = false;
|
|
12132
|
+
const isPosted = await (0, postToSession_1.postToIdleSession)({
|
|
12133
|
+
checkIsIdle: () => adapter.checkIsActive(key) && !(adapter.checkIsBusy?.(key) ?? false),
|
|
12134
|
+
forward: async () => { isDropped = (await forwardPromptToAgent(key, adapter, text, undefined, { isTechnicalPrompt: true })) === 'dropped'; },
|
|
12135
|
+
});
|
|
12136
|
+
return isPosted && !isDropped;
|
|
12137
|
+
}
|
|
11270
12138
|
/**
|
|
11271
12139
|
* @description The person took over the conversation (interrupt, quit, restart,
|
|
11272
12140
|
* switch or resume of the session, a switch to another agent or a shell, leaving
|
|
@@ -11302,8 +12170,8 @@ function createSessionPostDeps() {
|
|
|
11302
12170
|
// A scheduled run in a topic with requests on is a request (S7); a tracker's
|
|
11303
12171
|
// post arrives with its request already open and its header in the text.
|
|
11304
12172
|
const opening = await openTopicRequest(key, text, { source: 'scheduledRun', requesterId: topicRequest_1.scheduledRunRequester });
|
|
11305
|
-
await deliverPromptOrBuffer(key, `${opening.header}${text}`, startupPromptBuffer.checkIsStarting(conversationKey));
|
|
11306
|
-
|
|
12173
|
+
const delivery = await deliverPromptOrBuffer(key, `${opening.header}${text}`, startupPromptBuffer.checkIsStarting(conversationKey));
|
|
12174
|
+
await settleTopicRequestAfterDelivery(key, opening, delivery);
|
|
11307
12175
|
},
|
|
11308
12176
|
now: () => Date.now(),
|
|
11309
12177
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
@@ -11342,6 +12210,15 @@ function wireScheduler(wiring) {
|
|
|
11342
12210
|
await state.upsertSchedule({ ...record, isCheckFailing: isFailing, updatedAt: new Date().toISOString() });
|
|
11343
12211
|
await state.flush();
|
|
11344
12212
|
},
|
|
12213
|
+
recordAlertPin: async (jobId, messageId) => {
|
|
12214
|
+
const record = state.getSchedules()[jobId];
|
|
12215
|
+
if (!record)
|
|
12216
|
+
return;
|
|
12217
|
+
const alertPinMessageIds = [...(record.alertPinMessageIds ?? []), messageId];
|
|
12218
|
+
await state.upsertSchedule({ ...record, alertPinMessageIds, updatedAt: new Date().toISOString() });
|
|
12219
|
+
// Saved at once, like the failing flag: an id lost in a crash would leave its alert pinned for good.
|
|
12220
|
+
await state.flush();
|
|
12221
|
+
},
|
|
11345
12222
|
});
|
|
11346
12223
|
/**
|
|
11347
12224
|
* The engine's deliver callback: the S4 pipeline plus the S8 unbound-pause
|
|
@@ -11377,6 +12254,11 @@ function wireScheduler(wiring) {
|
|
|
11377
12254
|
const key = (0, sessionKey_1.keyFromString)(threadKeyStr);
|
|
11378
12255
|
return (0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name;
|
|
11379
12256
|
},
|
|
12257
|
+
unpinMessages: async (threadKeyStr, messageIds) => {
|
|
12258
|
+
const key = (0, sessionKey_1.keyFromString)(threadKeyStr);
|
|
12259
|
+
for (const messageId of messageIds)
|
|
12260
|
+
await unpinMessageQuiet(key, messageId);
|
|
12261
|
+
},
|
|
11380
12262
|
// Routed through the outbound seam: the agent-facing file tool is a
|
|
11381
12263
|
// connector delivery, not a direct call into the Telegram send pipeline.
|
|
11382
12264
|
// The MCP surface is string-keyed (its `threadKey` arrives as text), so the
|
|
@@ -11391,13 +12273,26 @@ function wireScheduler(wiring) {
|
|
|
11391
12273
|
sendMessagesToThread,
|
|
11392
12274
|
compactConversation: (threadKeyStr) => armDeferredCompaction((0, sessionKey_1.keyFromString)(threadKeyStr)),
|
|
11393
12275
|
listClaudeProfiles: listClaudeProfilesForAgent,
|
|
12276
|
+
isRemoteRunOffered: () => (0, runnersConfig_1.checkIsRemoteRunOffered)(),
|
|
12277
|
+
// A refused tool call leaves one line in the bounded diagnostic log (agent-diag.log): the SDK refuses before the bot sees it.
|
|
12278
|
+
logDiagnostic: diagLog_1.appendDiagLog,
|
|
11394
12279
|
...(jiraConnector
|
|
11395
12280
|
? { fetchJiraAttachment: (threadKeyStr, attachmentId) => jiraConnector.fetchAttachment((0, sessionKey_1.keyFromString)(threadKeyStr), attachmentId) }
|
|
11396
12281
|
: {}),
|
|
12282
|
+
// Read per request: General can turn the shared lists off, and the tools go with them.
|
|
12283
|
+
get todos() {
|
|
12284
|
+
return readTodoSettings().isEnabled
|
|
12285
|
+
? {
|
|
12286
|
+
update: (threadKeyStr, args) => getTodoTools().update((0, sessionKey_1.keyFromString)(threadKeyStr), args),
|
|
12287
|
+
read: (threadKeyStr, args) => getTodoTools().read((0, sessionKey_1.keyFromString)(threadKeyStr), args),
|
|
12288
|
+
}
|
|
12289
|
+
: undefined;
|
|
12290
|
+
},
|
|
11397
12291
|
answerRequest: (args) => (0, answerRequest_1.answerRequest)({
|
|
11398
12292
|
ledger: wiring.requestLedger,
|
|
11399
12293
|
answerSinks: wiring.answerSinks,
|
|
11400
12294
|
onAnswerDelivered: (key, requestId, kind) => requestWakeUpEngine?.noteAnswerDelivered(key, requestId, kind),
|
|
12295
|
+
getFinalAnswerNote: (key, requestCreatedAt) => (readTodoSettings().isEnabled ? getTodoRegistry().getFinalAnswerNote(key, requestCreatedAt) : null),
|
|
11401
12296
|
}, args),
|
|
11402
12297
|
whenSessionsRestored: wiring.whenSessionsRestored,
|
|
11403
12298
|
getSecret: () => state.getSchedulerMcpSecret(),
|
|
@@ -11616,10 +12511,11 @@ async function prepareJiraConnectorOrExit() {
|
|
|
11616
12511
|
* J5): the issue's binding, the request ledger, and the shared session post —
|
|
11617
12512
|
* after which the wake-up engine watches the turn the request started.
|
|
11618
12513
|
*/
|
|
11619
|
-
function createJiraSessionDeps(requestLedger, adapterName) {
|
|
12514
|
+
function createJiraSessionDeps(requestLedger, slots, adapterName) {
|
|
11620
12515
|
// D5: one conversation per issue — a session that sleeps between requests is resumed by the ensure (L-D4).
|
|
11621
12516
|
const sessionPostDeps = createSessionPostDeps();
|
|
11622
12517
|
return {
|
|
12518
|
+
slots,
|
|
11623
12519
|
bindConversation: async (key, folder) => {
|
|
11624
12520
|
if (state.getBinding(key)?.subdir !== folder)
|
|
11625
12521
|
await state.setBinding(key, folder);
|
|
@@ -11629,7 +12525,7 @@ function createJiraSessionDeps(requestLedger, adapterName) {
|
|
|
11629
12525
|
postRequest: async (key, requestId, prompt) => {
|
|
11630
12526
|
// A request is the issue's "user message" (L-D5): it clears the spent idle-compaction
|
|
11631
12527
|
// latch like a topic's message does, or the issue would never compact again.
|
|
11632
|
-
|
|
12528
|
+
notePersonActivity(key);
|
|
11633
12529
|
const posted = await (0, postToSession_1.postToSession)(sessionPostDeps, (0, sessionKey_1.keyToString)(key), prompt, adapterName);
|
|
11634
12530
|
if (!posted.ok) {
|
|
11635
12531
|
// R28: retried at about 1, 5 and 15 min, outside the wake-up cap.
|
|
@@ -11652,7 +12548,7 @@ async function startBot() {
|
|
|
11652
12548
|
console.log(`Default Claude backend: ${(0, createAdapter_1.getDefaultClaudeBackendName)()}`);
|
|
11653
12549
|
console.log(`Available agents: ${(0, createAdapter_1.getAvailableAdapters)().map(a => a.name).join(', ')}`);
|
|
11654
12550
|
// 1. State store. The `[startup] DATA_DIR=` shape is load-bearing: the
|
|
11655
|
-
//
|
|
12551
|
+
// docs/troubleshooting.md's two-instances section tells the operator to compare this
|
|
11656
12552
|
// exact line across instances.
|
|
11657
12553
|
state = await (0, state_1.getStateStore)({ saveDebounceMs: ENV.testTimings.stateSaveDebounceMs });
|
|
11658
12554
|
console.log(`[startup] DATA_DIR=${path.dirname(state.stateFilePath)}`);
|
|
@@ -11682,6 +12578,8 @@ async function startBot() {
|
|
|
11682
12578
|
// It starts polling once the sessions are restored.
|
|
11683
12579
|
// Every Claude spawn from here on reads the conversation's account profile (`/account`).
|
|
11684
12580
|
(0, createAdapter_1.registerClaudeConfigDirReader)(resolveThreadClaudeConfigDir);
|
|
12581
|
+
// The «Bot skills» switch (`/bot_skills`): every agent start, the OpenCode server's start and the bot MCP's instructions read it.
|
|
12582
|
+
(0, botSkills_1.setBotSkillsReader)(botSkillsReader);
|
|
11685
12583
|
(0, createAdapter_1.registerClaudeRateLimitSink)(onClaudeRateLimitEvent);
|
|
11686
12584
|
(0, createAdapter_1.registerClaudeTurnCostSink)(onClaudeTurnCost);
|
|
11687
12585
|
if (ENV.testTimings.openCodeServerUpBudgetMs !== undefined)
|
|
@@ -11690,6 +12588,7 @@ async function startBot() {
|
|
|
11690
12588
|
claudeUsageRefresher.start();
|
|
11691
12589
|
void claudeUsageRefresher.refreshAll();
|
|
11692
12590
|
const jiraConnector = ENV.servedPlatforms.has('jira') ? await prepareJiraConnectorOrExit() : null;
|
|
12591
|
+
todoJiraConversations = jiraConnector;
|
|
11693
12592
|
if (jiraConnector) {
|
|
11694
12593
|
const { launchDefaults } = jiraConnector;
|
|
11695
12594
|
(0, createAdapter_1.registerSessionLaunchDefaultsReader)((key) => (key.platform === 'jira' ? launchDefaults : null));
|
|
@@ -11709,24 +12608,26 @@ async function startBot() {
|
|
|
11709
12608
|
onRequestCreated: (key) => announceLimitWaitToNewRequest(key),
|
|
11710
12609
|
// C4: a connector counts what a prompt carried as sent only once its agent took it in.
|
|
11711
12610
|
onPromptSettled: (key, requestId, outcome) => connectorConversationHooks.get(key.platform)?.onPromptSettled(key, requestId, outcome),
|
|
12611
|
+
// D10: a closed hand-over may free a task-agent slot for a waiting issue.
|
|
12612
|
+
onRequestClosed: (record) => taskAgentSlots?.noteRequestClosed(record),
|
|
12613
|
+
// D10: a request the wake-up engine gave up on no longer holds a slot either.
|
|
12614
|
+
onRequestWakeStopped: (_key, request) => taskAgentSlots?.noteRequestWakeStopped(request),
|
|
11712
12615
|
});
|
|
11713
12616
|
await requestLedger.load();
|
|
12617
|
+
const slots = new taskAgentSlots_1.TaskAgentSlots({
|
|
12618
|
+
getMaxConcurrent: () => (0, taskAgentLimit_1.readTaskAgentLimit)(taskAgentLimitSources).maxConcurrent,
|
|
12619
|
+
ledger: requestLedger,
|
|
12620
|
+
store: state,
|
|
12621
|
+
});
|
|
12622
|
+
// A place held for a connector this process no longer serves could never start, and would hold up the others.
|
|
12623
|
+
slots.dropUnserved((key) => (0, connectorSet_1.checkIsServedConversation)(key, ENV.servedPlatforms));
|
|
12624
|
+
taskAgentSlots = slots;
|
|
11714
12625
|
topicRequestLedger = requestLedger;
|
|
11715
12626
|
// The wake-up engine (S4) watches the turns requests start. It is created now
|
|
11716
12627
|
// but started only once the sessions are restored (its sweep reads them).
|
|
11717
12628
|
requestWakeUpEngine = new wakeUpEngine_1.RequestWakeUpEngine({
|
|
11718
12629
|
ledger: requestLedger,
|
|
11719
|
-
probeTurn:
|
|
11720
|
-
getAdapter: createAdapter_1.getThreadAdapter,
|
|
11721
|
-
checkHasPendingQuestion: (keyString) => pendingQuestions.has(keyString),
|
|
11722
|
-
checkHasQuestionPin: (keyString) => questionPinnedMessageId.has(keyString),
|
|
11723
|
-
checkIsCompacting: (keyString) => threadsCompacting.has(keyString),
|
|
11724
|
-
getApiRetryTimer: (keyString) => apiRetryTimers.get(keyString)?.timer,
|
|
11725
|
-
checkIsWedgeRecoveryInFlight: (keyString) => wedgeRecoveriesInFlight.has(keyString),
|
|
11726
|
-
checkIsRetryKickInFlight: (keyString) => apiRetryKicksInFlight.has(keyString),
|
|
11727
|
-
checkIsSessionStarting: (keyString) => startupPromptBuffer.checkIsStarting(keyString),
|
|
11728
|
-
serializeKey: sessionKey_1.keyToString,
|
|
11729
|
-
}),
|
|
12630
|
+
probeTurn: probeThreadTurn,
|
|
11730
12631
|
prepareWakeUpSession: (key, message) => prepareRequestWakeUpSession(key, message, key.platform === 'jira' ? jiraConnector?.adapterName : undefined),
|
|
11731
12632
|
forwardWakeUp: forwardRequestWakeUp,
|
|
11732
12633
|
deliverAlert: async (key, request, reason) => {
|
|
@@ -11739,6 +12640,7 @@ async function startBot() {
|
|
|
11739
12640
|
return result.ok ? (result.alertRef ?? null) : null;
|
|
11740
12641
|
},
|
|
11741
12642
|
backstopMs: (0, wakeUpRules_1.getRequestBackstopMs)(process.env.REQUEST_BACKSTOP_MINUTES),
|
|
12643
|
+
pollIntervalMs: ENV.testTimings.requestTurnPollMs,
|
|
11742
12644
|
sweepIntervalMs: ENV.testTimings.requestSweepIntervalMs,
|
|
11743
12645
|
firstPostRetryDelayMs: ENV.testTimings.requestFirstPostRetryDelayMs,
|
|
11744
12646
|
// Only a Telegram topic in the answers-only view hides the stream from someone who expects to see the work.
|
|
@@ -11746,6 +12648,25 @@ async function startBot() {
|
|
|
11746
12648
|
remindAnswerTail,
|
|
11747
12649
|
});
|
|
11748
12650
|
requestLimitWaitAnswerDeps = { ledger: requestLedger, engine: requestWakeUpEngine, answerSinks };
|
|
12651
|
+
// The todo list's staleness reminder (D10–D12) polls the same turns; started once the sessions are restored.
|
|
12652
|
+
todoReminderEngine = new reminderEngine_1.TodoReminderEngine({
|
|
12653
|
+
registry: getTodoRegistry(),
|
|
12654
|
+
listConversations: () => state.listBindings().map(({ key }) => key),
|
|
12655
|
+
probeTurn: probeThreadTurn,
|
|
12656
|
+
getGate: (key) => ({
|
|
12657
|
+
hasOpenRequest: requestLedger.listOpenRequestsOf(key).length > 0,
|
|
12658
|
+
isStoppingAfterTurn: (0, createAdapter_1.getThreadAdapter)(key).name === adapterNames_1.claudePerTurnAdapterName,
|
|
12659
|
+
isAnswerTailFollowed: requestWakeUpEngine?.checkIsAnswerTailFollowed(key) ?? false,
|
|
12660
|
+
...getConversationAccountState(key),
|
|
12661
|
+
}),
|
|
12662
|
+
getIntervalMs: getTodoReminderIntervalMsNow,
|
|
12663
|
+
checkIsEnabled: () => readTodoSettings().isEnabled,
|
|
12664
|
+
postReminder: postTodoReminder,
|
|
12665
|
+
midTurn: getTodoMidTurn(),
|
|
12666
|
+
pollIntervalMs: ENV.testTimings.todoReminderPollMs,
|
|
12667
|
+
});
|
|
12668
|
+
getTodoMidTurn().clearInboxes();
|
|
12669
|
+
warmTodoProjects();
|
|
11749
12670
|
// Snapshot the persisted transient status-frame ids (S2) NOW, before reattach
|
|
11750
12671
|
// can run any frame-id setter. A reattached session's first frame lifecycle
|
|
11751
12672
|
// call persists the FRESH (all-null) in-memory state and would otherwise
|
|
@@ -11801,8 +12722,9 @@ async function startBot() {
|
|
|
11801
12722
|
// that gives a conversation of another platform only its request-side handling
|
|
11802
12723
|
// (Jira connector plan J2b, R2 — the routing table is unit-tested per event).
|
|
11803
12724
|
const noteTurnOutput = (key) => {
|
|
11804
|
-
// Output means a turn is under way — the wake-up engine's busy-onset signal.
|
|
12725
|
+
// Output means a turn is under way — the wake-up engine's busy-onset signal, and the todo reminder's.
|
|
11805
12726
|
requestWakeUpEngine?.noteAgentOutput(key);
|
|
12727
|
+
todoReminderEngine?.noteAgentOutput(key);
|
|
11806
12728
|
};
|
|
11807
12729
|
(0, createAdapter_1.registerAdapterEventHandlers)({
|
|
11808
12730
|
onOutput: (key, output, meta) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'output', () => {
|
|
@@ -11841,7 +12763,12 @@ async function startBot() {
|
|
|
11841
12763
|
}),
|
|
11842
12764
|
onStopped: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'stopped', () => withThreadLocale(key, () => handleAgentStopped(key)), () => clearForeignSessionState(key)),
|
|
11843
12765
|
onSuspended: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'suspended', () => withThreadLocale(key, () => handleAgentSuspended(key)), () => scheduleLostWorkNote(key, takeLostWorkOf(key), 'holdExpired')),
|
|
11844
|
-
onTurnEnded: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'turnEnded', () =>
|
|
12766
|
+
onTurnEnded: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'turnEnded', () => {
|
|
12767
|
+
// Every platform's (the route is `all`): the todo reminder's turn end, which the per-turn stop below would
|
|
12768
|
+
// otherwise hide from its poll.
|
|
12769
|
+
void todoReminderEngine?.noteAdapterTurnEnd(key).catch((e) => console.warn(`[todos] the turn end of ${(0, sessionKey_1.keyToString)(key)} was not noted:`, e instanceof Error ? e.message : e));
|
|
12770
|
+
handleAgentTurnEnded(key);
|
|
12771
|
+
}),
|
|
11845
12772
|
onContextCompacted: (key, trigger) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'contextCompacted', () => noteConversationContextReset(key, `compaction (${trigger ?? 'bot'})`)),
|
|
11846
12773
|
onConversationLost: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'conversationLost', () => withThreadLocale(key, () => handleConversationLost(key)), () => { dropLostConversationWatermark(key); noteConversationContextReset(key, 'its transcript was lost'); }),
|
|
11847
12774
|
onError: (key, error) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'error', () => withThreadLocale(key, () => handleAgentError(key, error)), () => {
|
|
@@ -11943,7 +12870,7 @@ async function startBot() {
|
|
|
11943
12870
|
});
|
|
11944
12871
|
// Known before any session starts or is adopted: every json-stream start persists the digest of the
|
|
11945
12872
|
// tools it connected to, and the reattach compares an adopted process's against it (L4).
|
|
11946
|
-
botMcpToolDigestReader = (
|
|
12873
|
+
botMcpToolDigestReader = (key) => schedulerMcpHandle.getToolDigest(key.platform, (0, botSkills_1.checkIsBotSkillsEnabled)(key));
|
|
11947
12874
|
const isSchedulerMcpStarted = await (0, mcpBoot_1.runSessionBootPhase)({
|
|
11948
12875
|
startBotMcp: () => (0, mcpBoot_1.startSchedulerMcpForBoot)({
|
|
11949
12876
|
handle: schedulerMcpHandle,
|
|
@@ -11964,6 +12891,9 @@ async function startBot() {
|
|
|
11964
12891
|
// existing option buttons keep working. A question is restored only for a
|
|
11965
12892
|
// thread whose session came back; otherwise it is unreachable, dropped.
|
|
11966
12893
|
restorePendingQuestions();
|
|
12894
|
+
// The questions the idle compaction re-asked: their buttons come back while the conversation can be resumed,
|
|
12895
|
+
// else the message is closed.
|
|
12896
|
+
restoreReAskedQuestions();
|
|
11967
12897
|
// Re-arm persisted API-error retries (S6) so a pending kick survives the
|
|
11968
12898
|
// restart. AFTER restorePendingQuestions so the kick lands in a live
|
|
11969
12899
|
// session; a past fireAt fires one delayed catch-up.
|
|
@@ -11986,7 +12916,8 @@ async function startBot() {
|
|
|
11986
12916
|
markSessionsRestored();
|
|
11987
12917
|
// From here the engine can tell a live turn from a dead session.
|
|
11988
12918
|
requestWakeUpEngine?.start();
|
|
11989
|
-
|
|
12919
|
+
todoReminderEngine?.start();
|
|
12920
|
+
jiraConnector?.start(createJiraSessionDeps(requestLedger, slots, jiraConnector.adapterName));
|
|
11990
12921
|
},
|
|
11991
12922
|
healActiveSessions: () => {
|
|
11992
12923
|
// When this boot ADOPTED an already-running opencode, that server may
|
|
@@ -11994,11 +12925,13 @@ async function startBot() {
|
|
|
11994
12925
|
// generation pointing at a now-dead port. Reconcile against the live MCP
|
|
11995
12926
|
// status and force-refresh any directory that is not `connected`.
|
|
11996
12927
|
// Fire-and-forget: boot must not block on opencode HTTP round-trips
|
|
11997
|
-
// before `bot.launch()`. The
|
|
11998
|
-
//
|
|
12928
|
+
// before `bot.launch()`. The skills step (a server started without the
|
|
12929
|
+
// bot's skills folder is replaced) and the compaction-plugin step go FIRST:
|
|
12930
|
+
// a replaced server or a recreated directory instance drops the MCP
|
|
11999
12931
|
// registration, which this same reconcile then restores.
|
|
12000
12932
|
const openCodeAdapter = (0, createAdapter_1.getAdapter)('opencode');
|
|
12001
12933
|
void (async () => {
|
|
12934
|
+
await openCodeAdapter.activateAgentSkillsForServer?.();
|
|
12002
12935
|
await openCodeAdapter.activateCompactionPluginForActiveSessions?.();
|
|
12003
12936
|
await openCodeAdapter.reconcileSchedulerMcpForActiveSessions?.();
|
|
12004
12937
|
})().catch((e) => console.warn('[scheduler] MCP reconcile failed:', e instanceof Error ? e.message : e));
|
|
@@ -12127,12 +13060,10 @@ async function startBot() {
|
|
|
12127
13060
|
// thread that no longer owns a json-stream session (the normal stop /
|
|
12128
13061
|
// release paths delete the dir themselves; reattach's adopt/orphan-kill
|
|
12129
13062
|
// covers live tmux sessions — this covers their dirs).
|
|
12130
|
-
void (0, jsonStreamHost_1.sweepOrphanJsonStreamDirs)((0, jsonStreamHost_1.resolveJsonStreamRoot)(getDataDir()), (key) => {
|
|
12131
|
-
|
|
12132
|
-
|
|
12133
|
-
|
|
12134
|
-
return agent?.name === adapterNames_1.claudeJsonStreamAdapterName && !!agent.claudeSessionId;
|
|
12135
|
-
})
|
|
13063
|
+
void (0, jsonStreamHost_1.sweepOrphanJsonStreamDirs)((0, jsonStreamHost_1.resolveJsonStreamRoot)(getDataDir()), (key) => (0, jsonStreamHost_1.checkIsJsonStreamHostOwned)({
|
|
13064
|
+
isSessionActive: [adapterNames_1.claudeJsonStreamAdapterName, adapterNames_1.claudePerTurnAdapterName].some((name) => (0, createAdapter_1.getAdapter)(name).checkIsActive(key)),
|
|
13065
|
+
agent: state.getAgent(key),
|
|
13066
|
+
}))
|
|
12136
13067
|
.then((removedCount) => {
|
|
12137
13068
|
if (removedCount > 0)
|
|
12138
13069
|
console.log(`[fileSweep] removed ${removedCount} orphan jsonstream dirs`);
|
|
@@ -12175,7 +13106,7 @@ async function startBot() {
|
|
|
12175
13106
|
console.error('[bot.catch] unhandled error:', err, 'update:', ctx.updateType);
|
|
12176
13107
|
});
|
|
12177
13108
|
// 7. Shutdown — preserve active agents for restart/reattach. Use /quit
|
|
12178
|
-
// (or /
|
|
13109
|
+
// (or /quitall) for an intentional agent stop; process signals only
|
|
12179
13110
|
// stop the bot itself. Ordering (bot.stop → state.flush →
|
|
12180
13111
|
// releaseLock → exit) is enforced by `gracefulShutdown` so the
|
|
12181
13112
|
// previous race against `lock.ts`'s synchronous `process.exit(0)`
|
|
@@ -12206,6 +13137,7 @@ async function startBot() {
|
|
|
12206
13137
|
// means the next boot's rearmAll picks them back up (catch-up replay).
|
|
12207
13138
|
schedulerEngine?.shutdown();
|
|
12208
13139
|
requestWakeUpEngine?.stop();
|
|
13140
|
+
todoReminderEngine?.stop();
|
|
12209
13141
|
jiraConnector?.stop();
|
|
12210
13142
|
if (isSchedulerMcpStarted)
|
|
12211
13143
|
void schedulerMcpHandle.stop().catch(() => { });
|