charness 0.0.0-stage → 3.0.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/.env.example +88 -0
- package/DEVELOPMENT.md +110 -0
- package/LICENSE +21 -0
- package/MIGRATION_1_TO_2.md +24 -0
- package/README.md +1024 -2
- package/dist/accessControl.d.ts +71 -0
- package/dist/accessControl.d.ts.map +1 -0
- package/dist/accessControl.js +108 -0
- package/dist/accessControl.js.map +1 -0
- package/dist/adapters/adapterEventRouting.d.ts +51 -0
- package/dist/adapters/adapterEventRouting.d.ts.map +1 -0
- package/dist/adapters/adapterEventRouting.js +74 -0
- package/dist/adapters/adapterEventRouting.js.map +1 -0
- package/dist/adapters/adapterNames.d.ts +19 -0
- package/dist/adapters/adapterNames.d.ts.map +1 -0
- package/dist/adapters/adapterNames.js +25 -0
- package/dist/adapters/adapterNames.js.map +1 -0
- package/dist/adapters/claudeCliAdapter.d.ts +1336 -0
- package/dist/adapters/claudeCliAdapter.d.ts.map +1 -0
- package/dist/adapters/claudeCliAdapter.js +4204 -0
- package/dist/adapters/claudeCliAdapter.js.map +1 -0
- package/dist/adapters/claudeJsonStreamAdapter.d.ts +548 -0
- package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -0
- package/dist/adapters/claudeJsonStreamAdapter.js +1810 -0
- package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -0
- package/dist/adapters/claudePlatformFlags.d.ts +35 -0
- package/dist/adapters/claudePlatformFlags.d.ts.map +1 -0
- package/dist/adapters/claudePlatformFlags.js +46 -0
- package/dist/adapters/claudePlatformFlags.js.map +1 -0
- package/dist/adapters/createAdapter.d.ts +252 -0
- package/dist/adapters/createAdapter.d.ts.map +1 -0
- package/dist/adapters/createAdapter.js +475 -0
- package/dist/adapters/createAdapter.js.map +1 -0
- package/dist/adapters/openCodeAdapter.d.ts +1446 -0
- package/dist/adapters/openCodeAdapter.d.ts.map +1 -0
- package/dist/adapters/openCodeAdapter.js +4540 -0
- package/dist/adapters/openCodeAdapter.js.map +1 -0
- package/dist/adapters/sessionLaunchDefaults.d.ts +28 -0
- package/dist/adapters/sessionLaunchDefaults.d.ts.map +1 -0
- package/dist/adapters/sessionLaunchDefaults.js +15 -0
- package/dist/adapters/sessionLaunchDefaults.js.map +1 -0
- package/dist/adapters/terminalAdapter.d.ts +68 -0
- package/dist/adapters/terminalAdapter.d.ts.map +1 -0
- package/dist/adapters/terminalAdapter.js +371 -0
- package/dist/adapters/terminalAdapter.js.map +1 -0
- package/dist/agentLogin/agentLogin.d.ts +31 -0
- package/dist/agentLogin/agentLogin.d.ts.map +1 -0
- package/dist/agentLogin/agentLogin.js +493 -0
- package/dist/agentLogin/agentLogin.js.map +1 -0
- package/dist/agentTrigger.d.ts +28 -0
- package/dist/agentTrigger.d.ts.map +1 -0
- package/dist/agentTrigger.js +53 -0
- package/dist/agentTrigger.js.map +1 -0
- package/dist/apiErrorRetry.d.ts +209 -0
- package/dist/apiErrorRetry.d.ts.map +1 -0
- package/dist/apiErrorRetry.js +351 -0
- package/dist/apiErrorRetry.js.map +1 -0
- package/dist/apiRetryKick.d.ts +148 -0
- package/dist/apiRetryKick.d.ts.map +1 -0
- package/dist/apiRetryKick.js +158 -0
- package/dist/apiRetryKick.js.map +1 -0
- package/dist/bootClassifier.d.ts +46 -0
- package/dist/bootClassifier.d.ts.map +1 -0
- package/dist/bootClassifier.js +43 -0
- package/dist/bootClassifier.js.map +1 -0
- package/dist/bot.d.ts +207 -0
- package/dist/bot.d.ts.map +1 -0
- package/dist/bot.js +10611 -0
- package/dist/bot.js.map +1 -0
- package/dist/botFileStorage.d.ts +60 -0
- package/dist/botFileStorage.d.ts.map +1 -0
- package/dist/botFileStorage.js +205 -0
- package/dist/botFileStorage.js.map +1 -0
- package/dist/cli/applyDnsFix.d.ts +7 -0
- package/dist/cli/applyDnsFix.d.ts.map +1 -0
- package/dist/cli/applyDnsFix.js +46 -0
- package/dist/cli/applyDnsFix.js.map +1 -0
- package/dist/cli/bot.d.ts +27 -0
- package/dist/cli/bot.d.ts.map +1 -0
- package/dist/cli/bot.js +98 -0
- package/dist/cli/bot.js.map +1 -0
- package/dist/cli/botEntry.d.ts +3 -0
- package/dist/cli/botEntry.d.ts.map +1 -0
- package/dist/cli/botEntry.js +11 -0
- package/dist/cli/botEntry.js.map +1 -0
- package/dist/cli/connectorGuards.d.ts +31 -0
- package/dist/cli/connectorGuards.d.ts.map +1 -0
- package/dist/cli/connectorGuards.js +159 -0
- package/dist/cli/connectorGuards.js.map +1 -0
- package/dist/cli/envLoader.d.ts +34 -0
- package/dist/cli/envLoader.d.ts.map +1 -0
- package/dist/cli/envLoader.js +132 -0
- package/dist/cli/envLoader.js.map +1 -0
- package/dist/cli/hot.d.ts +82 -0
- package/dist/cli/hot.d.ts.map +1 -0
- package/dist/cli/hot.js +323 -0
- package/dist/cli/hot.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +130 -0
- package/dist/cli.js.map +1 -0
- package/dist/connectors/jira/adf.d.ts +72 -0
- package/dist/connectors/jira/adf.d.ts.map +1 -0
- package/dist/connectors/jira/adf.js +407 -0
- package/dist/connectors/jira/adf.js.map +1 -0
- package/dist/connectors/jira/agentBinaries.d.ts +14 -0
- package/dist/connectors/jira/agentBinaries.d.ts.map +1 -0
- package/dist/connectors/jira/agentBinaries.js +75 -0
- package/dist/connectors/jira/agentBinaries.js.map +1 -0
- package/dist/connectors/jira/answerSink.d.ts +52 -0
- package/dist/connectors/jira/answerSink.d.ts.map +1 -0
- package/dist/connectors/jira/answerSink.js +220 -0
- package/dist/connectors/jira/answerSink.js.map +1 -0
- package/dist/connectors/jira/attachmentDownload.d.ts +41 -0
- package/dist/connectors/jira/attachmentDownload.d.ts.map +1 -0
- package/dist/connectors/jira/attachmentDownload.js +92 -0
- package/dist/connectors/jira/attachmentDownload.js.map +1 -0
- package/dist/connectors/jira/attachmentTool.d.ts +43 -0
- package/dist/connectors/jira/attachmentTool.d.ts.map +1 -0
- package/dist/connectors/jira/attachmentTool.js +150 -0
- package/dist/connectors/jira/attachmentTool.js.map +1 -0
- package/dist/connectors/jira/client.d.ts +490 -0
- package/dist/connectors/jira/client.d.ts.map +1 -0
- package/dist/connectors/jira/client.js +453 -0
- package/dist/connectors/jira/client.js.map +1 -0
- package/dist/connectors/jira/config.d.ts +117 -0
- package/dist/connectors/jira/config.d.ts.map +1 -0
- package/dist/connectors/jira/config.js +350 -0
- package/dist/connectors/jira/config.js.map +1 -0
- package/dist/connectors/jira/configFile.d.ts +10 -0
- package/dist/connectors/jira/configFile.d.ts.map +1 -0
- package/dist/connectors/jira/configFile.js +50 -0
- package/dist/connectors/jira/configFile.js.map +1 -0
- package/dist/connectors/jira/connector.d.ts +68 -0
- package/dist/connectors/jira/connector.d.ts.map +1 -0
- package/dist/connectors/jira/connector.js +257 -0
- package/dist/connectors/jira/connector.js.map +1 -0
- package/dist/connectors/jira/contextLedger.d.ts +59 -0
- package/dist/connectors/jira/contextLedger.d.ts.map +1 -0
- package/dist/connectors/jira/contextLedger.js +194 -0
- package/dist/connectors/jira/contextLedger.js.map +1 -0
- package/dist/connectors/jira/contextPreamble.d.ts +18 -0
- package/dist/connectors/jira/contextPreamble.d.ts.map +1 -0
- package/dist/connectors/jira/contextPreamble.js +31 -0
- package/dist/connectors/jira/contextPreamble.js.map +1 -0
- package/dist/connectors/jira/inbound.d.ts +132 -0
- package/dist/connectors/jira/inbound.d.ts.map +1 -0
- package/dist/connectors/jira/inbound.js +316 -0
- package/dist/connectors/jira/inbound.js.map +1 -0
- package/dist/connectors/jira/issueBlocks.d.ts +51 -0
- package/dist/connectors/jira/issueBlocks.d.ts.map +1 -0
- package/dist/connectors/jira/issueBlocks.js +182 -0
- package/dist/connectors/jira/issueBlocks.js.map +1 -0
- package/dist/connectors/jira/issueContext.d.ts +25 -0
- package/dist/connectors/jira/issueContext.d.ts.map +1 -0
- package/dist/connectors/jira/issueContext.js +53 -0
- package/dist/connectors/jira/issueContext.js.map +1 -0
- package/dist/connectors/jira/issueDelta.d.ts +35 -0
- package/dist/connectors/jira/issueDelta.d.ts.map +1 -0
- package/dist/connectors/jira/issueDelta.js +50 -0
- package/dist/connectors/jira/issueDelta.js.map +1 -0
- package/dist/connectors/jira/mediaPlaceholders.d.ts +20 -0
- package/dist/connectors/jira/mediaPlaceholders.d.ts.map +1 -0
- package/dist/connectors/jira/mediaPlaceholders.js +92 -0
- package/dist/connectors/jira/mediaPlaceholders.js.map +1 -0
- package/dist/connectors/jira/outbound.d.ts +6 -0
- package/dist/connectors/jira/outbound.d.ts.map +1 -0
- package/dist/connectors/jira/outbound.js +38 -0
- package/dist/connectors/jira/outbound.js.map +1 -0
- package/dist/connectors/jira/prompt.d.ts +37 -0
- package/dist/connectors/jira/prompt.d.ts.map +1 -0
- package/dist/connectors/jira/prompt.js +99 -0
- package/dist/connectors/jira/prompt.js.map +1 -0
- package/dist/connectors/jira/promptSpill.d.ts +50 -0
- package/dist/connectors/jira/promptSpill.d.ts.map +1 -0
- package/dist/connectors/jira/promptSpill.js +157 -0
- package/dist/connectors/jira/promptSpill.js.map +1 -0
- package/dist/connectors/jira/promptText.d.ts +7 -0
- package/dist/connectors/jira/promptText.d.ts.map +1 -0
- package/dist/connectors/jira/promptText.js +26 -0
- package/dist/connectors/jira/promptText.js.map +1 -0
- package/dist/connectors/jira/sessionKeyCodec.d.ts +16 -0
- package/dist/connectors/jira/sessionKeyCodec.d.ts.map +1 -0
- package/dist/connectors/jira/sessionKeyCodec.js +101 -0
- package/dist/connectors/jira/sessionKeyCodec.js.map +1 -0
- package/dist/connectors/jira/trigger.d.ts +50 -0
- package/dist/connectors/jira/trigger.d.ts.map +1 -0
- package/dist/connectors/jira/trigger.js +93 -0
- package/dist/connectors/jira/trigger.js.map +1 -0
- package/dist/connectors/jira/triggerLog.d.ts +51 -0
- package/dist/connectors/jira/triggerLog.d.ts.map +1 -0
- package/dist/connectors/jira/triggerLog.js +78 -0
- package/dist/connectors/jira/triggerLog.js.map +1 -0
- package/dist/connectors/jira/unconfirmedPosts.d.ts +58 -0
- package/dist/connectors/jira/unconfirmedPosts.d.ts.map +1 -0
- package/dist/connectors/jira/unconfirmedPosts.js +105 -0
- package/dist/connectors/jira/unconfirmedPosts.js.map +1 -0
- package/dist/connectors/telegram/answerSink.d.ts +46 -0
- package/dist/connectors/telegram/answerSink.d.ts.map +1 -0
- package/dist/connectors/telegram/answerSink.js +71 -0
- package/dist/connectors/telegram/answerSink.js.map +1 -0
- package/dist/connectors/telegram/commands/autoContinueLimits.d.ts +31 -0
- package/dist/connectors/telegram/commands/autoContinueLimits.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/autoContinueLimits.js +142 -0
- package/dist/connectors/telegram/commands/autoContinueLimits.js.map +1 -0
- package/dist/connectors/telegram/commands/botCore.d.ts +46 -0
- package/dist/connectors/telegram/commands/botCore.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/botCore.js +3 -0
- package/dist/connectors/telegram/commands/botCore.js.map +1 -0
- package/dist/connectors/telegram/commands/callbackMessage.d.ts +12 -0
- package/dist/connectors/telegram/commands/callbackMessage.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/callbackMessage.js +13 -0
- package/dist/connectors/telegram/commands/callbackMessage.js.map +1 -0
- package/dist/connectors/telegram/commands/compaction.d.ts +47 -0
- package/dist/connectors/telegram/commands/compaction.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/compaction.js +868 -0
- package/dist/connectors/telegram/commands/compaction.js.map +1 -0
- package/dist/connectors/telegram/commands/displayModes.d.ts +13 -0
- package/dist/connectors/telegram/commands/displayModes.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/displayModes.js +335 -0
- package/dist/connectors/telegram/commands/displayModes.js.map +1 -0
- package/dist/connectors/telegram/commands/modelProviders.d.ts +95 -0
- package/dist/connectors/telegram/commands/modelProviders.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/modelProviders.js +841 -0
- package/dist/connectors/telegram/commands/modelProviders.js.map +1 -0
- package/dist/connectors/telegram/commands/reminders.d.ts +54 -0
- package/dist/connectors/telegram/commands/reminders.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/reminders.js +624 -0
- package/dist/connectors/telegram/commands/reminders.js.map +1 -0
- package/dist/connectors/telegram/fileIntake.d.ts +166 -0
- package/dist/connectors/telegram/fileIntake.d.ts.map +1 -0
- package/dist/connectors/telegram/fileIntake.js +304 -0
- package/dist/connectors/telegram/fileIntake.js.map +1 -0
- package/dist/connectors/telegram/fileSendGateway.d.ts +57 -0
- package/dist/connectors/telegram/fileSendGateway.d.ts.map +1 -0
- package/dist/connectors/telegram/fileSendGateway.js +221 -0
- package/dist/connectors/telegram/fileSendGateway.js.map +1 -0
- package/dist/connectors/telegram/foreignKeyFallbacks.d.ts +52 -0
- package/dist/connectors/telegram/foreignKeyFallbacks.d.ts.map +1 -0
- package/dist/connectors/telegram/foreignKeyFallbacks.js +71 -0
- package/dist/connectors/telegram/foreignKeyFallbacks.js.map +1 -0
- package/dist/connectors/telegram/inbound.d.ts +131 -0
- package/dist/connectors/telegram/inbound.d.ts.map +1 -0
- package/dist/connectors/telegram/inbound.js +278 -0
- package/dist/connectors/telegram/inbound.js.map +1 -0
- package/dist/connectors/telegram/languagePicker.d.ts +32 -0
- package/dist/connectors/telegram/languagePicker.d.ts.map +1 -0
- package/dist/connectors/telegram/languagePicker.js +55 -0
- package/dist/connectors/telegram/languagePicker.js.map +1 -0
- package/dist/connectors/telegram/messageSplit.d.ts +62 -0
- package/dist/connectors/telegram/messageSplit.d.ts.map +1 -0
- package/dist/connectors/telegram/messageSplit.js +119 -0
- package/dist/connectors/telegram/messageSplit.js.map +1 -0
- package/dist/connectors/telegram/outbound.d.ts +62 -0
- package/dist/connectors/telegram/outbound.d.ts.map +1 -0
- package/dist/connectors/telegram/outbound.js +116 -0
- package/dist/connectors/telegram/outbound.js.map +1 -0
- package/dist/connectors/telegram/output/createOutputTransport.d.ts +34 -0
- package/dist/connectors/telegram/output/createOutputTransport.d.ts.map +1 -0
- package/dist/connectors/telegram/output/createOutputTransport.js +77 -0
- package/dist/connectors/telegram/output/createOutputTransport.js.map +1 -0
- package/dist/connectors/telegram/output/dmOutputTransport.d.ts +47 -0
- package/dist/connectors/telegram/output/dmOutputTransport.d.ts.map +1 -0
- package/dist/connectors/telegram/output/dmOutputTransport.js +412 -0
- package/dist/connectors/telegram/output/dmOutputTransport.js.map +1 -0
- package/dist/connectors/telegram/outputBacklogGlue.d.ts +38 -0
- package/dist/connectors/telegram/outputBacklogGlue.d.ts.map +1 -0
- package/dist/connectors/telegram/outputBacklogGlue.js +49 -0
- package/dist/connectors/telegram/outputBacklogGlue.js.map +1 -0
- package/dist/connectors/telegram/outputFlushPlan.d.ts +93 -0
- package/dist/connectors/telegram/outputFlushPlan.d.ts.map +1 -0
- package/dist/connectors/telegram/outputFlushPlan.js +120 -0
- package/dist/connectors/telegram/outputFlushPlan.js.map +1 -0
- package/dist/connectors/telegram/questionKeyboards.d.ts +30 -0
- package/dist/connectors/telegram/questionKeyboards.d.ts.map +1 -0
- package/dist/connectors/telegram/questionKeyboards.js +37 -0
- package/dist/connectors/telegram/questionKeyboards.js.map +1 -0
- package/dist/connectors/telegram/renderAgentHtml.d.ts +27 -0
- package/dist/connectors/telegram/renderAgentHtml.d.ts.map +1 -0
- package/dist/connectors/telegram/renderAgentHtml.js +97 -0
- package/dist/connectors/telegram/renderAgentHtml.js.map +1 -0
- package/dist/connectors/telegram/sessionKeyCodec.d.ts +28 -0
- package/dist/connectors/telegram/sessionKeyCodec.d.ts.map +1 -0
- package/dist/connectors/telegram/sessionKeyCodec.js +107 -0
- package/dist/connectors/telegram/sessionKeyCodec.js.map +1 -0
- package/dist/connectors/telegram/telegramCallGuard.d.ts +19 -0
- package/dist/connectors/telegram/telegramCallGuard.d.ts.map +1 -0
- package/dist/connectors/telegram/telegramCallGuard.js +35 -0
- package/dist/connectors/telegram/telegramCallGuard.js.map +1 -0
- package/dist/connectors/telegram/timezonePicker.d.ts +85 -0
- package/dist/connectors/telegram/timezonePicker.d.ts.map +1 -0
- package/dist/connectors/telegram/timezonePicker.js +142 -0
- package/dist/connectors/telegram/timezonePicker.js.map +1 -0
- package/dist/connectors/telegram/updateDispatcher.d.ts +85 -0
- package/dist/connectors/telegram/updateDispatcher.d.ts.map +1 -0
- package/dist/connectors/telegram/updateDispatcher.js +159 -0
- package/dist/connectors/telegram/updateDispatcher.js.map +1 -0
- package/dist/connectors/test/connector.d.ts +52 -0
- package/dist/connectors/test/connector.d.ts.map +1 -0
- package/dist/connectors/test/connector.js +143 -0
- package/dist/connectors/test/connector.js.map +1 -0
- package/dist/connectors/test/sessionKeyCodec.d.ts +25 -0
- package/dist/connectors/test/sessionKeyCodec.d.ts.map +1 -0
- package/dist/connectors/test/sessionKeyCodec.js +50 -0
- package/dist/connectors/test/sessionKeyCodec.js.map +1 -0
- package/dist/diagLog.d.ts +6 -0
- package/dist/diagLog.d.ts.map +1 -0
- package/dist/diagLog.js +58 -0
- package/dist/diagLog.js.map +1 -0
- package/dist/effortLevels.d.ts +68 -0
- package/dist/effortLevels.d.ts.map +1 -0
- package/dist/effortLevels.js +114 -0
- package/dist/effortLevels.js.map +1 -0
- package/dist/folderName.d.ts +42 -0
- package/dist/folderName.d.ts.map +1 -0
- package/dist/folderName.js +51 -0
- package/dist/folderName.js.map +1 -0
- package/dist/i18n/de.d.ts +2 -0
- package/dist/i18n/de.d.ts.map +1 -0
- package/dist/i18n/de.js +537 -0
- package/dist/i18n/de.js.map +1 -0
- package/dist/i18n/en.d.ts +7 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +566 -0
- package/dist/i18n/en.js.map +1 -0
- package/dist/i18n/es.d.ts +2 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +537 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/fr.d.ts +2 -0
- package/dist/i18n/fr.d.ts.map +1 -0
- package/dist/i18n/fr.js +537 -0
- package/dist/i18n/fr.js.map +1 -0
- package/dist/i18n/hi.d.ts +2 -0
- package/dist/i18n/hi.d.ts.map +1 -0
- package/dist/i18n/hi.js +537 -0
- package/dist/i18n/hi.js.map +1 -0
- package/dist/i18n/ja.d.ts +2 -0
- package/dist/i18n/ja.d.ts.map +1 -0
- package/dist/i18n/ja.js +537 -0
- package/dist/i18n/ja.js.map +1 -0
- package/dist/i18n/ka.d.ts +2 -0
- package/dist/i18n/ka.d.ts.map +1 -0
- package/dist/i18n/ka.js +537 -0
- package/dist/i18n/ka.js.map +1 -0
- package/dist/i18n/pt.d.ts +2 -0
- package/dist/i18n/pt.d.ts.map +1 -0
- package/dist/i18n/pt.js +537 -0
- package/dist/i18n/pt.js.map +1 -0
- package/dist/i18n/ru.d.ts +6 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +540 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/i18n/uk.d.ts +8 -0
- package/dist/i18n/uk.d.ts.map +1 -0
- package/dist/i18n/uk.js +542 -0
- package/dist/i18n/uk.js.map +1 -0
- package/dist/i18n/uz.d.ts +2 -0
- package/dist/i18n/uz.d.ts.map +1 -0
- package/dist/i18n/uz.js +537 -0
- package/dist/i18n/uz.js.map +1 -0
- package/dist/i18n/zh.d.ts +2 -0
- package/dist/i18n/zh.d.ts.map +1 -0
- package/dist/i18n/zh.js +537 -0
- package/dist/i18n/zh.js.map +1 -0
- package/dist/i18n.d.ts +115 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +233 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/installManager.d.ts +99 -0
- package/dist/installManager.d.ts.map +1 -0
- package/dist/installManager.js +943 -0
- package/dist/installManager.js.map +1 -0
- package/dist/instanceLock.d.ts +161 -0
- package/dist/instanceLock.d.ts.map +1 -0
- package/dist/instanceLock.js +388 -0
- package/dist/instanceLock.js.map +1 -0
- package/dist/mcpConfig.d.ts +76 -0
- package/dist/mcpConfig.d.ts.map +1 -0
- package/dist/mcpConfig.js +270 -0
- package/dist/mcpConfig.js.map +1 -0
- package/dist/openCodeOpenQuestion.d.ts +44 -0
- package/dist/openCodeOpenQuestion.d.ts.map +1 -0
- package/dist/openCodeOpenQuestion.js +64 -0
- package/dist/openCodeOpenQuestion.js.map +1 -0
- package/dist/openCodeQuestionFlow.d.ts +105 -0
- package/dist/openCodeQuestionFlow.d.ts.map +1 -0
- package/dist/openCodeQuestionFlow.js +143 -0
- package/dist/openCodeQuestionFlow.js.map +1 -0
- package/dist/openCodeSessionRouting.d.ts +133 -0
- package/dist/openCodeSessionRouting.d.ts.map +1 -0
- package/dist/openCodeSessionRouting.js +253 -0
- package/dist/openCodeSessionRouting.js.map +1 -0
- package/dist/openCodeSessionTitle.d.ts +50 -0
- package/dist/openCodeSessionTitle.d.ts.map +1 -0
- package/dist/openCodeSessionTitle.js +84 -0
- package/dist/openCodeSessionTitle.js.map +1 -0
- package/dist/outputTrace.d.ts +108 -0
- package/dist/outputTrace.d.ts.map +1 -0
- package/dist/outputTrace.js +430 -0
- package/dist/outputTrace.js.map +1 -0
- package/dist/pendingQuestionRepost.d.ts +47 -0
- package/dist/pendingQuestionRepost.d.ts.map +1 -0
- package/dist/pendingQuestionRepost.js +29 -0
- package/dist/pendingQuestionRepost.js.map +1 -0
- package/dist/pinnedStatus.d.ts +52 -0
- package/dist/pinnedStatus.d.ts.map +1 -0
- package/dist/pinnedStatus.js +36 -0
- package/dist/pinnedStatus.js.map +1 -0
- package/dist/platform/answerSink.d.ts +93 -0
- package/dist/platform/answerSink.d.ts.map +1 -0
- package/dist/platform/answerSink.js +31 -0
- package/dist/platform/answerSink.js.map +1 -0
- package/dist/platform/capabilityFallback.d.ts +39 -0
- package/dist/platform/capabilityFallback.d.ts.map +1 -0
- package/dist/platform/capabilityFallback.js +55 -0
- package/dist/platform/capabilityFallback.js.map +1 -0
- package/dist/platform/commandRouter.d.ts +51 -0
- package/dist/platform/commandRouter.d.ts.map +1 -0
- package/dist/platform/commandRouter.js +53 -0
- package/dist/platform/commandRouter.js.map +1 -0
- package/dist/platform/connectorSet.d.ts +39 -0
- package/dist/platform/connectorSet.d.ts.map +1 -0
- package/dist/platform/connectorSet.js +55 -0
- package/dist/platform/connectorSet.js.map +1 -0
- package/dist/platform/inbound.d.ts +147 -0
- package/dist/platform/inbound.d.ts.map +1 -0
- package/dist/platform/inbound.js +18 -0
- package/dist/platform/inbound.js.map +1 -0
- package/dist/platform/outbound.d.ts +225 -0
- package/dist/platform/outbound.d.ts.map +1 -0
- package/dist/platform/outbound.js +30 -0
- package/dist/platform/outbound.js.map +1 -0
- package/dist/platform/unservedStateGuard.d.ts +27 -0
- package/dist/platform/unservedStateGuard.d.ts.map +1 -0
- package/dist/platform/unservedStateGuard.js +42 -0
- package/dist/platform/unservedStateGuard.js.map +1 -0
- package/dist/postToSession.d.ts +94 -0
- package/dist/postToSession.d.ts.map +1 -0
- package/dist/postToSession.js +56 -0
- package/dist/postToSession.js.map +1 -0
- package/dist/progressLine.d.ts +180 -0
- package/dist/progressLine.d.ts.map +1 -0
- package/dist/progressLine.js +293 -0
- package/dist/progressLine.js.map +1 -0
- package/dist/rateLimiter.d.ts +246 -0
- package/dist/rateLimiter.d.ts.map +1 -0
- package/dist/rateLimiter.js +555 -0
- package/dist/rateLimiter.js.map +1 -0
- package/dist/requests/answerRequest.d.ts +57 -0
- package/dist/requests/answerRequest.d.ts.map +1 -0
- package/dist/requests/answerRequest.js +96 -0
- package/dist/requests/answerRequest.js.map +1 -0
- package/dist/requests/answerTail.d.ts +83 -0
- package/dist/requests/answerTail.d.ts.map +1 -0
- package/dist/requests/answerTail.js +102 -0
- package/dist/requests/answerTail.js.map +1 -0
- package/dist/requests/limitWaitAnswer.d.ts +87 -0
- package/dist/requests/limitWaitAnswer.d.ts.map +1 -0
- package/dist/requests/limitWaitAnswer.js +83 -0
- package/dist/requests/limitWaitAnswer.js.map +1 -0
- package/dist/requests/requestGroup.d.ts +44 -0
- package/dist/requests/requestGroup.d.ts.map +1 -0
- package/dist/requests/requestGroup.js +63 -0
- package/dist/requests/requestGroup.js.map +1 -0
- package/dist/requests/requestHeader.d.ts +70 -0
- package/dist/requests/requestHeader.d.ts.map +1 -0
- package/dist/requests/requestHeader.js +83 -0
- package/dist/requests/requestHeader.js.map +1 -0
- package/dist/requests/requestLedger.d.ts +217 -0
- package/dist/requests/requestLedger.d.ts.map +1 -0
- package/dist/requests/requestLedger.js +468 -0
- package/dist/requests/requestLedger.js.map +1 -0
- package/dist/requests/sessionTurnProbe.d.ts +52 -0
- package/dist/requests/sessionTurnProbe.d.ts.map +1 -0
- package/dist/requests/sessionTurnProbe.js +29 -0
- package/dist/requests/sessionTurnProbe.js.map +1 -0
- package/dist/requests/types.d.ts +181 -0
- package/dist/requests/types.d.ts.map +1 -0
- package/dist/requests/types.js +9 -0
- package/dist/requests/types.js.map +1 -0
- package/dist/requests/wakeUpEngine.d.ts +160 -0
- package/dist/requests/wakeUpEngine.d.ts.map +1 -0
- package/dist/requests/wakeUpEngine.js +399 -0
- package/dist/requests/wakeUpEngine.js.map +1 -0
- package/dist/requests/wakeUpRules.d.ts +151 -0
- package/dist/requests/wakeUpRules.d.ts.map +1 -0
- package/dist/requests/wakeUpRules.js +167 -0
- package/dist/requests/wakeUpRules.js.map +1 -0
- package/dist/resumeContext.d.ts +87 -0
- package/dist/resumeContext.d.ts.map +1 -0
- package/dist/resumeContext.js +114 -0
- package/dist/resumeContext.js.map +1 -0
- package/dist/scheduler/checkRun.d.ts +95 -0
- package/dist/scheduler/checkRun.d.ts.map +1 -0
- package/dist/scheduler/checkRun.js +172 -0
- package/dist/scheduler/checkRun.js.map +1 -0
- package/dist/scheduler/delivery.d.ts +99 -0
- package/dist/scheduler/delivery.d.ts.map +1 -0
- package/dist/scheduler/delivery.js +212 -0
- package/dist/scheduler/delivery.js.map +1 -0
- package/dist/scheduler/deliveryKind.d.ts +29 -0
- package/dist/scheduler/deliveryKind.d.ts.map +1 -0
- package/dist/scheduler/deliveryKind.js +39 -0
- package/dist/scheduler/deliveryKind.js.map +1 -0
- package/dist/scheduler/directoryThreads.d.ts +19 -0
- package/dist/scheduler/directoryThreads.d.ts.map +1 -0
- package/dist/scheduler/directoryThreads.js +28 -0
- package/dist/scheduler/directoryThreads.js.map +1 -0
- package/dist/scheduler/engine.d.ts +77 -0
- package/dist/scheduler/engine.d.ts.map +1 -0
- package/dist/scheduler/engine.js +337 -0
- package/dist/scheduler/engine.js.map +1 -0
- package/dist/scheduler/injection.d.ts +102 -0
- package/dist/scheduler/injection.d.ts.map +1 -0
- package/dist/scheduler/injection.js +122 -0
- package/dist/scheduler/injection.js.map +1 -0
- package/dist/scheduler/mcpBoot.d.ts +65 -0
- package/dist/scheduler/mcpBoot.d.ts.map +1 -0
- package/dist/scheduler/mcpBoot.js +60 -0
- package/dist/scheduler/mcpBoot.js.map +1 -0
- package/dist/scheduler/mcpSurface.d.ts +355 -0
- package/dist/scheduler/mcpSurface.d.ts.map +1 -0
- package/dist/scheduler/mcpSurface.js +1257 -0
- package/dist/scheduler/mcpSurface.js.map +1 -0
- package/dist/scheduler/rebindResume.d.ts +27 -0
- package/dist/scheduler/rebindResume.d.ts.map +1 -0
- package/dist/scheduler/rebindResume.js +17 -0
- package/dist/scheduler/rebindResume.js.map +1 -0
- package/dist/scheduler/recurrence.d.ts +58 -0
- package/dist/scheduler/recurrence.d.ts.map +1 -0
- package/dist/scheduler/recurrence.js +210 -0
- package/dist/scheduler/recurrence.js.map +1 -0
- package/dist/scheduler/runLedger.d.ts +59 -0
- package/dist/scheduler/runLedger.d.ts.map +1 -0
- package/dist/scheduler/runLedger.js +54 -0
- package/dist/scheduler/runLedger.js.map +1 -0
- package/dist/scheduler/store.d.ts +97 -0
- package/dist/scheduler/store.d.ts.map +1 -0
- package/dist/scheduler/store.js +124 -0
- package/dist/scheduler/store.js.map +1 -0
- package/dist/scheduler/timezoneRecompute.d.ts +61 -0
- package/dist/scheduler/timezoneRecompute.d.ts.map +1 -0
- package/dist/scheduler/timezoneRecompute.js +46 -0
- package/dist/scheduler/timezoneRecompute.js.map +1 -0
- package/dist/scheduler/types.d.ts +139 -0
- package/dist/scheduler/types.d.ts.map +1 -0
- package/dist/scheduler/types.js +8 -0
- package/dist/scheduler/types.js.map +1 -0
- package/dist/sendErrorClassifier.d.ts +45 -0
- package/dist/sendErrorClassifier.d.ts.map +1 -0
- package/dist/sendErrorClassifier.js +65 -0
- package/dist/sendErrorClassifier.js.map +1 -0
- package/dist/sessionKey.d.ts +136 -0
- package/dist/sessionKey.d.ts.map +1 -0
- package/dist/sessionKey.js +153 -0
- package/dist/sessionKey.js.map +1 -0
- package/dist/sessionPick.d.ts +42 -0
- package/dist/sessionPick.d.ts.map +1 -0
- package/dist/sessionPick.js +39 -0
- package/dist/sessionPick.js.map +1 -0
- package/dist/shutdown.d.ts +126 -0
- package/dist/shutdown.d.ts.map +1 -0
- package/dist/shutdown.js +164 -0
- package/dist/shutdown.js.map +1 -0
- package/dist/startupPromptBuffer.d.ts +85 -0
- package/dist/startupPromptBuffer.d.ts.map +1 -0
- package/dist/startupPromptBuffer.js +123 -0
- package/dist/startupPromptBuffer.js.map +1 -0
- package/dist/state.d.ts +1245 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +1830 -0
- package/dist/state.js.map +1 -0
- package/dist/threadContextPreamble.d.ts +89 -0
- package/dist/threadContextPreamble.d.ts.map +1 -0
- package/dist/threadContextPreamble.js +102 -0
- package/dist/threadContextPreamble.js.map +1 -0
- package/dist/threadRouting.d.ts +164 -0
- package/dist/threadRouting.d.ts.map +1 -0
- package/dist/threadRouting.js +182 -0
- package/dist/threadRouting.js.map +1 -0
- package/dist/types.d.ts +1015 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/abortableFifo.d.ts +10 -0
- package/dist/utils/abortableFifo.d.ts.map +1 -0
- package/dist/utils/abortableFifo.js +55 -0
- package/dist/utils/abortableFifo.js.map +1 -0
- package/dist/utils/adoptedToolList.d.ts +27 -0
- package/dist/utils/adoptedToolList.d.ts.map +1 -0
- package/dist/utils/adoptedToolList.js +22 -0
- package/dist/utils/adoptedToolList.js.map +1 -0
- package/dist/utils/agentEnvironment.d.ts +42 -0
- package/dist/utils/agentEnvironment.d.ts.map +1 -0
- package/dist/utils/agentEnvironment.js +122 -0
- package/dist/utils/agentEnvironment.js.map +1 -0
- package/dist/utils/ansiClean.d.ts +11 -0
- package/dist/utils/ansiClean.d.ts.map +1 -0
- package/dist/utils/ansiClean.js +139 -0
- package/dist/utils/ansiClean.js.map +1 -0
- package/dist/utils/autoContinueOnLimit.d.ts +49 -0
- package/dist/utils/autoContinueOnLimit.d.ts.map +1 -0
- package/dist/utils/autoContinueOnLimit.js +69 -0
- package/dist/utils/autoContinueOnLimit.js.map +1 -0
- package/dist/utils/canonicalPathContainment.d.ts +7 -0
- package/dist/utils/canonicalPathContainment.d.ts.map +1 -0
- package/dist/utils/canonicalPathContainment.js +48 -0
- package/dist/utils/canonicalPathContainment.js.map +1 -0
- package/dist/utils/claudeAuthLogin.d.ts +71 -0
- package/dist/utils/claudeAuthLogin.d.ts.map +1 -0
- package/dist/utils/claudeAuthLogin.js +93 -0
- package/dist/utils/claudeAuthLogin.js.map +1 -0
- package/dist/utils/claudeChunkClassifier.d.ts +97 -0
- package/dist/utils/claudeChunkClassifier.d.ts.map +1 -0
- package/dist/utils/claudeChunkClassifier.js +254 -0
- package/dist/utils/claudeChunkClassifier.js.map +1 -0
- package/dist/utils/claudeCodeVersion.d.ts +31 -0
- package/dist/utils/claudeCodeVersion.d.ts.map +1 -0
- package/dist/utils/claudeCodeVersion.js +62 -0
- package/dist/utils/claudeCodeVersion.js.map +1 -0
- package/dist/utils/claudeCompactHook.d.ts +64 -0
- package/dist/utils/claudeCompactHook.d.ts.map +1 -0
- package/dist/utils/claudeCompactHook.js +98 -0
- package/dist/utils/claudeCompactHook.js.map +1 -0
- package/dist/utils/claudeLivenessAction.d.ts +208 -0
- package/dist/utils/claudeLivenessAction.d.ts.map +1 -0
- package/dist/utils/claudeLivenessAction.js +135 -0
- package/dist/utils/claudeLivenessAction.js.map +1 -0
- package/dist/utils/claudeMcpHeal.d.ts +92 -0
- package/dist/utils/claudeMcpHeal.d.ts.map +1 -0
- package/dist/utils/claudeMcpHeal.js +91 -0
- package/dist/utils/claudeMcpHeal.js.map +1 -0
- package/dist/utils/claudeRelayRouting.d.ts +86 -0
- package/dist/utils/claudeRelayRouting.d.ts.map +1 -0
- package/dist/utils/claudeRelayRouting.js +159 -0
- package/dist/utils/claudeRelayRouting.js.map +1 -0
- package/dist/utils/claudeRuntimeInfo.d.ts +26 -0
- package/dist/utils/claudeRuntimeInfo.d.ts.map +1 -0
- package/dist/utils/claudeRuntimeInfo.js +151 -0
- package/dist/utils/claudeRuntimeInfo.js.map +1 -0
- package/dist/utils/claudeScrapeShapes.d.ts +256 -0
- package/dist/utils/claudeScrapeShapes.d.ts.map +1 -0
- package/dist/utils/claudeScrapeShapes.js +290 -0
- package/dist/utils/claudeScrapeShapes.js.map +1 -0
- package/dist/utils/claudeSessionTitle.d.ts +27 -0
- package/dist/utils/claudeSessionTitle.d.ts.map +1 -0
- package/dist/utils/claudeSessionTitle.js +90 -0
- package/dist/utils/claudeSessionTitle.js.map +1 -0
- package/dist/utils/claudeStreamJson.d.ts +202 -0
- package/dist/utils/claudeStreamJson.d.ts.map +1 -0
- package/dist/utils/claudeStreamJson.js +351 -0
- package/dist/utils/claudeStreamJson.js.map +1 -0
- package/dist/utils/claudeSubagentTail.d.ts +93 -0
- package/dist/utils/claudeSubagentTail.d.ts.map +1 -0
- package/dist/utils/claudeSubagentTail.js +136 -0
- package/dist/utils/claudeSubagentTail.js.map +1 -0
- package/dist/utils/clearThreadOutputQueues.d.ts +43 -0
- package/dist/utils/clearThreadOutputQueues.d.ts.map +1 -0
- package/dist/utils/clearThreadOutputQueues.js +39 -0
- package/dist/utils/clearThreadOutputQueues.js.map +1 -0
- package/dist/utils/compactCommandRoute.d.ts +45 -0
- package/dist/utils/compactCommandRoute.d.ts.map +1 -0
- package/dist/utils/compactCommandRoute.js +36 -0
- package/dist/utils/compactCommandRoute.js.map +1 -0
- package/dist/utils/compactOnIdle.d.ts +329 -0
- package/dist/utils/compactOnIdle.d.ts.map +1 -0
- package/dist/utils/compactOnIdle.js +379 -0
- package/dist/utils/compactOnIdle.js.map +1 -0
- package/dist/utils/consoleFileTap.d.ts +51 -0
- package/dist/utils/consoleFileTap.d.ts.map +1 -0
- package/dist/utils/consoleFileTap.js +86 -0
- package/dist/utils/consoleFileTap.js.map +1 -0
- package/dist/utils/displayVerbosity.d.ts +44 -0
- package/dist/utils/displayVerbosity.d.ts.map +1 -0
- package/dist/utils/displayVerbosity.js +75 -0
- package/dist/utils/displayVerbosity.js.map +1 -0
- package/dist/utils/download.d.ts +72 -0
- package/dist/utils/download.d.ts.map +1 -0
- package/dist/utils/download.js +216 -0
- package/dist/utils/download.js.map +1 -0
- package/dist/utils/draftFinalize.d.ts +110 -0
- package/dist/utils/draftFinalize.d.ts.map +1 -0
- package/dist/utils/draftFinalize.js +100 -0
- package/dist/utils/draftFinalize.js.map +1 -0
- package/dist/utils/draftId.d.ts +36 -0
- package/dist/utils/draftId.d.ts.map +1 -0
- package/dist/utils/draftId.js +46 -0
- package/dist/utils/draftId.js.map +1 -0
- package/dist/utils/draftPacer.d.ts +72 -0
- package/dist/utils/draftPacer.d.ts.map +1 -0
- package/dist/utils/draftPacer.js +53 -0
- package/dist/utils/draftPacer.js.map +1 -0
- package/dist/utils/effortStartupKeystroke.d.ts +20 -0
- package/dist/utils/effortStartupKeystroke.d.ts.map +1 -0
- package/dist/utils/effortStartupKeystroke.js +28 -0
- package/dist/utils/effortStartupKeystroke.js.map +1 -0
- package/dist/utils/ensureSessionPlan.d.ts +70 -0
- package/dist/utils/ensureSessionPlan.d.ts.map +1 -0
- package/dist/utils/ensureSessionPlan.js +54 -0
- package/dist/utils/ensureSessionPlan.js.map +1 -0
- package/dist/utils/fileSendPlan.d.ts +217 -0
- package/dist/utils/fileSendPlan.d.ts.map +1 -0
- package/dist/utils/fileSendPlan.js +291 -0
- package/dist/utils/fileSendPlan.js.map +1 -0
- package/dist/utils/fileSendService.d.ts +94 -0
- package/dist/utils/fileSendService.d.ts.map +1 -0
- package/dist/utils/fileSendService.js +387 -0
- package/dist/utils/fileSendService.js.map +1 -0
- package/dist/utils/groupFinalizePlan.d.ts +38 -0
- package/dist/utils/groupFinalizePlan.d.ts.map +1 -0
- package/dist/utils/groupFinalizePlan.js +35 -0
- package/dist/utils/groupFinalizePlan.js.map +1 -0
- package/dist/utils/identicalOutputGuard.d.ts +83 -0
- package/dist/utils/identicalOutputGuard.d.ts.map +1 -0
- package/dist/utils/identicalOutputGuard.js +99 -0
- package/dist/utils/identicalOutputGuard.js.map +1 -0
- package/dist/utils/isoTimestamp.d.ts +16 -0
- package/dist/utils/isoTimestamp.d.ts.map +1 -0
- package/dist/utils/isoTimestamp.js +34 -0
- package/dist/utils/isoTimestamp.js.map +1 -0
- package/dist/utils/jsonStreamBusyWatchdog.d.ts +130 -0
- package/dist/utils/jsonStreamBusyWatchdog.d.ts.map +1 -0
- package/dist/utils/jsonStreamBusyWatchdog.js +122 -0
- package/dist/utils/jsonStreamBusyWatchdog.js.map +1 -0
- package/dist/utils/jsonStreamHost.d.ts +227 -0
- package/dist/utils/jsonStreamHost.d.ts.map +1 -0
- package/dist/utils/jsonStreamHost.js +417 -0
- package/dist/utils/jsonStreamHost.js.map +1 -0
- package/dist/utils/keyedTransitionQueue.d.ts +18 -0
- package/dist/utils/keyedTransitionQueue.d.ts.map +1 -0
- package/dist/utils/keyedTransitionQueue.js +34 -0
- package/dist/utils/keyedTransitionQueue.js.map +1 -0
- package/dist/utils/limitEpisodeRecovery.d.ts +92 -0
- package/dist/utils/limitEpisodeRecovery.d.ts.map +1 -0
- package/dist/utils/limitEpisodeRecovery.js +133 -0
- package/dist/utils/limitEpisodeRecovery.js.map +1 -0
- package/dist/utils/limitHeldPromptQueue.d.ts +48 -0
- package/dist/utils/limitHeldPromptQueue.d.ts.map +1 -0
- package/dist/utils/limitHeldPromptQueue.js +59 -0
- package/dist/utils/limitHeldPromptQueue.js.map +1 -0
- package/dist/utils/limitHeldPrompts.d.ts +58 -0
- package/dist/utils/limitHeldPrompts.d.ts.map +1 -0
- package/dist/utils/limitHeldPrompts.js +65 -0
- package/dist/utils/limitHeldPrompts.js.map +1 -0
- package/dist/utils/linkPreviewSuppression.d.ts +18 -0
- package/dist/utils/linkPreviewSuppression.d.ts.map +1 -0
- package/dist/utils/linkPreviewSuppression.js +37 -0
- package/dist/utils/linkPreviewSuppression.js.map +1 -0
- package/dist/utils/localClock.d.ts +15 -0
- package/dist/utils/localClock.d.ts.map +1 -0
- package/dist/utils/localClock.js +29 -0
- package/dist/utils/localClock.js.map +1 -0
- package/dist/utils/markdownTableRender.d.ts +83 -0
- package/dist/utils/markdownTableRender.d.ts.map +1 -0
- package/dist/utils/markdownTableRender.js +298 -0
- package/dist/utils/markdownTableRender.js.map +1 -0
- package/dist/utils/mediaGroupCollector.d.ts +51 -0
- package/dist/utils/mediaGroupCollector.d.ts.map +1 -0
- package/dist/utils/mediaGroupCollector.js +90 -0
- package/dist/utils/mediaGroupCollector.js.map +1 -0
- package/dist/utils/messageSendService.d.ts +136 -0
- package/dist/utils/messageSendService.d.ts.map +1 -0
- package/dist/utils/messageSendService.js +165 -0
- package/dist/utils/messageSendService.js.map +1 -0
- package/dist/utils/minutesOverride.d.ts +7 -0
- package/dist/utils/minutesOverride.d.ts.map +1 -0
- package/dist/utils/minutesOverride.js +16 -0
- package/dist/utils/minutesOverride.js.map +1 -0
- package/dist/utils/modelPickerPlan.d.ts +119 -0
- package/dist/utils/modelPickerPlan.d.ts.map +1 -0
- package/dist/utils/modelPickerPlan.js +171 -0
- package/dist/utils/modelPickerPlan.js.map +1 -0
- package/dist/utils/modelSetReplyDecision.d.ts +67 -0
- package/dist/utils/modelSetReplyDecision.d.ts.map +1 -0
- package/dist/utils/modelSetReplyDecision.js +27 -0
- package/dist/utils/modelSetReplyDecision.js.map +1 -0
- package/dist/utils/openCodeAuthLogin.d.ts +181 -0
- package/dist/utils/openCodeAuthLogin.d.ts.map +1 -0
- package/dist/utils/openCodeAuthLogin.js +393 -0
- package/dist/utils/openCodeAuthLogin.js.map +1 -0
- package/dist/utils/openCodeCompactPlugin.d.ts +81 -0
- package/dist/utils/openCodeCompactPlugin.d.ts.map +1 -0
- package/dist/utils/openCodeCompactPlugin.js +146 -0
- package/dist/utils/openCodeCompactPlugin.js.map +1 -0
- package/dist/utils/openCodeTurnActivity.d.ts +67 -0
- package/dist/utils/openCodeTurnActivity.d.ts.map +1 -0
- package/dist/utils/openCodeTurnActivity.js +27 -0
- package/dist/utils/openCodeTurnActivity.js.map +1 -0
- package/dist/utils/outputDebounce.d.ts +24 -0
- package/dist/utils/outputDebounce.d.ts.map +1 -0
- package/dist/utils/outputDebounce.js +30 -0
- package/dist/utils/outputDebounce.js.map +1 -0
- package/dist/utils/outputFlushTiming.d.ts +54 -0
- package/dist/utils/outputFlushTiming.d.ts.map +1 -0
- package/dist/utils/outputFlushTiming.js +48 -0
- package/dist/utils/outputFlushTiming.js.map +1 -0
- package/dist/utils/paginateList.d.ts +25 -0
- package/dist/utils/paginateList.d.ts.map +1 -0
- package/dist/utils/paginateList.js +32 -0
- package/dist/utils/paginateList.js.map +1 -0
- package/dist/utils/paneDiff.d.ts +55 -0
- package/dist/utils/paneDiff.d.ts.map +1 -0
- package/dist/utils/paneDiff.js +88 -0
- package/dist/utils/paneDiff.js.map +1 -0
- package/dist/utils/paneResizeGuard.d.ts +69 -0
- package/dist/utils/paneResizeGuard.d.ts.map +1 -0
- package/dist/utils/paneResizeGuard.js +91 -0
- package/dist/utils/paneResizeGuard.js.map +1 -0
- package/dist/utils/persistAdapterSessionIds.d.ts +39 -0
- package/dist/utils/persistAdapterSessionIds.d.ts.map +1 -0
- package/dist/utils/persistAdapterSessionIds.js +28 -0
- package/dist/utils/persistAdapterSessionIds.js.map +1 -0
- package/dist/utils/pinnedBannerSkipDecision.d.ts +44 -0
- package/dist/utils/pinnedBannerSkipDecision.d.ts.map +1 -0
- package/dist/utils/pinnedBannerSkipDecision.js +20 -0
- package/dist/utils/pinnedBannerSkipDecision.js.map +1 -0
- package/dist/utils/pollBackoff.d.ts +45 -0
- package/dist/utils/pollBackoff.d.ts.map +1 -0
- package/dist/utils/pollBackoff.js +44 -0
- package/dist/utils/pollBackoff.js.map +1 -0
- package/dist/utils/promptDelivery.d.ts +46 -0
- package/dist/utils/promptDelivery.d.ts.map +1 -0
- package/dist/utils/promptDelivery.js +49 -0
- package/dist/utils/promptDelivery.js.map +1 -0
- package/dist/utils/providerDisconnectPlan.d.ts +82 -0
- package/dist/utils/providerDisconnectPlan.d.ts.map +1 -0
- package/dist/utils/providerDisconnectPlan.js +110 -0
- package/dist/utils/providerDisconnectPlan.js.map +1 -0
- package/dist/utils/rateLimitLog.d.ts +48 -0
- package/dist/utils/rateLimitLog.d.ts.map +1 -0
- package/dist/utils/rateLimitLog.js +43 -0
- package/dist/utils/rateLimitLog.js.map +1 -0
- package/dist/utils/recentRelayWindow.d.ts +165 -0
- package/dist/utils/recentRelayWindow.d.ts.map +1 -0
- package/dist/utils/recentRelayWindow.js +284 -0
- package/dist/utils/recentRelayWindow.js.map +1 -0
- package/dist/utils/recvPreviewRedaction.d.ts +23 -0
- package/dist/utils/recvPreviewRedaction.d.ts.map +1 -0
- package/dist/utils/recvPreviewRedaction.js +39 -0
- package/dist/utils/recvPreviewRedaction.js.map +1 -0
- package/dist/utils/reminderFlow.d.ts +148 -0
- package/dist/utils/reminderFlow.d.ts.map +1 -0
- package/dist/utils/reminderFlow.js +122 -0
- package/dist/utils/reminderFlow.js.map +1 -0
- package/dist/utils/reminderScheduleText.d.ts +87 -0
- package/dist/utils/reminderScheduleText.d.ts.map +1 -0
- package/dist/utils/reminderScheduleText.js +135 -0
- package/dist/utils/reminderScheduleText.js.map +1 -0
- package/dist/utils/reminderWizard.d.ts +710 -0
- package/dist/utils/reminderWizard.d.ts.map +1 -0
- package/dist/utils/reminderWizard.js +1164 -0
- package/dist/utils/reminderWizard.js.map +1 -0
- package/dist/utils/replyQuote.d.ts +111 -0
- package/dist/utils/replyQuote.d.ts.map +1 -0
- package/dist/utils/replyQuote.js +122 -0
- package/dist/utils/replyQuote.js.map +1 -0
- package/dist/utils/resolveBinary.d.ts +25 -0
- package/dist/utils/resolveBinary.d.ts.map +1 -0
- package/dist/utils/resolveBinary.js +99 -0
- package/dist/utils/resolveBinary.js.map +1 -0
- package/dist/utils/retryAfterHeader.d.ts +7 -0
- package/dist/utils/retryAfterHeader.d.ts.map +1 -0
- package/dist/utils/retryAfterHeader.js +17 -0
- package/dist/utils/retryAfterHeader.js.map +1 -0
- package/dist/utils/rotatingJsonlFile.d.ts +40 -0
- package/dist/utils/rotatingJsonlFile.d.ts.map +1 -0
- package/dist/utils/rotatingJsonlFile.js +106 -0
- package/dist/utils/rotatingJsonlFile.js.map +1 -0
- package/dist/utils/rotatingLogFile.d.ts +25 -0
- package/dist/utils/rotatingLogFile.d.ts.map +1 -0
- package/dist/utils/rotatingLogFile.js +74 -0
- package/dist/utils/rotatingLogFile.js.map +1 -0
- package/dist/utils/sendRateTracker.d.ts +59 -0
- package/dist/utils/sendRateTracker.d.ts.map +1 -0
- package/dist/utils/sendRateTracker.js +144 -0
- package/dist/utils/sendRateTracker.js.map +1 -0
- package/dist/utils/serialQueue.d.ts +5 -0
- package/dist/utils/serialQueue.d.ts.map +1 -0
- package/dist/utils/serialQueue.js +14 -0
- package/dist/utils/serialQueue.js.map +1 -0
- package/dist/utils/sseStreamLifecycle.d.ts +32 -0
- package/dist/utils/sseStreamLifecycle.d.ts.map +1 -0
- package/dist/utils/sseStreamLifecycle.js +41 -0
- package/dist/utils/sseStreamLifecycle.js.map +1 -0
- package/dist/utils/startupReadiness.d.ts +119 -0
- package/dist/utils/startupReadiness.d.ts.map +1 -0
- package/dist/utils/startupReadiness.js +148 -0
- package/dist/utils/startupReadiness.js.map +1 -0
- package/dist/utils/statusFlushDecision.d.ts +18 -0
- package/dist/utils/statusFlushDecision.d.ts.map +1 -0
- package/dist/utils/statusFlushDecision.js +53 -0
- package/dist/utils/statusFlushDecision.js.map +1 -0
- package/dist/utils/subagentRender.d.ts +52 -0
- package/dist/utils/subagentRender.d.ts.map +1 -0
- package/dist/utils/subagentRender.js +66 -0
- package/dist/utils/subagentRender.js.map +1 -0
- package/dist/utils/subagentStatusRender.d.ts +90 -0
- package/dist/utils/subagentStatusRender.d.ts.map +1 -0
- package/dist/utils/subagentStatusRender.js +124 -0
- package/dist/utils/subagentStatusRender.js.map +1 -0
- package/dist/utils/telegramError.d.ts +2 -0
- package/dist/utils/telegramError.d.ts.map +1 -0
- package/dist/utils/telegramError.js +16 -0
- package/dist/utils/telegramError.js.map +1 -0
- package/dist/utils/terminalEmitPlan.d.ts +74 -0
- package/dist/utils/terminalEmitPlan.d.ts.map +1 -0
- package/dist/utils/terminalEmitPlan.js +86 -0
- package/dist/utils/terminalEmitPlan.js.map +1 -0
- package/dist/utils/thinkingRender.d.ts +74 -0
- package/dist/utils/thinkingRender.d.ts.map +1 -0
- package/dist/utils/thinkingRender.js +85 -0
- package/dist/utils/thinkingRender.js.map +1 -0
- package/dist/utils/threadStatusReport.d.ts +55 -0
- package/dist/utils/threadStatusReport.d.ts.map +1 -0
- package/dist/utils/threadStatusReport.js +65 -0
- package/dist/utils/threadStatusReport.js.map +1 -0
- package/dist/utils/threadToggle.d.ts +29 -0
- package/dist/utils/threadToggle.d.ts.map +1 -0
- package/dist/utils/threadToggle.js +39 -0
- package/dist/utils/threadToggle.js.map +1 -0
- package/dist/utils/timezone.d.ts +125 -0
- package/dist/utils/timezone.d.ts.map +1 -0
- package/dist/utils/timezone.js +278 -0
- package/dist/utils/timezone.js.map +1 -0
- package/dist/utils/tmuxExec.d.ts +61 -0
- package/dist/utils/tmuxExec.d.ts.map +1 -0
- package/dist/utils/tmuxExec.js +109 -0
- package/dist/utils/tmuxExec.js.map +1 -0
- package/dist/utils/tmuxSessionName.d.ts +34 -0
- package/dist/utils/tmuxSessionName.d.ts.map +1 -0
- package/dist/utils/tmuxSessionName.js +51 -0
- package/dist/utils/tmuxSessionName.js.map +1 -0
- package/dist/utils/toolResultRender.d.ts +61 -0
- package/dist/utils/toolResultRender.d.ts.map +1 -0
- package/dist/utils/toolResultRender.js +62 -0
- package/dist/utils/toolResultRender.js.map +1 -0
- package/dist/utils/topicRequest.d.ts +61 -0
- package/dist/utils/topicRequest.d.ts.map +1 -0
- package/dist/utils/topicRequest.js +68 -0
- package/dist/utils/topicRequest.js.map +1 -0
- package/dist/utils/topicView.d.ts +55 -0
- package/dist/utils/topicView.d.ts.map +1 -0
- package/dist/utils/topicView.js +56 -0
- package/dist/utils/topicView.js.map +1 -0
- package/dist/utils/transcribeAudio.d.ts +89 -0
- package/dist/utils/transcribeAudio.d.ts.map +1 -0
- package/dist/utils/transcribeAudio.js +249 -0
- package/dist/utils/transcribeAudio.js.map +1 -0
- package/dist/utils/transientFrames.d.ts +52 -0
- package/dist/utils/transientFrames.d.ts.map +1 -0
- package/dist/utils/transientFrames.js +41 -0
- package/dist/utils/transientFrames.js.map +1 -0
- package/dist/utils/typingActive.d.ts +35 -0
- package/dist/utils/typingActive.d.ts.map +1 -0
- package/dist/utils/typingActive.js +22 -0
- package/dist/utils/typingActive.js.map +1 -0
- package/dist/utils/typingLoopBackstop.d.ts +64 -0
- package/dist/utils/typingLoopBackstop.d.ts.map +1 -0
- package/dist/utils/typingLoopBackstop.js +68 -0
- package/dist/utils/typingLoopBackstop.js.map +1 -0
- package/dist/utils/verbosityRender.d.ts +18 -0
- package/dist/utils/verbosityRender.d.ts.map +1 -0
- package/dist/utils/verbosityRender.js +17 -0
- package/dist/utils/verbosityRender.js.map +1 -0
- package/dist/utils/wedgeRecovery.d.ts +57 -0
- package/dist/utils/wedgeRecovery.d.ts.map +1 -0
- package/dist/utils/wedgeRecovery.js +24 -0
- package/dist/utils/wedgeRecovery.js.map +1 -0
- package/dist/utils.d.ts +30 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +61 -0
- package/dist/utils.js.map +1 -0
- package/dist/validation.d.ts +113 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +226 -0
- package/dist/validation.js.map +1 -0
- package/dist/voiceQueue.d.ts +7 -0
- package/dist/voiceQueue.d.ts.map +1 -0
- package/dist/voiceQueue.js +33 -0
- package/dist/voiceQueue.js.map +1 -0
- package/examples/.env.example +17 -0
- package/examples/docker-compose.yml +36 -0
- package/package.json +83 -4
|
@@ -0,0 +1,4540 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.OpenCodeAdapter = exports.schedulerMcpRetryDelaysMs = exports.openCodeErrorMessageMaxLength = exports.providerRetryReplacementStartTimeoutMs = exports.criticalSseEventTypes = exports.openCodeRuntimeContextHydrationConcurrency = void 0;
|
|
37
|
+
exports.normaliseOpenCodeSseEvent = normaliseOpenCodeSseEvent;
|
|
38
|
+
exports.buildDirectoryScopedPath = buildDirectoryScopedPath;
|
|
39
|
+
exports.mapOpenCodeMessagesToTurns = mapOpenCodeMessagesToTurns;
|
|
40
|
+
exports.countOpenCodeAssistantMessagesSinceId = countOpenCodeAssistantMessagesSinceId;
|
|
41
|
+
exports.getLatestOpenCodeAssistantMessageId = getLatestOpenCodeAssistantMessageId;
|
|
42
|
+
exports.getLatestOpenCodeCompactionSummary = getLatestOpenCodeCompactionSummary;
|
|
43
|
+
exports.checkIsOpenCodeTurnInFlight = checkIsOpenCodeTurnInFlight;
|
|
44
|
+
exports.checkIsOpenCodeAbortError = checkIsOpenCodeAbortError;
|
|
45
|
+
exports.getOpenCodeErrorMessage = getOpenCodeErrorMessage;
|
|
46
|
+
exports.checkIsOpenCodeSessionBusy = checkIsOpenCodeSessionBusy;
|
|
47
|
+
exports.applyOpenCodeStatusEvent = applyOpenCodeStatusEvent;
|
|
48
|
+
exports.buildPromptBody = buildPromptBody;
|
|
49
|
+
exports.checkIsValidProviderId = checkIsValidProviderId;
|
|
50
|
+
exports.getLatestOpenCodeParentAssistantContext = getLatestOpenCodeParentAssistantContext;
|
|
51
|
+
exports.buildProviderAuthPath = buildProviderAuthPath;
|
|
52
|
+
exports.checkNeedsSchedulerMcpReregister = checkNeedsSchedulerMcpReregister;
|
|
53
|
+
exports.getSchedulerMcpRetryDelayMs = getSchedulerMcpRetryDelayMs;
|
|
54
|
+
exports.buildProviderApiAuthPayload = buildProviderApiAuthPayload;
|
|
55
|
+
exports.resetOpenCodeProviderCaches = resetOpenCodeProviderCaches;
|
|
56
|
+
exports.parseProvidersResponse = parseProvidersResponse;
|
|
57
|
+
const events_1 = require("events");
|
|
58
|
+
const child_process_1 = require("child_process");
|
|
59
|
+
const util_1 = require("util");
|
|
60
|
+
const fs = __importStar(require("fs"));
|
|
61
|
+
const path = __importStar(require("path"));
|
|
62
|
+
const sessionKey_1 = require("../sessionKey");
|
|
63
|
+
const apiErrorRetry_1 = require("../apiErrorRetry");
|
|
64
|
+
const installManager_1 = require("../installManager");
|
|
65
|
+
const state_1 = require("../state");
|
|
66
|
+
const diagLog_1 = require("../diagLog");
|
|
67
|
+
const openCodeSessionRouting_1 = require("../openCodeSessionRouting");
|
|
68
|
+
const i18n_1 = require("../i18n");
|
|
69
|
+
const resumeContext_1 = require("../resumeContext");
|
|
70
|
+
const threadContextPreamble_1 = require("../threadContextPreamble");
|
|
71
|
+
const openCodeOpenQuestion_1 = require("../openCodeOpenQuestion");
|
|
72
|
+
const injection_1 = require("../scheduler/injection");
|
|
73
|
+
const openCodeSessionTitle_1 = require("../openCodeSessionTitle");
|
|
74
|
+
const effortLevels_1 = require("../effortLevels");
|
|
75
|
+
const providerDisconnectPlan_1 = require("../utils/providerDisconnectPlan");
|
|
76
|
+
const sseStreamLifecycle_1 = require("../utils/sseStreamLifecycle");
|
|
77
|
+
const subagentRender_1 = require("../utils/subagentRender");
|
|
78
|
+
const displayVerbosity_1 = require("../utils/displayVerbosity");
|
|
79
|
+
const openCodeTurnActivity_1 = require("../utils/openCodeTurnActivity");
|
|
80
|
+
const openCodeCompactPlugin_1 = require("../utils/openCodeCompactPlugin");
|
|
81
|
+
const openCodeAuthLogin_1 = require("../utils/openCodeAuthLogin");
|
|
82
|
+
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
83
|
+
/**
|
|
84
|
+
* Persist per-thread model selection so it survives bot restarts.
|
|
85
|
+
* Stored in `DATA_DIR` (resolved via `resolveDataDir()` for parity with
|
|
86
|
+
* `state.json` and the Claude adapter) as a JSON map keyed by serialised
|
|
87
|
+
* `SessionKey`: `{ "<chatId>:<threadId>": "provider/model" }` (plan §10.3, D22).
|
|
88
|
+
*
|
|
89
|
+
* Audit S3 / #9: previous fallback chain `DATA_DIR || HOME || /tmp` drifted
|
|
90
|
+
* from `state.ts:resolveDataDir`, which uses `~/.telegramCode` when
|
|
91
|
+
* `DATA_DIR` is unset. Two bots on one host sharing a Linux user would have
|
|
92
|
+
* stored prefs in `$HOME/.opencode-model-prefs.json` and silently collided.
|
|
93
|
+
*
|
|
94
|
+
* Older versions of the bot used `{ "<userId>": "provider/model" }`. Those
|
|
95
|
+
* entries are silently ignored after the 2.0 upgrade — users can re-select
|
|
96
|
+
* their model via `/model`; we deliberately don't try to migrate (one of two
|
|
97
|
+
* adapters, easy to recover, not worth the migration complexity).
|
|
98
|
+
*/
|
|
99
|
+
const modelStateFile = path.join((0, state_1.resolveDataDir)(), '.opencode-model-prefs.json');
|
|
100
|
+
function loadSavedModel(key) {
|
|
101
|
+
try {
|
|
102
|
+
if (!fs.existsSync(modelStateFile))
|
|
103
|
+
return null;
|
|
104
|
+
const data = JSON.parse(fs.readFileSync(modelStateFile, 'utf-8'));
|
|
105
|
+
const saved = data[(0, sessionKey_1.keyToString)(key)];
|
|
106
|
+
if (!saved)
|
|
107
|
+
return null;
|
|
108
|
+
const idx = saved.indexOf('/');
|
|
109
|
+
if (idx <= 0)
|
|
110
|
+
return null;
|
|
111
|
+
return { providerID: saved.slice(0, idx), modelID: saved.slice(idx + 1) };
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
// Audit S15: surface and archive a corrupt prefs file so the next
|
|
115
|
+
// save can start clean; previous silent return left users wondering
|
|
116
|
+
// why /model selections didn't stick across restarts.
|
|
117
|
+
console.error(`[OpenCode] loadSavedModel failed:`, e instanceof Error ? e.message : e);
|
|
118
|
+
if (fs.existsSync(modelStateFile)) {
|
|
119
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
120
|
+
try {
|
|
121
|
+
fs.renameSync(modelStateFile, `${modelStateFile}.corrupted-${ts}`);
|
|
122
|
+
}
|
|
123
|
+
catch (re) {
|
|
124
|
+
console.warn(`[OpenCode] archive of corrupt prefs failed:`, re);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function saveModelPref(key, label) {
|
|
131
|
+
try {
|
|
132
|
+
let data = {};
|
|
133
|
+
if (fs.existsSync(modelStateFile)) {
|
|
134
|
+
data = JSON.parse(fs.readFileSync(modelStateFile, 'utf-8'));
|
|
135
|
+
}
|
|
136
|
+
data[(0, sessionKey_1.keyToString)(key)] = label;
|
|
137
|
+
fs.writeFileSync(modelStateFile, JSON.stringify(data, null, 2));
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
console.error(`[OpenCode] Failed to save model pref:`, e instanceof Error ? e.message : e);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* @description Per-thread OpenCode effort (variant) selection. Plan
|
|
145
|
+
* 2026-05-30-effort-command, D6 — mirrors the model-prefs pattern above
|
|
146
|
+
* exactly: same DATA_DIR placement, same `{ "<chatId>:<threadId>": "<level>" }`
|
|
147
|
+
* shape, same corrupt-file archive behaviour.
|
|
148
|
+
*
|
|
149
|
+
* Effort is applied **per-prompt** by `sendPromptAsync` as the model
|
|
150
|
+
* `variant` on the prompt body. Persisting it here means the choice
|
|
151
|
+
* survives bot restarts and re-attached SSE sessions.
|
|
152
|
+
*/
|
|
153
|
+
const effortStateFile = path.join((0, state_1.resolveDataDir)(), '.opencode-effort-prefs.json');
|
|
154
|
+
function loadEffortPrefs() {
|
|
155
|
+
try {
|
|
156
|
+
if (!fs.existsSync(effortStateFile))
|
|
157
|
+
return {};
|
|
158
|
+
const raw = fs.readFileSync(effortStateFile, 'utf-8');
|
|
159
|
+
const parsed = JSON.parse(raw);
|
|
160
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
161
|
+
? parsed
|
|
162
|
+
: {};
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
console.error(`[OpenCode] loadEffortPrefs failed:`, e instanceof Error ? e.message : e);
|
|
166
|
+
if (fs.existsSync(effortStateFile)) {
|
|
167
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
168
|
+
try {
|
|
169
|
+
fs.renameSync(effortStateFile, `${effortStateFile}.corrupted-${ts}`);
|
|
170
|
+
}
|
|
171
|
+
catch (re) {
|
|
172
|
+
console.warn(`[OpenCode] archive of corrupt effort prefs failed:`, re);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function loadSavedEffort(key) {
|
|
179
|
+
const prefs = loadEffortPrefs();
|
|
180
|
+
return prefs[(0, sessionKey_1.keyToString)(key)] ?? null;
|
|
181
|
+
}
|
|
182
|
+
function saveEffortPref(key, level) {
|
|
183
|
+
try {
|
|
184
|
+
const data = loadEffortPrefs();
|
|
185
|
+
data[(0, sessionKey_1.keyToString)(key)] = level;
|
|
186
|
+
fs.writeFileSync(effortStateFile, JSON.stringify(data, null, 2));
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
console.error(`[OpenCode] Failed to save effort pref:`, e instanceof Error ? e.message : e);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function clearEffortPref(key) {
|
|
193
|
+
try {
|
|
194
|
+
const data = loadEffortPrefs();
|
|
195
|
+
if (!((0, sessionKey_1.keyToString)(key) in data))
|
|
196
|
+
return;
|
|
197
|
+
delete data[(0, sessionKey_1.keyToString)(key)];
|
|
198
|
+
fs.writeFileSync(effortStateFile, JSON.stringify(data, null, 2));
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
console.error(`[OpenCode] Failed to clear effort pref:`, e instanceof Error ? e.message : e);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** How many recent user message ids a session remembers to de-duplicate `message.updated`. */
|
|
205
|
+
const seenUserMessageIdsMax = 100;
|
|
206
|
+
/**
|
|
207
|
+
* OpenCode `/global/event` wraps the real event in `payload`, while `/event`
|
|
208
|
+
* emits it directly. Normalise both shapes before dispatching.
|
|
209
|
+
*/
|
|
210
|
+
function normaliseOpenCodeSseEvent(raw) {
|
|
211
|
+
if (!raw || typeof raw !== 'object')
|
|
212
|
+
return null;
|
|
213
|
+
const candidate = raw;
|
|
214
|
+
const event = typeof candidate.type === 'string'
|
|
215
|
+
? candidate
|
|
216
|
+
: candidate.payload && typeof candidate.payload === 'object'
|
|
217
|
+
? candidate.payload
|
|
218
|
+
: null;
|
|
219
|
+
if (!event || typeof event.type !== 'string')
|
|
220
|
+
return null;
|
|
221
|
+
const properties = event.properties && typeof event.properties === 'object'
|
|
222
|
+
? event.properties
|
|
223
|
+
: {};
|
|
224
|
+
const directory = typeof candidate.directory === 'string' && candidate.directory
|
|
225
|
+
? candidate.directory
|
|
226
|
+
: undefined;
|
|
227
|
+
return directory !== undefined
|
|
228
|
+
? { type: event.type, properties, directory }
|
|
229
|
+
: { type: event.type, properties };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* @description Append the `?directory=` instance selector to an API path when
|
|
233
|
+
* the owning instance is known. Without it the server resolves its serve-cwd
|
|
234
|
+
* default instance, whose in-memory state (questions, permissions) does not
|
|
235
|
+
* contain requests raised in other project instances — the reply then fails
|
|
236
|
+
* with 404 even though the request is alive in its own instance.
|
|
237
|
+
*/
|
|
238
|
+
function buildDirectoryScopedPath(basePath, directory) {
|
|
239
|
+
if (!directory)
|
|
240
|
+
return basePath;
|
|
241
|
+
return `${basePath}?directory=${encodeURIComponent(directory)}`;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* @description One renderability rule for a message `part`, shared by
|
|
245
|
+
* {@link mapOpenCodeMessagesToTurns} (turn body) and
|
|
246
|
+
* {@link countOpenCodeAssistantMessagesSinceId} (missed count) so both agree on
|
|
247
|
+
* what counts as visible prose: a non-empty `{ type: 'text' }` part (tool / step
|
|
248
|
+
* / empty parts don't count). Type guard so the caller can read `part.text` as a
|
|
249
|
+
* string without a cast.
|
|
250
|
+
*/
|
|
251
|
+
function checkIsRenderableTextPart(part) {
|
|
252
|
+
return (!!part &&
|
|
253
|
+
typeof part === 'object' &&
|
|
254
|
+
part.type === 'text' &&
|
|
255
|
+
typeof part.text === 'string' &&
|
|
256
|
+
part.text.trim().length > 0);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* @description Map raw `GET /session/:id/message` records to the last `limit`
|
|
260
|
+
* conversational turns (oldest→newest) for the resume context block.
|
|
261
|
+
*
|
|
262
|
+
* Pure + exported (no I/O) so it is unit-testable. For each record it joins the
|
|
263
|
+
* text of every `{ type: 'text' }` part (skipping tool / step / empty parts),
|
|
264
|
+
* trims, and emits a {@link RecentTurn} only when the role is user/assistant
|
|
265
|
+
* AND the joined text is non-empty. Every field is guarded with `typeof` /
|
|
266
|
+
* `Array.isArray` so a malformed record is skipped, never crashes — no casts.
|
|
267
|
+
* Keeps only the last `limit` turns.
|
|
268
|
+
*/
|
|
269
|
+
function mapOpenCodeMessagesToTurns(records, limit) {
|
|
270
|
+
if (!Array.isArray(records))
|
|
271
|
+
return [];
|
|
272
|
+
const turns = [];
|
|
273
|
+
for (const record of records) {
|
|
274
|
+
if (!record || typeof record !== 'object')
|
|
275
|
+
continue;
|
|
276
|
+
const { info, parts } = record;
|
|
277
|
+
const role = info?.role;
|
|
278
|
+
if (role !== 'user' && role !== 'assistant')
|
|
279
|
+
continue;
|
|
280
|
+
if (!Array.isArray(parts))
|
|
281
|
+
continue;
|
|
282
|
+
const textChunks = [];
|
|
283
|
+
for (const part of parts) {
|
|
284
|
+
if (checkIsRenderableTextPart(part))
|
|
285
|
+
textChunks.push(part.text.trim());
|
|
286
|
+
}
|
|
287
|
+
if (textChunks.length === 0)
|
|
288
|
+
continue;
|
|
289
|
+
turns.push({ role, text: textChunks.join('\n\n') });
|
|
290
|
+
}
|
|
291
|
+
return turns.slice(-limit);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* @description Count the renderable assistant messages that appear AFTER the
|
|
295
|
+
* watermark message in a `GET /session/:id/message` payload — the OpenCode side
|
|
296
|
+
* of "how many agent messages did the user miss while the bot was down".
|
|
297
|
+
*
|
|
298
|
+
* Pure + exported (no I/O) so it is unit-testable against a records array. Walks
|
|
299
|
+
* the chronological records (oldest→newest), skipping everything up to and
|
|
300
|
+
* including the message whose `info.id === watermarkId`, then counts each later
|
|
301
|
+
* `assistant`-role record that carries at least one non-empty text part (the
|
|
302
|
+
* same renderability rule as {@link mapOpenCodeMessagesToTurns}, so the count
|
|
303
|
+
* matches what a turn body would show).
|
|
304
|
+
*
|
|
305
|
+
* `isWatermarkKnown` is `false` — and `missedCount` `0` — when the payload is
|
|
306
|
+
* not an array, no `watermarkId` was given, or the id is absent from the records
|
|
307
|
+
* (pruned / different session). The caller treats that as the fallback
|
|
308
|
+
* (no-count) recap, never a crash.
|
|
309
|
+
*/
|
|
310
|
+
function countOpenCodeAssistantMessagesSinceId(records, watermarkId) {
|
|
311
|
+
if (!Array.isArray(records) || !watermarkId) {
|
|
312
|
+
return { missedCount: 0, isWatermarkKnown: false };
|
|
313
|
+
}
|
|
314
|
+
let foundWatermark = false;
|
|
315
|
+
let missedCount = 0;
|
|
316
|
+
for (const record of records) {
|
|
317
|
+
if (!record || typeof record !== 'object')
|
|
318
|
+
continue;
|
|
319
|
+
const { info, parts } = record;
|
|
320
|
+
if (!foundWatermark) {
|
|
321
|
+
if (info?.id === watermarkId)
|
|
322
|
+
foundWatermark = true;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (info?.role !== 'assistant' || !Array.isArray(parts))
|
|
326
|
+
continue;
|
|
327
|
+
if (parts.some(checkIsRenderableTextPart))
|
|
328
|
+
missedCount += 1;
|
|
329
|
+
}
|
|
330
|
+
if (!foundWatermark)
|
|
331
|
+
return { missedCount: 0, isWatermarkKnown: false };
|
|
332
|
+
return { missedCount, isWatermarkKnown: true };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* @description Last assistant message `id` in a `GET /session/:id/message`
|
|
336
|
+
* payload — the OpenCode reattach-recap HEAD watermark ("everything currently in
|
|
337
|
+
* the record is accounted for"). Pure + exported (no I/O) so it is unit-testable.
|
|
338
|
+
*
|
|
339
|
+
* Walks the chronological records and returns the `info.id` of the LAST record
|
|
340
|
+
* whose `info.role === 'assistant'` and whose `info.id` is a string; returns
|
|
341
|
+
* `undefined` for a non-array payload or when no assistant message carries an id
|
|
342
|
+
* (head unknown → the caller omits the watermark and retries next reattach).
|
|
343
|
+
*
|
|
344
|
+
* Anchors on the last ASSISTANT message (not the last record of any role) to
|
|
345
|
+
* mirror the live-advance watermark ({@link OpenCodeSession.lastMessageId}, set
|
|
346
|
+
* on the parent turn's final assistant message) and the count keyed off it in
|
|
347
|
+
* {@link countOpenCodeAssistantMessagesSinceId} — so the next reattach's
|
|
348
|
+
* `[watermark, …)` window starts at the true tail and yields 0 on a clean
|
|
349
|
+
* reattach. Unlike the missed-count it does NOT require renderable text: the
|
|
350
|
+
* watermark must advance past every assistant message seen, tool-only ones too.
|
|
351
|
+
*/
|
|
352
|
+
function getLatestOpenCodeAssistantMessageId(records) {
|
|
353
|
+
if (!Array.isArray(records))
|
|
354
|
+
return undefined;
|
|
355
|
+
let latestId;
|
|
356
|
+
for (const record of records) {
|
|
357
|
+
if (!record || typeof record !== 'object')
|
|
358
|
+
continue;
|
|
359
|
+
const { info } = record;
|
|
360
|
+
if (info?.role === 'assistant' && typeof info.id === 'string')
|
|
361
|
+
latestId = info.id;
|
|
362
|
+
}
|
|
363
|
+
return latestId;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* @description Text of the NEWEST compaction summary in a `GET /session/:id/message`
|
|
367
|
+
* payload — the assistant message the fork stored with `info.summary === true`.
|
|
368
|
+
* Joins its renderable text parts (same rule as {@link mapOpenCodeMessagesToTurns}).
|
|
369
|
+
* Pure + exported (no I/O) so it is unit-testable. Returns `null` for a non-array
|
|
370
|
+
* payload or when no summary message with text is present, so F2's notice omits
|
|
371
|
+
* the closing block rather than posting an empty one.
|
|
372
|
+
*/
|
|
373
|
+
function getLatestOpenCodeCompactionSummary(records) {
|
|
374
|
+
if (!Array.isArray(records))
|
|
375
|
+
return null;
|
|
376
|
+
let summaryText = null;
|
|
377
|
+
for (const record of records) {
|
|
378
|
+
if (!record || typeof record !== 'object')
|
|
379
|
+
continue;
|
|
380
|
+
const { info, parts } = record;
|
|
381
|
+
if (info?.role !== 'assistant' || info.summary !== true || !Array.isArray(parts))
|
|
382
|
+
continue;
|
|
383
|
+
const chunks = [];
|
|
384
|
+
for (const part of parts) {
|
|
385
|
+
if (checkIsRenderableTextPart(part))
|
|
386
|
+
chunks.push(part.text.trim());
|
|
387
|
+
}
|
|
388
|
+
if (chunks.length > 0)
|
|
389
|
+
summaryText = chunks.join('\n\n');
|
|
390
|
+
}
|
|
391
|
+
return summaryText;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* @description Best-effort "the turn is still in flight" signal for the reattach
|
|
395
|
+
* recap, derived from the already-fetched `GET /session/:id/message` payload —
|
|
396
|
+
* NO extra HTTP probe. True only when the LAST record is an `assistant` message
|
|
397
|
+
* with no `finish` reason yet (a turn the agent was mid-way through when the bot
|
|
398
|
+
* went down). Any uncertainty (empty / non-array payload, last record not a
|
|
399
|
+
* finished-less assistant message) → `false`, so the trailing "still working"
|
|
400
|
+
* line is never shown on a guess.
|
|
401
|
+
*/
|
|
402
|
+
function checkIsOpenCodeTurnInFlight(records) {
|
|
403
|
+
if (!Array.isArray(records) || records.length === 0)
|
|
404
|
+
return false;
|
|
405
|
+
const last = records[records.length - 1];
|
|
406
|
+
if (!last || typeof last !== 'object')
|
|
407
|
+
return false;
|
|
408
|
+
const { info } = last;
|
|
409
|
+
return info?.role === 'assistant' && info.finish === undefined;
|
|
410
|
+
}
|
|
411
|
+
/** Delay (ms) to batch SSE text deltas before emitting output event */
|
|
412
|
+
const sseOutputBatchMs = 500;
|
|
413
|
+
/**
|
|
414
|
+
* Name of OpenCode's delegation tool — the parent session invokes it to run a
|
|
415
|
+
* sub-agent in a CHILD session (verified live 2026-06-10: its tool part carries
|
|
416
|
+
* `state.title` + `state.input.{description, subagent_type, prompt}`, and the
|
|
417
|
+
* completed `state.output` embeds the child session id).
|
|
418
|
+
*/
|
|
419
|
+
const delegationToolName = 'task';
|
|
420
|
+
/**
|
|
421
|
+
* Fixed `SseStreamState.directory` label for the single `/global/event` stream
|
|
422
|
+
* in logs/diag. The global stream has no per-folder scope (it multiplexes every
|
|
423
|
+
* project instance), so its "directory" is a constant marker, not a selector.
|
|
424
|
+
*/
|
|
425
|
+
const globalStreamLabel = '<global>';
|
|
426
|
+
/** Base delay (ms) for exponential backoff on SSE reconnect */
|
|
427
|
+
const sseReconnectBaseDelayMs = 2000;
|
|
428
|
+
/**
|
|
429
|
+
* SSE reconnect never gives up — the bot must reconnect until success — but the
|
|
430
|
+
* backoff is capped so steady-state retries settle at a fixed interval instead
|
|
431
|
+
* of growing unbounded: delay = min(base · 2^min(attempt, exp), maxDelay).
|
|
432
|
+
*/
|
|
433
|
+
const maxSseReconnectBackoffExponent = 5;
|
|
434
|
+
const maxSseReconnectDelayMs = 60_000;
|
|
435
|
+
/** Measured cadence of OpenCode `server.heartbeat` on the global event stream. */
|
|
436
|
+
const sseHeartbeatIntervalMs = 10_000;
|
|
437
|
+
/**
|
|
438
|
+
* If no SSE bytes — not even a heartbeat — arrive within this window, the
|
|
439
|
+
* stream is silently dead (TCP still open, nothing delivered) and the parked
|
|
440
|
+
* `reader.read()` would never return. 4× the heartbeat tolerates a few dropped
|
|
441
|
+
* beats before forcing a reconnect, so it can't false-fire on a healthy idle
|
|
442
|
+
* stream.
|
|
443
|
+
*/
|
|
444
|
+
const sseStallTimeoutMs = sseHeartbeatIntervalMs * 4;
|
|
445
|
+
/** Cap on tracked child→parent session links (subagent lineage). */
|
|
446
|
+
const maxTrackedSessionLineageEntries = 1000;
|
|
447
|
+
/** Bound silent-restart metadata reads so many active sessions do not overload OpenCode. */
|
|
448
|
+
exports.openCodeRuntimeContextHydrationConcurrency = 3;
|
|
449
|
+
/**
|
|
450
|
+
* Minimum gap between diag-logged "sse drop" lines for the SAME
|
|
451
|
+
* (eventType, eventSessionId). A truly orphaned session streams hundreds of
|
|
452
|
+
* deltas; without throttling the diag log floods (B19). One line per
|
|
453
|
+
* (type, session) per window is enough to spot a lost turn.
|
|
454
|
+
*/
|
|
455
|
+
const sseDropLogThrottleMs = 60_000;
|
|
456
|
+
/** Bound on the drop-throttle map so an unbounded run of distinct orphan
|
|
457
|
+
* sessions can't grow it without limit (matches the lineage-map discipline). */
|
|
458
|
+
const maxSseDropThrottleEntries = 500;
|
|
459
|
+
/** Synthetic "event type" namespacing the throttle key for ignored foreign
|
|
460
|
+
* busy=true status events, so they share the drop-throttle map without
|
|
461
|
+
* colliding with real `session.status` drop entries. */
|
|
462
|
+
const foreignBusyIgnoredLogType = 'busy-ignored';
|
|
463
|
+
/**
|
|
464
|
+
* SSE event types whose loss makes a turn silently hang — diag-logged on drop.
|
|
465
|
+
* `question.asked` / `permission.asked` are here so an unrouted one is LOGGED
|
|
466
|
+
* (the user's question vanishing silently was the worst failure mode): they DO
|
|
467
|
+
* carry a real top-level `sessionID` in the current OpenCode build (verified
|
|
468
|
+
* live 2026-06-08), so they resolve like any other event — by sessionID, with a
|
|
469
|
+
* directory fallback — and a genuine miss is loud, never a no-op.
|
|
470
|
+
*/
|
|
471
|
+
exports.criticalSseEventTypes = new Set([
|
|
472
|
+
'message.part.updated',
|
|
473
|
+
'message.part.delta',
|
|
474
|
+
'message.updated',
|
|
475
|
+
'session.idle',
|
|
476
|
+
'session.error',
|
|
477
|
+
'question.asked',
|
|
478
|
+
'permission.asked',
|
|
479
|
+
]);
|
|
480
|
+
/**
|
|
481
|
+
* Event types the dispatcher intentionally ignores WITHOUT a stdout log. On the
|
|
482
|
+
* global stream the bot sees OWNED sessions emit global-only bookkeeping events
|
|
483
|
+
* (`sync` / `session.diff`) the per-directory stream never surfaced; logging
|
|
484
|
+
* each "unhandled SSE event" would spam stdout (plan 2026-06-17 S2). The silent
|
|
485
|
+
* `server.heartbeat` is handled before dispatch and is not listed here.
|
|
486
|
+
*/
|
|
487
|
+
const ignoredSseEventTypes = new Set(['sync', 'session.diff']);
|
|
488
|
+
/**
|
|
489
|
+
* @description Grace period before the bot-side fallback rename checks whether
|
|
490
|
+
* opencode's native auto-title has landed. Auto-title is generated as a side
|
|
491
|
+
* effect of the first prompt's LLM turn; it was observed live to appear within
|
|
492
|
+
* ~2-3 s. We wait comfortably longer so the fallback only fires when auto-title
|
|
493
|
+
* genuinely failed — it must never overwrite a real LLM name with a raw snippet.
|
|
494
|
+
*/
|
|
495
|
+
const fallbackRenameGraceMs = 8000;
|
|
496
|
+
/** Milliseconds per minute for rendering OpenCode's provider-retry notice. */
|
|
497
|
+
const providerRetryMsPerMinute = 60_000;
|
|
498
|
+
/**
|
|
499
|
+
* @description Headroom on top of the worst-case event-stream gap before a
|
|
500
|
+
* replacement prompt is declared never-started.
|
|
501
|
+
*/
|
|
502
|
+
const providerRetryReplacementStartHeadroomMs = 20_000;
|
|
503
|
+
/**
|
|
504
|
+
* @description How long a post-provider-retry replacement prompt may take to
|
|
505
|
+
* produce its first `busy` status before the adapter declares that it never
|
|
506
|
+
* started a turn. Derived from the SSE bounds rather than a flat number: the
|
|
507
|
+
* `busy` arrives sub-second on the happy path, but it is DELIVERED over the
|
|
508
|
+
* event stream, so a stall detected at {@link sseStallTimeoutMs} followed by a
|
|
509
|
+
* fully backed-off reconnect ({@link maxSseReconnectDelayMs}) can hide it for
|
|
510
|
+
* their sum. A flat 60s sat inside that window, so a stream hiccup would have
|
|
511
|
+
* been read as a dead turn and forked/restarted a session that was answering.
|
|
512
|
+
* Waiting only delays recovery; firing early discards live work.
|
|
513
|
+
*/
|
|
514
|
+
exports.providerRetryReplacementStartTimeoutMs = sseStallTimeoutMs + maxSseReconnectDelayMs + providerRetryReplacementStartHeadroomMs;
|
|
515
|
+
/**
|
|
516
|
+
* @description Console / diag labels per cause of a "prompt delivered but no
|
|
517
|
+
* turn ran" report, kept as data so the single emit choke point stays free of
|
|
518
|
+
* branchy string building. The `wedged` diag label is unchanged from before the
|
|
519
|
+
* bound existed, so log greps for it keep matching.
|
|
520
|
+
*/
|
|
521
|
+
const noTurnResponseCauseLabels = {
|
|
522
|
+
wedgedIdle: { console: 'wedged session', diag: 'wedged' },
|
|
523
|
+
replacementNeverStarted: {
|
|
524
|
+
console: 'post-retry replacement never started',
|
|
525
|
+
diag: 'replacement-never-started',
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
/**
|
|
529
|
+
* @description Whether a `session.error` message is the bot-issued abort of a
|
|
530
|
+
* running generation (`POST /session/:id/abort`) — the ONLY source of an
|
|
531
|
+
* "Aborted" error in OpenCode (the user has no other abort trigger). It fires
|
|
532
|
+
* on the question-cancel SIGINT, `/esc`, and the provider-retry interrupt, and
|
|
533
|
+
* must be SWALLOWED rather than surfaced as "OpenCode error: Aborted" — that
|
|
534
|
+
* bogus line was the third message on the question-cancel path (parity with the
|
|
535
|
+
* json-stream "Claude error: API error" swallow). Matched as the exact bare
|
|
536
|
+
* word so a real provider error that merely quotes "aborted" still surfaces.
|
|
537
|
+
*/
|
|
538
|
+
function checkIsOpenCodeAbortError(errorMsg) {
|
|
539
|
+
return errorMsg.trim().toLowerCase() === 'aborted';
|
|
540
|
+
}
|
|
541
|
+
const genericOpenCodeErrorMessage = 'OpenCode request failed';
|
|
542
|
+
/** Placeholder written over every redacted credential. */
|
|
543
|
+
const openCodeErrorRedactedMarker = '[redacted]';
|
|
544
|
+
/**
|
|
545
|
+
* Cap for a surfaced error string. A provider can attach a whole
|
|
546
|
+
* request/response dump; the head carries the readable reason, the tail is
|
|
547
|
+
* noise that would flood the topic.
|
|
548
|
+
*/
|
|
549
|
+
exports.openCodeErrorMessageMaxLength = 500;
|
|
550
|
+
const openCodeErrorAuthSchemeRe = /\b(Bearer|Basic)\s+\S+/gi;
|
|
551
|
+
/**
|
|
552
|
+
* Redaction is BEST-EFFORT and exists for the STRING path only — a provider's
|
|
553
|
+
* prose message or an HTTP error-response body, which must be surfaced for the
|
|
554
|
+
* user to act on. Being name-based it cannot promise to catch a credential a
|
|
555
|
+
* provider ships under a field name (or in a shape) nobody has seen yet.
|
|
556
|
+
*
|
|
557
|
+
* That is precisely why an unrecognised STRUCTURED payload is deliberately NOT
|
|
558
|
+
* rendered: it collapses to {@link genericOpenCodeErrorMessage} instead of
|
|
559
|
+
* being serialised through these rules. Escaped nested JSON, header-pair
|
|
560
|
+
* arrays (`[["x-api-key","…"]]`), unlisted field names and bare values all slip
|
|
561
|
+
* past a name-based rule, and the rendered text goes straight into a Telegram
|
|
562
|
+
* topic and the console log.
|
|
563
|
+
*
|
|
564
|
+
* The value branch stops at a JSON/prose separator instead of running to the
|
|
565
|
+
* next whitespace: a greedy `\S+` swallowed the rest of the object, taking the
|
|
566
|
+
* later fields with it — including the `429` / "rate limited" markers
|
|
567
|
+
* {@link classifyAgentApiError} needs to arm the auto-retry.
|
|
568
|
+
*/
|
|
569
|
+
const openCodeErrorSecretAssignmentRe = /\b((?:api|auth|access|refresh|id|app|client|private|session|user|x)?[_ -]?(?:api[_ -]?key|key|token|secret|password|passwd|pwd|authorization|credential|cookie|signature)s?)\b("?\s*[:=]\s*)(?:(?:Bearer|Basic)\s+)?(?:"[^"]*"|'[^']*'|[^\s,{}\]"']+)/gi;
|
|
570
|
+
/**
|
|
571
|
+
* Credential VALUE shapes, redacted whatever carries them: a provider echoes a
|
|
572
|
+
* rejected key in prose with no field name at all ("401 invalid credential
|
|
573
|
+
* sk-ant-…"), inside a header-pair array, or in a URL query string — none of
|
|
574
|
+
* which the name-based rule above can see.
|
|
575
|
+
*/
|
|
576
|
+
const openCodeErrorCredentialValueRe = /\bsk-[A-Za-z0-9_-]+|\bghp_[A-Za-z0-9]+|\bAIza[A-Za-z0-9_-]+|\beyJ[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g;
|
|
577
|
+
/**
|
|
578
|
+
* URL userinfo (`https://user:password@host`). The password half is not
|
|
579
|
+
* required: a bare `https://<token>@host` is just as much a credential.
|
|
580
|
+
*/
|
|
581
|
+
const openCodeErrorUrlUserInfoRe = /(\w+:\/\/)[^\s/@]+@/g;
|
|
582
|
+
/**
|
|
583
|
+
* @description Redact the credentials a surfaced error string is known to carry
|
|
584
|
+
* and cap it. Redaction runs BEFORE the cap: capping first could cut a secret
|
|
585
|
+
* assignment in half and leave the head of the credential visible.
|
|
586
|
+
*/
|
|
587
|
+
function redactOpenCodeErrorMessage(message) {
|
|
588
|
+
return message
|
|
589
|
+
.replace(openCodeErrorSecretAssignmentRe, `$1$2${openCodeErrorRedactedMarker}`)
|
|
590
|
+
.replace(openCodeErrorAuthSchemeRe, `$1 ${openCodeErrorRedactedMarker}`)
|
|
591
|
+
.replace(openCodeErrorCredentialValueRe, openCodeErrorRedactedMarker)
|
|
592
|
+
.replace(openCodeErrorUrlUserInfoRe, `$1${openCodeErrorRedactedMarker}@`)
|
|
593
|
+
.slice(0, exports.openCodeErrorMessageMaxLength);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* @description Extract the readable, SAFE-TO-SURFACE error form from OpenCode's
|
|
597
|
+
* persisted and SSE payload shapes. Every return value of this function reaches
|
|
598
|
+
* a Telegram topic and the console log, so a payload whose shape carries no
|
|
599
|
+
* known message field yields the fixed {@link genericOpenCodeErrorMessage}: such
|
|
600
|
+
* a payload is an arbitrary provider dump (a rejected request body, a header
|
|
601
|
+
* list, a response echo) and `/connect` PUTs the user's provider key, so
|
|
602
|
+
* rendering it is a credential-leak path that name-based redaction cannot close.
|
|
603
|
+
* The retry classifier reads {@link getOpenCodeErrorClassificationText} instead.
|
|
604
|
+
*/
|
|
605
|
+
function getOpenCodeErrorMessage(error) {
|
|
606
|
+
if (typeof error === 'string')
|
|
607
|
+
return redactOpenCodeErrorMessage(error);
|
|
608
|
+
if (!error || typeof error !== 'object')
|
|
609
|
+
return genericOpenCodeErrorMessage;
|
|
610
|
+
const errorRecord = error;
|
|
611
|
+
if (errorRecord.data && typeof errorRecord.data === 'object') {
|
|
612
|
+
const errorData = errorRecord.data;
|
|
613
|
+
if (typeof errorData.message === 'string')
|
|
614
|
+
return redactOpenCodeErrorMessage(errorData.message);
|
|
615
|
+
}
|
|
616
|
+
if (typeof errorRecord.message === 'string')
|
|
617
|
+
return redactOpenCodeErrorMessage(errorRecord.message);
|
|
618
|
+
return genericOpenCodeErrorMessage;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* @description Serialise an error payload FOR CLASSIFICATION ONLY. The result
|
|
622
|
+
* is UNREDACTED and may contain the user's provider key verbatim: it must never
|
|
623
|
+
* be emitted to a topic, never logged, and never returned to a caller that
|
|
624
|
+
* surfaces text — the single legal consumer is
|
|
625
|
+
* {@link classifyAgentApiError}, which only runs regexes over it and returns an
|
|
626
|
+
* error CLASS.
|
|
627
|
+
*
|
|
628
|
+
* It exists because the safe user-facing text collapses an unrecognised payload
|
|
629
|
+
* shape to a constant, and the classifier reads text to decide whether an error
|
|
630
|
+
* is an auto-retryable rate-limit / usage-limit. Feeding it that constant
|
|
631
|
+
* silently cancelled the retry of a real rate limit, so the two paths are split:
|
|
632
|
+
* the user gets the constant, the classifier gets the full payload.
|
|
633
|
+
*/
|
|
634
|
+
function getOpenCodeErrorClassificationText(error) {
|
|
635
|
+
if (typeof error === 'string')
|
|
636
|
+
return error;
|
|
637
|
+
if (error instanceof Error)
|
|
638
|
+
return error.message;
|
|
639
|
+
if (!error || typeof error !== 'object')
|
|
640
|
+
return '';
|
|
641
|
+
try {
|
|
642
|
+
// A payload whose `toJSON` opts out yields undefined rather than text.
|
|
643
|
+
const serialized = JSON.stringify(error);
|
|
644
|
+
return serialized ?? '';
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
// Circular / non-serialisable payload (e.g. a self-referencing error object).
|
|
648
|
+
return '';
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* @description Sync "is this session occupied with a turn?" decision for the
|
|
653
|
+
* scheduler's wait-for-idle loop ({@link AgentAdapter.checkIsBusy}): busy when
|
|
654
|
+
* its own generation runs, a sub-agent runs, or context is compacting. This is
|
|
655
|
+
* a read-only probe — a new prompt to a busy session is never aborted; OpenCode
|
|
656
|
+
* queues it via `prompt_async` and reads it promptly, so unlike the Claude TUI
|
|
657
|
+
* there is no interrupt-before-prompt (user decision 2026-06-06). Pure +
|
|
658
|
+
* exported for unit testing.
|
|
659
|
+
*/
|
|
660
|
+
function checkIsOpenCodeSessionBusy(state) {
|
|
661
|
+
return state.isBusy || state.isCompacting || state.busyChildCount > 0;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* @description Apply a busy/idle transition (from `session.status` or
|
|
665
|
+
* `session.idle`) to a session's busy-tracking state. The own session's status
|
|
666
|
+
* drives `isBusy`; a lineage-VERIFIED descendant's (sub-agent child session's)
|
|
667
|
+
* status maintains `busyChildSessionIds` — a child going idle must NOT clear
|
|
668
|
+
* the parent's `isBusy`. A foreign non-descendant id (e.g. a wedged sibling
|
|
669
|
+
* top-level session routed in via the directory fallback) is never recorded:
|
|
670
|
+
* its busy=true is ignored — recording it would pin the thread busy forever,
|
|
671
|
+
* since a wedged session never goes idle (live incident 2026-06-10) — while
|
|
672
|
+
* its busy=false still deletes, self-healing ids that slipped in before
|
|
673
|
+
* verification existed. Pure + exported so the routing is unit-testable.
|
|
674
|
+
*
|
|
675
|
+
* @returns `true` when a foreign non-descendant busy=true was ignored, so the
|
|
676
|
+
* caller can diag-log it (throttled — never per event).
|
|
677
|
+
*/
|
|
678
|
+
function applyOpenCodeStatusEvent(tracking, ownSessionId, eventSessionId, isBusy, isVerifiedDescendant) {
|
|
679
|
+
if (!eventSessionId || eventSessionId === ownSessionId) {
|
|
680
|
+
tracking.isBusy = isBusy;
|
|
681
|
+
}
|
|
682
|
+
else if (!isBusy) {
|
|
683
|
+
tracking.busyChildSessionIds.delete(eventSessionId);
|
|
684
|
+
}
|
|
685
|
+
else if (isVerifiedDescendant) {
|
|
686
|
+
tracking.busyChildSessionIds.add(eventSessionId);
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* @description Default timeout for non-SSE HTTP requests to OpenCode.
|
|
695
|
+
* 30 s comfortably covers `prompt_async` (returns 204 immediately) and
|
|
696
|
+
* `getSessions` / `getModels` (file scans on the server side). The SSE
|
|
697
|
+
* stream uses its own long-lived connection without this timeout.
|
|
698
|
+
*/
|
|
699
|
+
const apiRequestTimeoutMs = 30_000;
|
|
700
|
+
/**
|
|
701
|
+
* @description Validate `OPENCODE_URL`. SSRF guard for audit S7 / #34:
|
|
702
|
+
* an operator-controlled env var was previously trusted verbatim, so a
|
|
703
|
+
* misconfigured `OPENCODE_URL=http://169.254.169.254/` (or similar
|
|
704
|
+
* metadata endpoint) would route every adapter call to that host. We
|
|
705
|
+
* restrict the scheme to http/https and the host to loopback unless
|
|
706
|
+
* `OPENCODE_ALLOW_REMOTE=1` opts in.
|
|
707
|
+
*/
|
|
708
|
+
function validateOpenCodeUrl(raw) {
|
|
709
|
+
let parsed;
|
|
710
|
+
try {
|
|
711
|
+
parsed = new URL(raw);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
throw new Error(`OPENCODE_URL is not a valid URL: ${raw}`);
|
|
715
|
+
}
|
|
716
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
717
|
+
throw new Error(`OPENCODE_URL must use http(s); got ${parsed.protocol}`);
|
|
718
|
+
}
|
|
719
|
+
const host = parsed.hostname;
|
|
720
|
+
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
721
|
+
if (!isLoopback && process.env.OPENCODE_ALLOW_REMOTE !== '1') {
|
|
722
|
+
throw new Error(`OPENCODE_URL points at non-loopback host ${host}; set OPENCODE_ALLOW_REMOTE=1 to allow remote OpenCode servers`);
|
|
723
|
+
}
|
|
724
|
+
return parsed.origin + parsed.pathname.replace(/\/$/, '');
|
|
725
|
+
}
|
|
726
|
+
/** Cache for available models from OpenCode CLI */
|
|
727
|
+
let cachedModels = null;
|
|
728
|
+
let modelsCacheTime = 0;
|
|
729
|
+
const modelsCacheTtlMs = 5 * 60 * 1000; // 5 minutes
|
|
730
|
+
/**
|
|
731
|
+
* @description Cache for `GET /config/providers` (OpenCode 1.15.x). Same
|
|
732
|
+
* 5-minute TTL as the models cache — variant maps change about as often as
|
|
733
|
+
* the model list (operator edits `opencode.json`, restarts server).
|
|
734
|
+
* Shape (observed against opencode 1.15.12):
|
|
735
|
+
* { providers: [ { id, name, models: { <modelId>: { variants?: {...} } } } ] }
|
|
736
|
+
* but newer versions may key by provider id directly. The parser below
|
|
737
|
+
* tolerates both.
|
|
738
|
+
*/
|
|
739
|
+
let cachedProviders = null;
|
|
740
|
+
let providersCacheTime = 0;
|
|
741
|
+
const providersCacheTtlMs = 5 * 60 * 1000;
|
|
742
|
+
const providerIdRe = /^[a-z0-9][a-z0-9._-]*$/;
|
|
743
|
+
/**
|
|
744
|
+
* @description Build the JSON body for a `prompt_async` POST. Pure + exported
|
|
745
|
+
* so the two per-prompt overrides — the model selector and the reasoning-effort
|
|
746
|
+
* `variant` — can be unit-tested without a live session. Both are optional and
|
|
747
|
+
* ride the same body, parallel to each other.
|
|
748
|
+
*
|
|
749
|
+
* @returns Body with `parts` always present; `model` only when a model override
|
|
750
|
+
* is set; `variant` only when an effort level is set.
|
|
751
|
+
*/
|
|
752
|
+
function buildPromptBody(input, modelOverride, effortLevel) {
|
|
753
|
+
const body = {
|
|
754
|
+
parts: [{ type: 'text', text: input }],
|
|
755
|
+
};
|
|
756
|
+
if (modelOverride) {
|
|
757
|
+
const modelParam = { modelID: modelOverride.modelID };
|
|
758
|
+
if (modelOverride.providerID) {
|
|
759
|
+
modelParam.providerID = modelOverride.providerID;
|
|
760
|
+
}
|
|
761
|
+
body.model = modelParam;
|
|
762
|
+
}
|
|
763
|
+
if (effortLevel) {
|
|
764
|
+
body.variant = effortLevel;
|
|
765
|
+
}
|
|
766
|
+
return body;
|
|
767
|
+
}
|
|
768
|
+
function checkIsValidProviderId(providerId) {
|
|
769
|
+
return providerIdRe.test(providerId);
|
|
770
|
+
}
|
|
771
|
+
function checkIsOpenCodeRecord(value) {
|
|
772
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
773
|
+
}
|
|
774
|
+
function checkIsOpenCodeTokenCount(value) {
|
|
775
|
+
return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
|
|
776
|
+
}
|
|
777
|
+
function getOpenCodeContextUsedTokens(usage) {
|
|
778
|
+
if (!checkIsOpenCodeRecord(usage) || !checkIsOpenCodeTokenCount(usage.input))
|
|
779
|
+
return null;
|
|
780
|
+
const outputTokens = usage.output;
|
|
781
|
+
const cache = usage.cache;
|
|
782
|
+
if ((outputTokens !== undefined && !checkIsOpenCodeTokenCount(outputTokens)) ||
|
|
783
|
+
(cache !== undefined && !checkIsOpenCodeRecord(cache))) {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
const cacheReadTokens = cache?.read;
|
|
787
|
+
const cacheWriteTokens = cache?.write;
|
|
788
|
+
if ((cacheReadTokens !== undefined && !checkIsOpenCodeTokenCount(cacheReadTokens)) ||
|
|
789
|
+
(cacheWriteTokens !== undefined && !checkIsOpenCodeTokenCount(cacheWriteTokens))) {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
return usage.input + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0) + (outputTokens ?? 0);
|
|
793
|
+
}
|
|
794
|
+
function checkIsOpenCodePendingContextPlaceholder(finish, contextUsedTokens) {
|
|
795
|
+
return contextUsedTokens === 0 && (typeof finish !== 'string' || !finish.trim());
|
|
796
|
+
}
|
|
797
|
+
function getOpenCodeRuntimeModel(providerID, modelID) {
|
|
798
|
+
if (typeof providerID !== 'string' ||
|
|
799
|
+
!providerID.trim() ||
|
|
800
|
+
typeof modelID !== 'string' ||
|
|
801
|
+
!modelID.trim()) {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
return { providerID, modelID };
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* @description Return the newest parent assistant message with a valid complete
|
|
808
|
+
* input-context usage total from a session-history payload. A malformed or
|
|
809
|
+
* foreign record is ignored rather than making a previous valid observation
|
|
810
|
+
* unusable.
|
|
811
|
+
*/
|
|
812
|
+
function getLatestOpenCodeParentAssistantContext(records, sessionId) {
|
|
813
|
+
if (!Array.isArray(records))
|
|
814
|
+
return null;
|
|
815
|
+
let latestContext = null;
|
|
816
|
+
for (const record of records) {
|
|
817
|
+
if (!checkIsOpenCodeRecord(record) || !checkIsOpenCodeRecord(record.info))
|
|
818
|
+
continue;
|
|
819
|
+
const info = record.info;
|
|
820
|
+
if (info.role !== 'assistant' || typeof info.id !== 'string' || !info.id.trim())
|
|
821
|
+
continue;
|
|
822
|
+
if (info.sessionID !== undefined && info.sessionID !== sessionId)
|
|
823
|
+
continue;
|
|
824
|
+
if (info.error !== undefined && checkIsOpenCodeAbortError(getOpenCodeErrorMessage(info.error)))
|
|
825
|
+
continue;
|
|
826
|
+
const contextUsedTokens = getOpenCodeContextUsedTokens(info.tokens);
|
|
827
|
+
if (contextUsedTokens === null)
|
|
828
|
+
continue;
|
|
829
|
+
if (checkIsOpenCodePendingContextPlaceholder(info.finish, contextUsedTokens))
|
|
830
|
+
continue;
|
|
831
|
+
const model = getOpenCodeRuntimeModel(info.providerID, info.modelID);
|
|
832
|
+
latestContext = {
|
|
833
|
+
messageId: info.id,
|
|
834
|
+
...(model ? { model } : {}),
|
|
835
|
+
contextUsedTokens,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
return latestContext;
|
|
839
|
+
}
|
|
840
|
+
function getOpenCodeModelVariants(model) {
|
|
841
|
+
const variants = model.variants;
|
|
842
|
+
return checkIsOpenCodeRecord(variants) ? Object.keys(variants) : [];
|
|
843
|
+
}
|
|
844
|
+
function getOpenCodeModelContextWindowTokens(model) {
|
|
845
|
+
const limit = model.limit;
|
|
846
|
+
if (!checkIsOpenCodeRecord(limit) || !checkIsOpenCodeTokenCount(limit.context))
|
|
847
|
+
return null;
|
|
848
|
+
return limit.context;
|
|
849
|
+
}
|
|
850
|
+
function buildProviderAuthPath(providerId) {
|
|
851
|
+
return `/auth/${encodeURIComponent(providerId)}`;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* @description Pure decision for the boot self-heal: given opencode's live
|
|
855
|
+
* `GET /mcp` status map (`{ <serverName>: { status, error? }, … }`), does the
|
|
856
|
+
* bot-owned `telegramBot` server need a (re)registration? True when the entry is
|
|
857
|
+
* ABSENT (never registered on this server generation) or its `status` is
|
|
858
|
+
* anything other than `connected` (e.g. `failed` — a stale registration to a
|
|
859
|
+
* dead port left by a previous bot generation). A malformed/empty response is
|
|
860
|
+
* treated as "needs register": a redundant POST is a harmless overwrite on the
|
|
861
|
+
* server, whereas skipping a real failure would leave agents without the tools.
|
|
862
|
+
*/
|
|
863
|
+
function checkNeedsSchedulerMcpReregister(mcpStatus) {
|
|
864
|
+
if (!mcpStatus || typeof mcpStatus !== 'object')
|
|
865
|
+
return true;
|
|
866
|
+
const entry = mcpStatus[injection_1.schedulerMcpServerName];
|
|
867
|
+
if (!entry || typeof entry !== 'object')
|
|
868
|
+
return true;
|
|
869
|
+
return entry.status !== 'connected';
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Pauses before each retry of a failed scheduler-MCP registration. A server that
|
|
873
|
+
* was just (re)started can take longer than the request timeout to create a
|
|
874
|
+
* folder's instance — live, the first POST after a restart timed out at 30 s and
|
|
875
|
+
* the folder's agent ran without the bot's tools until the next bot boot. After
|
|
876
|
+
* the last pause the bot stops; the next session start or bot boot tries again.
|
|
877
|
+
*/
|
|
878
|
+
exports.schedulerMcpRetryDelaysMs = [15_000, 60_000, 5 * 60_000, 15 * 60_000];
|
|
879
|
+
/** @description The pause before retry number `attempt` (0-based), or `null` once the retries are used up. */
|
|
880
|
+
function getSchedulerMcpRetryDelayMs(attempt) {
|
|
881
|
+
return exports.schedulerMcpRetryDelaysMs[attempt] ?? null;
|
|
882
|
+
}
|
|
883
|
+
function buildProviderApiAuthPayload(apiKey) {
|
|
884
|
+
return { type: 'api', key: apiKey };
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* @description Drop the cached model list + provider config so the next read
|
|
888
|
+
* re-fetches. Called after a credential change (`/connect`), which can change
|
|
889
|
+
* which providers/models the server exposes; also the isolation seam tests use
|
|
890
|
+
* to keep the module-level cache from leaking between cases.
|
|
891
|
+
*/
|
|
892
|
+
function resetOpenCodeProviderCaches() {
|
|
893
|
+
cachedModels = null;
|
|
894
|
+
modelsCacheTime = 0;
|
|
895
|
+
cachedProviders = null;
|
|
896
|
+
providersCacheTime = 0;
|
|
897
|
+
}
|
|
898
|
+
function getFreshProvidersConfig(now = Date.now()) {
|
|
899
|
+
if (!cachedProviders || now - providersCacheTime >= providersCacheTtlMs)
|
|
900
|
+
return null;
|
|
901
|
+
return cachedProviders;
|
|
902
|
+
}
|
|
903
|
+
function parseProvidersResponse(raw) {
|
|
904
|
+
const out = { providers: new Map() };
|
|
905
|
+
if (!checkIsOpenCodeRecord(raw))
|
|
906
|
+
return out;
|
|
907
|
+
// Two shapes seen in the wild: `{ providers: [...] }` (array, opencode
|
|
908
|
+
// 1.15.x) and `{ providers: { <id>: {...} } }` (older bundles). Parse
|
|
909
|
+
// both into the same flat (providerId → modelId → model config) map so
|
|
910
|
+
// the rest of the code doesn't care which shape the server sent.
|
|
911
|
+
const collect = (providerId, providerObj) => {
|
|
912
|
+
if (!checkIsOpenCodeRecord(providerObj) || !checkIsOpenCodeRecord(providerObj.models))
|
|
913
|
+
return;
|
|
914
|
+
const modelMap = new Map();
|
|
915
|
+
for (const [modelId, modelObj] of Object.entries(providerObj.models)) {
|
|
916
|
+
if (!checkIsOpenCodeRecord(modelObj)) {
|
|
917
|
+
modelMap.set(modelId, { variants: [], contextWindowTokens: null });
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
modelMap.set(modelId, {
|
|
921
|
+
variants: getOpenCodeModelVariants(modelObj),
|
|
922
|
+
contextWindowTokens: getOpenCodeModelContextWindowTokens(modelObj),
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
out.providers.set(providerId, modelMap);
|
|
926
|
+
};
|
|
927
|
+
const providersField = raw.providers;
|
|
928
|
+
if (Array.isArray(providersField)) {
|
|
929
|
+
for (const item of providersField) {
|
|
930
|
+
if (checkIsOpenCodeRecord(item) && typeof item.id === 'string')
|
|
931
|
+
collect(item.id, item);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
else if (checkIsOpenCodeRecord(providersField)) {
|
|
935
|
+
for (const [id, value] of Object.entries(providersField)) {
|
|
936
|
+
collect(id, value);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return out;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* @description Fetch available models from OpenCode CLI.
|
|
943
|
+
* Results are cached for 5 minutes.
|
|
944
|
+
*/
|
|
945
|
+
async function fetchAvailableModels() {
|
|
946
|
+
const now = Date.now();
|
|
947
|
+
if (cachedModels && now - modelsCacheTime < modelsCacheTtlMs) {
|
|
948
|
+
return cachedModels;
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
const opencodeCmd = (0, installManager_1.getToolCommand)('opencode');
|
|
952
|
+
const { stdout } = await execAsync(`${opencodeCmd} models`, {
|
|
953
|
+
timeout: 10000,
|
|
954
|
+
env: (0, installManager_1.getOpenCodeChildEnv)(),
|
|
955
|
+
});
|
|
956
|
+
const models = stdout.trim().split('\n').filter(line => line.includes('/'));
|
|
957
|
+
if (models.length > 0) {
|
|
958
|
+
cachedModels = models;
|
|
959
|
+
modelsCacheTime = now;
|
|
960
|
+
console.log(`[OpenCode] Fetched ${models.length} models from CLI`);
|
|
961
|
+
}
|
|
962
|
+
return models;
|
|
963
|
+
}
|
|
964
|
+
catch (e) {
|
|
965
|
+
console.log(`[OpenCode] Failed to fetch models:`, e instanceof Error ? e.message : e);
|
|
966
|
+
return cachedModels || [];
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* @description Find model by partial match in available models.
|
|
971
|
+
* Searches for models containing the query string (case-insensitive).
|
|
972
|
+
*/
|
|
973
|
+
function findModelByQuery(query, models) {
|
|
974
|
+
const normalized = query.toLowerCase().trim().replace(/\s+/g, '-');
|
|
975
|
+
// Exact match first
|
|
976
|
+
const exact = models.find(m => m.toLowerCase() === normalized);
|
|
977
|
+
if (exact)
|
|
978
|
+
return exact;
|
|
979
|
+
// Match by model name part (after /)
|
|
980
|
+
const byModelName = models.find(m => {
|
|
981
|
+
const modelPart = m.split('/').slice(1).join('/').toLowerCase();
|
|
982
|
+
return modelPart === normalized || modelPart.includes(normalized);
|
|
983
|
+
});
|
|
984
|
+
if (byModelName)
|
|
985
|
+
return byModelName;
|
|
986
|
+
// Fuzzy match - contains query anywhere
|
|
987
|
+
const fuzzy = models.find(m => m.toLowerCase().includes(normalized));
|
|
988
|
+
if (fuzzy)
|
|
989
|
+
return fuzzy;
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* @description Resolve model input to provider/modelId format.
|
|
994
|
+
* Accepts full format (provider/model) or partial query to search in available models.
|
|
995
|
+
*/
|
|
996
|
+
async function resolveModelId(input, models) {
|
|
997
|
+
const trimmed = input.trim();
|
|
998
|
+
// If already in provider/model format, validate it exists
|
|
999
|
+
const slashIndex = trimmed.indexOf('/');
|
|
1000
|
+
if (slashIndex > 0) {
|
|
1001
|
+
const providerID = trimmed.slice(0, slashIndex);
|
|
1002
|
+
const modelID = trimmed.slice(slashIndex + 1);
|
|
1003
|
+
// Check if model exists in available list
|
|
1004
|
+
const exists = models.some(m => m.toLowerCase() === trimmed.toLowerCase());
|
|
1005
|
+
if (exists || models.length === 0) {
|
|
1006
|
+
return { providerID, modelID };
|
|
1007
|
+
}
|
|
1008
|
+
// Model not in list, but might be valid - allow it with warning
|
|
1009
|
+
console.log(`[OpenCode] Model "${trimmed}" not in available list, using anyway`);
|
|
1010
|
+
return { providerID, modelID };
|
|
1011
|
+
}
|
|
1012
|
+
// Search by partial match
|
|
1013
|
+
const found = findModelByQuery(trimmed, models);
|
|
1014
|
+
if (found) {
|
|
1015
|
+
const foundSlash = found.indexOf('/');
|
|
1016
|
+
return {
|
|
1017
|
+
providerID: found.slice(0, foundSlash),
|
|
1018
|
+
modelID: found.slice(foundSlash + 1),
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
class OpenCodeAdapter extends events_1.EventEmitter {
|
|
1024
|
+
name = 'opencode';
|
|
1025
|
+
label = 'OpenCode';
|
|
1026
|
+
/**
|
|
1027
|
+
* `summarize` produces a real assistant message, which rides the SSE stream into
|
|
1028
|
+
* the topic as agent output marked `isCompactionSummary` — so the bot must not
|
|
1029
|
+
* post its own copy of the summary here, or the operator would read it twice.
|
|
1030
|
+
*/
|
|
1031
|
+
streamsCompactionSummary = true;
|
|
1032
|
+
/**
|
|
1033
|
+
* Map of serialised `SessionKey` (`"<chatId>:<threadId>"`) → live session.
|
|
1034
|
+
* Keyed by string rather than `SessionKey` object so map lookups work — JS
|
|
1035
|
+
* `Map` compares object identity, not structural equality.
|
|
1036
|
+
*/
|
|
1037
|
+
sessions = new Map();
|
|
1038
|
+
/**
|
|
1039
|
+
* The ONE `/global/event` SSE stream for the whole adapter (plan 2026-06-17).
|
|
1040
|
+
* It multiplexes every project instance's events, each tagged with a top-level
|
|
1041
|
+
* `directory` field, so a single reader parses each event once and routes it
|
|
1042
|
+
* by envelope `directory` + `sessionID`. Opened when the FIRST active session
|
|
1043
|
+
* (any folder) appears, closed when the LAST one goes away. `null` while no
|
|
1044
|
+
* session is active.
|
|
1045
|
+
*/
|
|
1046
|
+
globalStream = null;
|
|
1047
|
+
/**
|
|
1048
|
+
* Directories whose OpenCode instance already has the bot's scheduler MCP
|
|
1049
|
+
* server registered for the CURRENT server generation (plan 2026-06-17 S3).
|
|
1050
|
+
* Registration is now per directory on session start (decoupled from the
|
|
1051
|
+
* stream, which is no longer per-folder); the Set gates it to once per
|
|
1052
|
+
* directory per generation and is cleared on `restartServer` so each dir is
|
|
1053
|
+
* re-registered after the server (and its in-memory MCP table) restarts.
|
|
1054
|
+
*/
|
|
1055
|
+
registeredSchedulerMcpDirs = new Set();
|
|
1056
|
+
/**
|
|
1057
|
+
* Directories whose scheduler-MCP registration failed and is waiting for a
|
|
1058
|
+
* retry: the number of retries already scheduled, and the pending timer (null
|
|
1059
|
+
* while the retry itself runs). Cleared by a registration that succeeds.
|
|
1060
|
+
*/
|
|
1061
|
+
schedulerMcpRetries = new Map();
|
|
1062
|
+
/**
|
|
1063
|
+
* child sessionID → parent sessionID, learned from `session.updated` events.
|
|
1064
|
+
* OpenCode runs subagents (e.g. `@explore`) in child sessions whose SSE
|
|
1065
|
+
* events carry the child id; this map routes them back to the topic bound to
|
|
1066
|
+
* the parent. Bounded by `maxTrackedSessionLineageEntries`.
|
|
1067
|
+
*/
|
|
1068
|
+
sessionLineage = new Map();
|
|
1069
|
+
/**
|
|
1070
|
+
* Last diag-log timestamp per `"<eventType>|<eventSessionId>"`, so a flood of
|
|
1071
|
+
* dropped events for an orphaned session logs at most once per
|
|
1072
|
+
* `sseDropLogThrottleMs` instead of once per delta per bound thread (B19).
|
|
1073
|
+
*/
|
|
1074
|
+
sseDropLogThrottle = new Map();
|
|
1075
|
+
baseUrl;
|
|
1076
|
+
authHeader;
|
|
1077
|
+
/**
|
|
1078
|
+
* Grace period (ms) the fallback rename waits for opencode's native
|
|
1079
|
+
* auto-title before stepping in. Instance field (initialised from
|
|
1080
|
+
* {@link fallbackRenameGraceMs}) purely so the naming test can drive it to 0
|
|
1081
|
+
* and assert the PATCH path deterministically — production never changes it.
|
|
1082
|
+
*/
|
|
1083
|
+
fallbackRenameGraceMs = fallbackRenameGraceMs;
|
|
1084
|
+
/**
|
|
1085
|
+
* @description Per-key start/stop serialisation queue (audit S8 / #13).
|
|
1086
|
+
* Two near-simultaneous `startSession(key, …)` calls otherwise both
|
|
1087
|
+
* progress through `POST /session`; the second's `stopSession(key)`
|
|
1088
|
+
* (line 529) clears whatever the first inserted into `this.sessions`,
|
|
1089
|
+
* but the losing thread still has its SSE stream and a server-side
|
|
1090
|
+
* session id with no owner.
|
|
1091
|
+
*/
|
|
1092
|
+
lifecycleChains = new Map();
|
|
1093
|
+
/** Number of in-flight optional history reads used to hydrate `/status` metadata. */
|
|
1094
|
+
runtimeContextHydrationCount = 0;
|
|
1095
|
+
/** Callers waiting for one of the bounded runtime-context hydration slots. */
|
|
1096
|
+
runtimeContextHydrationWaiters = [];
|
|
1097
|
+
/** Whether a server restart is already in progress (prevents concurrent restart attempts) */
|
|
1098
|
+
isServerRestarting = false;
|
|
1099
|
+
/**
|
|
1100
|
+
* Per-thread display-prefs reader, injected by the bot at boot via
|
|
1101
|
+
* `createAdapter.registerDisplayPrefsReader` (S4). DELIBERATE deviation from
|
|
1102
|
+
* the S2/S3 "adapter stays mode-agnostic" pattern: the sub-agent branch
|
|
1103
|
+
* decides WHAT to accumulate (non-`full` = refresh a status, `full` = stream
|
|
1104
|
+
* into the separate child accumulator), which cannot be deferred to the
|
|
1105
|
+
* bot's render time. `null` until wired → reads fall back to all-`minimal`.
|
|
1106
|
+
*/
|
|
1107
|
+
displayPrefsReader = null;
|
|
1108
|
+
/** @description Inject the per-thread display-prefs reader (see the field's JSDoc). */
|
|
1109
|
+
setDisplayPrefsReader(reader) {
|
|
1110
|
+
this.displayPrefsReader = reader;
|
|
1111
|
+
}
|
|
1112
|
+
/** Per-thread locale reader, injected at boot via `createAdapter.registerThreadLocaleReader`. */
|
|
1113
|
+
threadLocaleReader = null;
|
|
1114
|
+
/** @description Inject the per-thread locale reader (for adapter-side `t(...)` calls). */
|
|
1115
|
+
setThreadLocaleReader(reader) {
|
|
1116
|
+
this.threadLocaleReader = reader;
|
|
1117
|
+
}
|
|
1118
|
+
/** @description Run `fn` inside the thread's locale context so `t(...)` resolves correctly. */
|
|
1119
|
+
tl(key, fn) {
|
|
1120
|
+
return (0, i18n_1.runWithLocale)(this.threadLocaleReader?.(key) ?? i18n_1.defaultLocale, fn);
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Per-thread seen-watermark writer, injected by the bot at boot via
|
|
1124
|
+
* `createAdapter.registerSeenWatermarkWriter`. Called at turn end
|
|
1125
|
+
* (`session.idle`) to persist how far the live relay had shown the record.
|
|
1126
|
+
* `null` until wired → {@link advanceSeenWatermark} is a no-op.
|
|
1127
|
+
*/
|
|
1128
|
+
seenWatermarkWriter = null;
|
|
1129
|
+
/** @description Inject the per-thread seen-watermark writer (see the field's JSDoc). */
|
|
1130
|
+
setSeenWatermarkWriter(writer) {
|
|
1131
|
+
this.seenWatermarkWriter = writer;
|
|
1132
|
+
}
|
|
1133
|
+
/** @description Advance the persisted seen-watermark for `key` (no-op until the
|
|
1134
|
+
* writer is wired). Skips a watermark with no anchor id so the bot's recap
|
|
1135
|
+
* cleanly falls back to its no-count path. */
|
|
1136
|
+
advanceSeenWatermark(key, watermark) {
|
|
1137
|
+
if (watermark.opencodeMessageId === undefined)
|
|
1138
|
+
return;
|
|
1139
|
+
this.seenWatermarkWriter?.(key, watermark);
|
|
1140
|
+
}
|
|
1141
|
+
/** @description Resolve the thread's full display prefs, defaulting every
|
|
1142
|
+
* field to `minimal` for any read before the bot wires the reader at boot. */
|
|
1143
|
+
getDisplayPrefs(key) {
|
|
1144
|
+
return this.displayPrefsReader?.(key) ?? (0, displayVerbosity_1.getDefaultDisplayPrefs)();
|
|
1145
|
+
}
|
|
1146
|
+
/** @description Resolve the thread's `/subagent` mode (the only pref the
|
|
1147
|
+
* OpenCode adapter consults today), via the full prefs reader. */
|
|
1148
|
+
getSubagentMode(key) {
|
|
1149
|
+
return this.getDisplayPrefs(key).subagent;
|
|
1150
|
+
}
|
|
1151
|
+
constructor() {
|
|
1152
|
+
super();
|
|
1153
|
+
// Audit S7 / #34: SSRF guard on OPENCODE_URL before any fetch runs.
|
|
1154
|
+
this.baseUrl = validateOpenCodeUrl(process.env.OPENCODE_URL || installManager_1.defaultOpenCodeUrl);
|
|
1155
|
+
const password = process.env.OPENCODE_PASSWORD;
|
|
1156
|
+
if (password) {
|
|
1157
|
+
const username = process.env.OPENCODE_USERNAME || 'opencode';
|
|
1158
|
+
this.authHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
this.authHeader = null;
|
|
1162
|
+
}
|
|
1163
|
+
// Listen for unexpected server crashes (OOM kill, segfault, etc.)
|
|
1164
|
+
(0, installManager_1.onOpenCodeServerExit)((code, signal) => {
|
|
1165
|
+
console.error(`[OpenCode] Server crashed unexpectedly: code=${code}, signal=${signal}`);
|
|
1166
|
+
this.handleServerCrash(code, signal);
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
restoreSavedModel(key, session, emitOutput) {
|
|
1170
|
+
const saved = loadSavedModel(key);
|
|
1171
|
+
if (!saved)
|
|
1172
|
+
return false;
|
|
1173
|
+
const label = `${saved.providerID}/${saved.modelID}`;
|
|
1174
|
+
session.currentModelLabel = label;
|
|
1175
|
+
session.modelOverride = saved;
|
|
1176
|
+
session.isModelInfoShown = true;
|
|
1177
|
+
console.log(`[OpenCode] Restored saved model: ${label}`);
|
|
1178
|
+
if (emitOutput)
|
|
1179
|
+
this.emit('output', key, `Model: ${label}`);
|
|
1180
|
+
return true;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* @description Handle unexpected server process death.
|
|
1184
|
+
* Notifies all active users and attempts to restart the server.
|
|
1185
|
+
* SSE reconnect loop will detect the restart and recover automatically.
|
|
1186
|
+
*/
|
|
1187
|
+
handleServerCrash(code, signal) {
|
|
1188
|
+
const reason = code === 137 ? 'out of memory (OOM killed)' :
|
|
1189
|
+
signal ? `signal ${signal}` : `exit code ${code}`;
|
|
1190
|
+
// Notify all active session threads about the crash
|
|
1191
|
+
for (const session of this.sessions.values()) {
|
|
1192
|
+
if (session.isActive) {
|
|
1193
|
+
this.emit('output', session.key, `OpenCode server crashed (${reason}). Restarting...`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// Auto-restart the server (SSE reconnect loop will pick it up)
|
|
1197
|
+
this.restartServer();
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* @description Restart the OpenCode server process.
|
|
1201
|
+
* Prevents concurrent restart attempts. If restart is already in progress,
|
|
1202
|
+
* waits for it to complete and returns its result.
|
|
1203
|
+
*/
|
|
1204
|
+
async restartServer(force = false) {
|
|
1205
|
+
if (this.isServerRestarting) {
|
|
1206
|
+
console.log(`[OpenCode] Server restart already in progress, waiting...`);
|
|
1207
|
+
// Wait for the in-progress restart to complete
|
|
1208
|
+
while (this.isServerRestarting) {
|
|
1209
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1210
|
+
}
|
|
1211
|
+
// Check if server is now running
|
|
1212
|
+
return (0, installManager_1.checkIsOpenCodeServerRunning)();
|
|
1213
|
+
}
|
|
1214
|
+
this.isServerRestarting = true;
|
|
1215
|
+
try {
|
|
1216
|
+
console.log(`[OpenCode] Attempting server restart...`);
|
|
1217
|
+
if (force)
|
|
1218
|
+
await (0, installManager_1.restartOpenCodeServer)();
|
|
1219
|
+
else
|
|
1220
|
+
await (0, installManager_1.ensureOpenCodeServer)();
|
|
1221
|
+
console.log(`[OpenCode] Server restarted successfully`);
|
|
1222
|
+
// Close the global stream up front: its reader points at the crashed
|
|
1223
|
+
// server's connection. The resume loop below re-opens the (idempotent)
|
|
1224
|
+
// single stream via connectSse (plan 2026-06-17 S4). Without this the
|
|
1225
|
+
// stale reader would only self-heal later via the stall watchdog —
|
|
1226
|
+
// closing here makes recovery deterministic and tied to the restart.
|
|
1227
|
+
this.closeGlobalStream();
|
|
1228
|
+
// The restarted server has a fresh, empty MCP table, so every directory's
|
|
1229
|
+
// scheduler-MCP registration died with the old generation. Clear the gate
|
|
1230
|
+
// so connectSse re-registers each still-active directory on resume.
|
|
1231
|
+
this.registeredSchedulerMcpDirs.clear();
|
|
1232
|
+
// OpenCode persists sessions to disk, so the restarted server still
|
|
1233
|
+
// knows the previous session ids — that's the basis of this recovery.
|
|
1234
|
+
// For each in-memory session that was active, re-resume the SAME id
|
|
1235
|
+
// (verify it on the new server, rebuild the session object, reconnect
|
|
1236
|
+
// SSE) via the normal resume path. Only if the id is genuinely gone
|
|
1237
|
+
// (e.g. GET /session/:id 404) do we fall back to teardown. Take a
|
|
1238
|
+
// snapshot first so the resume/teardown loop can mutate `this.sessions`.
|
|
1239
|
+
const snapshot = Array.from(this.sessions.entries());
|
|
1240
|
+
for (const [k, session] of snapshot) {
|
|
1241
|
+
if (!session.isActive)
|
|
1242
|
+
continue;
|
|
1243
|
+
const { key: sessionKey, sessionId, workDir } = session;
|
|
1244
|
+
try {
|
|
1245
|
+
// resumeSessionInner is the lock-free body; we acquire the per-key
|
|
1246
|
+
// lifecycle lock here exactly like the public resumeSession does, so
|
|
1247
|
+
// the lock is taken once (no double-acquire from the crash handler).
|
|
1248
|
+
await this.withLifecycleLock(k, () => this.resumeSessionInner(sessionKey, workDir, sessionId));
|
|
1249
|
+
this.emit('output', sessionKey, `OpenCode server restarted; session restored. In-flight reply was lost — resend if needed.`);
|
|
1250
|
+
}
|
|
1251
|
+
catch (e) {
|
|
1252
|
+
// The id is gone on the restarted server (or resume otherwise
|
|
1253
|
+
// failed). Tear the session down and notify, same as before. Emit
|
|
1254
|
+
// `closed` so downstream (bot.ts, wired through createAdapter.ts)
|
|
1255
|
+
// wipes the persisted session id too — otherwise the next bot
|
|
1256
|
+
// restart would try to resume an id the server no longer has.
|
|
1257
|
+
console.error(`[OpenCode] Could not restore session ${sessionId} after restart:`, e);
|
|
1258
|
+
this.emit('output', sessionKey, `OpenCode server restarted; previous session lost. Starting a fresh one with /opencode (or /quit to release).`);
|
|
1259
|
+
this.stopSessionInner(sessionKey);
|
|
1260
|
+
this.emit('closed', sessionKey);
|
|
1261
|
+
// Defensive: stopSessionInner deletes from `this.sessions`, but
|
|
1262
|
+
// emit order matters for downstream cleanup races.
|
|
1263
|
+
this.sessions.delete(k);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return true;
|
|
1267
|
+
}
|
|
1268
|
+
catch (e) {
|
|
1269
|
+
console.error(`[OpenCode] Server restart failed:`, e);
|
|
1270
|
+
// Notify threads about the failure and tear them down
|
|
1271
|
+
// Take a snapshot first so we can safely mutate `this.sessions` inside the loop.
|
|
1272
|
+
const snapshot = Array.from(this.sessions.entries());
|
|
1273
|
+
for (const [k, session] of snapshot) {
|
|
1274
|
+
if (session.isActive) {
|
|
1275
|
+
this.emit('output', session.key, `Failed to restart OpenCode server: ${e instanceof Error ? e.message : String(e)}`);
|
|
1276
|
+
// Mark session as inactive — no point keeping it alive
|
|
1277
|
+
session.isActive = false;
|
|
1278
|
+
// This teardown bypasses stopSessionInner, so the replacement bound
|
|
1279
|
+
// has to be dropped here or it outlives the session it belonged to.
|
|
1280
|
+
this.clearProviderRetryReplacementStartTimer(session);
|
|
1281
|
+
this.sessions.delete(k);
|
|
1282
|
+
this.emit('stopped', session.key);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
finally {
|
|
1288
|
+
this.isServerRestarting = false;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* @description Reload credentials written by the out-of-band `opencode auth
|
|
1293
|
+
* login` CLI. The CLI updates auth.json directly, but a running OpenCode
|
|
1294
|
+
* server retains its prior provider credential in memory until restarted.
|
|
1295
|
+
* Reuses the normal restart + active-session restoration path.
|
|
1296
|
+
*/
|
|
1297
|
+
async reloadProviderAuth() {
|
|
1298
|
+
return this.restartServer(true);
|
|
1299
|
+
}
|
|
1300
|
+
getHeaders() {
|
|
1301
|
+
const headers = {
|
|
1302
|
+
'Content-Type': 'application/json',
|
|
1303
|
+
'Accept': 'application/json',
|
|
1304
|
+
};
|
|
1305
|
+
if (this.authHeader) {
|
|
1306
|
+
headers['Authorization'] = this.authHeader;
|
|
1307
|
+
}
|
|
1308
|
+
return headers;
|
|
1309
|
+
}
|
|
1310
|
+
async apiRequest(method, urlPath, body, options) {
|
|
1311
|
+
const url = `${this.baseUrl}${urlPath}`;
|
|
1312
|
+
// Audit S7 / #12: every fetch carries a timeout so a saturated server can
|
|
1313
|
+
// never park a request forever (B18). An external `AbortSignal` (e.g. tied
|
|
1314
|
+
// to session lifetime) is COMPOSED with the timeout via `AbortSignal.any`
|
|
1315
|
+
// (Node 22+) — the previous `options?.signal ?? timeoutSignal` SILENTLY
|
|
1316
|
+
// dropped the timeout whenever a caller passed its own signal, so such a
|
|
1317
|
+
// request could hang indefinitely.
|
|
1318
|
+
const timeoutSignal = AbortSignal.timeout(apiRequestTimeoutMs);
|
|
1319
|
+
const signal = options?.signal
|
|
1320
|
+
? AbortSignal.any([options.signal, timeoutSignal])
|
|
1321
|
+
: timeoutSignal;
|
|
1322
|
+
const requestInit = {
|
|
1323
|
+
method,
|
|
1324
|
+
headers: this.getHeaders(),
|
|
1325
|
+
signal,
|
|
1326
|
+
};
|
|
1327
|
+
if (body !== undefined) {
|
|
1328
|
+
requestInit.body = JSON.stringify(body);
|
|
1329
|
+
}
|
|
1330
|
+
let response;
|
|
1331
|
+
try {
|
|
1332
|
+
response = await fetch(url, requestInit);
|
|
1333
|
+
}
|
|
1334
|
+
catch (e) {
|
|
1335
|
+
// `e instanceof DOMException && e.name === 'TimeoutError'` is the
|
|
1336
|
+
// AbortSignal.timeout path; treat it like a connection failure so
|
|
1337
|
+
// the user gets a friendly hint, not a stack trace.
|
|
1338
|
+
if (e instanceof DOMException && e.name === 'TimeoutError') {
|
|
1339
|
+
throw new Error(`OpenCode request timed out after ${apiRequestTimeoutMs}ms (${method} ${urlPath})`);
|
|
1340
|
+
}
|
|
1341
|
+
// Node's fetch wraps low-level errors in a TypeError with `.cause`
|
|
1342
|
+
// holding the underlying ErrnoException. Only `'cause' in e` is a
|
|
1343
|
+
// reliable check (TypeScript narrows `cause` to `unknown` even on
|
|
1344
|
+
// TypeError in Node's lib types).
|
|
1345
|
+
const causeUnknown = e.cause;
|
|
1346
|
+
const cause = causeUnknown && typeof causeUnknown === 'object' && 'code' in causeUnknown
|
|
1347
|
+
? causeUnknown
|
|
1348
|
+
: null;
|
|
1349
|
+
if (cause?.code === 'ECONNREFUSED') {
|
|
1350
|
+
throw new Error(`OpenCode server not available at ${this.baseUrl}. Is "opencode serve" running?`);
|
|
1351
|
+
}
|
|
1352
|
+
throw new Error(`OpenCode server connection failed (${this.baseUrl}): ${getOpenCodeErrorMessage(e)}`);
|
|
1353
|
+
}
|
|
1354
|
+
// prompt_async returns 204 with no body
|
|
1355
|
+
if (response.status === 204) {
|
|
1356
|
+
return undefined;
|
|
1357
|
+
}
|
|
1358
|
+
if (!response.ok) {
|
|
1359
|
+
const errorText = await response.text().catch(() => '');
|
|
1360
|
+
throw new Error(`OpenCode API ${method} ${urlPath} failed: ${response.status} ${getOpenCodeErrorMessage(errorText)}`);
|
|
1361
|
+
}
|
|
1362
|
+
const contentType = response.headers.get('content-type') || '';
|
|
1363
|
+
if (contentType.includes('application/json')) {
|
|
1364
|
+
return await response.json();
|
|
1365
|
+
}
|
|
1366
|
+
// Audit S7 / #35: undici keeps the socket open until the body is
|
|
1367
|
+
// drained. Even when we discard it, we must read it.
|
|
1368
|
+
await response.text().catch(() => '');
|
|
1369
|
+
return undefined;
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* @description Serialise start/stop ops per key. Returns the queued
|
|
1373
|
+
* promise so callers still see the original result/throw. Audit S8.
|
|
1374
|
+
*/
|
|
1375
|
+
withLifecycleLock(k, fn) {
|
|
1376
|
+
const prev = this.lifecycleChains.get(k);
|
|
1377
|
+
const run = async () => {
|
|
1378
|
+
if (prev) {
|
|
1379
|
+
try {
|
|
1380
|
+
await prev;
|
|
1381
|
+
}
|
|
1382
|
+
catch { /* swallow predecessor failures */ }
|
|
1383
|
+
}
|
|
1384
|
+
return fn();
|
|
1385
|
+
};
|
|
1386
|
+
const current = run();
|
|
1387
|
+
const tracked = current.then(() => undefined, () => undefined);
|
|
1388
|
+
this.lifecycleChains.set(k, tracked);
|
|
1389
|
+
return current.finally(() => {
|
|
1390
|
+
if (this.lifecycleChains.get(k) === tracked)
|
|
1391
|
+
this.lifecycleChains.delete(k);
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
async startSession(key, workDir, args, _sessionId) {
|
|
1395
|
+
// OpenCode's session id is server-assigned via POST /session, so an
|
|
1396
|
+
// externally-supplied sessionId is not honoured here. The bot keeps the
|
|
1397
|
+
// mapping `SessionKey → opencodeSessionId` in state.json (plan §13.19) so
|
|
1398
|
+
// resumes after a restart go through `resumeSession()`, not startSession.
|
|
1399
|
+
const k = (0, sessionKey_1.keyToString)(key);
|
|
1400
|
+
return this.withLifecycleLock(k, async () => {
|
|
1401
|
+
this.stopSessionInner(key);
|
|
1402
|
+
if (!(0, installManager_1.checkIsInstalled)('opencode')) {
|
|
1403
|
+
this.emit('output', key, 'Installing OpenCode...');
|
|
1404
|
+
await (0, installManager_1.installTool)('opencode');
|
|
1405
|
+
}
|
|
1406
|
+
if (!await (0, installManager_1.checkIsOpenCodeServerRunning)()) {
|
|
1407
|
+
this.emit('output', key, 'Starting OpenCode server...');
|
|
1408
|
+
await (0, installManager_1.ensureOpenCodeServer)();
|
|
1409
|
+
}
|
|
1410
|
+
console.log(`[OpenCode] Starting session for ${k} in ${workDir}`);
|
|
1411
|
+
try {
|
|
1412
|
+
// R1 (verified live 2026-06-04): a session created WITHOUT a title is
|
|
1413
|
+
// auto-named by opencode from its first prompt's LLM turn — nicer than
|
|
1414
|
+
// a raw snippet and it ignores the glued thread-context preamble. A
|
|
1415
|
+
// session created WITH a title is treated as user-set and NEVER
|
|
1416
|
+
// auto-renamed, which is exactly why the old `Telegram session <k>`
|
|
1417
|
+
// default left every session stuck. So only pass a title for the
|
|
1418
|
+
// explicit `/opencode <args>` case (explicit title, no auto-rename).
|
|
1419
|
+
const createBody = {};
|
|
1420
|
+
if (args)
|
|
1421
|
+
createBody.title = args;
|
|
1422
|
+
// The bound folder IS the agent's working directory: create the session
|
|
1423
|
+
// in that folder's project instance (`?directory=`) so its cwd/tools are
|
|
1424
|
+
// scoped there. Without it the server falls back to its serve-cwd default
|
|
1425
|
+
// instance and the agent runs outside the topic's folder.
|
|
1426
|
+
const apiSession = await this.apiRequest('POST', buildDirectoryScopedPath('/session', workDir), createBody);
|
|
1427
|
+
const session = {
|
|
1428
|
+
key,
|
|
1429
|
+
sessionId: apiSession.id,
|
|
1430
|
+
workDir,
|
|
1431
|
+
isActive: true,
|
|
1432
|
+
currentResponseText: '',
|
|
1433
|
+
lastEmittedLength: 0,
|
|
1434
|
+
outputTimer: null,
|
|
1435
|
+
compactionSummaryMessageId: null,
|
|
1436
|
+
isResponseCompactionSummary: false,
|
|
1437
|
+
childResponseText: '',
|
|
1438
|
+
childLastEmittedLength: 0,
|
|
1439
|
+
childOutputTimer: null,
|
|
1440
|
+
activeSubagentTitle: null,
|
|
1441
|
+
isModelInfoShown: false,
|
|
1442
|
+
modelOverride: null,
|
|
1443
|
+
currentModelLabel: null,
|
|
1444
|
+
latestParentRuntimeContext: null,
|
|
1445
|
+
parentAssistantObservationVersion: 0,
|
|
1446
|
+
partTypes: new Map(),
|
|
1447
|
+
statusDebounceTimer: null,
|
|
1448
|
+
pendingStatus: null,
|
|
1449
|
+
reasoningText: '',
|
|
1450
|
+
reasoningStartedAt: null,
|
|
1451
|
+
reasoningTimer: null,
|
|
1452
|
+
emittedToolResultPartIds: new Set(),
|
|
1453
|
+
pendingQuestion: null,
|
|
1454
|
+
effortLevel: loadSavedEffort(key),
|
|
1455
|
+
isBusy: false,
|
|
1456
|
+
awaitingTurnResponse: false,
|
|
1457
|
+
sawTurnActivity: false,
|
|
1458
|
+
unconsumedInputCount: 0,
|
|
1459
|
+
seenUserMessageIds: new Set(),
|
|
1460
|
+
providerRetrySignature: null,
|
|
1461
|
+
isAwaitingModelAfterProviderRetryAbort: false,
|
|
1462
|
+
isAwaitingProviderRetryAbortIdle: false,
|
|
1463
|
+
isAwaitingProviderRetryReplacementStart: false,
|
|
1464
|
+
providerRetryReplacementStartTimer: null,
|
|
1465
|
+
providerRetryAbortPromise: null,
|
|
1466
|
+
isCompacting: false,
|
|
1467
|
+
busyChildSessionIds: new Set(),
|
|
1468
|
+
// No assistant turn has finished yet — the seen-watermark anchor is
|
|
1469
|
+
// set on the first `session.idle` after a turn completes.
|
|
1470
|
+
lastMessageId: undefined,
|
|
1471
|
+
// Only untitled (no-args) sessions are eligible for the bot-side
|
|
1472
|
+
// fallback rename; an explicit `args` title is user-set and final.
|
|
1473
|
+
isAutoNamePending: !args,
|
|
1474
|
+
};
|
|
1475
|
+
this.detachDuplicateSessionOwners(k, session.sessionId);
|
|
1476
|
+
this.sessions.set(k, session);
|
|
1477
|
+
this.connectSse(key);
|
|
1478
|
+
// Ready BEFORE model resolution (B18): a busy server can stall
|
|
1479
|
+
// GET /config for tens of seconds, and the topic showed no "ready"
|
|
1480
|
+
// reply meanwhile. The session exists and SSE is live the moment we
|
|
1481
|
+
// get here, so announce readiness now and resolve the model after.
|
|
1482
|
+
this.emit('started', key);
|
|
1483
|
+
// Resolve the model after readiness so a slow /config can never gate
|
|
1484
|
+
// the ready reply. SILENT (emitOutput=false), like `resumeSession`: the
|
|
1485
|
+
// bot's own `agent.ready` notice already names the resolved model (it
|
|
1486
|
+
// reads `getCurrentModel` after `startSession` returns), so emitting a
|
|
1487
|
+
// standalone `Model: <label>` line here posted a SECOND message for
|
|
1488
|
+
// every `/new` / `/opencode` start. Resolution itself must still run —
|
|
1489
|
+
// it populates `modelOverride`/`currentModelLabel` for that notice, for
|
|
1490
|
+
// `/effort`, and for the prompt body. A transient `/config` failure
|
|
1491
|
+
// leaves `isModelInfoShown` false, so the first assistant message still
|
|
1492
|
+
// corrects the label out loud (B9 unchanged).
|
|
1493
|
+
await this.fetchModelInfo(key, false);
|
|
1494
|
+
// Seed the bot's default reasoning effort (xhigh, clamped to the now
|
|
1495
|
+
// resolved model's variants) UNLESS the thread already has an explicit
|
|
1496
|
+
// /effort pref — the model ref is resolved by fetchModelInfo above.
|
|
1497
|
+
await this.applyDefaultEffortIfUnset(key);
|
|
1498
|
+
// If args provided, send as first message. After fetchModelInfo so the
|
|
1499
|
+
// resolved model override rides the prompt body.
|
|
1500
|
+
if (args) {
|
|
1501
|
+
this.sendPromptAsync(key, args);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
catch (e) {
|
|
1505
|
+
console.error(`[OpenCode] Failed to start session:`, e);
|
|
1506
|
+
throw e;
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
stopSession(key) {
|
|
1511
|
+
// Public API contract: stop is fire-and-forget. We still queue it on
|
|
1512
|
+
// the lifecycle chain so an in-flight start finishes first.
|
|
1513
|
+
const k = (0, sessionKey_1.keyToString)(key);
|
|
1514
|
+
void this.withLifecycleLock(k, async () => { this.stopSessionInner(key); });
|
|
1515
|
+
}
|
|
1516
|
+
/** Lock-free body of `stopSession`; safe to call from start/resume paths
|
|
1517
|
+
* that already hold the lifecycle lock. */
|
|
1518
|
+
stopSessionInner(key) {
|
|
1519
|
+
const k = (0, sessionKey_1.keyToString)(key);
|
|
1520
|
+
const session = this.sessions.get(k);
|
|
1521
|
+
if (!session)
|
|
1522
|
+
return;
|
|
1523
|
+
console.log(`[OpenCode] Stopping session for ${k}`);
|
|
1524
|
+
session.isActive = false;
|
|
1525
|
+
if (session.outputTimer) {
|
|
1526
|
+
clearTimeout(session.outputTimer);
|
|
1527
|
+
session.outputTimer = null;
|
|
1528
|
+
}
|
|
1529
|
+
// Same teardown for the child (sub-agent) debounce — a late fire would
|
|
1530
|
+
// emit a marked chunk to a thread that just announced `stopped`.
|
|
1531
|
+
if (session.childOutputTimer) {
|
|
1532
|
+
clearTimeout(session.childOutputTimer);
|
|
1533
|
+
session.childOutputTimer = null;
|
|
1534
|
+
}
|
|
1535
|
+
// Audit S8 / #11: previously the status timer kept firing after
|
|
1536
|
+
// `stopSession`, emitting transient `status` events to a thread that
|
|
1537
|
+
// had just announced `stopped`.
|
|
1538
|
+
if (session.statusDebounceTimer) {
|
|
1539
|
+
clearTimeout(session.statusDebounceTimer);
|
|
1540
|
+
session.statusDebounceTimer = null;
|
|
1541
|
+
}
|
|
1542
|
+
// Same rationale: a live `thinking` debounce timer must not fire (emitting a
|
|
1543
|
+
// stale "thinking …" frame) after the session announced `stopped`.
|
|
1544
|
+
if (session.reasoningTimer) {
|
|
1545
|
+
clearTimeout(session.reasoningTimer);
|
|
1546
|
+
session.reasoningTimer = null;
|
|
1547
|
+
}
|
|
1548
|
+
// Same rationale, higher stakes: a fired replacement bound would emit
|
|
1549
|
+
// `noResponse` against a session that no longer exists, kicking wedge
|
|
1550
|
+
// recovery for a thread that was deliberately stopped.
|
|
1551
|
+
this.clearProviderRetryReplacementStartTimer(session);
|
|
1552
|
+
// Tear down the global SSE stream if this was the last active session
|
|
1553
|
+
// anywhere (also clears that stream's reconnect + stall timers).
|
|
1554
|
+
this.disconnectSse(key);
|
|
1555
|
+
// Abort any running generation. Audit S15: log failures instead
|
|
1556
|
+
// of swallowing — a 4xx here usually means the server already
|
|
1557
|
+
// dropped the session, which is worth surfacing.
|
|
1558
|
+
this.apiRequest('POST', `/session/${session.sessionId}/abort`)
|
|
1559
|
+
.catch(e => console.warn(`[OpenCode] abort on stopSession failed:`, e instanceof Error ? e.message : e));
|
|
1560
|
+
this.sessions.delete(k);
|
|
1561
|
+
this.emit('stopped', key);
|
|
1562
|
+
}
|
|
1563
|
+
checkIsActive(key) {
|
|
1564
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
1565
|
+
return session?.isActive ?? false;
|
|
1566
|
+
}
|
|
1567
|
+
checkHasUnconsumedInput(key) {
|
|
1568
|
+
return (this.sessions.get((0, sessionKey_1.keyToString)(key))?.unconsumedInputCount ?? 0) > 0;
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Count a parent-session user message the first time it shows up: OpenCode
|
|
1572
|
+
* re-sends `message.updated` for one message, so ids are de-duplicated (the set
|
|
1573
|
+
* is bounded — only recent ids can still repeat).
|
|
1574
|
+
*/
|
|
1575
|
+
noteUserMessageSeen(session, messageId) {
|
|
1576
|
+
if (messageId === undefined || session.seenUserMessageIds.has(messageId))
|
|
1577
|
+
return;
|
|
1578
|
+
session.seenUserMessageIds.add(messageId);
|
|
1579
|
+
if (session.seenUserMessageIds.size > seenUserMessageIdsMax) {
|
|
1580
|
+
const oldestId = session.seenUserMessageIds.values().next().value;
|
|
1581
|
+
if (oldestId !== undefined)
|
|
1582
|
+
session.seenUserMessageIds.delete(oldestId);
|
|
1583
|
+
}
|
|
1584
|
+
session.unconsumedInputCount = Math.max(0, session.unconsumedInputCount - 1);
|
|
1585
|
+
}
|
|
1586
|
+
checkIsBusy(key) {
|
|
1587
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
1588
|
+
if (!session?.isActive)
|
|
1589
|
+
return false;
|
|
1590
|
+
return checkIsOpenCodeSessionBusy({
|
|
1591
|
+
isBusy: session.isBusy,
|
|
1592
|
+
isCompacting: session.isCompacting,
|
|
1593
|
+
busyChildCount: session.busyChildSessionIds.size,
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
sendInput(key, input) {
|
|
1597
|
+
this.sendPromptAsync(key, input);
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* @description Send message via async endpoint (returns 204, response streams via SSE).
|
|
1601
|
+
* Fire-and-forget — errors are logged but don't block.
|
|
1602
|
+
*
|
|
1603
|
+
* A normal busy turn is preserved and receives the prompt through OpenCode's
|
|
1604
|
+
* queue. A provider-managed retry is interrupted first: otherwise a model
|
|
1605
|
+
* switch plus "continue" is accepted into the transcript but remains unread
|
|
1606
|
+
* behind the old provider's retry window.
|
|
1607
|
+
*
|
|
1608
|
+
* **Per-prompt effort apply:** if the thread has a stored effort level
|
|
1609
|
+
* (a model variant), it's sent as `body.variant` alongside `body.model`
|
|
1610
|
+
* — the same per-prompt override the prompt endpoint already uses for the
|
|
1611
|
+
* model selector. No separate request, no env configuration.
|
|
1612
|
+
*/
|
|
1613
|
+
sendPromptAsync(key, input) {
|
|
1614
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
1615
|
+
if (!session?.isActive) {
|
|
1616
|
+
console.log(`[OpenCode] sendInput: no active session for ${(0, sessionKey_1.keyToString)(key)}`);
|
|
1617
|
+
(0, diagLog_1.appendDiagLog)(`prompt DROPPED no-active-session key=${(0, sessionKey_1.keyToString)(key)}`);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
console.log(`[OpenCode] sendPromptAsync: "${input}"`);
|
|
1621
|
+
session.unconsumedInputCount += 1;
|
|
1622
|
+
// Bot-side fallback naming (R1 safety net): on the first MEANINGFUL prompt
|
|
1623
|
+
// of an untitled session, schedule a rename that only fires if opencode's
|
|
1624
|
+
// own auto-title never lands. Uses the RAW user text (preamble stripped) so
|
|
1625
|
+
// a fallback title can never carry the service header. Fire-and-forget — it
|
|
1626
|
+
// must not gate prompt delivery, and a failure leaves the placeholder.
|
|
1627
|
+
this.maybeScheduleFallbackRename(session, input);
|
|
1628
|
+
// Reset accumulated response text for new message
|
|
1629
|
+
session.currentResponseText = '';
|
|
1630
|
+
session.lastEmittedLength = 0;
|
|
1631
|
+
session.isResponseCompactionSummary = false;
|
|
1632
|
+
// The child (sub-agent) accumulator resets with the parent's — a new turn
|
|
1633
|
+
// starts clean (a stale debounce firing later emits nothing: empty tail).
|
|
1634
|
+
session.childResponseText = '';
|
|
1635
|
+
session.childLastEmittedLength = 0;
|
|
1636
|
+
// New turn → previous turn's tool-result dedup ids can no longer matter
|
|
1637
|
+
// (see the field's JSDoc for why the turn flush is too early to clear).
|
|
1638
|
+
session.emittedToolResultPartIds.clear();
|
|
1639
|
+
// Mark busy optimistically so an immediately-following message correctly
|
|
1640
|
+
// sees a turn in flight; the `session.status` stream corrects/confirms it.
|
|
1641
|
+
session.isBusy = true;
|
|
1642
|
+
const isReplacingProviderRetry = session.providerRetrySignature !== null || session.providerRetryAbortPromise !== null;
|
|
1643
|
+
if (!isReplacingProviderRetry) {
|
|
1644
|
+
// Arm the wedged-turn detector: a healthy turn sets `sawTurnActivity` from
|
|
1645
|
+
// its first assistant message/part; if the next own-session idle arrives
|
|
1646
|
+
// with none, the prompt produced no turn (a wedged session accepts prompts
|
|
1647
|
+
// but its agent loop exits at once) and we surface a notice instead of
|
|
1648
|
+
// hanging silently.
|
|
1649
|
+
session.awaitingTurnResponse = true;
|
|
1650
|
+
session.sawTurnActivity = false;
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
// The prior retry's pending detector belongs to the turn being aborted.
|
|
1654
|
+
// The replacement is armed only at its own observed busy transition.
|
|
1655
|
+
session.awaitingTurnResponse = false;
|
|
1656
|
+
session.sawTurnActivity = false;
|
|
1657
|
+
}
|
|
1658
|
+
// Model + reasoning-effort overrides ride the prompt body (see buildPromptBody).
|
|
1659
|
+
const body = buildPromptBody(input, session.modelOverride, session.effortLevel);
|
|
1660
|
+
void (async () => {
|
|
1661
|
+
try {
|
|
1662
|
+
const retryAbortPromise = this.getProviderRetryAbortPromise(session);
|
|
1663
|
+
if (retryAbortPromise)
|
|
1664
|
+
await retryAbortPromise;
|
|
1665
|
+
if (isReplacingProviderRetry) {
|
|
1666
|
+
const current = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
1667
|
+
if (current !== session || !current.isActive)
|
|
1668
|
+
return;
|
|
1669
|
+
// SSE can report busy before prompt_async resolves. Arm the boundary
|
|
1670
|
+
// before sending so that busy unambiguously belongs to this prompt.
|
|
1671
|
+
this.armProviderRetryReplacementStart(current);
|
|
1672
|
+
}
|
|
1673
|
+
await this.apiRequest('POST', `/session/${session.sessionId}/prompt_async`, body);
|
|
1674
|
+
}
|
|
1675
|
+
catch (e) {
|
|
1676
|
+
// A prompt OpenCode never accepted will never show up as a user message,
|
|
1677
|
+
// so it must stop counting as unconsumed — left counted, every later turn
|
|
1678
|
+
// of this session would read as "not taken in yet" and never end.
|
|
1679
|
+
session.unconsumedInputCount = Math.max(0, session.unconsumedInputCount - 1);
|
|
1680
|
+
// The optimistic `isBusy = true` above never gets a `session.status`
|
|
1681
|
+
// idle to clear it if the POST failed — clear it so the next prompt
|
|
1682
|
+
// doesn't eat a spurious abort + wait.
|
|
1683
|
+
const current = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
1684
|
+
if (current && !current.providerRetrySignature) {
|
|
1685
|
+
current.isBusy = false;
|
|
1686
|
+
current.awaitingTurnResponse = false;
|
|
1687
|
+
this.clearProviderRetryReplacementBoundary(current);
|
|
1688
|
+
}
|
|
1689
|
+
console.error(`[OpenCode] Failed to send message:`, e);
|
|
1690
|
+
this.emit('error', key, e instanceof Error ? e : new Error(String(e)));
|
|
1691
|
+
}
|
|
1692
|
+
})();
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* @description Return the one shared abort request for a provider-managed
|
|
1696
|
+
* retry. Concurrent prompts await the same request, preventing a later abort
|
|
1697
|
+
* from cancelling a prompt that an earlier caller already posted.
|
|
1698
|
+
*/
|
|
1699
|
+
getProviderRetryAbortPromise(session) {
|
|
1700
|
+
if (session.providerRetryAbortPromise)
|
|
1701
|
+
return session.providerRetryAbortPromise;
|
|
1702
|
+
if (!session.providerRetrySignature)
|
|
1703
|
+
return null;
|
|
1704
|
+
console.log(`[OpenCode] Aborting provider retry before new prompt`);
|
|
1705
|
+
session.isAwaitingModelAfterProviderRetryAbort = true;
|
|
1706
|
+
// The abort's idle may arrive before this request resolves, so arm its
|
|
1707
|
+
// one-shot consumer before dispatching rather than in the success handler.
|
|
1708
|
+
session.isAwaitingProviderRetryAbortIdle = true;
|
|
1709
|
+
const abortPromise = this.apiRequest('POST', `/session/${session.sessionId}/abort`).then(() => undefined);
|
|
1710
|
+
session.providerRetryAbortPromise = abortPromise;
|
|
1711
|
+
void abortPromise.then(() => {
|
|
1712
|
+
const current = this.sessions.get((0, sessionKey_1.keyToString)(session.key));
|
|
1713
|
+
if (current !== session || current.providerRetryAbortPromise !== abortPromise)
|
|
1714
|
+
return;
|
|
1715
|
+
current.providerRetrySignature = null;
|
|
1716
|
+
current.providerRetryAbortPromise = null;
|
|
1717
|
+
}, () => {
|
|
1718
|
+
const current = this.sessions.get((0, sessionKey_1.keyToString)(session.key));
|
|
1719
|
+
if (current === session && current.providerRetryAbortPromise === abortPromise) {
|
|
1720
|
+
current.isAwaitingModelAfterProviderRetryAbort = false;
|
|
1721
|
+
current.isAwaitingProviderRetryAbortIdle = false;
|
|
1722
|
+
this.clearProviderRetryReplacementBoundary(current);
|
|
1723
|
+
current.providerRetryAbortPromise = null;
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
return abortPromise;
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* @description Arm the post-abort replacement boundary together with the bound
|
|
1730
|
+
* on how long that boundary may stay armed.
|
|
1731
|
+
*
|
|
1732
|
+
* The boundary exists because `session.status` carries no turn identifier: the
|
|
1733
|
+
* first observed `busy` is taken as the replacement turn's start. A wedged
|
|
1734
|
+
* session never produces that `busy`, so without a bound the boundary latches
|
|
1735
|
+
* forever and the topic hangs with nothing able to recover it.
|
|
1736
|
+
*/
|
|
1737
|
+
armProviderRetryReplacementStart(session) {
|
|
1738
|
+
// A second concurrent replacement prompt re-arms: drop the previous bound so
|
|
1739
|
+
// at most one timer per session is ever pending.
|
|
1740
|
+
this.clearProviderRetryReplacementStartTimer(session);
|
|
1741
|
+
session.isAwaitingProviderRetryReplacementStart = true;
|
|
1742
|
+
const timer = setTimeout(() => {
|
|
1743
|
+
const current = this.sessions.get((0, sessionKey_1.keyToString)(session.key));
|
|
1744
|
+
// A restart / resume swapped the session object: the new session owns its
|
|
1745
|
+
// own bound, so this expired one has nothing left to report.
|
|
1746
|
+
if (current !== session)
|
|
1747
|
+
return;
|
|
1748
|
+
const isReplacementMissing = (0, openCodeTurnActivity_1.checkIsReplacementTurnMissing)({
|
|
1749
|
+
isSessionActive: current.isActive,
|
|
1750
|
+
isAwaitingReplacementStart: current.isAwaitingProviderRetryReplacementStart,
|
|
1751
|
+
sawActivity: current.sawTurnActivity,
|
|
1752
|
+
});
|
|
1753
|
+
// Release the boundary UNCONDITIONALLY, before deciding whether to report.
|
|
1754
|
+
// Returning early while the flag is still armed would leave it with no
|
|
1755
|
+
// bound behind it — the exact latch this bound exists to prevent (the idle
|
|
1756
|
+
// guard swallows every own idle while it is set). It is reachable: activity
|
|
1757
|
+
// from a CHILD session satisfies the predicate, while only a PARENT message
|
|
1758
|
+
// releases the boundary on the normal path.
|
|
1759
|
+
this.clearProviderRetryReplacementBoundary(current);
|
|
1760
|
+
if (!isReplacementMissing)
|
|
1761
|
+
return;
|
|
1762
|
+
// End the abort episode here: the optimistic busy flag waits on a `busy`
|
|
1763
|
+
// that is never coming, and the wedged-turn detector stays disarmed because
|
|
1764
|
+
// this path defers arming it to that same `busy`. `isAwaitingProviderRetryAbortIdle`
|
|
1765
|
+
// is deliberately LEFT armed — the aborted retry's own idle is exactly the
|
|
1766
|
+
// event that arrives late here, and it must still be consumed once rather
|
|
1767
|
+
// than counting as the recovery prompt's idle.
|
|
1768
|
+
current.isBusy = false;
|
|
1769
|
+
this.reportNoTurnResponse(current.key, current, 'replacementNeverStarted');
|
|
1770
|
+
}, exports.providerRetryReplacementStartTimeoutMs);
|
|
1771
|
+
// A pending boundary bound must never keep the process alive on shutdown.
|
|
1772
|
+
timer.unref();
|
|
1773
|
+
session.providerRetryReplacementStartTimer = timer;
|
|
1774
|
+
}
|
|
1775
|
+
/** Drop a pending replacement-start bound (teardown, or before re-arming). */
|
|
1776
|
+
clearProviderRetryReplacementStartTimer(session) {
|
|
1777
|
+
if (!session.providerRetryReplacementStartTimer)
|
|
1778
|
+
return;
|
|
1779
|
+
clearTimeout(session.providerRetryReplacementStartTimer);
|
|
1780
|
+
session.providerRetryReplacementStartTimer = null;
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* @description Resolve the replacement boundary: the flag and its bound are
|
|
1784
|
+
* cleared together so the invariant "boundary disarmed ⇒ no pending timer"
|
|
1785
|
+
* holds at every site, and a resolved boundary can never fire a late
|
|
1786
|
+
* `noResponse` against a turn that did start.
|
|
1787
|
+
*/
|
|
1788
|
+
clearProviderRetryReplacementBoundary(session) {
|
|
1789
|
+
session.isAwaitingProviderRetryReplacementStart = false;
|
|
1790
|
+
this.clearProviderRetryReplacementStartTimer(session);
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* @description The single emit point for "the delivered prompt produced no
|
|
1794
|
+
* turn". Both detectors watch the SAME prompt from different angles — the
|
|
1795
|
+
* idle-time wedge check and the replacement-start bound — so every trigger is
|
|
1796
|
+
* disarmed here before emitting, and one prompt can never reach the bot's
|
|
1797
|
+
* recovery escalation twice.
|
|
1798
|
+
*/
|
|
1799
|
+
reportNoTurnResponse(key, session, cause) {
|
|
1800
|
+
session.awaitingTurnResponse = false;
|
|
1801
|
+
this.clearProviderRetryReplacementBoundary(session);
|
|
1802
|
+
const labels = noTurnResponseCauseLabels[cause];
|
|
1803
|
+
console.warn(`[OpenCode] Prompt produced no turn (${labels.console}) for ${(0, sessionKey_1.keyToString)(key)}`);
|
|
1804
|
+
(0, diagLog_1.appendDiagLog)(`prompt NO-RESPONSE ${labels.diag} key=${(0, sessionKey_1.keyToString)(key)} session=${session.sessionId}`);
|
|
1805
|
+
this.emit('noResponse', key);
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* @description Bot-side fallback for naming an untitled session (R1 safety
|
|
1809
|
+
* net). The primary mechanism is opencode's own LLM auto-title; this runs
|
|
1810
|
+
* ONLY if that never lands.
|
|
1811
|
+
*
|
|
1812
|
+
* On the first MEANINGFUL prompt of an eligible (untitled, not-yet-renamed)
|
|
1813
|
+
* session it clears the pending flag — so it tries at most once — then, after
|
|
1814
|
+
* a grace period that lets auto-title land, reads the live title and PATCHes a
|
|
1815
|
+
* snippet ONLY when the title is still the bare placeholder. The snippet
|
|
1816
|
+
* derives from the RAW user text (preamble stripped) so it can never carry the
|
|
1817
|
+
* thread-context header. Fire-and-forget; a failure leaves the placeholder.
|
|
1818
|
+
*/
|
|
1819
|
+
maybeScheduleFallbackRename(session, input) {
|
|
1820
|
+
if (!session.isAutoNamePending)
|
|
1821
|
+
return;
|
|
1822
|
+
// The text arriving here is the preamble-glued prompt; meaningfulness and
|
|
1823
|
+
// the snippet must both be judged on the RAW user text, never the glue.
|
|
1824
|
+
const rawText = (0, threadContextPreamble_1.stripThreadContextPreamble)(input);
|
|
1825
|
+
if (!(0, openCodeSessionTitle_1.checkIsMeaningfulPrompt)(rawText))
|
|
1826
|
+
return;
|
|
1827
|
+
// First meaningful prompt: try at most once, regardless of the outcome.
|
|
1828
|
+
session.isAutoNamePending = false;
|
|
1829
|
+
const snippet = (0, openCodeSessionTitle_1.buildSessionTitleSnippet)(rawText);
|
|
1830
|
+
if (!snippet)
|
|
1831
|
+
return;
|
|
1832
|
+
const { sessionId } = session;
|
|
1833
|
+
void (async () => {
|
|
1834
|
+
await new Promise((resolve) => setTimeout(resolve, this.fallbackRenameGraceMs));
|
|
1835
|
+
try {
|
|
1836
|
+
const apiSession = await this.apiRequest('GET', `/session/${sessionId}`);
|
|
1837
|
+
// Auto-title already produced a real (LLM) name — leave it; the native
|
|
1838
|
+
// name is better than our snippet.
|
|
1839
|
+
if (!(0, openCodeSessionTitle_1.checkIsPlaceholderTitle)(apiSession.title))
|
|
1840
|
+
return;
|
|
1841
|
+
await this.apiRequest('PATCH', `/session/${sessionId}`, { title: snippet });
|
|
1842
|
+
console.log(`[OpenCode] Fallback-renamed untitled session ${sessionId} to "${snippet}"`);
|
|
1843
|
+
}
|
|
1844
|
+
catch (e) {
|
|
1845
|
+
// Best-effort: a failed rename just leaves the placeholder title.
|
|
1846
|
+
console.warn(`[OpenCode] fallback session rename failed:`, e instanceof Error ? e.message : e);
|
|
1847
|
+
}
|
|
1848
|
+
})();
|
|
1849
|
+
}
|
|
1850
|
+
sendSignal(key, signal) {
|
|
1851
|
+
if (signal === 'SIGINT') {
|
|
1852
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
1853
|
+
if (!session?.isActive)
|
|
1854
|
+
return;
|
|
1855
|
+
console.log(`[OpenCode] Aborting session (SIGINT)`);
|
|
1856
|
+
this.apiRequest('POST', `/session/${session.sessionId}/abort`).catch((e) => {
|
|
1857
|
+
console.error(`[OpenCode] abort error:`, e);
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* @description Connect an OpenCode provider through the same API-key auth
|
|
1863
|
+
* endpoint the OpenCode UI uses (`PUT /auth/{providerID}`). Providers with a
|
|
1864
|
+
* custom auth entry must expose a one-field API method; ordinary providers
|
|
1865
|
+
* (including OpenRouter) use the generic API-key method synthesized from the
|
|
1866
|
+
* full `/provider` catalog.
|
|
1867
|
+
*/
|
|
1868
|
+
async connectProvider(_key, providerId, apiKey) {
|
|
1869
|
+
const normalizedProviderId = providerId.trim().toLowerCase();
|
|
1870
|
+
if (!checkIsValidProviderId(normalizedProviderId)) {
|
|
1871
|
+
return (0, i18n_1.t)('connect.invalid_provider', { provider: providerId });
|
|
1872
|
+
}
|
|
1873
|
+
const trimmedApiKey = apiKey.trim();
|
|
1874
|
+
if (!trimmedApiKey)
|
|
1875
|
+
return (0, i18n_1.t)('connect.empty_key');
|
|
1876
|
+
try {
|
|
1877
|
+
const methods = await this.fetchProviderAuthMethods(normalizedProviderId);
|
|
1878
|
+
if (!methods.some(openCodeAuthLogin_1.checkIsSimpleApiMethod)) {
|
|
1879
|
+
return (0, i18n_1.t)('connect.unsupported_provider', { provider: normalizedProviderId });
|
|
1880
|
+
}
|
|
1881
|
+
await this.apiRequest('PUT', buildProviderAuthPath(normalizedProviderId), buildProviderApiAuthPayload(trimmedApiKey));
|
|
1882
|
+
resetOpenCodeProviderCaches();
|
|
1883
|
+
console.log(`[OpenCode] Connected provider: ${normalizedProviderId}`);
|
|
1884
|
+
return null;
|
|
1885
|
+
}
|
|
1886
|
+
catch (error) {
|
|
1887
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1888
|
+
console.warn(`[OpenCode] provider connect failed for ${normalizedProviderId}:`, reason);
|
|
1889
|
+
return (0, i18n_1.t)('connect.failed', { provider: normalizedProviderId, reason });
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* @description Disconnect a provider through the same auth endpoint
|
|
1894
|
+
* `/connect` writes to (`DELETE /auth/{providerID}`), then report what the
|
|
1895
|
+
* delete actually achieved.
|
|
1896
|
+
*
|
|
1897
|
+
* `DELETE /auth` only clears OpenCode's own credential store. A provider
|
|
1898
|
+
* OpenCode enables from an ENVIRONMENT VARIABLE (`openrouter` ←
|
|
1899
|
+
* `OPENROUTER_API_KEY`) is still fully active afterwards, so we re-read
|
|
1900
|
+
* `GET /config/providers` (caches dropped first, exactly like
|
|
1901
|
+
* `connectProvider` does after a credential change) and hand the still-active
|
|
1902
|
+
* provider ids to {@link getProviderDisconnectOutcome}. Claiming a clean
|
|
1903
|
+
* disconnect there would be a lie — that case is what the bot-side "hide
|
|
1904
|
+
* provider" toggle in `/model` exists for.
|
|
1905
|
+
*/
|
|
1906
|
+
async disconnectProvider(_key, providerId) {
|
|
1907
|
+
const normalizedProviderId = providerId.trim().toLowerCase();
|
|
1908
|
+
if (!checkIsValidProviderId(normalizedProviderId)) {
|
|
1909
|
+
return (0, i18n_1.t)('disconnect.invalid_provider', { provider: providerId });
|
|
1910
|
+
}
|
|
1911
|
+
try {
|
|
1912
|
+
await this.ensureProviderAuthServerReady();
|
|
1913
|
+
await this.apiRequest('DELETE', buildProviderAuthPath(normalizedProviderId));
|
|
1914
|
+
resetOpenCodeProviderCaches();
|
|
1915
|
+
const config = await this.getProvidersConfig();
|
|
1916
|
+
const outcome = (0, providerDisconnectPlan_1.getProviderDisconnectOutcome)(normalizedProviderId, [...config.providers.keys()]);
|
|
1917
|
+
console.log(`[OpenCode] Disconnected provider: ${normalizedProviderId} (${outcome})`);
|
|
1918
|
+
return outcome === 'stillActiveViaEnv'
|
|
1919
|
+
? (0, i18n_1.t)('disconnect.still_active_env', { provider: normalizedProviderId })
|
|
1920
|
+
: null;
|
|
1921
|
+
}
|
|
1922
|
+
catch (error) {
|
|
1923
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1924
|
+
console.warn(`[OpenCode] provider disconnect failed for ${normalizedProviderId}:`, reason);
|
|
1925
|
+
return (0, i18n_1.t)('disconnect.failed', { provider: normalizedProviderId, reason });
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
async ensureProviderAuthServerReady() {
|
|
1929
|
+
if (!(0, installManager_1.checkIsInstalled)('opencode')) {
|
|
1930
|
+
await (0, installManager_1.installTool)('opencode');
|
|
1931
|
+
}
|
|
1932
|
+
if (!await (0, installManager_1.checkIsOpenCodeServerRunning)()) {
|
|
1933
|
+
await (0, installManager_1.ensureOpenCodeServer)();
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
/**
|
|
1937
|
+
* @description Fetch a provider's auth methods for the `/connect` picker.
|
|
1938
|
+
* Custom OAuth/multi-step methods come from `/provider/auth`; a provider that
|
|
1939
|
+
* only appears in the full `/provider` catalog gets OpenCode's ordinary
|
|
1940
|
+
* one-field API-key method. Throws on a server/transport error.
|
|
1941
|
+
*/
|
|
1942
|
+
async fetchProviderAuthMethods(providerId) {
|
|
1943
|
+
await this.ensureProviderAuthServerReady();
|
|
1944
|
+
const authCatalog = await this.apiRequest('GET', '/provider/auth');
|
|
1945
|
+
if ((0, openCodeAuthLogin_1.checkProviderHasAuthCatalogEntry)(authCatalog, providerId)) {
|
|
1946
|
+
return (0, openCodeAuthLogin_1.parseProviderAuthMethods)(authCatalog, providerId);
|
|
1947
|
+
}
|
|
1948
|
+
const providerCatalog = await this.apiRequest('GET', '/provider');
|
|
1949
|
+
return (0, openCodeAuthLogin_1.getGenericProviderAuthMethods)(providerCatalog, providerId);
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* @description Set model override for the current session.
|
|
1953
|
+
* Accepts either "provider/modelId" format or partial name to search.
|
|
1954
|
+
* @returns Error message if model not found, null on success
|
|
1955
|
+
*/
|
|
1956
|
+
async setModel(key, modelId) {
|
|
1957
|
+
// Resolve first — both calls hit the server/CLI, not session state, so a
|
|
1958
|
+
// model can be picked BEFORE a session exists. `getAvailableModels` is the
|
|
1959
|
+
// stubbable wrapper around `fetchAvailableModels`.
|
|
1960
|
+
const models = await this.getAvailableModels();
|
|
1961
|
+
const resolved = await resolveModelId(modelId, models);
|
|
1962
|
+
if (!resolved) {
|
|
1963
|
+
return `Model "${modelId}" not found. Use /model to see available models.`;
|
|
1964
|
+
}
|
|
1965
|
+
const label = `${resolved.providerID}/${resolved.modelID}`;
|
|
1966
|
+
// Persist the choice unconditionally: a session started later replays the
|
|
1967
|
+
// saved pref via `restoreSavedModel`, so picking a model before `/opencode`
|
|
1968
|
+
// is no longer lost (the "No active session" bug).
|
|
1969
|
+
saveModelPref(key, label);
|
|
1970
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
1971
|
+
if (session?.isActive) {
|
|
1972
|
+
session.modelOverride = resolved;
|
|
1973
|
+
session.isModelInfoShown = false;
|
|
1974
|
+
session.currentModelLabel = label;
|
|
1975
|
+
console.log(`[OpenCode] Model set to: ${label}`);
|
|
1976
|
+
// Discriminate by the PERSISTED pref, not session.effortLevel (which may
|
|
1977
|
+
// already hold a clamped default we seeded). An explicit /effort pick is
|
|
1978
|
+
// validated against the new model and cleared-with-notice if invalid
|
|
1979
|
+
// (today's behavior, plan 2026-05-30-effort-command / S4). With no
|
|
1980
|
+
// explicit pref the default is silently re-clamped for the new model.
|
|
1981
|
+
const explicit = loadSavedEffort(key);
|
|
1982
|
+
if (explicit) {
|
|
1983
|
+
const stillValid = (await this.getAvailableEffortLevels(key)).includes(explicit);
|
|
1984
|
+
if (stillValid) {
|
|
1985
|
+
session.effortLevel = explicit;
|
|
1986
|
+
}
|
|
1987
|
+
else {
|
|
1988
|
+
session.effortLevel = null;
|
|
1989
|
+
clearEffortPref(key);
|
|
1990
|
+
this.emit('output', key, (0, i18n_1.t)('effort.cleared_on_model_switch', { level: explicit, model: label }));
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
else {
|
|
1994
|
+
session.effortLevel = (0, effortLevels_1.clampEffortToAvailable)(effortLevels_1.defaultEffortLevel, await this.getModelVariants(resolved));
|
|
1995
|
+
}
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
// No active session: the pref is saved above. Guard the SAME invariant as
|
|
1999
|
+
// the live branch — a saved effort the new model can't honour would make
|
|
2000
|
+
// the next session POST an invalid `body.variant`, so drop it now and tell
|
|
2001
|
+
// the user, mirroring the live `effort.cleared_on_model_switch` notice.
|
|
2002
|
+
const savedEffort = loadSavedEffort(key);
|
|
2003
|
+
if (savedEffort) {
|
|
2004
|
+
const stillValid = (await this.getModelVariants(resolved)).includes(savedEffort);
|
|
2005
|
+
if (!stillValid) {
|
|
2006
|
+
clearEffortPref(key);
|
|
2007
|
+
this.emit('output', key, (0, i18n_1.t)('effort.cleared_on_model_switch', { level: savedEffort, model: label }));
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
console.log(`[OpenCode] Model pref saved (no active session): ${label}`);
|
|
2011
|
+
return null;
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* @description Get list of available models from OpenCode CLI.
|
|
2015
|
+
*/
|
|
2016
|
+
async getAvailableModels() {
|
|
2017
|
+
return fetchAvailableModels();
|
|
2018
|
+
}
|
|
2019
|
+
getCurrentModel(key) {
|
|
2020
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2021
|
+
if (session?.currentModelLabel)
|
|
2022
|
+
return session.currentModelLabel;
|
|
2023
|
+
// No live label (no session, or session not yet resolved) → fall back to
|
|
2024
|
+
// the saved pref so the /model header and success copy stay correct
|
|
2025
|
+
// pre-session. Mirrors getEffort's session-then-disk read order.
|
|
2026
|
+
const saved = loadSavedModel(key);
|
|
2027
|
+
return saved ? `${saved.providerID}/${saved.modelID}` : null;
|
|
2028
|
+
}
|
|
2029
|
+
async getRuntimeInfo(key) {
|
|
2030
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2031
|
+
if (!session?.isActive) {
|
|
2032
|
+
return { version: null, model: null, contextWindowTokens: null, contextUsedTokens: null };
|
|
2033
|
+
}
|
|
2034
|
+
const runtimeContext = session.latestParentRuntimeContext;
|
|
2035
|
+
const runtimeModel = runtimeContext?.model ?? null;
|
|
2036
|
+
// Fetch (not cache-peek) the provider config: a cache-only read reported
|
|
2037
|
+
// "context limit unavailable" for the whole TTL window after a server
|
|
2038
|
+
// restart. A failed lookup degrades to an unknown limit — `/status` must
|
|
2039
|
+
// never throw. Skipped entirely without an observed model, since there is
|
|
2040
|
+
// then no model to look a limit up for; the two independent HTTP reads run
|
|
2041
|
+
// concurrently so `/status` waits for the slower one, not for their sum.
|
|
2042
|
+
const [health, providers] = await Promise.all([
|
|
2043
|
+
(0, installManager_1.getOpenCodeServerHealth)(),
|
|
2044
|
+
runtimeModel
|
|
2045
|
+
? this.getProvidersConfig().catch((error) => {
|
|
2046
|
+
console.warn('[OpenCode] getRuntimeInfo providers lookup failed:', error instanceof Error ? error.message : error);
|
|
2047
|
+
return null;
|
|
2048
|
+
})
|
|
2049
|
+
: null,
|
|
2050
|
+
]);
|
|
2051
|
+
const contextWindowTokens = runtimeModel && providers
|
|
2052
|
+
? providers.providers.get(runtimeModel.providerID)?.get(runtimeModel.modelID)?.contextWindowTokens ?? null
|
|
2053
|
+
: null;
|
|
2054
|
+
return {
|
|
2055
|
+
version: health.version,
|
|
2056
|
+
model: runtimeModel ? `${runtimeModel.providerID}/${runtimeModel.modelID}` : null,
|
|
2057
|
+
contextWindowTokens,
|
|
2058
|
+
contextUsedTokens: runtimeContext?.contextUsedTokens ?? null,
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
// ── Reasoning effort (variants) ───────────────────────────────────────────
|
|
2062
|
+
/**
|
|
2063
|
+
* @description Fetch + cache the provider/variant config
|
|
2064
|
+
* (`GET /config/providers`). 5-minute TTL — variant maps change about as
|
|
2065
|
+
* often as the model list. The response is not logged because provider
|
|
2066
|
+
* configuration can contain credentials.
|
|
2067
|
+
*/
|
|
2068
|
+
async getProvidersConfig() {
|
|
2069
|
+
const now = Date.now();
|
|
2070
|
+
const freshConfig = getFreshProvidersConfig(now);
|
|
2071
|
+
if (freshConfig)
|
|
2072
|
+
return freshConfig;
|
|
2073
|
+
const raw = await this.apiRequest('GET', '/config/providers');
|
|
2074
|
+
const parsed = parseProvidersResponse(raw);
|
|
2075
|
+
cachedProviders = parsed;
|
|
2076
|
+
providersCacheTime = now;
|
|
2077
|
+
return parsed;
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* @description Variant names a `{providerID, modelID}` model exposes, or
|
|
2081
|
+
* `[]` when the ref is incomplete or the model declares no variants.
|
|
2082
|
+
*
|
|
2083
|
+
* Takes a model ref (not a session) so the no-session `setModel` path can
|
|
2084
|
+
* validate a freshly-picked model's variants before any session exists —
|
|
2085
|
+
* `getAvailableEffortLevels` resolves the ref from the live session.
|
|
2086
|
+
*/
|
|
2087
|
+
async getModelVariants(model) {
|
|
2088
|
+
const providerID = model?.providerID;
|
|
2089
|
+
const modelID = model?.modelID;
|
|
2090
|
+
if (!providerID || !modelID)
|
|
2091
|
+
return [];
|
|
2092
|
+
try {
|
|
2093
|
+
const config = await this.getProvidersConfig();
|
|
2094
|
+
return config.providers.get(providerID)?.get(modelID)?.variants ?? [];
|
|
2095
|
+
}
|
|
2096
|
+
catch (e) {
|
|
2097
|
+
console.warn(`[OpenCode] getProvidersConfig failed:`, e instanceof Error ? e.message : e);
|
|
2098
|
+
return [];
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* @description Resolve the live session's current model into a
|
|
2103
|
+
* `{providerID, modelID}` ref from the override (preferred) or the label.
|
|
2104
|
+
*/
|
|
2105
|
+
getSessionModelRef(session) {
|
|
2106
|
+
if (session.modelOverride)
|
|
2107
|
+
return session.modelOverride;
|
|
2108
|
+
const label = session.currentModelLabel;
|
|
2109
|
+
if (label && label.includes('/')) {
|
|
2110
|
+
return {
|
|
2111
|
+
providerID: label.slice(0, label.indexOf('/')),
|
|
2112
|
+
modelID: label.slice(label.indexOf('/') + 1),
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
return null;
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* @description Seed the bot's default reasoning effort onto a freshly
|
|
2119
|
+
* started/resumed session that has NO explicit per-thread `/effort` pref.
|
|
2120
|
+
*
|
|
2121
|
+
* Explicit prefs always win and are left untouched (early return). With no
|
|
2122
|
+
* pref, the default (`defaultEffortLevel`) is clamped to the resolved
|
|
2123
|
+
* model's variants — OpenCode effort is a model variant and not every model
|
|
2124
|
+
* ships `xhigh`, so an unclamped value would POST an invalid `body.variant`.
|
|
2125
|
+
* A model with no variants resolves to `null` (no effort), which is correct.
|
|
2126
|
+
*
|
|
2127
|
+
* Never persisted: the default stays a derived fallback, the prefs file
|
|
2128
|
+
* remains "explicit choices only".
|
|
2129
|
+
*/
|
|
2130
|
+
async applyDefaultEffortIfUnset(key) {
|
|
2131
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2132
|
+
if (!session?.isActive)
|
|
2133
|
+
return;
|
|
2134
|
+
if (loadSavedEffort(key) !== null)
|
|
2135
|
+
return; // explicit pref wins
|
|
2136
|
+
const variants = await this.getModelVariants(this.getSessionModelRef(session));
|
|
2137
|
+
session.effortLevel = (0, effortLevels_1.clampEffortToAvailable)(effortLevels_1.defaultEffortLevel, variants);
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* @description Resolve the model the thread's effort applies to, WITHOUT
|
|
2141
|
+
* requiring a live session — the prospective model the NEXT session will
|
|
2142
|
+
* use. Priority mirrors `fetchModelInfo`'s resolution order so the
|
|
2143
|
+
* pre-session effort surface lines up with what a started session would
|
|
2144
|
+
* actually run:
|
|
2145
|
+
* 1. live session's model ref (active session wins), else
|
|
2146
|
+
* 2. the thread's saved `/model` pref (`loadSavedModel`), else
|
|
2147
|
+
* 3. the OpenCode server's default model (`GET /config`
|
|
2148
|
+
* `defaultModel` / `model`).
|
|
2149
|
+
* Returns `null` only when none of these yield a complete `{providerID,
|
|
2150
|
+
* modelID}` ref, so `/effort` pre-session validates against the same model
|
|
2151
|
+
* `/model` would persist.
|
|
2152
|
+
*/
|
|
2153
|
+
async getProspectiveModelRef(key) {
|
|
2154
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2155
|
+
if (session?.isActive) {
|
|
2156
|
+
const liveRef = this.getSessionModelRef(session);
|
|
2157
|
+
if (liveRef)
|
|
2158
|
+
return liveRef;
|
|
2159
|
+
}
|
|
2160
|
+
const saved = loadSavedModel(key);
|
|
2161
|
+
if (saved)
|
|
2162
|
+
return saved;
|
|
2163
|
+
// No session and no saved pref → fall back to the server's default model,
|
|
2164
|
+
// resolved the SAME way `fetchModelInfo` does (defaultModel, then a
|
|
2165
|
+
// `provider/model` config string). Best-effort: a sick/booting server
|
|
2166
|
+
// just yields null and the picker reports "no levels".
|
|
2167
|
+
try {
|
|
2168
|
+
const config = await this.apiRequest('GET', '/config');
|
|
2169
|
+
if (config?.defaultModel?.providerID && config?.defaultModel?.modelID) {
|
|
2170
|
+
return { providerID: config.defaultModel.providerID, modelID: config.defaultModel.modelID };
|
|
2171
|
+
}
|
|
2172
|
+
if (config?.model) {
|
|
2173
|
+
const slashIdx = config.model.indexOf('/');
|
|
2174
|
+
if (slashIdx > 0) {
|
|
2175
|
+
return {
|
|
2176
|
+
providerID: config.model.slice(0, slashIdx),
|
|
2177
|
+
modelID: config.model.slice(slashIdx + 1),
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
catch (e) {
|
|
2183
|
+
console.warn(`[OpenCode] getProspectiveModelRef config lookup failed:`, e instanceof Error ? e.message : e);
|
|
2184
|
+
}
|
|
2185
|
+
return null;
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* @description Effort levels the thread can use: exactly the variants its
|
|
2189
|
+
* model declares. Works pre-session too — with no live session it resolves
|
|
2190
|
+
* the PROSPECTIVE model (saved `/model` pref → server default) so the
|
|
2191
|
+
* `/effort` picker can list the next session's variants before `/opencode`,
|
|
2192
|
+
* mirroring how `/model` works pre-session. Empty when the (live or
|
|
2193
|
+
* prospective) model declares no variants or can't be resolved.
|
|
2194
|
+
*/
|
|
2195
|
+
async getAvailableEffortLevels(key) {
|
|
2196
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2197
|
+
if (session?.isActive) {
|
|
2198
|
+
return this.getModelVariants(this.getSessionModelRef(session));
|
|
2199
|
+
}
|
|
2200
|
+
return this.getModelVariants(await this.getProspectiveModelRef(key));
|
|
2201
|
+
}
|
|
2202
|
+
getEffort(key) {
|
|
2203
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2204
|
+
if (session)
|
|
2205
|
+
return session.effortLevel;
|
|
2206
|
+
return loadSavedEffort(key);
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* @description Set the per-thread effort (variant). Validates against the
|
|
2210
|
+
* thread's available variants — live model when a session is running, the
|
|
2211
|
+
* PROSPECTIVE model (saved `/model` pref → server default) when not — then
|
|
2212
|
+
* persists on success (applied per-prompt by `sendPromptAsync`, plan D3; a
|
|
2213
|
+
* later session seeds `effortLevel` from this pref at creation). Returns a
|
|
2214
|
+
* user-facing notice string on any non-success, `null` on success.
|
|
2215
|
+
*
|
|
2216
|
+
* Mirrors `setModel`'s persist-first symmetry (no `'No active session'`
|
|
2217
|
+
* hard-fail): a level picked BEFORE `/opencode` is saved and replayed by the
|
|
2218
|
+
* next session, instead of being lost — same intent as the model pref.
|
|
2219
|
+
*/
|
|
2220
|
+
async setEffort(key, level) {
|
|
2221
|
+
// Session-free capable: resolves variants from the live model if active,
|
|
2222
|
+
// else the prospective model the next session will use.
|
|
2223
|
+
const available = await this.getAvailableEffortLevels(key);
|
|
2224
|
+
if (available.length === 0) {
|
|
2225
|
+
// Name the model the validation ran against — the live label if a
|
|
2226
|
+
// session is up, else the prospective model — so the notice is correct
|
|
2227
|
+
// pre-session (the live `currentModelLabel` is null then).
|
|
2228
|
+
const modelLabel = await this.getEffortModelLabel(key);
|
|
2229
|
+
return (0, i18n_1.t)('effort.not_supported', { model: modelLabel });
|
|
2230
|
+
}
|
|
2231
|
+
if (!available.includes(level)) {
|
|
2232
|
+
return (0, i18n_1.t)('effort.invalid_level', { level, valid: available.join(', ') });
|
|
2233
|
+
}
|
|
2234
|
+
// Persist unconditionally (like `setModel`'s `saveModelPref`): a session
|
|
2235
|
+
// started later seeds `effortLevel: loadSavedEffort(key)` at creation.
|
|
2236
|
+
saveEffortPref(key, level);
|
|
2237
|
+
// Live session → also apply now so the in-flight session honours it on the
|
|
2238
|
+
// next prompt without waiting for a restart.
|
|
2239
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2240
|
+
if (session?.isActive)
|
|
2241
|
+
session.effortLevel = level;
|
|
2242
|
+
console.log(`[OpenCode] Effort set to: ${level}`);
|
|
2243
|
+
return null;
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* @description Human label for the model the thread's effort validates
|
|
2247
|
+
* against — the live session's label when active, else the prospective
|
|
2248
|
+
* model's `provider/model` — for the `effort.not_supported` notice. `'?'`
|
|
2249
|
+
* only when no model can be resolved at all.
|
|
2250
|
+
*/
|
|
2251
|
+
async getEffortModelLabel(key) {
|
|
2252
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2253
|
+
if (session?.isActive && session.currentModelLabel)
|
|
2254
|
+
return session.currentModelLabel;
|
|
2255
|
+
const prospective = await this.getProspectiveModelRef(key);
|
|
2256
|
+
return prospective ? `${prospective.providerID}/${prospective.modelID}` : '?';
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* @description Manually rename the live session via `PATCH /session/:id
|
|
2260
|
+
* { title }`, scoped to the session's owning project instance
|
|
2261
|
+
* (`?directory=<workDir>`) so it hits the same instance the session was
|
|
2262
|
+
* created in. Reuses {@link apiRequest} — no duplicate HTTP code.
|
|
2263
|
+
*
|
|
2264
|
+
* A manual rename is final: it clears `isAutoNamePending` so the bot-side
|
|
2265
|
+
* auto-name fallback can never overwrite the user's title later (the
|
|
2266
|
+
* fallback only fires while that flag is set — see
|
|
2267
|
+
* {@link maybeScheduleFallbackRename}).
|
|
2268
|
+
*/
|
|
2269
|
+
async renameSession(key, title) {
|
|
2270
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2271
|
+
if (!session?.isActive)
|
|
2272
|
+
return (0, i18n_1.t)('rename_session.start_agent_first');
|
|
2273
|
+
// The user explicitly named it — auto-title must never win over a manual
|
|
2274
|
+
// rename, so retire the fallback for this session regardless of the PATCH
|
|
2275
|
+
// outcome.
|
|
2276
|
+
session.isAutoNamePending = false;
|
|
2277
|
+
try {
|
|
2278
|
+
await this.apiRequest('PATCH', buildDirectoryScopedPath(`/session/${session.sessionId}`, session.workDir), { title });
|
|
2279
|
+
console.log(`[OpenCode] Renamed session ${session.sessionId} to "${title}"`);
|
|
2280
|
+
return null;
|
|
2281
|
+
}
|
|
2282
|
+
catch (e) {
|
|
2283
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
2284
|
+
console.warn(`[OpenCode] manual session rename failed:`, reason);
|
|
2285
|
+
return (0, i18n_1.t)('rename_session.failed', { reason });
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* @description Compact the live session's context via `POST /session/:id/
|
|
2290
|
+
* summarize`, scoped to the session's owning project instance
|
|
2291
|
+
* (`?directory=<workDir>`) so it hits the same instance the session was
|
|
2292
|
+
* created in. Reuses {@link apiRequest} — no duplicate HTTP code.
|
|
2293
|
+
*
|
|
2294
|
+
* This is the REAL compaction path. `prompt_async` (the only other transport)
|
|
2295
|
+
* does no slash-command parsing server-side, so forwarding the literal
|
|
2296
|
+
* `/compact` text there just burned a model turn answering a two-word prompt
|
|
2297
|
+
* — the bug this method fixes.
|
|
2298
|
+
*
|
|
2299
|
+
* `summarize` runs a summarisation turn, so it REQUIRES a complete model ref:
|
|
2300
|
+
* the session's `modelOverride` (populated from the server default at session
|
|
2301
|
+
* start) wins, else {@link getProspectiveModelRef} resolves it exactly the way
|
|
2302
|
+
* `/effort` does (saved `/model` pref → server `defaultModel`). Unresolvable →
|
|
2303
|
+
* a notice, never a partial/empty `providerID`/`modelID`.
|
|
2304
|
+
*
|
|
2305
|
+
* `auto` is deliberately omitted: this is a MANUAL compaction and the server
|
|
2306
|
+
* defaults the flag to false (the automatic, overflow-triggered compaction is
|
|
2307
|
+
* server-side and untouched by this).
|
|
2308
|
+
*/
|
|
2309
|
+
async compactContext(key, instruction) {
|
|
2310
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2311
|
+
if (!session?.isActive)
|
|
2312
|
+
return { ok: false, error: (0, i18n_1.t)('compact.start_agent_first') };
|
|
2313
|
+
const modelRef = session.modelOverride ?? (await this.getProspectiveModelRef(key));
|
|
2314
|
+
if (!modelRef)
|
|
2315
|
+
return { ok: false, error: (0, i18n_1.t)('compact.model_unresolved') };
|
|
2316
|
+
// The fork's summarize endpoint appends `instruction` to its baked
|
|
2317
|
+
// compaction prompt (F2/S6) — only send it when non-empty so an ordinary
|
|
2318
|
+
// `/compact` stays byte-identical to before.
|
|
2319
|
+
const trimmedInstruction = instruction?.trim();
|
|
2320
|
+
try {
|
|
2321
|
+
await this.apiRequest('POST', buildDirectoryScopedPath(`/session/${session.sessionId}/summarize`, session.workDir), {
|
|
2322
|
+
providerID: modelRef.providerID,
|
|
2323
|
+
modelID: modelRef.modelID,
|
|
2324
|
+
...(trimmedInstruction ? { instruction: trimmedInstruction } : {}),
|
|
2325
|
+
});
|
|
2326
|
+
console.log(`[OpenCode] Compacting session ${session.sessionId} with ${modelRef.providerID}/${modelRef.modelID}`);
|
|
2327
|
+
// Counts are always `null` here: `/summarize` reports none, so the bot's
|
|
2328
|
+
// completion message drops the numbers rather than inventing them.
|
|
2329
|
+
return { ok: true, preTokens: null, postTokens: null };
|
|
2330
|
+
}
|
|
2331
|
+
catch (e) {
|
|
2332
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
2333
|
+
console.warn(`[OpenCode] context compaction failed:`, reason);
|
|
2334
|
+
return { ok: false, error: (0, i18n_1.t)('compact.failed', { reason }) };
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
2338
|
+
* @description Whether this thread's directory instance loaded the bot's
|
|
2339
|
+
* compaction plugin, read from its merged config (`GET /config`, directory
|
|
2340
|
+
* scoped). An instance created before the plugin was installed lacks it until
|
|
2341
|
+
* it is recreated, and then the bot must keep sending the skills guidance
|
|
2342
|
+
* itself. A read failure answers `false`: the guidance then appears twice,
|
|
2343
|
+
* which beats not at all.
|
|
2344
|
+
*/
|
|
2345
|
+
async checkHasCompactionSkillsHook(key) {
|
|
2346
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2347
|
+
if (!session?.isActive)
|
|
2348
|
+
return false;
|
|
2349
|
+
try {
|
|
2350
|
+
const config = await this.apiRequest('GET', buildDirectoryScopedPath('/config', session.workDir));
|
|
2351
|
+
return (0, openCodeCompactPlugin_1.checkHasOpenCodeCompactPlugin)(config?.plugin);
|
|
2352
|
+
}
|
|
2353
|
+
catch (e) {
|
|
2354
|
+
console.warn(`[OpenCode] reading the server config failed:`, e instanceof Error ? e.message : e);
|
|
2355
|
+
return false;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* @description Read the newest compaction summary for the live session via
|
|
2360
|
+
* `GET /session/:id/message` (directory-scoped) and lift its text out with the
|
|
2361
|
+
* pure {@link getLatestOpenCodeCompactionSummary}. Used by F2 to surface the
|
|
2362
|
+
* appended "Where we stopped" closing section in the idle-compaction notice. A
|
|
2363
|
+
* read failure / no summary yields `null` (the notice then omits the block).
|
|
2364
|
+
*/
|
|
2365
|
+
async getLatestCompactionSummary(key) {
|
|
2366
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2367
|
+
if (!session?.isActive)
|
|
2368
|
+
return null;
|
|
2369
|
+
try {
|
|
2370
|
+
const records = await this.apiRequest('GET', buildDirectoryScopedPath(`/session/${session.sessionId}/message`, session.workDir));
|
|
2371
|
+
return getLatestOpenCodeCompactionSummary(records);
|
|
2372
|
+
}
|
|
2373
|
+
catch (e) {
|
|
2374
|
+
console.warn(`[OpenCode] reading compaction summary failed:`, e instanceof Error ? e.message : e);
|
|
2375
|
+
return null;
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
getOpenCodeSessionId(key) {
|
|
2379
|
+
return this.sessions.get((0, sessionKey_1.keyToString)(key))?.sessionId ?? null;
|
|
2380
|
+
}
|
|
2381
|
+
// Folder-scoped listing: `GET /session?directory=<workDir>` returns only the
|
|
2382
|
+
// sessions of the bound folder's project instance. The topic is always bound
|
|
2383
|
+
// before this is reached (the bot gates `/sessions` on a binding), so workDir
|
|
2384
|
+
// is the real working folder, never a serve-cwd fallback. Sessions created in
|
|
2385
|
+
// other instances (e.g. by-hand serve-cwd scatter) are intentionally absent.
|
|
2386
|
+
async getSessions(_key, workDir) {
|
|
2387
|
+
try {
|
|
2388
|
+
const apiSessions = await this.apiRequest('GET', buildDirectoryScopedPath('/session', workDir));
|
|
2389
|
+
if (!Array.isArray(apiSessions))
|
|
2390
|
+
return [];
|
|
2391
|
+
return apiSessions.map(s => ({
|
|
2392
|
+
id: s.id,
|
|
2393
|
+
title: s.title || s.id,
|
|
2394
|
+
// `time.created`/`time.updated` are already epoch MILLISECONDS
|
|
2395
|
+
// (13-digit values verified live via GET /session). The old `* 1000`
|
|
2396
|
+
// pushed every date millennia into the future, so the /sessions list
|
|
2397
|
+
// showed "(just now)" for every entry (B13).
|
|
2398
|
+
createdAt: s.time?.created ? new Date(s.time.created) : new Date(),
|
|
2399
|
+
updatedAt: s.time?.updated ? new Date(s.time.updated) : new Date(),
|
|
2400
|
+
}));
|
|
2401
|
+
}
|
|
2402
|
+
catch (e) {
|
|
2403
|
+
console.error(`[OpenCode] Failed to get sessions:`, e);
|
|
2404
|
+
return [];
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* @description Read the last `limit` conversational turns of a session for the
|
|
2409
|
+
* resume context block via `GET /session/:id/message`. `key`/`workDir` are
|
|
2410
|
+
* unused — OpenCode scopes messages by session id, server-side. Maps the raw
|
|
2411
|
+
* records with the pure {@link mapOpenCodeMessagesToTurns}; a request failure
|
|
2412
|
+
* yields `[]`, so the caller posts no context block.
|
|
2413
|
+
*/
|
|
2414
|
+
async getRecentTurns(_key, _workDir, sessionId, limit) {
|
|
2415
|
+
try {
|
|
2416
|
+
const records = await this.apiRequest('GET', `/session/${sessionId}/message`);
|
|
2417
|
+
return mapOpenCodeMessagesToTurns(records, limit);
|
|
2418
|
+
}
|
|
2419
|
+
catch (e) {
|
|
2420
|
+
console.error(`[OpenCode] Failed to read recent turns:`, e);
|
|
2421
|
+
return [];
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
/**
|
|
2425
|
+
* @description Assemble the silent-reattach recap for OpenCode: ONE
|
|
2426
|
+
* `GET /session/:id/message` read serves both the missed-message count (since
|
|
2427
|
+
* the watermark id) and the last-{@link resumeContextTurnLimit} turns. A read
|
|
2428
|
+
* failure degrades to an empty, watermark-unknown recap (the caller posts
|
|
2429
|
+
* nothing). The watermark only counts when its `sessionId` matches the session
|
|
2430
|
+
* being recapped — a stale watermark from a different session (`/new`,
|
|
2431
|
+
* `/sessions` resume keep the agent row) is treated as unknown. `isActive` is
|
|
2432
|
+
* derived from the fetched records (last record = an unfinished assistant
|
|
2433
|
+
* turn), never a blocking server probe.
|
|
2434
|
+
*/
|
|
2435
|
+
async getReattachRecap(_key, _workDir, sessionId, watermark) {
|
|
2436
|
+
let records;
|
|
2437
|
+
try {
|
|
2438
|
+
records = await this.apiRequest('GET', `/session/${sessionId}/message`);
|
|
2439
|
+
}
|
|
2440
|
+
catch (e) {
|
|
2441
|
+
console.error(`[OpenCode] getReattachRecap read failed:`, e);
|
|
2442
|
+
return { missedCount: 0, turns: [], isWatermarkKnown: false, isActive: false };
|
|
2443
|
+
}
|
|
2444
|
+
// Only trust the watermark id when it was taken against THIS session.
|
|
2445
|
+
const watermarkId = watermark?.sessionId === sessionId ? watermark.opencodeMessageId : undefined;
|
|
2446
|
+
const { missedCount, isWatermarkKnown } = countOpenCodeAssistantMessagesSinceId(records, watermarkId);
|
|
2447
|
+
const turns = mapOpenCodeMessagesToTurns(records, resumeContext_1.resumeContextTurnLimit);
|
|
2448
|
+
const isActive = checkIsOpenCodeTurnInFlight(records);
|
|
2449
|
+
// Head watermark = the latest assistant message id, scoped to THIS session.
|
|
2450
|
+
// Omitted when none resolvable (head unknown → no idempotent advance).
|
|
2451
|
+
const latestId = getLatestOpenCodeAssistantMessageId(records);
|
|
2452
|
+
const headWatermark = latestId === undefined ? undefined : { sessionId, opencodeMessageId: latestId };
|
|
2453
|
+
return { missedCount, turns, isWatermarkKnown, isActive, headWatermark };
|
|
2454
|
+
}
|
|
2455
|
+
async resumeSession(key, workDir, sessionId, options) {
|
|
2456
|
+
// `workDir` is now an explicit argument from the bot, sourced from the
|
|
2457
|
+
// thread's binding in state.json. The old code defaulted to
|
|
2458
|
+
// `process.env.WORK_DIR || '/workspace'`, which silently mis-routed
|
|
2459
|
+
// resumes to the wrong folder as soon as the bot started managing
|
|
2460
|
+
// multiple bindings (plan §10.3, fix to old openCodeAdapter.ts:599).
|
|
2461
|
+
const k = (0, sessionKey_1.keyToString)(key);
|
|
2462
|
+
return this.withLifecycleLock(k, () => this.resumeSessionInner(key, workDir, sessionId, options));
|
|
2463
|
+
}
|
|
2464
|
+
/**
|
|
2465
|
+
* @description Fork the thread's CURRENT session into a new one that carries
|
|
2466
|
+
* the full conversation, and attach to it. Used by the bot's wedged-turn
|
|
2467
|
+
* recovery (tier 2) to restore the last dialog into a fresh, unwedged session
|
|
2468
|
+
* before replaying the prompt — so a stuck session doesn't lose context. The
|
|
2469
|
+
* new session's id is exposed via {@link getOpenCodeSessionId}, so the bot
|
|
2470
|
+
* persists it with the shared `persistAdapterSessionIds`. Returns the new
|
|
2471
|
+
* session id, or null when there is no active session or the fork failed.
|
|
2472
|
+
*/
|
|
2473
|
+
async forkSession(key) {
|
|
2474
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2475
|
+
if (!session?.isActive)
|
|
2476
|
+
return null;
|
|
2477
|
+
const { workDir, sessionId } = session;
|
|
2478
|
+
try {
|
|
2479
|
+
const forked = await this.apiRequest('POST', `/session/${sessionId}/fork`);
|
|
2480
|
+
if (!forked?.id)
|
|
2481
|
+
return null;
|
|
2482
|
+
// resumeSession (locked) stops the wedged session and attaches to the fork
|
|
2483
|
+
// — same machinery as a /sessions pick, so SSE routing + watermarking are
|
|
2484
|
+
// re-seeded for the new id.
|
|
2485
|
+
await this.resumeSession(key, workDir, forked.id);
|
|
2486
|
+
console.log(`[OpenCode] Forked ${sessionId} -> ${forked.id} for ${(0, sessionKey_1.keyToString)(key)} (dialog preserved)`);
|
|
2487
|
+
return forked.id;
|
|
2488
|
+
}
|
|
2489
|
+
catch (e) {
|
|
2490
|
+
console.error(`[OpenCode] forkSession failed:`, e instanceof Error ? e.message : e);
|
|
2491
|
+
return null;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
/**
|
|
2495
|
+
* @description Detach any RUNNING sub-agent that is synchronously blocking the
|
|
2496
|
+
* thread's session, so a new prompt can be answered while the sub-agent keeps
|
|
2497
|
+
* working in the background (OpenCode injects its result back when it finishes).
|
|
2498
|
+
* No-op (returns false) when the session is inactive or has no busy sub-agent.
|
|
2499
|
+
* Needs the server started with `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`
|
|
2500
|
+
* (the bot always sets it); if not enabled the endpoint 400s and we swallow it.
|
|
2501
|
+
* Best-effort — a failure must never block the prompt that follows it.
|
|
2502
|
+
*/
|
|
2503
|
+
async detachRunningSubagents(key) {
|
|
2504
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2505
|
+
if (!session?.isActive || session.busyChildSessionIds.size === 0)
|
|
2506
|
+
return false;
|
|
2507
|
+
try {
|
|
2508
|
+
const dir = encodeURIComponent(session.workDir);
|
|
2509
|
+
const result = await this.apiRequest('POST', `/experimental/session/${session.sessionId}/background?directory=${dir}`);
|
|
2510
|
+
if (result)
|
|
2511
|
+
console.log(`[OpenCode] Backgrounded sub-agent(s) for ${(0, sessionKey_1.keyToString)(key)} to unblock the session`);
|
|
2512
|
+
return result === true;
|
|
2513
|
+
}
|
|
2514
|
+
catch (e) {
|
|
2515
|
+
console.warn(`[OpenCode] detachRunningSubagents failed:`, e instanceof Error ? e.message : e);
|
|
2516
|
+
return false;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
async resumeSessionInner(key, workDir, sessionId, options) {
|
|
2520
|
+
this.stopSessionInner(key);
|
|
2521
|
+
const k = (0, sessionKey_1.keyToString)(key);
|
|
2522
|
+
if (!(0, installManager_1.checkIsInstalled)('opencode')) {
|
|
2523
|
+
this.emit('output', key, 'Installing OpenCode...');
|
|
2524
|
+
await (0, installManager_1.installTool)('opencode');
|
|
2525
|
+
}
|
|
2526
|
+
if (!await (0, installManager_1.checkIsOpenCodeServerRunning)()) {
|
|
2527
|
+
this.emit('output', key, 'Starting OpenCode server...');
|
|
2528
|
+
await (0, installManager_1.ensureOpenCodeServer)();
|
|
2529
|
+
}
|
|
2530
|
+
console.log(`[OpenCode] Resuming session ${sessionId} for ${k} in ${workDir}`);
|
|
2531
|
+
try {
|
|
2532
|
+
// Verify session exists
|
|
2533
|
+
const apiSession = await this.apiRequest('GET', `/session/${sessionId}`);
|
|
2534
|
+
const session = {
|
|
2535
|
+
key,
|
|
2536
|
+
sessionId: apiSession.id,
|
|
2537
|
+
workDir,
|
|
2538
|
+
isActive: true,
|
|
2539
|
+
currentResponseText: '',
|
|
2540
|
+
lastEmittedLength: 0,
|
|
2541
|
+
outputTimer: null,
|
|
2542
|
+
compactionSummaryMessageId: null,
|
|
2543
|
+
isResponseCompactionSummary: false,
|
|
2544
|
+
childResponseText: '',
|
|
2545
|
+
childLastEmittedLength: 0,
|
|
2546
|
+
childOutputTimer: null,
|
|
2547
|
+
activeSubagentTitle: null,
|
|
2548
|
+
isModelInfoShown: false,
|
|
2549
|
+
modelOverride: null,
|
|
2550
|
+
currentModelLabel: null,
|
|
2551
|
+
latestParentRuntimeContext: null,
|
|
2552
|
+
parentAssistantObservationVersion: 0,
|
|
2553
|
+
partTypes: new Map(),
|
|
2554
|
+
statusDebounceTimer: null,
|
|
2555
|
+
pendingStatus: null,
|
|
2556
|
+
reasoningText: '',
|
|
2557
|
+
reasoningStartedAt: null,
|
|
2558
|
+
reasoningTimer: null,
|
|
2559
|
+
emittedToolResultPartIds: new Set(),
|
|
2560
|
+
pendingQuestion: null,
|
|
2561
|
+
effortLevel: loadSavedEffort(key),
|
|
2562
|
+
isBusy: false,
|
|
2563
|
+
awaitingTurnResponse: false,
|
|
2564
|
+
sawTurnActivity: false,
|
|
2565
|
+
unconsumedInputCount: 0,
|
|
2566
|
+
seenUserMessageIds: new Set(),
|
|
2567
|
+
providerRetrySignature: null,
|
|
2568
|
+
isAwaitingModelAfterProviderRetryAbort: false,
|
|
2569
|
+
isAwaitingProviderRetryAbortIdle: false,
|
|
2570
|
+
isAwaitingProviderRetryReplacementStart: false,
|
|
2571
|
+
providerRetryReplacementStartTimer: null,
|
|
2572
|
+
providerRetryAbortPromise: null,
|
|
2573
|
+
isCompacting: false,
|
|
2574
|
+
busyChildSessionIds: new Set(),
|
|
2575
|
+
// The watermark anchor is re-learned from the next finished turn; the
|
|
2576
|
+
// persisted watermark (read by the bot for the reattach recap) is
|
|
2577
|
+
// independent of this fresh-session field.
|
|
2578
|
+
lastMessageId: undefined,
|
|
2579
|
+
// A resumed session already has a name and history — never auto-rename.
|
|
2580
|
+
isAutoNamePending: false,
|
|
2581
|
+
};
|
|
2582
|
+
this.detachDuplicateSessionOwners(k, session.sessionId);
|
|
2583
|
+
this.sessions.set(k, session);
|
|
2584
|
+
this.connectSse(key);
|
|
2585
|
+
// Ready BEFORE model resolution (B18): a busy server can stall GET /config
|
|
2586
|
+
// for tens of seconds; the topic must not wait on it for the reattach
|
|
2587
|
+
// notice. Session + SSE are live here.
|
|
2588
|
+
this.emit('started', key);
|
|
2589
|
+
// History is only a fallback for context that predates this attachment.
|
|
2590
|
+
// It must not hold up the resumed session's ready signal or post a recap.
|
|
2591
|
+
void this.hydrateLatestParentAssistantContext(key, session);
|
|
2592
|
+
// Re-surface a question still open on the server (G1): this method runs on
|
|
2593
|
+
// BOTH silent restart re-attach AND explicit /sessions resume, so an
|
|
2594
|
+
// unanswered question is recovered in either case. Runs UNCONDITIONALLY
|
|
2595
|
+
// (not gated on isWithRecentContext) because the silent reattach — the
|
|
2596
|
+
// path that auto-unsticks a wedged topic after a hot rebuild — passes it
|
|
2597
|
+
// false. Awaited so the buttons re-post before model resolution; it
|
|
2598
|
+
// never throws (best-effort inside).
|
|
2599
|
+
await this.restoreOpenQuestion(key, apiSession.id, workDir);
|
|
2600
|
+
// Post the short last-N-turn context block so a resume shows where the
|
|
2601
|
+
// conversation left off (parity with Claude — OpenCode has no flood, but
|
|
2602
|
+
// gets the same block). ONLY on the explicit user resume: this method
|
|
2603
|
+
// also runs on silent re-attach after every bot restart and on crash
|
|
2604
|
+
// recovery, and posting the block there spammed every active topic on
|
|
2605
|
+
// every hot rebuild. Best-effort: a read failure or empty history posts
|
|
2606
|
+
// nothing extra. Runs before model resolution for the same
|
|
2607
|
+
// responsiveness reason as `emit('started')` above.
|
|
2608
|
+
if (options?.isWithRecentContext) {
|
|
2609
|
+
try {
|
|
2610
|
+
const turns = await this.getRecentTurns(key, workDir, apiSession.id, resumeContext_1.resumeContextTurnLimit);
|
|
2611
|
+
const rendered = (0, resumeContext_1.formatResumeContext)(turns);
|
|
2612
|
+
// Complete one-shot block — post instantly, never via the dm draft
|
|
2613
|
+
// channel (its typing animation would "draw" this already-whole text).
|
|
2614
|
+
if (rendered)
|
|
2615
|
+
this.emit('output', key, rendered, { isComplete: true });
|
|
2616
|
+
}
|
|
2617
|
+
catch (e) {
|
|
2618
|
+
console.warn(`[OpenCode] resume context block failed:`, e instanceof Error ? e.message : e);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
// Re-resolve the model on every resume so a session that took its model
|
|
2622
|
+
// from the server default (no saved /model pref) keeps a populated
|
|
2623
|
+
// modelOverride/currentModelLabel after a bot restart — otherwise /effort
|
|
2624
|
+
// can't resolve the model and reports "levels unavailable" (B17).
|
|
2625
|
+
// Silent (emitOutput=false): the label is unchanged from the previous run
|
|
2626
|
+
// and already shown in the topic, so re-emitting on each restart is noise.
|
|
2627
|
+
await this.fetchModelInfo(key, false);
|
|
2628
|
+
// Re-establish the default effort on resume too (no explicit pref →
|
|
2629
|
+
// clamp xhigh to the re-resolved model's variants). Mirrors startSession.
|
|
2630
|
+
await this.applyDefaultEffortIfUnset(key);
|
|
2631
|
+
}
|
|
2632
|
+
catch (e) {
|
|
2633
|
+
console.error(`[OpenCode] Failed to resume session:`, e);
|
|
2634
|
+
throw e;
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
/**
|
|
2638
|
+
* @description Best-effort hydration for an idle resumed session's context
|
|
2639
|
+
* usage. A live SSE observation wins over this historical read even when the
|
|
2640
|
+
* history request resolves later.
|
|
2641
|
+
*/
|
|
2642
|
+
async hydrateLatestParentAssistantContext(key, session) {
|
|
2643
|
+
// Snapshot before waiting: a queued read must never overwrite an SSE tuple
|
|
2644
|
+
// that arrives while the semaphore is occupied by other resumed sessions.
|
|
2645
|
+
const observationVersion = session.parentAssistantObservationVersion;
|
|
2646
|
+
await this.acquireRuntimeContextHydrationSlot();
|
|
2647
|
+
try {
|
|
2648
|
+
const currentSession = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2649
|
+
if (currentSession !== session ||
|
|
2650
|
+
!currentSession.isActive ||
|
|
2651
|
+
currentSession.parentAssistantObservationVersion !== observationVersion) {
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
const records = await this.apiRequest('GET', `/session/${session.sessionId}/message`);
|
|
2655
|
+
const latestContext = getLatestOpenCodeParentAssistantContext(records, session.sessionId);
|
|
2656
|
+
if (!latestContext)
|
|
2657
|
+
return;
|
|
2658
|
+
const currentSessionAfterRead = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2659
|
+
if (currentSessionAfterRead !== session ||
|
|
2660
|
+
!currentSessionAfterRead.isActive ||
|
|
2661
|
+
currentSessionAfterRead.parentAssistantObservationVersion !== observationVersion) {
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
currentSessionAfterRead.latestParentRuntimeContext = latestContext;
|
|
2665
|
+
}
|
|
2666
|
+
catch (error) {
|
|
2667
|
+
console.warn(`[OpenCode] context hydration failed for ${(0, sessionKey_1.keyToString)(key)}:`, error instanceof Error ? error.message : error);
|
|
2668
|
+
}
|
|
2669
|
+
finally {
|
|
2670
|
+
this.releaseRuntimeContextHydrationSlot();
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
/** @description Wait for a bounded slot before fetching optional history metadata. */
|
|
2674
|
+
async acquireRuntimeContextHydrationSlot() {
|
|
2675
|
+
if (this.runtimeContextHydrationCount < exports.openCodeRuntimeContextHydrationConcurrency) {
|
|
2676
|
+
this.runtimeContextHydrationCount += 1;
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
await new Promise((resolve) => {
|
|
2680
|
+
this.runtimeContextHydrationWaiters.push(resolve);
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
/** @description Hand a completed metadata-read slot to the next queued session. */
|
|
2684
|
+
releaseRuntimeContextHydrationSlot() {
|
|
2685
|
+
const nextWaiter = this.runtimeContextHydrationWaiters.shift();
|
|
2686
|
+
if (nextWaiter) {
|
|
2687
|
+
nextWaiter();
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
this.runtimeContextHydrationCount -= 1;
|
|
2691
|
+
}
|
|
2692
|
+
/**
|
|
2693
|
+
* @description Ensure the single global SSE stream is open and the session's
|
|
2694
|
+
* directory has its scheduler MCP registered (plan 2026-06-17). Called after
|
|
2695
|
+
* a session is inserted active into `this.sessions` (start / resume / restart).
|
|
2696
|
+
* The stream is shared by ALL sessions, so it opens only for the FIRST active
|
|
2697
|
+
* session anywhere; later calls reuse it. Scheduler-MCP registration is per
|
|
2698
|
+
* directory (decoupled from the stream), gated to once per dir per server
|
|
2699
|
+
* generation. Keeps the `(key)` signature so the lifecycle paths and tests
|
|
2700
|
+
* (which stub `connectSse` / `pollSseStream`) are unchanged.
|
|
2701
|
+
*/
|
|
2702
|
+
connectSse(key) {
|
|
2703
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2704
|
+
if (!session)
|
|
2705
|
+
return;
|
|
2706
|
+
this.ensureGlobalStream();
|
|
2707
|
+
void this.registerSchedulerMcpForDirectory(session.workDir);
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Re-register active directories once the bot scheduler listener is ready,
|
|
2711
|
+
* for sessions that connected while injection was still inert. Boot binds
|
|
2712
|
+
* the listener before re-attaching sessions, so it does not call this.
|
|
2713
|
+
*/
|
|
2714
|
+
registerSchedulerMcpForActiveSessions() {
|
|
2715
|
+
for (const session of this.sessions.values()) {
|
|
2716
|
+
if (session.isActive) {
|
|
2717
|
+
void this.registerSchedulerMcpForDirectory(session.workDir);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* @description Boot self-heal for the adopt path. When the bot restarts and
|
|
2723
|
+
* ADOPTS an already-running opencode (the server outlived the bot), that
|
|
2724
|
+
* server still holds the PREVIOUS bot generation's `telegramBot` registration
|
|
2725
|
+
* pointing at a now-dead ephemeral port — so every session in the directory
|
|
2726
|
+
* reports the MCP `failed` and loses its scheduling / file-send tools. For
|
|
2727
|
+
* each active session's directory (de-duped — a folder can host several
|
|
2728
|
+
* threads), read the LIVE `GET /mcp` status and, when the `telegramBot` entry
|
|
2729
|
+
* is missing or not `connected`, drop the Set gate and force a fresh POST (the
|
|
2730
|
+
* server overwrites a same-name registration with the current port). A dir
|
|
2731
|
+
* already `connected` is left untouched. Best-effort per directory: a read /
|
|
2732
|
+
* registration error is logged and swallowed — it must never throw out of
|
|
2733
|
+
* boot. Supersedes {@link registerSchedulerMcpForActiveSessions} at the boot
|
|
2734
|
+
* call site (a not-yet-registered dir has no entry → treated as needing one).
|
|
2735
|
+
*/
|
|
2736
|
+
async reconcileSchedulerMcpForActiveSessions() {
|
|
2737
|
+
const directories = new Set();
|
|
2738
|
+
for (const session of this.sessions.values()) {
|
|
2739
|
+
if (session.isActive)
|
|
2740
|
+
directories.add(session.workDir);
|
|
2741
|
+
}
|
|
2742
|
+
// Reconcile every directory concurrently so one slow/unresponsive dir can't
|
|
2743
|
+
// delay the others; each is best-effort (never rejects), so allSettled is
|
|
2744
|
+
// just a join point.
|
|
2745
|
+
await Promise.allSettled(Array.from(directories, (directory) => this.reconcileSchedulerMcpForDirectory(directory)));
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* @description Boot step that makes every active directory's instance load the
|
|
2749
|
+
* bot's compaction plugin. OpenCode reads plugins once per directory instance,
|
|
2750
|
+
* so an instance created before the plugin file existed (a server that outlived
|
|
2751
|
+
* the bot, actagent's included) never gets it on its own. Installs the file,
|
|
2752
|
+
* then per directory (de-duped, concurrently) recreates the instance when it
|
|
2753
|
+
* lacks the plugin and nothing in it is running. A recreated directory loses
|
|
2754
|
+
* its runtime `telegramBot` registration, so its Set gate is dropped here and
|
|
2755
|
+
* the boot caller reconciles the MCP right after.
|
|
2756
|
+
*/
|
|
2757
|
+
async activateCompactionPluginForActiveSessions() {
|
|
2758
|
+
const directories = new Set();
|
|
2759
|
+
for (const session of this.sessions.values()) {
|
|
2760
|
+
if (session.isActive)
|
|
2761
|
+
directories.add(session.workDir);
|
|
2762
|
+
}
|
|
2763
|
+
if (directories.size === 0)
|
|
2764
|
+
return;
|
|
2765
|
+
if ((0, openCodeCompactPlugin_1.installOpenCodeCompactPlugin)() === 'failed')
|
|
2766
|
+
return;
|
|
2767
|
+
await Promise.allSettled(Array.from(directories, (directory) => this.activateCompactionPluginForDirectory(directory)));
|
|
2768
|
+
}
|
|
2769
|
+
/**
|
|
2770
|
+
* @description One directory of {@link activateCompactionPluginForActiveSessions}:
|
|
2771
|
+
* read its instance's plugin list and session status, and recreate it
|
|
2772
|
+
* (`POST /instance/dispose`) only when the plugin is missing and every session
|
|
2773
|
+
* is idle. Best-effort — errors are logged and swallowed.
|
|
2774
|
+
*/
|
|
2775
|
+
async activateCompactionPluginForDirectory(directory) {
|
|
2776
|
+
try {
|
|
2777
|
+
const config = await this.apiRequest('GET', buildDirectoryScopedPath('/config', directory));
|
|
2778
|
+
const sessionStatus = await this.apiRequest('GET', buildDirectoryScopedPath('/session/status', directory));
|
|
2779
|
+
const activation = (0, openCodeCompactPlugin_1.getCompactPluginActivation)({ pluginSpecs: config?.plugin, sessionStatus });
|
|
2780
|
+
if (activation === 'loaded')
|
|
2781
|
+
return;
|
|
2782
|
+
if (activation === 'busy') {
|
|
2783
|
+
console.log(`[OpenCode] compaction plugin not loaded in ${directory} yet; it is busy, the next boot retries`);
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
await this.apiRequest('POST', buildDirectoryScopedPath('/instance/dispose', directory));
|
|
2787
|
+
this.registeredSchedulerMcpDirs.delete(directory);
|
|
2788
|
+
console.log(`[OpenCode] recreated the ${directory} instance so it loads the compaction plugin`);
|
|
2789
|
+
}
|
|
2790
|
+
catch (e) {
|
|
2791
|
+
console.warn(`[OpenCode] compaction plugin activation failed for ${directory}:`, e instanceof Error ? e.message : e);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* @description Reconcile ONE directory: read the live `GET /mcp` status and,
|
|
2796
|
+
* when `telegramBot` is missing or not `connected`, drop the Set gate and
|
|
2797
|
+
* force a fresh POST. Best-effort — a read/registration error is logged,
|
|
2798
|
+
* scheduled for a retry, and swallowed so it never rejects out of
|
|
2799
|
+
* {@link reconcileSchedulerMcpForActiveSessions}.
|
|
2800
|
+
*/
|
|
2801
|
+
async reconcileSchedulerMcpForDirectory(directory) {
|
|
2802
|
+
try {
|
|
2803
|
+
const mcpStatus = await this.apiRequest('GET', buildDirectoryScopedPath('/mcp', directory));
|
|
2804
|
+
if (!checkNeedsSchedulerMcpReregister(mcpStatus)) {
|
|
2805
|
+
// A registration that timed out can still have landed on the server.
|
|
2806
|
+
this.clearSchedulerMcpRetry(directory);
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
// The live server contradicts the Set gate (stale entry from a prior
|
|
2810
|
+
// generation): clear it so the re-POST actually fires.
|
|
2811
|
+
this.registeredSchedulerMcpDirs.delete(directory);
|
|
2812
|
+
await this.registerSchedulerMcpForDirectory(directory);
|
|
2813
|
+
}
|
|
2814
|
+
catch (e) {
|
|
2815
|
+
console.warn(`[OpenCode] scheduler MCP reconcile failed for ${directory}:`, e instanceof Error ? e.message : e);
|
|
2816
|
+
this.scheduleSchedulerMcpRetry(directory);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
/**
|
|
2820
|
+
* @description Retry a failed scheduler-MCP registration for `directory` after
|
|
2821
|
+
* the next pause of {@link schedulerMcpRetryDelaysMs}. The retry runs the
|
|
2822
|
+
* reconcile rather than a bare POST: a registration that timed out may have
|
|
2823
|
+
* landed after all, and then nothing needs sending. A retry already pending
|
|
2824
|
+
* covers a second failure, and one that fires after the folder's last session
|
|
2825
|
+
* ended does nothing.
|
|
2826
|
+
*/
|
|
2827
|
+
scheduleSchedulerMcpRetry(directory) {
|
|
2828
|
+
const retry = this.schedulerMcpRetries.get(directory) ?? { attempt: 0, timer: null };
|
|
2829
|
+
if (retry.timer)
|
|
2830
|
+
return;
|
|
2831
|
+
const delayMs = getSchedulerMcpRetryDelayMs(retry.attempt);
|
|
2832
|
+
if (delayMs === null) {
|
|
2833
|
+
this.schedulerMcpRetries.delete(directory);
|
|
2834
|
+
console.warn(`[OpenCode] scheduler MCP for ${directory} is still not registered; the next session start or bot boot retries`);
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
retry.attempt += 1;
|
|
2838
|
+
retry.timer = setTimeout(() => {
|
|
2839
|
+
retry.timer = null;
|
|
2840
|
+
const hasActiveSession = Array.from(this.sessions.values()).some((session) => session.isActive && session.workDir === directory);
|
|
2841
|
+
if (!hasActiveSession) {
|
|
2842
|
+
this.schedulerMcpRetries.delete(directory);
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
void this.reconcileSchedulerMcpForDirectory(directory);
|
|
2846
|
+
}, delayMs);
|
|
2847
|
+
retry.timer.unref?.();
|
|
2848
|
+
this.schedulerMcpRetries.set(directory, retry);
|
|
2849
|
+
console.log(`[OpenCode] retrying the scheduler MCP registration for ${directory} in ${Math.round(delayMs / 1000)}s ` +
|
|
2850
|
+
`(${retry.attempt}/${exports.schedulerMcpRetryDelaysMs.length})`);
|
|
2851
|
+
}
|
|
2852
|
+
/** @description Forget `directory`'s pending registration retry: it is registered now. */
|
|
2853
|
+
clearSchedulerMcpRetry(directory) {
|
|
2854
|
+
const retry = this.schedulerMcpRetries.get(directory);
|
|
2855
|
+
if (!retry)
|
|
2856
|
+
return;
|
|
2857
|
+
if (retry.timer)
|
|
2858
|
+
clearTimeout(retry.timer);
|
|
2859
|
+
this.schedulerMcpRetries.delete(directory);
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* @description Mark a session inactive and tear down the global SSE stream IFF
|
|
2863
|
+
* it was the LAST active session anywhere. The stream is reference-counted by
|
|
2864
|
+
* the TOTAL active-session count (recomputed from `this.sessions` so it can
|
|
2865
|
+
* never drift): any other active session — in any folder — keeps it open.
|
|
2866
|
+
*
|
|
2867
|
+
* Order-independent: callers vary in whether they pre-set `isActive = false`
|
|
2868
|
+
* (`stopSessionInner` does, before calling here), so the decision counts OTHER
|
|
2869
|
+
* active sessions and treats this one as departing. `before = other + 1`,
|
|
2870
|
+
* `after = other` → `close` exactly when no sibling keeps the stream alive.
|
|
2871
|
+
*/
|
|
2872
|
+
disconnectSse(key) {
|
|
2873
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2874
|
+
if (!session)
|
|
2875
|
+
return;
|
|
2876
|
+
session.isActive = false;
|
|
2877
|
+
// Count OTHER active sessions (excluding this departing one) — `before =
|
|
2878
|
+
// other + 1`, `after = other` → `close` exactly when no sibling anywhere
|
|
2879
|
+
// keeps the global stream alive.
|
|
2880
|
+
const otherActive = this.countActiveSessions(key);
|
|
2881
|
+
if ((0, sseStreamLifecycle_1.getSseStreamTransition)(otherActive + 1, otherActive) === 'close') {
|
|
2882
|
+
this.closeGlobalStream();
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
/**
|
|
2886
|
+
* @description Total count of currently-active sessions, optionally excluding
|
|
2887
|
+
* one key (the departing session in a teardown decision) — the reference count
|
|
2888
|
+
* the single global stream's lifecycle keys on.
|
|
2889
|
+
*/
|
|
2890
|
+
countActiveSessions(excludeKey) {
|
|
2891
|
+
const excludeKeyStr = excludeKey ? (0, sessionKey_1.keyToString)(excludeKey) : null;
|
|
2892
|
+
let count = 0;
|
|
2893
|
+
for (const [keyStr, session] of this.sessions) {
|
|
2894
|
+
if (keyStr === excludeKeyStr)
|
|
2895
|
+
continue;
|
|
2896
|
+
if (session.isActive)
|
|
2897
|
+
count += 1;
|
|
2898
|
+
}
|
|
2899
|
+
return count;
|
|
2900
|
+
}
|
|
2901
|
+
/**
|
|
2902
|
+
* @description Open the single `/global/event` stream if it is not already
|
|
2903
|
+
* open (plan 2026-06-17). Idempotent — a second active session finds the
|
|
2904
|
+
* stream present and no-ops. The reader runs detached; a fatal start error
|
|
2905
|
+
* surfaces to every active session so the user isn't left with a silently-dead
|
|
2906
|
+
* session (audit S10 / #43).
|
|
2907
|
+
*/
|
|
2908
|
+
ensureGlobalStream() {
|
|
2909
|
+
if (this.globalStream)
|
|
2910
|
+
return;
|
|
2911
|
+
const stream = {
|
|
2912
|
+
directory: globalStreamLabel,
|
|
2913
|
+
controller: null,
|
|
2914
|
+
stallTimer: null,
|
|
2915
|
+
reconnectTimer: null,
|
|
2916
|
+
isClosed: false,
|
|
2917
|
+
};
|
|
2918
|
+
this.globalStream = stream;
|
|
2919
|
+
// `/global/event` multiplexes every project instance's events (each wrapped
|
|
2920
|
+
// in `payload`, tagged with a top-level `directory`), delivering reliably
|
|
2921
|
+
// regardless of connection age (plan 2026-06-17 S1: the per-directory
|
|
2922
|
+
// endpoint goes silent for an aged sole subscriber on opencode 1.14.41).
|
|
2923
|
+
// Each event is parsed once here and routed by envelope directory + sessionID.
|
|
2924
|
+
const sseUrl = `${this.baseUrl}/global/event`;
|
|
2925
|
+
console.log(`[OpenCode] Connecting global SSE: ${sseUrl}`);
|
|
2926
|
+
(0, diagLog_1.appendDiagLog)(`sse open ${globalStreamLabel}`);
|
|
2927
|
+
this.pollSseStream(stream, sseUrl).catch((e) => {
|
|
2928
|
+
console.error(`[OpenCode] global SSE connection error:`, e);
|
|
2929
|
+
this.emitToAllActiveSessions('error', e instanceof Error ? e : new Error(String(e)));
|
|
2930
|
+
});
|
|
2931
|
+
}
|
|
2932
|
+
/**
|
|
2933
|
+
* @description Register the bot's scheduler MCP server for `directory`'s
|
|
2934
|
+
* OpenCode instance via the runtime `POST /mcp?directory=` endpoint (plan
|
|
2935
|
+
* 2026-06-17 S3). Idempotent on the server (re-POSTing the same `name`
|
|
2936
|
+
* overwrites/no-ops) and gated by `registeredSchedulerMcpDirs` so it runs once
|
|
2937
|
+
* per directory per server generation — `restartServer` clears the Set so a
|
|
2938
|
+
* dir is re-registered after the server's MCP table is wiped. Inert until
|
|
2939
|
+
* injection is configured — the builder returns `null` and this no-ops. A
|
|
2940
|
+
* registration FAILURE is logged, scheduled for a retry, and swallowed:
|
|
2941
|
+
* scheduling tools are an enhancement, the session must still start.
|
|
2942
|
+
*/
|
|
2943
|
+
async registerSchedulerMcpForDirectory(directory) {
|
|
2944
|
+
if (this.registeredSchedulerMcpDirs.has(directory))
|
|
2945
|
+
return;
|
|
2946
|
+
const registration = await (0, injection_1.buildOpenCodeSchedulerMcpRegistration)(directory);
|
|
2947
|
+
if (!registration)
|
|
2948
|
+
return;
|
|
2949
|
+
if (this.registeredSchedulerMcpDirs.has(directory))
|
|
2950
|
+
return;
|
|
2951
|
+
try {
|
|
2952
|
+
await this.apiRequest('POST', buildDirectoryScopedPath('/mcp', directory), registration);
|
|
2953
|
+
this.registeredSchedulerMcpDirs.add(directory);
|
|
2954
|
+
this.clearSchedulerMcpRetry(directory);
|
|
2955
|
+
(0, diagLog_1.appendDiagLog)(`scheduler mcp registered dir=${directory}`);
|
|
2956
|
+
}
|
|
2957
|
+
catch (e) {
|
|
2958
|
+
console.warn(`[OpenCode] scheduler MCP registration failed for ${directory}:`, e instanceof Error ? e.message : e);
|
|
2959
|
+
this.scheduleSchedulerMcpRetry(directory);
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
/**
|
|
2963
|
+
* @description Tear down the global SSE stream: latch it closed, abort the
|
|
2964
|
+
* in-flight reader (unblocks `reader.read()` immediately — audit S7 / #12),
|
|
2965
|
+
* cancel its stall + reconnect timers, and drop the reference.
|
|
2966
|
+
*/
|
|
2967
|
+
closeGlobalStream() {
|
|
2968
|
+
const stream = this.globalStream;
|
|
2969
|
+
if (!stream)
|
|
2970
|
+
return;
|
|
2971
|
+
stream.isClosed = true;
|
|
2972
|
+
if (stream.controller) {
|
|
2973
|
+
stream.controller.abort();
|
|
2974
|
+
stream.controller = null;
|
|
2975
|
+
}
|
|
2976
|
+
this.clearStreamStallTimer(stream);
|
|
2977
|
+
if (stream.reconnectTimer) {
|
|
2978
|
+
clearTimeout(stream.reconnectTimer);
|
|
2979
|
+
stream.reconnectTimer = null;
|
|
2980
|
+
}
|
|
2981
|
+
this.globalStream = null;
|
|
2982
|
+
(0, diagLog_1.appendDiagLog)(`sse close ${globalStreamLabel}`);
|
|
2983
|
+
}
|
|
2984
|
+
/** Emit an event to every active session. */
|
|
2985
|
+
emitToAllActiveSessions(eventName, error) {
|
|
2986
|
+
for (const session of this.sessions.values()) {
|
|
2987
|
+
if (session.isActive) {
|
|
2988
|
+
this.emit(eventName, session.key, error);
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
/**
|
|
2993
|
+
* @description Resolve model for the session. Priority:
|
|
2994
|
+
* 1. Thread's saved preference (from previous /model selection)
|
|
2995
|
+
* 2. OpenCode server's defaultModel (config.model -> model.json recent -> first provider)
|
|
2996
|
+
* 3. "not set"
|
|
2997
|
+
*
|
|
2998
|
+
* `emitOutput` controls whether the resolved `Model: <label>` line is sent to
|
|
2999
|
+
* the topic. A fresh `startSession` emits it (the user just opened the
|
|
3000
|
+
* session); a `resumeSession` after a bot restart re-resolves the SAME model
|
|
3001
|
+
* silently — the label is already in the topic from the previous run, so
|
|
3002
|
+
* re-emitting on every hot-restart is noise (B17). Resolution must still run
|
|
3003
|
+
* to repopulate `modelOverride`/`currentModelLabel` so `/effort` can read them.
|
|
3004
|
+
*/
|
|
3005
|
+
async fetchModelInfo(key, emitOutput = true) {
|
|
3006
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3007
|
+
if (!session?.isActive || session.isModelInfoShown)
|
|
3008
|
+
return;
|
|
3009
|
+
// 1. Check this thread's saved model preference
|
|
3010
|
+
if (this.restoreSavedModel(key, session, emitOutput))
|
|
3011
|
+
return;
|
|
3012
|
+
// 2. Ask OpenCode server for default model
|
|
3013
|
+
try {
|
|
3014
|
+
const config = await this.apiRequest('GET', '/config');
|
|
3015
|
+
console.log(`[OpenCode] GET /config: model=${config?.model || 'unset'}, defaultModel=${config?.defaultModel ? `${config.defaultModel.providerID}/${config.defaultModel.modelID}` : 'unset'}`);
|
|
3016
|
+
if (config?.defaultModel?.providerID && config?.defaultModel?.modelID) {
|
|
3017
|
+
const label = `${config.defaultModel.providerID}/${config.defaultModel.modelID}`;
|
|
3018
|
+
session.currentModelLabel = label;
|
|
3019
|
+
session.modelOverride = {
|
|
3020
|
+
providerID: config.defaultModel.providerID,
|
|
3021
|
+
modelID: config.defaultModel.modelID,
|
|
3022
|
+
};
|
|
3023
|
+
session.isModelInfoShown = true;
|
|
3024
|
+
console.log(`[OpenCode] Default model: ${label}`);
|
|
3025
|
+
if (emitOutput)
|
|
3026
|
+
this.emit('output', key, `Model: ${label}`);
|
|
3027
|
+
return;
|
|
3028
|
+
}
|
|
3029
|
+
if (config?.model) {
|
|
3030
|
+
const slashIdx = config.model.indexOf('/');
|
|
3031
|
+
if (slashIdx > 0) {
|
|
3032
|
+
session.modelOverride = {
|
|
3033
|
+
providerID: config.model.slice(0, slashIdx),
|
|
3034
|
+
modelID: config.model.slice(slashIdx + 1),
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
session.currentModelLabel = config.model;
|
|
3038
|
+
session.isModelInfoShown = true;
|
|
3039
|
+
console.log(`[OpenCode] Default model (config): ${config.model}`);
|
|
3040
|
+
if (emitOutput)
|
|
3041
|
+
this.emit('output', key, `Model: ${config.model}`);
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
catch (e) {
|
|
3046
|
+
// Transient failure (server booting / sick). Do NOT claim "not set":
|
|
3047
|
+
// leave isModelInfoShown false and currentModelLabel untouched so the
|
|
3048
|
+
// next assistant message (handleMessageUpdate) corrects it with the
|
|
3049
|
+
// real model label.
|
|
3050
|
+
console.log(`[OpenCode] fetchModelInfo failed:`, e instanceof Error ? e.message : e);
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
// 3. Server answered but reported no default model — genuinely not set.
|
|
3054
|
+
console.log(`[OpenCode] No default model resolved`);
|
|
3055
|
+
session.currentModelLabel = 'not set';
|
|
3056
|
+
if (emitOutput)
|
|
3057
|
+
this.emit('output', key, `Model: not set (use /model to select)`);
|
|
3058
|
+
}
|
|
3059
|
+
/**
|
|
3060
|
+
* @description Fetch-based SSE reader for the single `/global/event` stream
|
|
3061
|
+
* (plan 2026-06-17). OpenCode sends every event as a `data:` line with a JSON
|
|
3062
|
+
* envelope `{ directory, payload: { type, properties } }`; this one stream
|
|
3063
|
+
* multiplexes every project instance, so each line is parsed once here and
|
|
3064
|
+
* routed by the envelope `directory` + `sessionID` to its owning session.
|
|
3065
|
+
*
|
|
3066
|
+
* On connection failure: checks if the server is alive, attempts a restart if
|
|
3067
|
+
* dead, and retries forever with capped exponential backoff until reconnect
|
|
3068
|
+
* succeeds — as long as the stream is still wanted (not closed).
|
|
3069
|
+
*/
|
|
3070
|
+
async pollSseStream(stream, sseUrl, reconnectAttempt = 0, reconnectStartTs = Date.now()) {
|
|
3071
|
+
if (stream.isClosed)
|
|
3072
|
+
return;
|
|
3073
|
+
// The "directory" is the fixed `globalStreamLabel` — a log marker only; the
|
|
3074
|
+
// global stream has no per-folder scope (events carry their own directory).
|
|
3075
|
+
const streamLabel = stream.directory;
|
|
3076
|
+
const headers = {
|
|
3077
|
+
'Accept': 'text/event-stream',
|
|
3078
|
+
};
|
|
3079
|
+
if (this.authHeader) {
|
|
3080
|
+
headers['Authorization'] = this.authHeader;
|
|
3081
|
+
}
|
|
3082
|
+
// Audit S7 / #12: SSE was previously read with no abort path —
|
|
3083
|
+
// `await reader.read()` blocks until bytes arrive, so a teardown during a
|
|
3084
|
+
// silent server couldn't actually free the connection. The stream-scoped
|
|
3085
|
+
// controller is stored so `closeGlobalStream` can abort the in-flight
|
|
3086
|
+
// `fetch` and `reader.read` immediately.
|
|
3087
|
+
const controller = new AbortController();
|
|
3088
|
+
stream.controller = controller;
|
|
3089
|
+
let sawData = false;
|
|
3090
|
+
try {
|
|
3091
|
+
const response = await fetch(sseUrl, { headers, signal: controller.signal });
|
|
3092
|
+
if (!response.ok || !response.body) {
|
|
3093
|
+
console.error(`[OpenCode] SSE connection failed for ${streamLabel}: ${response.status}`);
|
|
3094
|
+
await response.body?.cancel().catch(() => { });
|
|
3095
|
+
await this.handleSseReconnect(stream, sseUrl, reconnectAttempt, reconnectStartTs, `HTTP ${response.status}`);
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
console.log(`[OpenCode] SSE connected for ${streamLabel}`);
|
|
3099
|
+
(0, diagLog_1.appendDiagLog)(`sse connected dir=${streamLabel} attempt=${reconnectAttempt}`);
|
|
3100
|
+
const reader = response.body.getReader();
|
|
3101
|
+
const decoder = new TextDecoder();
|
|
3102
|
+
let buffer = '';
|
|
3103
|
+
// Arm the stall watchdog before the first read and re-arm on every chunk;
|
|
3104
|
+
// a silently dead stream (no bytes, not even a heartbeat) would otherwise
|
|
3105
|
+
// park `reader.read()` forever with no reconnect.
|
|
3106
|
+
this.armSseStallWatchdog(stream, controller);
|
|
3107
|
+
while (!stream.isClosed) {
|
|
3108
|
+
const { done, value } = await reader.read();
|
|
3109
|
+
if (done)
|
|
3110
|
+
break;
|
|
3111
|
+
this.armSseStallWatchdog(stream, controller);
|
|
3112
|
+
// Audit S7 / #12: a flapping server used to reset `reconnectAttempt`
|
|
3113
|
+
// on a *successful TCP connect*, so the 5-attempts ceiling never
|
|
3114
|
+
// bounded anything. Reset only once we observe actual application
|
|
3115
|
+
// data, and keep a wall-clock budget so even data-producing flaps
|
|
3116
|
+
// get capped.
|
|
3117
|
+
if (!sawData) {
|
|
3118
|
+
sawData = true;
|
|
3119
|
+
reconnectAttempt = 0;
|
|
3120
|
+
reconnectStartTs = Date.now();
|
|
3121
|
+
(0, diagLog_1.appendDiagLog)(`sse first-data dir=${streamLabel}`);
|
|
3122
|
+
}
|
|
3123
|
+
buffer += decoder.decode(value, { stream: true });
|
|
3124
|
+
const lines = buffer.split('\n');
|
|
3125
|
+
buffer = lines.pop() || '';
|
|
3126
|
+
for (const line of lines) {
|
|
3127
|
+
if (!line.startsWith('data: '))
|
|
3128
|
+
continue;
|
|
3129
|
+
const dataStr = line.slice(6);
|
|
3130
|
+
this.routeSseData(dataStr);
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
// Leaving the read loop: disarm before any reconnect await so the
|
|
3134
|
+
// watchdog can't fire against this (now-finished) controller.
|
|
3135
|
+
this.clearStreamStallTimer(stream);
|
|
3136
|
+
reader.cancel().catch(() => { });
|
|
3137
|
+
// If the stream is still wanted but the server closed it, reconnect.
|
|
3138
|
+
if (!stream.isClosed) {
|
|
3139
|
+
console.log(`[OpenCode] SSE stream for ${streamLabel} ended while wanted, reconnecting...`);
|
|
3140
|
+
await this.handleSseReconnect(stream, sseUrl, reconnectAttempt, reconnectStartTs, 'stream ended');
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
catch (e) {
|
|
3144
|
+
this.clearStreamStallTimer(stream);
|
|
3145
|
+
// An aborted controller means either `closeGlobalStream` (stream no
|
|
3146
|
+
// longer wanted → just exit) or the stall watchdog firing on a silently
|
|
3147
|
+
// dead stream (stream still wanted → reconnect).
|
|
3148
|
+
if (controller.signal.aborted) {
|
|
3149
|
+
if (!stream.isClosed) {
|
|
3150
|
+
await this.handleSseReconnect(stream, sseUrl, reconnectAttempt, reconnectStartTs, 'stall');
|
|
3151
|
+
}
|
|
3152
|
+
return;
|
|
3153
|
+
}
|
|
3154
|
+
if (!stream.isClosed) {
|
|
3155
|
+
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
3156
|
+
console.error(`[OpenCode] SSE error for ${streamLabel}:`, errorMessage);
|
|
3157
|
+
await this.handleSseReconnect(stream, sseUrl, reconnectAttempt, reconnectStartTs, errorMessage);
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
finally {
|
|
3161
|
+
this.clearStreamStallTimer(stream);
|
|
3162
|
+
if (stream.controller === controller)
|
|
3163
|
+
stream.controller = null;
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* @description (Re)arm a stream's stall watchdog. OpenCode emits a
|
|
3168
|
+
* `server.heartbeat` every ~10 s, so a live stream always delivers bytes
|
|
3169
|
+
* within `sseStallTimeoutMs`; if none arrive the socket is silently dead
|
|
3170
|
+
* (open TCP, no FIN/RST) and `reader.read()` would park forever. Aborting the
|
|
3171
|
+
* controller unblocks the reader so `pollSseStream`'s catch path reconnects.
|
|
3172
|
+
* Re-armed on every chunk, so heartbeats keep a healthy stream alive.
|
|
3173
|
+
*/
|
|
3174
|
+
armSseStallWatchdog(stream, controller) {
|
|
3175
|
+
this.clearStreamStallTimer(stream);
|
|
3176
|
+
stream.stallTimer = setTimeout(() => {
|
|
3177
|
+
stream.stallTimer = null;
|
|
3178
|
+
(0, diagLog_1.appendDiagLog)(`sse stall dir=${stream.directory} idle>${sseStallTimeoutMs}ms`);
|
|
3179
|
+
controller.abort();
|
|
3180
|
+
}, sseStallTimeoutMs);
|
|
3181
|
+
}
|
|
3182
|
+
/** Cancel a stream's stall watchdog if armed. Idempotent. */
|
|
3183
|
+
clearStreamStallTimer(stream) {
|
|
3184
|
+
if (stream.stallTimer) {
|
|
3185
|
+
clearTimeout(stream.stallTimer);
|
|
3186
|
+
stream.stallTimer = null;
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
/**
|
|
3190
|
+
* @description Handle reconnection for the single global stream with server
|
|
3191
|
+
* health check, auto-restart, and exponential backoff. If the server is dead,
|
|
3192
|
+
* attempts a restart before reconnecting. Never gives up while the stream is
|
|
3193
|
+
* still wanted — reconnects until success — with backoff capped at
|
|
3194
|
+
* `maxSseReconnectDelayMs`. The only exits are an unrestartable server
|
|
3195
|
+
* (handled by `restartServer`) or a closed stream.
|
|
3196
|
+
*/
|
|
3197
|
+
async handleSseReconnect(stream, sseUrl, attempt, reconnectStartTs, reason) {
|
|
3198
|
+
if (stream.isClosed)
|
|
3199
|
+
return;
|
|
3200
|
+
const streamLabel = stream.directory;
|
|
3201
|
+
const elapsed = Date.now() - reconnectStartTs;
|
|
3202
|
+
// Check if the server process is still alive
|
|
3203
|
+
const isServerAlive = await (0, installManager_1.checkIsOpenCodeServerRunning)();
|
|
3204
|
+
if (!isServerAlive) {
|
|
3205
|
+
console.log(`[OpenCode] Server is not responding, attempting restart before SSE reconnect...`);
|
|
3206
|
+
const restarted = await this.restartServer();
|
|
3207
|
+
if (!restarted) {
|
|
3208
|
+
// restartServer already notified threads and cleaned up sessions
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
// restartServer re-opens the global stream via the resume path; if this
|
|
3212
|
+
// stream was torn down in the process, stop here so we don't double-open it.
|
|
3213
|
+
if (stream.isClosed)
|
|
3214
|
+
return;
|
|
3215
|
+
// Server restarted — reset the attempt counter so backoff starts fresh.
|
|
3216
|
+
attempt = 0;
|
|
3217
|
+
}
|
|
3218
|
+
// Never give up while the stream is wanted: reconnect forever until
|
|
3219
|
+
// success. Cap the exponential backoff so steady-state retries settle at
|
|
3220
|
+
// `maxSseReconnectDelayMs` instead of growing without bound.
|
|
3221
|
+
const delay = Math.min(sseReconnectBaseDelayMs * Math.pow(2, Math.min(attempt, maxSseReconnectBackoffExponent)), maxSseReconnectDelayMs);
|
|
3222
|
+
console.log(`[OpenCode] SSE reconnecting ${streamLabel} in ${delay}ms (attempt ${attempt + 1}, reason: ${reason}, elapsed: ${Math.round(elapsed / 1000)}s)`);
|
|
3223
|
+
(0, diagLog_1.appendDiagLog)(`sse reconnect dir=${streamLabel} attempt=${attempt + 1} reason=${reason} delay=${delay}ms`);
|
|
3224
|
+
// Store the timer handle on the stream so `closeGlobalStream` can cancel
|
|
3225
|
+
// it; otherwise a `setTimeout` fired after the stream is gone re-enters
|
|
3226
|
+
// `pollSseStream` for a dead stream and keeps the event loop alive (audit
|
|
3227
|
+
// S8 / #14).
|
|
3228
|
+
await new Promise(resolve => {
|
|
3229
|
+
stream.reconnectTimer = setTimeout(() => {
|
|
3230
|
+
stream.reconnectTimer = null;
|
|
3231
|
+
resolve();
|
|
3232
|
+
}, delay);
|
|
3233
|
+
});
|
|
3234
|
+
if (!stream.isClosed) {
|
|
3235
|
+
this.pollSseStream(stream, sseUrl, attempt + 1, reconnectStartTs).catch(() => { });
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
/**
|
|
3239
|
+
* @description Route a single raw SSE `data:` line from the global stream
|
|
3240
|
+
* (plan 2026-06-17). The JSON is parsed ONCE here, the owning session is
|
|
3241
|
+
* resolved from its sessionID/lineage (with an envelope-directory fallback),
|
|
3242
|
+
* and the event is dispatched to that session. The owning project instance
|
|
3243
|
+
* comes from the event ENVELOPE'S `directory` field — `/global/event` tags
|
|
3244
|
+
* every session event with it — so instance-local replies
|
|
3245
|
+
* (questions/permissions) target the right instance.
|
|
3246
|
+
*
|
|
3247
|
+
* Also the entry the SSE unit tests drive (a session injected into the map +
|
|
3248
|
+
* synthesized payload-wrapped envelope), so the real parse→route→dispatch
|
|
3249
|
+
* path is exercised end to end.
|
|
3250
|
+
*/
|
|
3251
|
+
routeSseData(dataStr) {
|
|
3252
|
+
let parsed;
|
|
3253
|
+
try {
|
|
3254
|
+
parsed = JSON.parse(dataStr);
|
|
3255
|
+
}
|
|
3256
|
+
catch {
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
const event = normaliseOpenCodeSseEvent(parsed);
|
|
3260
|
+
if (!event)
|
|
3261
|
+
return;
|
|
3262
|
+
const ownerKeyStr = this.resolveSseEventOwner(event);
|
|
3263
|
+
if (ownerKeyStr === undefined)
|
|
3264
|
+
return;
|
|
3265
|
+
if (ownerKeyStr === null) {
|
|
3266
|
+
// Session-less event (server.connected / heartbeat) — no owner to look up.
|
|
3267
|
+
this.dispatchSessionLessEvent(event);
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
const ownerSession = this.sessions.get(ownerKeyStr);
|
|
3271
|
+
if (!ownerSession?.isActive)
|
|
3272
|
+
return;
|
|
3273
|
+
// The owning project instance is the envelope's `directory` (session-less
|
|
3274
|
+
// events have none and need none) — used for instance-scoped replies.
|
|
3275
|
+
this.dispatchSseEvent(ownerSession.key, event, event.directory);
|
|
3276
|
+
}
|
|
3277
|
+
/**
|
|
3278
|
+
* @description Resolve which thread (if any) should process `event`, applying
|
|
3279
|
+
* the single-owner invariant (B20) and recording subagent lineage. Owner
|
|
3280
|
+
* resolution is by sessionID (direct id, else lineage ancestor) with a
|
|
3281
|
+
* DIRECTORY fallback off the event ENVELOPE'S `directory` when both miss
|
|
3282
|
+
* (plan 2026-06-17). Returns:
|
|
3283
|
+
* - the owning thread's serialised key for a per-session event;
|
|
3284
|
+
* - `null` for a session-less event (`server.connected`/`heartbeat`) that
|
|
3285
|
+
* every reader handles directly;
|
|
3286
|
+
* - `undefined` to signal "do not dispatch" (missing sessionID, or no bound
|
|
3287
|
+
* thread owns the session — a genuine drop, loud-logged for every critical
|
|
3288
|
+
* type incl. question/permission, so a routable event is never a no-op).
|
|
3289
|
+
* Events for directories the bot does not own (the user's by-hand opencode
|
|
3290
|
+
* in other folders, now visible on the global stream) drop here cheaply.
|
|
3291
|
+
*/
|
|
3292
|
+
resolveSseEventOwner(event) {
|
|
3293
|
+
const eventType = event.type;
|
|
3294
|
+
// OpenCode runs subagents in CHILD sessions; their events carry the child
|
|
3295
|
+
// sessionID, not the bound parent's. The child→parent link must be known
|
|
3296
|
+
// BEFORE the child's first routed event or that event drops "no owner".
|
|
3297
|
+
// `session.updated` is no longer the ONLY source — record from ANY event
|
|
3298
|
+
// whose properties expose `parentID` (S2 lineage durability), capturing it
|
|
3299
|
+
// BEFORE the session-id gate below so descendant events route to the owner.
|
|
3300
|
+
this.trackSessionLineage(event.properties);
|
|
3301
|
+
// Session-less events are server-wide by design; they have no owner to
|
|
3302
|
+
// resolve and are processed directly by the dispatch switch.
|
|
3303
|
+
if (eventType === 'server.connected' || eventType === 'server.heartbeat') {
|
|
3304
|
+
return null;
|
|
3305
|
+
}
|
|
3306
|
+
const eventSessionId = this.getSessionIdFromEvent(event);
|
|
3307
|
+
if (!eventSessionId) {
|
|
3308
|
+
console.warn(`[OpenCode] dropping ${eventType} without sessionID`);
|
|
3309
|
+
if (exports.criticalSseEventTypes.has(eventType)) {
|
|
3310
|
+
this.logSseDropOncePerWindow(eventType, '<no-session-id>');
|
|
3311
|
+
}
|
|
3312
|
+
return undefined;
|
|
3313
|
+
}
|
|
3314
|
+
// Single-owner delivery (B20): resolve the ONE thread that owns
|
|
3315
|
+
// `eventSessionId` (direct id match, else nearest lineage ancestor). A
|
|
3316
|
+
// false lineage link or a duplicated session id must never emit the same
|
|
3317
|
+
// answer to two topics. On success, refresh the lineage links walked so an
|
|
3318
|
+
// actively-routing child is never the eviction victim (S2 durability).
|
|
3319
|
+
const ownerKeyStr = this.resolveEventOwnerKey(eventSessionId, { refreshLineageOnUse: true });
|
|
3320
|
+
if (ownerKeyStr !== null)
|
|
3321
|
+
return ownerKeyStr;
|
|
3322
|
+
// Id/lineage resolution failed. Before dropping, fall back to the event
|
|
3323
|
+
// ENVELOPE'S DIRECTORY (plan 2026-06-17): `/global/event` tags every session
|
|
3324
|
+
// event with its owning folder, so the event provably belongs to a thread
|
|
3325
|
+
// bound to THAT folder even when the per-session lineage map briefly
|
|
3326
|
+
// disagrees (link evicted / not yet recorded). The fallback also drops an
|
|
3327
|
+
// event for a directory the bot does not own (the user's by-hand opencode in
|
|
3328
|
+
// another folder): no active bound session there → `null` → cheap drop. SYNC
|
|
3329
|
+
// only — no HTTP on the per-event hot path; the decision uses in-memory state.
|
|
3330
|
+
const eventDirectory = event.directory;
|
|
3331
|
+
const fallbackOwnerKeyStr = eventDirectory !== undefined
|
|
3332
|
+
? this.resolveOwnerByDirectoryFallback(eventSessionId, eventDirectory)
|
|
3333
|
+
: null;
|
|
3334
|
+
if (fallbackOwnerKeyStr !== null) {
|
|
3335
|
+
(0, diagLog_1.appendDiagLog)(`sse dir-fallback ${eventType} es=${eventSessionId} dir=${eventDirectory} -> ${fallbackOwnerKeyStr}`);
|
|
3336
|
+
return fallbackOwnerKeyStr;
|
|
3337
|
+
}
|
|
3338
|
+
// A genuine loss is "no thread owns this event at all". Throttle the log
|
|
3339
|
+
// per (eventType, eventSessionId) — an orphaned session's delta firehose
|
|
3340
|
+
// would otherwise flood the diag file (B19). question/permission are now
|
|
3341
|
+
// critical, so a vanished question is LOUD, never silent (S1).
|
|
3342
|
+
if (exports.criticalSseEventTypes.has(eventType)) {
|
|
3343
|
+
this.logSseDropOncePerWindow(eventType, eventSessionId);
|
|
3344
|
+
}
|
|
3345
|
+
return undefined;
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* @description Handle a session-less event (`server.connected` /
|
|
3349
|
+
* `server.heartbeat`) — server-wide by design, with no owning topic. Kept
|
|
3350
|
+
* separate from {@link dispatchSseEvent} so the latter always has a real
|
|
3351
|
+
* owner key.
|
|
3352
|
+
*/
|
|
3353
|
+
dispatchSessionLessEvent(event) {
|
|
3354
|
+
if (event.type === 'server.connected') {
|
|
3355
|
+
console.log(`[OpenCode] SSE: server.connected`);
|
|
3356
|
+
}
|
|
3357
|
+
// server.heartbeat is intentionally silent — it only re-arms the watchdog.
|
|
3358
|
+
}
|
|
3359
|
+
/**
|
|
3360
|
+
* @description Dispatch a normalised, OWNED SSE event to its owning session's
|
|
3361
|
+
* handlers. `key` is the resolved single owner (B20); `directory` is the
|
|
3362
|
+
* owning project instance (the event envelope's `directory`), used for
|
|
3363
|
+
* instance-scoped question / permission replies.
|
|
3364
|
+
*/
|
|
3365
|
+
dispatchSseEvent(key, event, directory) {
|
|
3366
|
+
const eventType = event.type;
|
|
3367
|
+
const eventSessionId = this.getSessionIdFromEvent(event);
|
|
3368
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3369
|
+
// A routed event whose session id is NOT the owner's own id reached us via
|
|
3370
|
+
// lineage descent → it belongs to a SUB-AGENT (child session). Computed
|
|
3371
|
+
// ONCE here and threaded into the part handlers — they must never
|
|
3372
|
+
// re-derive it (S4).
|
|
3373
|
+
const isSubagentEvent = Boolean(session?.isActive && eventSessionId && eventSessionId !== session.sessionId);
|
|
3374
|
+
if (session && isSubagentEvent) {
|
|
3375
|
+
(0, diagLog_1.appendDiagLog)(`sse route-descendant ${eventType} es=${eventSessionId} -> ${session.sessionId}`);
|
|
3376
|
+
}
|
|
3377
|
+
switch (eventType) {
|
|
3378
|
+
case 'message.part.updated':
|
|
3379
|
+
case 'message.part.delta':
|
|
3380
|
+
this.handlePartUpdate(key, event.properties, isSubagentEvent);
|
|
3381
|
+
break;
|
|
3382
|
+
case 'message.updated':
|
|
3383
|
+
this.handleMessageUpdate(key, event.properties);
|
|
3384
|
+
break;
|
|
3385
|
+
case 'session.idle':
|
|
3386
|
+
this.handleSessionIdle(key, event.properties);
|
|
3387
|
+
break;
|
|
3388
|
+
case 'session.status':
|
|
3389
|
+
this.handleSessionStatus(key, eventSessionId, event.properties);
|
|
3390
|
+
break;
|
|
3391
|
+
case 'session.next.compaction.started':
|
|
3392
|
+
this.setCompacting(key, true);
|
|
3393
|
+
break;
|
|
3394
|
+
case 'session.next.compaction.ended':
|
|
3395
|
+
case 'session.compacted':
|
|
3396
|
+
this.setCompacting(key, false);
|
|
3397
|
+
break;
|
|
3398
|
+
case 'session.error':
|
|
3399
|
+
this.handleSessionError(key, eventSessionId, event.properties);
|
|
3400
|
+
break;
|
|
3401
|
+
case 'permission.asked':
|
|
3402
|
+
this.handlePermissionAsked(key, event.properties, directory);
|
|
3403
|
+
break;
|
|
3404
|
+
case 'question.asked':
|
|
3405
|
+
this.handleQuestionAsked(key, event.properties, directory);
|
|
3406
|
+
break;
|
|
3407
|
+
default:
|
|
3408
|
+
// Log unhandled event types for debugging, skipping the ones we
|
|
3409
|
+
// intentionally ignore (heartbeat + global-only bookkeeping) so the
|
|
3410
|
+
// multiplexed global stream doesn't spam stdout (plan 2026-06-17 S2).
|
|
3411
|
+
if (eventType !== 'server.heartbeat' && !ignoredSseEventTypes.has(eventType)) {
|
|
3412
|
+
console.log(`[OpenCode] SSE event: ${eventType}`);
|
|
3413
|
+
}
|
|
3414
|
+
break;
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
getSessionIdFromEvent(event) {
|
|
3418
|
+
const props = event.properties;
|
|
3419
|
+
// Different events carry sessionID in different places
|
|
3420
|
+
if (typeof props.sessionID === 'string')
|
|
3421
|
+
return props.sessionID;
|
|
3422
|
+
if (props.part && typeof props.part.sessionID === 'string') {
|
|
3423
|
+
return props.part.sessionID;
|
|
3424
|
+
}
|
|
3425
|
+
if (props.info && typeof props.info.sessionID === 'string') {
|
|
3426
|
+
return props.info.sessionID;
|
|
3427
|
+
}
|
|
3428
|
+
return null;
|
|
3429
|
+
}
|
|
3430
|
+
/**
|
|
3431
|
+
* @description Record a child→parent session link from ANY event that exposes
|
|
3432
|
+
* `parentID`, so subagent (child-session) events route to the topic bound to
|
|
3433
|
+
* the parent. Reliably populated by `session.updated` (its `info` carries the
|
|
3434
|
+
* child id + `parentID`); also reads a top-level `properties.parentID`
|
|
3435
|
+
* (paired with the event's session id) when a build emits it on other events,
|
|
3436
|
+
* so a child's link is known BEFORE its first routed event instead of dropping
|
|
3437
|
+
* "no owner" until the next `session.updated` beat (S2 durability). Root
|
|
3438
|
+
* sessions (no `parentID`) and non-session ids are ignored by
|
|
3439
|
+
* {@link updateSessionLineage}; the map is bounded by
|
|
3440
|
+
* `maxTrackedSessionLineageEntries`.
|
|
3441
|
+
*/
|
|
3442
|
+
trackSessionLineage(properties) {
|
|
3443
|
+
const info = properties.info;
|
|
3444
|
+
// `session.updated` shape: info.id is the CHILD, info.parentID its parent.
|
|
3445
|
+
this.recordSessionLineageLink(info?.id, info?.parentID);
|
|
3446
|
+
// Other events: a top-level parentID belongs to the event's OWN session id.
|
|
3447
|
+
const topLevelParentId = properties.parentID;
|
|
3448
|
+
if (typeof topLevelParentId === 'string') {
|
|
3449
|
+
this.recordSessionLineageLink(this.getSessionIdFromEvent({ type: '', properties }), topLevelParentId);
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
/** @description Store one child→parent link and diag-log it when it is new. */
|
|
3453
|
+
recordSessionLineageLink(childSessionId, parentSessionId) {
|
|
3454
|
+
const recorded = (0, openCodeSessionRouting_1.updateSessionLineage)(this.sessionLineage, childSessionId ?? undefined, parentSessionId, maxTrackedSessionLineageEntries);
|
|
3455
|
+
if (recorded) {
|
|
3456
|
+
(0, diagLog_1.appendDiagLog)(`sse lineage child=${childSessionId} parent=${parentSessionId}`);
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
/**
|
|
3460
|
+
* @description Tear down any OTHER active thread already bound to
|
|
3461
|
+
* `sessionId` before `keyStr` adopts it (B20 prevention). One server-side
|
|
3462
|
+
* OpenCode session belongs to exactly one thread; if two threads held the
|
|
3463
|
+
* same id (e.g. resuming a session already live in another topic), the
|
|
3464
|
+
* server's multiplexed events would match BOTH and the same answer would
|
|
3465
|
+
* land in two topics. Detaching the stale owner keeps the single-owner
|
|
3466
|
+
* invariant true at the source, not just in dispatch.
|
|
3467
|
+
*/
|
|
3468
|
+
detachDuplicateSessionOwners(keyStr, sessionId) {
|
|
3469
|
+
for (const [otherKeyStr, otherSession] of this.sessions) {
|
|
3470
|
+
if (otherKeyStr === keyStr)
|
|
3471
|
+
continue;
|
|
3472
|
+
if (!otherSession.isActive || otherSession.sessionId !== sessionId)
|
|
3473
|
+
continue;
|
|
3474
|
+
console.warn(`[OpenCode] session ${sessionId} already owned by ${otherKeyStr}; detaching it before ${keyStr} adopts it`);
|
|
3475
|
+
(0, diagLog_1.appendDiagLog)(`sse dup-owner-detach session=${sessionId} from=${otherKeyStr} to=${keyStr}`);
|
|
3476
|
+
// SOFT detach: tear down the stale thread's local tracking (SSE, timers,
|
|
3477
|
+
// map entry) and announce `stopped`, but do NOT abort the server session
|
|
3478
|
+
// — the new thread is adopting that exact live session, so aborting it
|
|
3479
|
+
// would kill the generation the new owner wants to keep streaming.
|
|
3480
|
+
this.softDetachSession(otherSession.key);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
/**
|
|
3484
|
+
* @description Tear down a thread's local session tracking WITHOUT aborting
|
|
3485
|
+
* the server-side session. Used when another thread is adopting the same
|
|
3486
|
+
* server session (B20 prevention) — a normal `stopSessionInner` would abort
|
|
3487
|
+
* the shared generation. Mirrors `stopSessionInner` minus the abort POST.
|
|
3488
|
+
*/
|
|
3489
|
+
softDetachSession(key) {
|
|
3490
|
+
const k = (0, sessionKey_1.keyToString)(key);
|
|
3491
|
+
const session = this.sessions.get(k);
|
|
3492
|
+
if (!session)
|
|
3493
|
+
return;
|
|
3494
|
+
session.isActive = false;
|
|
3495
|
+
if (session.outputTimer) {
|
|
3496
|
+
clearTimeout(session.outputTimer);
|
|
3497
|
+
session.outputTimer = null;
|
|
3498
|
+
}
|
|
3499
|
+
if (session.childOutputTimer) {
|
|
3500
|
+
clearTimeout(session.childOutputTimer);
|
|
3501
|
+
session.childOutputTimer = null;
|
|
3502
|
+
}
|
|
3503
|
+
if (session.statusDebounceTimer) {
|
|
3504
|
+
clearTimeout(session.statusDebounceTimer);
|
|
3505
|
+
session.statusDebounceTimer = null;
|
|
3506
|
+
}
|
|
3507
|
+
// The adopting thread owns this server session now; a bound left running
|
|
3508
|
+
// here would report a missing turn on behalf of the detached owner.
|
|
3509
|
+
this.clearProviderRetryReplacementStartTimer(session);
|
|
3510
|
+
// Tear down the directory's SSE stream if this was its last active session.
|
|
3511
|
+
this.disconnectSse(key);
|
|
3512
|
+
this.sessions.delete(k);
|
|
3513
|
+
this.emit('stopped', key);
|
|
3514
|
+
}
|
|
3515
|
+
/**
|
|
3516
|
+
* @description Resolve the SINGLE active thread that owns an SSE event for
|
|
3517
|
+
* `eventSessionId` (single-owner delivery, B20). Direct session-id match wins
|
|
3518
|
+
* over lineage descent; ties resolve deterministically. Returns the owning
|
|
3519
|
+
* thread's serialised key, or `null` if no active thread owns it.
|
|
3520
|
+
*
|
|
3521
|
+
* `refreshLineageOnUse` marks the lineage links walked to reach the owner as
|
|
3522
|
+
* most-recently-used, so an actively-routing child can never be the eviction
|
|
3523
|
+
* victim of the bounded lineage map (S2 durability) — only set on the live
|
|
3524
|
+
* routing path, never when merely probing.
|
|
3525
|
+
*/
|
|
3526
|
+
resolveEventOwnerKey(eventSessionId, options) {
|
|
3527
|
+
const boundSessions = this.getActiveBoundSessions();
|
|
3528
|
+
const ownerKeyStr = (0, openCodeSessionRouting_1.getEventOwnerKey)(eventSessionId, boundSessions, this.sessionLineage);
|
|
3529
|
+
if (ownerKeyStr !== null && options?.refreshLineageOnUse) {
|
|
3530
|
+
const ownerSessionId = boundSessions.find((bound) => bound.keyStr === ownerKeyStr)?.sessionId;
|
|
3531
|
+
// Only a descendant (event id ≠ owner id) walked the lineage chain; a
|
|
3532
|
+
// direct id match used no links to refresh.
|
|
3533
|
+
if (ownerSessionId !== undefined && ownerSessionId !== eventSessionId) {
|
|
3534
|
+
(0, openCodeSessionRouting_1.touchLineageOnUse)(this.sessionLineage, eventSessionId, ownerSessionId);
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
return ownerKeyStr;
|
|
3538
|
+
}
|
|
3539
|
+
/** @description Every currently-active session as a routing `BoundSessionRef`. */
|
|
3540
|
+
getActiveBoundSessions() {
|
|
3541
|
+
const boundSessions = [];
|
|
3542
|
+
for (const [keyStr, session] of this.sessions) {
|
|
3543
|
+
if (session.isActive)
|
|
3544
|
+
boundSessions.push({ keyStr, sessionId: session.sessionId });
|
|
3545
|
+
}
|
|
3546
|
+
return boundSessions;
|
|
3547
|
+
}
|
|
3548
|
+
/**
|
|
3549
|
+
* @description Directory fallback (plan 2026-06-17): resolve an owner from the
|
|
3550
|
+
* event envelope's `directory` when id/lineage resolution already failed.
|
|
3551
|
+
* `/global/event` tags every session event with its owning folder, so an event
|
|
3552
|
+
* provably belongs to a thread bound to that folder — and an event for a
|
|
3553
|
+
* directory with no active bound session (the user's by-hand opencode
|
|
3554
|
+
* elsewhere) resolves to `null` and is dropped. SYNC — uses only in-memory
|
|
3555
|
+
* session state, NEVER an HTTP call on the per-event hot path. Delegates the
|
|
3556
|
+
* decision to the pure {@link resolveOwnerByDirectoryFallbackPure}; the wiring
|
|
3557
|
+
* here is just selecting the directory's active sessions.
|
|
3558
|
+
*/
|
|
3559
|
+
resolveOwnerByDirectoryFallback(eventSessionId, directory) {
|
|
3560
|
+
const directoryActiveSessions = [];
|
|
3561
|
+
for (const [keyStr, session] of this.sessions) {
|
|
3562
|
+
if (session.isActive && session.workDir === directory) {
|
|
3563
|
+
directoryActiveSessions.push({ keyStr, sessionId: session.sessionId });
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
return (0, openCodeSessionRouting_1.resolveOwnerByDirectoryFallback)(eventSessionId, directoryActiveSessions, this.sessionLineage);
|
|
3567
|
+
}
|
|
3568
|
+
/**
|
|
3569
|
+
* @description Diag-log one "sse drop" line for an event no thread owns,
|
|
3570
|
+
* throttled to at most once per (eventType, eventSessionId) per
|
|
3571
|
+
* `sseDropLogThrottleMs`. Without throttling an orphaned session's delta
|
|
3572
|
+
* firehose floods the diag log — once per delta per bound thread (B19).
|
|
3573
|
+
*/
|
|
3574
|
+
logSseDropOncePerWindow(eventType, eventSessionId) {
|
|
3575
|
+
const shouldLog = (0, openCodeSessionRouting_1.checkShouldLogDrop)(this.sseDropLogThrottle, eventType, eventSessionId, Date.now(), sseDropLogThrottleMs, maxSseDropThrottleEntries);
|
|
3576
|
+
if (shouldLog) {
|
|
3577
|
+
(0, diagLog_1.appendDiagLog)(`sse drop ${eventType} es=${eventSessionId} (no owner)`);
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
/**
|
|
3581
|
+
* @description Handle streaming part updates from assistant response.
|
|
3582
|
+
* Two event formats:
|
|
3583
|
+
* message.part.updated: { part: OpenCodePart, delta?: string }
|
|
3584
|
+
* message.part.delta: { sessionID, messageID, partID, field, delta }
|
|
3585
|
+
*
|
|
3586
|
+
* Text parts are accumulated and emitted as 'output' (permanent messages).
|
|
3587
|
+
* Tool, reasoning, step-* parts are emitted as 'status' (transient messages).
|
|
3588
|
+
*
|
|
3589
|
+
* `isSubagent` (resolved ONCE in `dispatchSseEvent`) marks parts streamed by
|
|
3590
|
+
* a CHILD session (sub-agent); they are routed per the
|
|
3591
|
+
* {@link getSubagentPartAction} matrix instead of the parent's handlers, so
|
|
3592
|
+
* a child transcript can never merge unmarked into the parent's reply (S4).
|
|
3593
|
+
*/
|
|
3594
|
+
handlePartUpdate(key, properties, isSubagent) {
|
|
3595
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3596
|
+
if (!session?.isActive)
|
|
3597
|
+
return;
|
|
3598
|
+
const part = properties.part;
|
|
3599
|
+
const delta = properties.delta;
|
|
3600
|
+
const field = properties.field;
|
|
3601
|
+
const partId = properties.partID || part?.id;
|
|
3602
|
+
const messageId = properties.messageID || part?.messageID;
|
|
3603
|
+
// Track part type from message.part.updated events (full part object)
|
|
3604
|
+
if (part?.type && partId) {
|
|
3605
|
+
session.partTypes.set(partId, part.type);
|
|
3606
|
+
}
|
|
3607
|
+
// Resolve the part type: from the part object or from our tracking map
|
|
3608
|
+
const partType = part?.type || (partId ? session.partTypes.get(partId) : undefined) || 'text';
|
|
3609
|
+
// Reasoning end signal #1: the first NON-reasoning part after reasoning was
|
|
3610
|
+
// streaming (the answer or a tool call begins). The most responsive of the
|
|
3611
|
+
// three end signals; `endReasoning` is idempotent so the later
|
|
3612
|
+
// `message.updated` finish / `session.idle` signals are harmless no-ops.
|
|
3613
|
+
// Child parts are excluded: a sub-agent streaming must not collapse the
|
|
3614
|
+
// PARENT's thinking indicator (its own first non-reasoning part — e.g. the
|
|
3615
|
+
// `task` tool call — already ended it).
|
|
3616
|
+
if (partType !== 'reasoning' && !isSubagent)
|
|
3617
|
+
this.endReasoning(key, session);
|
|
3618
|
+
switch (partType) {
|
|
3619
|
+
case 'text':
|
|
3620
|
+
if (isSubagent) {
|
|
3621
|
+
this.handleSubagentTextPart(key, session, delta, field);
|
|
3622
|
+
break;
|
|
3623
|
+
}
|
|
3624
|
+
this.handleTextDelta(key, session, delta, field, messageId);
|
|
3625
|
+
break;
|
|
3626
|
+
case 'tool':
|
|
3627
|
+
this.handleToolPart(key, session, part, isSubagent);
|
|
3628
|
+
break;
|
|
3629
|
+
case 'reasoning':
|
|
3630
|
+
// Child chain-of-thought is never rendered in ANY mode (locked
|
|
3631
|
+
// decision — `getSubagentPartAction` returns 'ignore' for reasoning):
|
|
3632
|
+
// it must not feed the parent's thinking channel.
|
|
3633
|
+
if (isSubagent)
|
|
3634
|
+
break;
|
|
3635
|
+
this.handleReasoningPart(key, session, part, delta, field);
|
|
3636
|
+
break;
|
|
3637
|
+
case 'step-start':
|
|
3638
|
+
case 'step-finish':
|
|
3639
|
+
// Silently skip step markers — tool parts provide better status info
|
|
3640
|
+
break;
|
|
3641
|
+
default:
|
|
3642
|
+
// Log unknown part types for future debugging
|
|
3643
|
+
if (part?.type) {
|
|
3644
|
+
console.log(`[OpenCode] Unhandled part type: ${part.type}`);
|
|
3645
|
+
}
|
|
3646
|
+
break;
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
/**
|
|
3650
|
+
* @description Handle text delta — accumulate and emit as 'output'. Text of the
|
|
3651
|
+
* compaction summary message marks the accumulated response as that summary.
|
|
3652
|
+
*/
|
|
3653
|
+
handleTextDelta(key, session, delta, field, messageId) {
|
|
3654
|
+
// For message.part.delta: only process text field deltas
|
|
3655
|
+
if (field && field !== 'text')
|
|
3656
|
+
return;
|
|
3657
|
+
const text = delta || '';
|
|
3658
|
+
if (!text)
|
|
3659
|
+
return;
|
|
3660
|
+
if (messageId !== undefined && messageId === session.compactionSummaryMessageId)
|
|
3661
|
+
session.isResponseCompactionSummary = true;
|
|
3662
|
+
session.currentResponseText += text;
|
|
3663
|
+
// Debounce: batch rapid SSE deltas before emitting
|
|
3664
|
+
if (session.outputTimer) {
|
|
3665
|
+
clearTimeout(session.outputTimer);
|
|
3666
|
+
}
|
|
3667
|
+
session.outputTimer = setTimeout(() => {
|
|
3668
|
+
session.outputTimer = null;
|
|
3669
|
+
this.emitResponseTail(key, session);
|
|
3670
|
+
}, sseOutputBatchMs);
|
|
3671
|
+
}
|
|
3672
|
+
/**
|
|
3673
|
+
* @description Emit only the part of `currentResponseText` not yet sent as
|
|
3674
|
+
* an `output` event, then advance `lastEmittedLength`. The bot does not
|
|
3675
|
+
* delta-render OpenCode output (`outputsDeltas` is unset), so each emit is a
|
|
3676
|
+
* raw tail of the in-flight response — possibly cut mid-word.
|
|
3677
|
+
*
|
|
3678
|
+
* Every tail except the FIRST of a response carries `isContinuation: true`
|
|
3679
|
+
* (a {@link OutboundHints}). The bot appends a continuation tail to the
|
|
3680
|
+
* same growing Telegram message instead of starting a new one, so the long
|
|
3681
|
+
* reply reads as one message rather than each edit replacing the previous
|
|
3682
|
+
* text. `lastEmittedLength === 0` means this is the first tail of a fresh
|
|
3683
|
+
* response (nothing emitted yet), so it is NOT a continuation. `flushOutput`
|
|
3684
|
+
* resets `lastEmittedLength` to 0 after the final tail, so the next response
|
|
3685
|
+
* again starts with a non-continuation first tail.
|
|
3686
|
+
*/
|
|
3687
|
+
emitResponseTail(key, session, isFinal = false) {
|
|
3688
|
+
const tail = session.currentResponseText.slice(session.lastEmittedLength);
|
|
3689
|
+
if (!tail.trim())
|
|
3690
|
+
return;
|
|
3691
|
+
const isContinuation = session.lastEmittedLength > 0;
|
|
3692
|
+
// `isFinal` rides only the idle-triggered flush so the bot can skip the
|
|
3693
|
+
// possibly-429-stretched debounce for the turn's last frame.
|
|
3694
|
+
const meta = { isContinuation, isFinal };
|
|
3695
|
+
if (session.isResponseCompactionSummary)
|
|
3696
|
+
meta.isCompactionSummary = true;
|
|
3697
|
+
this.emit('output', key, tail, meta);
|
|
3698
|
+
session.lastEmittedLength = session.currentResponseText.length;
|
|
3699
|
+
}
|
|
3700
|
+
/**
|
|
3701
|
+
* @description Handle a SUB-AGENT (child session) text part per the
|
|
3702
|
+
* mode×kind matrix ({@link getSubagentPartAction}):
|
|
3703
|
+
*
|
|
3704
|
+
* - `compact` → never touch any accumulator; signal the dedicated
|
|
3705
|
+
* `subagentStatus` event instead (the bot owns a single self-updating
|
|
3706
|
+
* "🤖 sub-agent: <title> · m:ss" message + elapsed timer). This replaced
|
|
3707
|
+
* the old shared-status refresh, whose lost single-message identity between
|
|
3708
|
+
* sparse bursts re-`sendMessage`d a new message each time (the flood bug).
|
|
3709
|
+
* - `full` → accumulate into the SEPARATE child accumulator and flush via
|
|
3710
|
+
* the same debounce discipline as {@link handleTextDelta} — never through
|
|
3711
|
+
* `currentResponseText` (a child transcript in the parent accumulator
|
|
3712
|
+
* would corrupt the parent reply's continuation accounting).
|
|
3713
|
+
*/
|
|
3714
|
+
handleSubagentTextPart(key, session, delta, field) {
|
|
3715
|
+
if (field && field !== 'text')
|
|
3716
|
+
return;
|
|
3717
|
+
const text = delta || '';
|
|
3718
|
+
if (!text)
|
|
3719
|
+
return;
|
|
3720
|
+
if ((0, subagentRender_1.getSubagentPartAction)(this.getSubagentMode(key), 'text') === 'status') {
|
|
3721
|
+
// Dedicated channel (NOT the shared transient status): the bot keeps ONE
|
|
3722
|
+
// self-updating message with a ticking elapsed counter, edited in place.
|
|
3723
|
+
this.emit('subagentStatus', key, { active: true, title: session.activeSubagentTitle });
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
session.childResponseText += text;
|
|
3727
|
+
if (session.childOutputTimer) {
|
|
3728
|
+
clearTimeout(session.childOutputTimer);
|
|
3729
|
+
}
|
|
3730
|
+
session.childOutputTimer = setTimeout(() => {
|
|
3731
|
+
session.childOutputTimer = null;
|
|
3732
|
+
this.emitChildResponseTail(key, session);
|
|
3733
|
+
}, sseOutputBatchMs);
|
|
3734
|
+
}
|
|
3735
|
+
/**
|
|
3736
|
+
* @description Emit the unsent tail of the CHILD (sub-agent) accumulator as
|
|
3737
|
+
* a marked `output` event (`meta.isSubagent`, `/subagent full` mode only).
|
|
3738
|
+
* Mirrors {@link emitResponseTail} over the dedicated child fields so the
|
|
3739
|
+
* parent's emit cursor is never advanced by child text. Carries NO
|
|
3740
|
+
* `isContinuation`: the bot renders every sub-agent chunk as its own marked
|
|
3741
|
+
* message outside the parent's edit-in-place chain (acceptable v1 — `full`
|
|
3742
|
+
* is opt-in). The empty-string guard doubles as runtime tolerance for test
|
|
3743
|
+
* fixtures that inject minimal session shapes.
|
|
3744
|
+
*/
|
|
3745
|
+
emitChildResponseTail(key, session) {
|
|
3746
|
+
if (!session.childResponseText)
|
|
3747
|
+
return;
|
|
3748
|
+
const tail = session.childResponseText.slice(session.childLastEmittedLength);
|
|
3749
|
+
if (!tail.trim())
|
|
3750
|
+
return;
|
|
3751
|
+
const meta = { isSubagent: true };
|
|
3752
|
+
this.emit('output', key, tail, meta);
|
|
3753
|
+
session.childLastEmittedLength = session.childResponseText.length;
|
|
3754
|
+
}
|
|
3755
|
+
/**
|
|
3756
|
+
* @description Is this part the delegation (`task`) tool still in flight
|
|
3757
|
+
* (pending/running)? Drives both the delegation-title tracking and the
|
|
3758
|
+
* dedicated "🤖 Delegating: <title> …" activity status (S5) — once the
|
|
3759
|
+
* delegation completes/errors the generic ✅/❌ status forms take over.
|
|
3760
|
+
*/
|
|
3761
|
+
checkIsDelegationInFlight(part, toolState) {
|
|
3762
|
+
return part.tool === delegationToolName
|
|
3763
|
+
&& (toolState.status === 'pending' || toolState.status === 'running');
|
|
3764
|
+
}
|
|
3765
|
+
/**
|
|
3766
|
+
* @description Read the delegation's title off a `task` tool part's state:
|
|
3767
|
+
* the explicit `title` when present, else the prompt `description` from the
|
|
3768
|
+
* tool input. `null` when the part carries neither (the status builders fall
|
|
3769
|
+
* back to a localized generic label).
|
|
3770
|
+
*/
|
|
3771
|
+
getDelegationTitle(toolState) {
|
|
3772
|
+
const inputDescription = typeof toolState.input?.description === 'string' ? toolState.input.description : null;
|
|
3773
|
+
return toolState.title || inputDescription;
|
|
3774
|
+
}
|
|
3775
|
+
/**
|
|
3776
|
+
* @description Record / clear the CURRENT delegation's title from the
|
|
3777
|
+
* PARENT's `task` tool part: stored while the delegation is pending/running
|
|
3778
|
+
* (feeds the dedicated sub-agent status message; S5's "Delegating" activity
|
|
3779
|
+
* status reuses this field), cleared once it completes/errors. Only ever
|
|
3780
|
+
* called for the parent's own parts — a child's nested `task` must not
|
|
3781
|
+
* overwrite the parent-level title.
|
|
3782
|
+
*
|
|
3783
|
+
* The title is STICKY (D2 fix): while in-flight we only OVERWRITE it with a
|
|
3784
|
+
* non-null title — a beat where the `task` part momentarily lacks
|
|
3785
|
+
* title/description leaves the last known title intact, instead of clobbering
|
|
3786
|
+
* it to `null` mid-run (which made the line read "sub-agent: sub-agent").
|
|
3787
|
+
* It is cleared to `null` only on the terminal (completed/error) part.
|
|
3788
|
+
*/
|
|
3789
|
+
trackDelegationTitle(session, part, toolState) {
|
|
3790
|
+
if (part.tool !== delegationToolName)
|
|
3791
|
+
return;
|
|
3792
|
+
if (this.checkIsDelegationInFlight(part, toolState)) {
|
|
3793
|
+
const title = this.getDelegationTitle(toolState);
|
|
3794
|
+
if (title)
|
|
3795
|
+
session.activeSubagentTitle = title;
|
|
3796
|
+
return;
|
|
3797
|
+
}
|
|
3798
|
+
session.activeSubagentTitle = null;
|
|
3799
|
+
}
|
|
3800
|
+
/**
|
|
3801
|
+
* @description Handle tool part — format and emit as 'status'.
|
|
3802
|
+
*/
|
|
3803
|
+
handleToolPart(key, session, part, isSubagent = false) {
|
|
3804
|
+
if (!part)
|
|
3805
|
+
return;
|
|
3806
|
+
const toolName = part.tool || 'tool';
|
|
3807
|
+
const state = part.state;
|
|
3808
|
+
if (!state)
|
|
3809
|
+
return;
|
|
3810
|
+
// Whether THIS part is the PARENT's own in-flight delegation whose
|
|
3811
|
+
// "working" indicator is owned by the dedicated `subagentStatus` message
|
|
3812
|
+
// (minimal/short) — in that case the shared-status "Delegating…" emit below
|
|
3813
|
+
// must be SUPPRESSED so the two lines don't compete. Full mode keeps the
|
|
3814
|
+
// shared-status behaviour (the streamed child transcript is the indicator).
|
|
3815
|
+
let suppressSharedDelegatingStatus = false;
|
|
3816
|
+
if (isSubagent) {
|
|
3817
|
+
// Child tool part: `ignore` in compact (a generic 🔧 status would
|
|
3818
|
+
// overwrite the sub-agent status line); transient `status` in full.
|
|
3819
|
+
// Child toolResult BODIES are suppressed in both modes (the return at
|
|
3820
|
+
// the bottom never runs `maybeEmitToolResult` for a child): the
|
|
3821
|
+
// parent's `task` output already carries the child's final result.
|
|
3822
|
+
if ((0, subagentRender_1.getSubagentPartAction)(this.getSubagentMode(key), 'tool') === 'ignore')
|
|
3823
|
+
return;
|
|
3824
|
+
}
|
|
3825
|
+
else {
|
|
3826
|
+
this.trackDelegationTitle(session, part, state);
|
|
3827
|
+
// Parent's own `task` part drives the dedicated sub-agent status message
|
|
3828
|
+
// (minimal/short modes): open/refresh while in flight, close on
|
|
3829
|
+
// completed/error. Full mode keeps the old shared "Delegating…" status.
|
|
3830
|
+
if (part.tool === delegationToolName && this.getSubagentMode(key) !== 'full') {
|
|
3831
|
+
if (this.checkIsDelegationInFlight(part, state)) {
|
|
3832
|
+
this.emit('subagentStatus', key, {
|
|
3833
|
+
active: true,
|
|
3834
|
+
title: this.getDelegationTitle(state) ?? session.activeSubagentTitle,
|
|
3835
|
+
});
|
|
3836
|
+
suppressSharedDelegatingStatus = true;
|
|
3837
|
+
}
|
|
3838
|
+
else if (state.status === 'completed' || state.status === 'error') {
|
|
3839
|
+
this.emit('subagentStatus', key, { active: false, title: null });
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
let statusText;
|
|
3844
|
+
if (this.checkIsDelegationInFlight(part, state)) {
|
|
3845
|
+
// An in-flight delegation mirrors the terminal's "~ Delegating…" line
|
|
3846
|
+
// instead of the generic 🔄/🔧 forms (S5) — the parent-side counterpart
|
|
3847
|
+
// of the compact "🤖 sub-agent: …" status, same style. The title comes
|
|
3848
|
+
// off THIS part's state, so a child's own nested `task` (full mode)
|
|
3849
|
+
// renders its own title, never the parent's tracked one. In minimal/short
|
|
3850
|
+
// the dedicated `subagentStatus` message already owns this indicator, so
|
|
3851
|
+
// skip the competing shared-status emit.
|
|
3852
|
+
if (suppressSharedDelegatingStatus) {
|
|
3853
|
+
if (!isSubagent)
|
|
3854
|
+
this.maybeEmitToolResult(key, session, part);
|
|
3855
|
+
return;
|
|
3856
|
+
}
|
|
3857
|
+
statusText = this.tl(key, () => (0, subagentRender_1.buildDelegatingStatusText)(this.getDelegationTitle(state)));
|
|
3858
|
+
}
|
|
3859
|
+
else {
|
|
3860
|
+
switch (state.status) {
|
|
3861
|
+
case 'pending':
|
|
3862
|
+
statusText = `🔄 ${toolName}...`;
|
|
3863
|
+
break;
|
|
3864
|
+
case 'running':
|
|
3865
|
+
statusText = `🔧 ${state.title || toolName}...`;
|
|
3866
|
+
break;
|
|
3867
|
+
case 'completed':
|
|
3868
|
+
statusText = `✅ ${state.title || toolName}`;
|
|
3869
|
+
break;
|
|
3870
|
+
case 'error':
|
|
3871
|
+
statusText = `❌ ${toolName}: ${state.error || 'failed'}`;
|
|
3872
|
+
break;
|
|
3873
|
+
default:
|
|
3874
|
+
statusText = `🔧 ${toolName}`;
|
|
3875
|
+
break;
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
this.emitStatus(key, session, statusText);
|
|
3879
|
+
if (!isSubagent)
|
|
3880
|
+
this.maybeEmitToolResult(key, session, part);
|
|
3881
|
+
}
|
|
3882
|
+
/**
|
|
3883
|
+
* @description Emit the dedicated MODE-AGNOSTIC `toolResult` event once a
|
|
3884
|
+
* tool part reaches `completed` with a non-empty output (S3). The bot
|
|
3885
|
+
* resolves the per-thread tool-results mode (`minimal` drops it there) — the
|
|
3886
|
+
* adapter emits every result exactly once, guarded by
|
|
3887
|
+
* {@link OpenCodeSession.emittedToolResultPartIds} against the re-sent part
|
|
3888
|
+
* shapes. The output is kept strictly OUT of `currentResponseText` so it can
|
|
3889
|
+
* never pollute the answer or its continuation accounting.
|
|
3890
|
+
*/
|
|
3891
|
+
maybeEmitToolResult(key, session, part) {
|
|
3892
|
+
const toolState = part.state;
|
|
3893
|
+
if (toolState?.status !== 'completed')
|
|
3894
|
+
return;
|
|
3895
|
+
if (typeof toolState.output !== 'string' || !toolState.output.trim())
|
|
3896
|
+
return;
|
|
3897
|
+
// A part id is required for the double-emit guard. Only full
|
|
3898
|
+
// `message.part.updated` part objects reach handleToolPart, and those
|
|
3899
|
+
// always carry an id — an id-less shape is safer dropped than duplicated.
|
|
3900
|
+
if (!part.id || session.emittedToolResultPartIds.has(part.id))
|
|
3901
|
+
return;
|
|
3902
|
+
session.emittedToolResultPartIds.add(part.id);
|
|
3903
|
+
const payload = { tool: part.tool || 'tool', output: toolState.output };
|
|
3904
|
+
if (toolState.title)
|
|
3905
|
+
payload.title = toolState.title;
|
|
3906
|
+
this.emit('toolResult', key, payload);
|
|
3907
|
+
}
|
|
3908
|
+
/** Debounce delay (ms) for live `thinking` emits — the reasoning delta
|
|
3909
|
+
* stream is chatty, so live frames are coalesced like text deltas. */
|
|
3910
|
+
static reasoningLiveEmitMs = 400;
|
|
3911
|
+
/**
|
|
3912
|
+
* @description Handle a reasoning (chain-of-thought) part.
|
|
3913
|
+
*
|
|
3914
|
+
* Reasoning text is accumulated into {@link OpenCodeSession.reasoningText}
|
|
3915
|
+
* — kept STRICTLY separate from `currentResponseText` so it never leaks into
|
|
3916
|
+
* the answer — and surfaced through the DEDICATED `thinking` event (not the
|
|
3917
|
+
* generic `status` coalescer), so the thinking indicator can persist
|
|
3918
|
+
* independently of tool status. The adapter stays MODE-AGNOSTIC: it emits the
|
|
3919
|
+
* raw accumulated text + phase, and the bot applies the per-thread mode.
|
|
3920
|
+
*
|
|
3921
|
+
* On the FIRST reasoning delta of a response we record `reasoningStartedAt`
|
|
3922
|
+
* (drives the "thought for {N}s" duration on the eventual `done` emit). Live
|
|
3923
|
+
* emits are debounced.
|
|
3924
|
+
*/
|
|
3925
|
+
handleReasoningPart(key, session, part, delta, field) {
|
|
3926
|
+
// For message.part.delta only the reasoning `text` field carries content.
|
|
3927
|
+
if (field && field !== 'text')
|
|
3928
|
+
return;
|
|
3929
|
+
const text = delta || part?.text || '';
|
|
3930
|
+
if (!text)
|
|
3931
|
+
return;
|
|
3932
|
+
if (session.reasoningStartedAt === null) {
|
|
3933
|
+
session.reasoningStartedAt = Date.now();
|
|
3934
|
+
}
|
|
3935
|
+
session.reasoningText += text;
|
|
3936
|
+
// Debounce the live emit — newest accumulated text always wins.
|
|
3937
|
+
if (session.reasoningTimer)
|
|
3938
|
+
clearTimeout(session.reasoningTimer);
|
|
3939
|
+
session.reasoningTimer = setTimeout(() => {
|
|
3940
|
+
session.reasoningTimer = null;
|
|
3941
|
+
this.emitThinking(key, session, 'live');
|
|
3942
|
+
}, OpenCodeAdapter.reasoningLiveEmitMs);
|
|
3943
|
+
}
|
|
3944
|
+
/**
|
|
3945
|
+
* @description Emit the `thinking` event for the current phase. `live` carries
|
|
3946
|
+
* the accumulated reasoning text so far; `done` adds the elapsed `durationMs`.
|
|
3947
|
+
*/
|
|
3948
|
+
emitThinking(key, session, phase) {
|
|
3949
|
+
const payload = { phase, text: session.reasoningText };
|
|
3950
|
+
if (phase === 'done' && session.reasoningStartedAt !== null) {
|
|
3951
|
+
payload.durationMs = Date.now() - session.reasoningStartedAt;
|
|
3952
|
+
}
|
|
3953
|
+
this.emit('thinking', key, payload);
|
|
3954
|
+
}
|
|
3955
|
+
/**
|
|
3956
|
+
* @description End the current response's reasoning stream, if one is active.
|
|
3957
|
+
* Flushes any pending live frame, emits the `thinking` `done` event (so the
|
|
3958
|
+
* bot can collapse / persist per mode), and resets the per-response reasoning
|
|
3959
|
+
* accumulator. Idempotent — a no-op when no reasoning is in flight, so it can
|
|
3960
|
+
* be called from every "reasoning end" signal (first non-reasoning part,
|
|
3961
|
+
* `message.updated` finish, `session.idle`) without double-emitting.
|
|
3962
|
+
*/
|
|
3963
|
+
endReasoning(key, session) {
|
|
3964
|
+
if (session.reasoningStartedAt === null)
|
|
3965
|
+
return;
|
|
3966
|
+
if (session.reasoningTimer) {
|
|
3967
|
+
clearTimeout(session.reasoningTimer);
|
|
3968
|
+
session.reasoningTimer = null;
|
|
3969
|
+
}
|
|
3970
|
+
this.emitThinking(key, session, 'done');
|
|
3971
|
+
session.reasoningText = '';
|
|
3972
|
+
session.reasoningStartedAt = null;
|
|
3973
|
+
}
|
|
3974
|
+
/** Debounce delay (ms) for status updates to avoid Telegram rate limits */
|
|
3975
|
+
static statusDebounceMs = 400;
|
|
3976
|
+
/**
|
|
3977
|
+
* @description Debounced emit of 'status' event.
|
|
3978
|
+
* Rapid status updates (e.g. multiple tool state changes) are batched —
|
|
3979
|
+
* only the latest status text is emitted after a quiet period.
|
|
3980
|
+
*/
|
|
3981
|
+
emitStatus(key, session, text) {
|
|
3982
|
+
session.pendingStatus = text;
|
|
3983
|
+
if (session.statusDebounceTimer) {
|
|
3984
|
+
clearTimeout(session.statusDebounceTimer);
|
|
3985
|
+
}
|
|
3986
|
+
session.statusDebounceTimer = setTimeout(() => {
|
|
3987
|
+
session.statusDebounceTimer = null;
|
|
3988
|
+
if (session.pendingStatus) {
|
|
3989
|
+
this.emit('status', key, session.pendingStatus);
|
|
3990
|
+
session.pendingStatus = null;
|
|
3991
|
+
}
|
|
3992
|
+
}, OpenCodeAdapter.statusDebounceMs);
|
|
3993
|
+
}
|
|
3994
|
+
/**
|
|
3995
|
+
* @description Handle message completion.
|
|
3996
|
+
* Event properties: { info: OpenCodeMessageInfo }
|
|
3997
|
+
*/
|
|
3998
|
+
handleMessageUpdate(key, properties) {
|
|
3999
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4000
|
+
if (!session?.isActive)
|
|
4001
|
+
return;
|
|
4002
|
+
const info = properties.info;
|
|
4003
|
+
if (!info)
|
|
4004
|
+
return;
|
|
4005
|
+
const isParentAssistantMessage = info.role === 'assistant' && (!info.sessionID || info.sessionID === session.sessionId);
|
|
4006
|
+
const modelLabel = info.modelID
|
|
4007
|
+
? (info.providerID ? `${info.providerID}/${info.modelID}` : info.modelID)
|
|
4008
|
+
: null;
|
|
4009
|
+
const selectedModelLabel = session.modelOverride
|
|
4010
|
+
? `${session.modelOverride.providerID}/${session.modelOverride.modelID}`
|
|
4011
|
+
: null;
|
|
4012
|
+
const isCompleteConflictingModel = Boolean(selectedModelLabel !== null
|
|
4013
|
+
&& info.providerID
|
|
4014
|
+
&& info.modelID
|
|
4015
|
+
&& modelLabel !== selectedModelLabel);
|
|
4016
|
+
const isAbortedRetryMessage = isParentAssistantMessage
|
|
4017
|
+
&& session.isAwaitingModelAfterProviderRetryAbort
|
|
4018
|
+
&& ((info.error !== undefined && checkIsOpenCodeAbortError(getOpenCodeErrorMessage(info.error)))
|
|
4019
|
+
|| isCompleteConflictingModel);
|
|
4020
|
+
if (isAbortedRetryMessage) {
|
|
4021
|
+
// The event is stale for this turn, but newer than any reattach history.
|
|
4022
|
+
// Record that ordering without retaining the aborted runtime tuple.
|
|
4023
|
+
session.parentAssistantObservationVersion += 1;
|
|
4024
|
+
console.log(`[OpenCode] Ignoring model from aborted provider retry: ${modelLabel ?? 'unknown'}`);
|
|
4025
|
+
return;
|
|
4026
|
+
}
|
|
4027
|
+
// An assistant message (own or sub-agent child) means the turn genuinely
|
|
4028
|
+
// started — the SOLE wedged-turn activity signal. Must be assistant-only:
|
|
4029
|
+
// `prompt_async` also emits message/part events for the ECHOED USER PROMPT,
|
|
4030
|
+
// and counting those (the old unconditional set in handlePartUpdate) marked
|
|
4031
|
+
// every wedge as "active" and masked it (live miss 2026-08-16).
|
|
4032
|
+
if (info.role === 'assistant')
|
|
4033
|
+
session.sawTurnActivity = true;
|
|
4034
|
+
// The summary message is stored (and announced here) before its text streams.
|
|
4035
|
+
if (isParentAssistantMessage && info.summary === true && info.id)
|
|
4036
|
+
session.compactionSummaryMessageId = info.id;
|
|
4037
|
+
if (info.role === 'user' && (!info.sessionID || info.sessionID === session.sessionId)) {
|
|
4038
|
+
this.noteUserMessageSeen(session, info.id);
|
|
4039
|
+
}
|
|
4040
|
+
if (isParentAssistantMessage) {
|
|
4041
|
+
// The first non-stale parent assistant event marks the replacement turn's
|
|
4042
|
+
// boundary, even when it lacks the optional runtime token tuple.
|
|
4043
|
+
session.isAwaitingModelAfterProviderRetryAbort = false;
|
|
4044
|
+
this.clearProviderRetryReplacementBoundary(session);
|
|
4045
|
+
session.isAwaitingProviderRetryAbortIdle = false;
|
|
4046
|
+
const contextUsedTokens = getOpenCodeContextUsedTokens(info.tokens);
|
|
4047
|
+
if (typeof info.id === 'string'
|
|
4048
|
+
&& info.id.trim()
|
|
4049
|
+
&& contextUsedTokens !== null
|
|
4050
|
+
&& !checkIsOpenCodePendingContextPlaceholder(info.finish, contextUsedTokens)) {
|
|
4051
|
+
session.parentAssistantObservationVersion += 1;
|
|
4052
|
+
const model = getOpenCodeRuntimeModel(info.providerID, info.modelID);
|
|
4053
|
+
session.latestParentRuntimeContext = {
|
|
4054
|
+
messageId: info.id,
|
|
4055
|
+
...(model ? { model } : {}),
|
|
4056
|
+
contextUsedTokens,
|
|
4057
|
+
};
|
|
4058
|
+
}
|
|
4059
|
+
}
|
|
4060
|
+
// Track and show model info on first assistant message (or after model change)
|
|
4061
|
+
if (info.role === 'assistant' && modelLabel !== null) {
|
|
4062
|
+
session.currentModelLabel = modelLabel;
|
|
4063
|
+
if (!session.isModelInfoShown) {
|
|
4064
|
+
session.isModelInfoShown = true;
|
|
4065
|
+
console.log(`[OpenCode] Using model: ${modelLabel}`);
|
|
4066
|
+
this.emit('output', key, `Model: ${modelLabel}`);
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
// When assistant message completes (has finish reason), flush output
|
|
4070
|
+
if (info.finish && info.role === 'assistant') {
|
|
4071
|
+
// A CHILD (sub-agent) assistant message finishing must NOT close the
|
|
4072
|
+
// dedicated sub-agent status — the parent's delegation is still in flight.
|
|
4073
|
+
// Only the PARENT's own finishing message ends the delegation defensively.
|
|
4074
|
+
const isParentMessage = !info.sessionID || info.sessionID === session.sessionId;
|
|
4075
|
+
if (isParentMessage) {
|
|
4076
|
+
// Anchor the seen-watermark on the PARENT turn's final assistant message
|
|
4077
|
+
// id (a child's id must never become the watermark).
|
|
4078
|
+
if (info.id)
|
|
4079
|
+
session.lastMessageId = info.id;
|
|
4080
|
+
this.closeSubagentStatusOnParentTurnEnd(key);
|
|
4081
|
+
// S7: advance the persisted watermark on EACH parent-message `finish`,
|
|
4082
|
+
// not only at turn-end idle. The bot relays every completed assistant
|
|
4083
|
+
// message live (`flushOutput` below), so tracking each finished parent id
|
|
4084
|
+
// keeps `missedCount` at what was genuinely unseen — a mid-turn restart
|
|
4085
|
+
// after these messages already landed re-shows nothing (the false
|
|
4086
|
+
// "missed N" fix, live 2026-07-04). The `session.idle` advance stays as
|
|
4087
|
+
// the final safety net (a dropped `finish`); child messages never reach
|
|
4088
|
+
// here (the `isParentMessage` guard).
|
|
4089
|
+
this.advanceSeenWatermark(key, { sessionId: session.sessionId, opencodeMessageId: session.lastMessageId });
|
|
4090
|
+
}
|
|
4091
|
+
this.flushOutput(key);
|
|
4092
|
+
}
|
|
4093
|
+
// Surface errors to the user — except a bot-issued abort ("Aborted"), which
|
|
4094
|
+
// the aborted turn's assistant message also carries in `info.error`. It is
|
|
4095
|
+
// swallowed for the same reason as the `session.error` twin above (the
|
|
4096
|
+
// question-cancel / `/esc` / provider-retry interrupt is ours, not a fault).
|
|
4097
|
+
if (info.error) {
|
|
4098
|
+
const errorMsg = getOpenCodeErrorMessage(info.error);
|
|
4099
|
+
if (checkIsOpenCodeAbortError(errorMsg)) {
|
|
4100
|
+
console.log(`[OpenCode] Swallowed bot-issued abort (message.error "Aborted") for ${(0, sessionKey_1.keyToString)(key)}`);
|
|
4101
|
+
}
|
|
4102
|
+
else {
|
|
4103
|
+
console.error(`[OpenCode] Message error:`, errorMsg);
|
|
4104
|
+
this.emit('output', key, `Error: ${errorMsg}`);
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
/**
|
|
4109
|
+
* @description Handle session becoming idle (AI done processing).
|
|
4110
|
+
* Flush any remaining accumulated output.
|
|
4111
|
+
*/
|
|
4112
|
+
handleSessionIdle(key, properties) {
|
|
4113
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4114
|
+
if (!session?.isActive)
|
|
4115
|
+
return;
|
|
4116
|
+
const sessionId = properties.sessionID;
|
|
4117
|
+
if (sessionId && !(0, openCodeSessionRouting_1.checkIsEventForSession)(sessionId, session.sessionId, this.sessionLineage))
|
|
4118
|
+
return;
|
|
4119
|
+
const isOwnIdle = !sessionId || sessionId === session.sessionId;
|
|
4120
|
+
if (isOwnIdle &&
|
|
4121
|
+
// A turn we are actively waiting on owns its idle: the abort guard may
|
|
4122
|
+
// still be armed when the replacement bound expired without the aborted
|
|
4123
|
+
// retry's idle ever arriving, and the recovery prompt that follows would
|
|
4124
|
+
// otherwise have ITS idle swallowed — hanging the topic one prompt later
|
|
4125
|
+
// and stalling the escalation. During the legitimate swallow window this
|
|
4126
|
+
// flag is false by construction (a retry replacement defers arming it to
|
|
4127
|
+
// its own `busy`, which clears the guards as it arms).
|
|
4128
|
+
!session.awaitingTurnResponse &&
|
|
4129
|
+
(session.isAwaitingProviderRetryAbortIdle || session.isAwaitingProviderRetryReplacementStart)) {
|
|
4130
|
+
// The abort completes the old retry turn asynchronously. Its idle must not
|
|
4131
|
+
// settle or recover the replacement prompt that was posted after the abort.
|
|
4132
|
+
// Swallowing is bounded on both flags: this one is a one-shot cleared right
|
|
4133
|
+
// here, and the replacement boundary is released by its own `busy` or, if
|
|
4134
|
+
// that never comes, by `providerRetryReplacementStartTimer`. Neither can
|
|
4135
|
+
// latch idle handling off for the rest of the session.
|
|
4136
|
+
session.isAwaitingProviderRetryAbortIdle = false;
|
|
4137
|
+
console.log(`[OpenCode] Ignoring idle from aborted provider retry`);
|
|
4138
|
+
return;
|
|
4139
|
+
}
|
|
4140
|
+
// Snapshot the wedged-turn guards BEFORE the own-idle clears below reset
|
|
4141
|
+
// them: a legitimate compaction cycle and a still-pending provider retry
|
|
4142
|
+
// both idle without assistant text and must NOT be flagged as wedged.
|
|
4143
|
+
// `Boolean(...)` (not `!== null`) so a reattached session whose field is
|
|
4144
|
+
// `undefined` — never set by the resume path — reads as "no retry" instead
|
|
4145
|
+
// of silently suppressing the wedge notice (live miss 2026-08-16).
|
|
4146
|
+
const wasCompacting = session.isCompacting;
|
|
4147
|
+
const hadPendingProviderRetry = Boolean(session.providerRetrySignature);
|
|
4148
|
+
// Idle = busy/idle transition to idle; also a defensive clear if a
|
|
4149
|
+
// `session.status` idle was missed. Own idle also ends any compaction
|
|
4150
|
+
// (the `.ended` / `session.compacted` event may be lost) — without this the
|
|
4151
|
+
// latched flag would force every later prompt down the queue path.
|
|
4152
|
+
if (isOwnIdle) {
|
|
4153
|
+
session.isCompacting = false;
|
|
4154
|
+
session.providerRetrySignature = null;
|
|
4155
|
+
}
|
|
4156
|
+
applyOpenCodeStatusEvent(session, session.sessionId, sessionId ?? null, false, this.checkIsVerifiedDescendant(sessionId ?? null, session.sessionId));
|
|
4157
|
+
// The PARENT going idle ends the turn → defensively close the sub-agent
|
|
4158
|
+
// status. A CHILD (sub-agent) idle (routed here via lineage) must NOT close
|
|
4159
|
+
// it — the delegation is still in flight, only the child finished a step.
|
|
4160
|
+
if (!sessionId || sessionId === session.sessionId) {
|
|
4161
|
+
this.closeSubagentStatusOnParentTurnEnd(key);
|
|
4162
|
+
}
|
|
4163
|
+
console.log(`[OpenCode] Session idle`);
|
|
4164
|
+
// The turn just ended: mark this flush final so the bot delivers the last
|
|
4165
|
+
// frame promptly instead of letting it sit out a stretched 429 debounce.
|
|
4166
|
+
this.flushOutput(key, true);
|
|
4167
|
+
// Advance the seen-watermark only on the PARENT's own idle — a sub-agent
|
|
4168
|
+
// child idle (routed here via lineage) leaves the parent turn in flight.
|
|
4169
|
+
// Anchored on the last completed parent assistant message id (captured in
|
|
4170
|
+
// handleMessageUpdate); a missing anchor is skipped (recap falls back).
|
|
4171
|
+
// S7: this is now the SAFETY NET — the primary advance runs per parent
|
|
4172
|
+
// `finish` in handleMessageUpdate, so a mid-turn restart already sees the
|
|
4173
|
+
// relayed tail; idle still fires in case a `finish` was dropped.
|
|
4174
|
+
if (isOwnIdle) {
|
|
4175
|
+
this.advanceSeenWatermark(key, { sessionId: session.sessionId, opencodeMessageId: session.lastMessageId });
|
|
4176
|
+
}
|
|
4177
|
+
// Wedged-turn detection: a delivered prompt that produced NO assistant
|
|
4178
|
+
// activity before idling means OpenCode accepted it but never ran a turn
|
|
4179
|
+
// (a bloated / stuck session — live 2026-08-15/16). Emit `noResponse` so the
|
|
4180
|
+
// bot can auto-recover (fresh session + replay); the session is
|
|
4181
|
+
// unrecoverable in place, so a notice alone leaves the topic dead. Resolved
|
|
4182
|
+
// once per prompt: the pending flag is cleared on this own idle regardless.
|
|
4183
|
+
if (isOwnIdle && session.awaitingTurnResponse) {
|
|
4184
|
+
const isWedged = (0, openCodeTurnActivity_1.checkIsWedgedTurn)({
|
|
4185
|
+
awaitingResponse: true,
|
|
4186
|
+
sawActivity: session.sawTurnActivity,
|
|
4187
|
+
wasCompacting,
|
|
4188
|
+
hadPendingProviderRetry,
|
|
4189
|
+
});
|
|
4190
|
+
session.awaitingTurnResponse = false;
|
|
4191
|
+
if (isWedged)
|
|
4192
|
+
this.reportNoTurnResponse(key, session, 'wedgedIdle');
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
/**
|
|
4196
|
+
* @description Track live busy/idle/retry from `session.status`. The own
|
|
4197
|
+
* session's status drives {@link OpenCodeSession.isBusy}; provider-managed
|
|
4198
|
+
* retry remains busy and is surfaced once, because OpenCode otherwise accepts
|
|
4199
|
+
* later prompts into an unread queue with no user-visible explanation. A
|
|
4200
|
+
* lineage-verified child
|
|
4201
|
+
* (sub-agent) status maintains {@link OpenCodeSession.busyChildSessionIds}
|
|
4202
|
+
* so a new prompt never aborts a running sub-agent. A foreign non-descendant
|
|
4203
|
+
* session's busy=true (dir-fallback-routed wedged sibling) is ignored —
|
|
4204
|
+
* see {@link applyOpenCodeStatusEvent}.
|
|
4205
|
+
*/
|
|
4206
|
+
handleSessionStatus(key, eventSessionId, properties) {
|
|
4207
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4208
|
+
if (!session?.isActive)
|
|
4209
|
+
return;
|
|
4210
|
+
const status = properties.status;
|
|
4211
|
+
const isProviderRetry = status?.type === 'retry';
|
|
4212
|
+
const wasForeignBusyIgnored = applyOpenCodeStatusEvent(session, session.sessionId, eventSessionId, status?.type === 'busy' || isProviderRetry, this.checkIsVerifiedDescendant(eventSessionId, session.sessionId));
|
|
4213
|
+
if (wasForeignBusyIgnored && eventSessionId) {
|
|
4214
|
+
this.logForeignBusyIgnoredOncePerWindow(eventSessionId, session.sessionId);
|
|
4215
|
+
}
|
|
4216
|
+
const isOwnSession = !eventSessionId || eventSessionId === session.sessionId;
|
|
4217
|
+
if (isOwnSession
|
|
4218
|
+
&& isProviderRetry
|
|
4219
|
+
&& (session.providerRetryAbortPromise
|
|
4220
|
+
|| (session.isAwaitingProviderRetryReplacementStart
|
|
4221
|
+
&& (session.isAwaitingProviderRetryAbortIdle || session.isAwaitingModelAfterProviderRetryAbort)))) {
|
|
4222
|
+
// A retry status that races the abort belongs to the cancelled turn. It
|
|
4223
|
+
// must not restore the retry guard for the prompt we are about to send.
|
|
4224
|
+
console.log(`[OpenCode] Ignoring retry status from aborted provider retry`);
|
|
4225
|
+
return;
|
|
4226
|
+
}
|
|
4227
|
+
if (!isOwnSession)
|
|
4228
|
+
return;
|
|
4229
|
+
if (status?.type === 'busy' && session.isAwaitingProviderRetryReplacementStart) {
|
|
4230
|
+
// `busy` is the observable boundary for the replacement request. Older
|
|
4231
|
+
// retry termination events can no longer consume this new turn's idle, and
|
|
4232
|
+
// the bound on this wait is released with the boundary it guarded.
|
|
4233
|
+
this.clearProviderRetryReplacementBoundary(session);
|
|
4234
|
+
session.isAwaitingProviderRetryAbortIdle = false;
|
|
4235
|
+
session.awaitingTurnResponse = true;
|
|
4236
|
+
session.sawTurnActivity = false;
|
|
4237
|
+
}
|
|
4238
|
+
if (!isProviderRetry) {
|
|
4239
|
+
if (!session.providerRetryAbortPromise) {
|
|
4240
|
+
session.providerRetrySignature = null;
|
|
4241
|
+
}
|
|
4242
|
+
return;
|
|
4243
|
+
}
|
|
4244
|
+
const retryAttempt = typeof status.attempt === 'number' ? status.attempt : 1;
|
|
4245
|
+
const retryAt = typeof status.next === 'number' ? status.next : Date.now();
|
|
4246
|
+
const retryMessage = typeof status.message === 'string' ? status.message : '';
|
|
4247
|
+
const retrySignature = `${retryAttempt}:${retryAt}:${retryMessage}`;
|
|
4248
|
+
if (session.providerRetrySignature === retrySignature)
|
|
4249
|
+
return;
|
|
4250
|
+
session.providerRetrySignature = retrySignature;
|
|
4251
|
+
const retryMinutes = Math.max(1, Math.ceil((retryAt - Date.now()) / providerRetryMsPerMinute));
|
|
4252
|
+
console.warn(`[OpenCode] Provider retry attempt=${retryAttempt} in=${retryMinutes}m: ${retryMessage}`);
|
|
4253
|
+
this.emit('output', key, this.tl(key, () => (0, i18n_1.t)('apiRetry.transientNotice', { minutes: retryMinutes, attempt: retryAttempt })), { isComplete: true });
|
|
4254
|
+
}
|
|
4255
|
+
/**
|
|
4256
|
+
* @description Strict-descendant verification for busy tracking: `true` only
|
|
4257
|
+
* when `eventSessionId` walks up the lineage map to `ownSessionId` — never
|
|
4258
|
+
* for the own id itself or a foreign sibling. Distinct from
|
|
4259
|
+
* {@link checkIsEventForSession} (own-or-descendant), which routing uses.
|
|
4260
|
+
*/
|
|
4261
|
+
checkIsVerifiedDescendant(eventSessionId, ownSessionId) {
|
|
4262
|
+
return (eventSessionId !== null &&
|
|
4263
|
+
(0, openCodeSessionRouting_1.getLineageDepthToAncestor)(eventSessionId, ownSessionId, this.sessionLineage) !== null);
|
|
4264
|
+
}
|
|
4265
|
+
/**
|
|
4266
|
+
* @description Diag-log one "busy-ignored" line for a foreign non-descendant
|
|
4267
|
+
* busy=true that busy tracking refused to record, throttled per session id
|
|
4268
|
+
* like {@link logSseDropOncePerWindow} — a wedged sibling re-emits
|
|
4269
|
+
* `session.status` every ~30 s and would otherwise flood the diag log.
|
|
4270
|
+
*/
|
|
4271
|
+
logForeignBusyIgnoredOncePerWindow(eventSessionId, ownSessionId) {
|
|
4272
|
+
const shouldLog = (0, openCodeSessionRouting_1.checkShouldLogDrop)(this.sseDropLogThrottle, foreignBusyIgnoredLogType, eventSessionId, Date.now(), sseDropLogThrottleMs, maxSseDropThrottleEntries);
|
|
4273
|
+
if (shouldLog) {
|
|
4274
|
+
(0, diagLog_1.appendDiagLog)(`sse busy-ignored foreign es=${eventSessionId} own=${ownSessionId}`);
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
/**
|
|
4278
|
+
* @description Flip the compaction flag from `session.next.compaction.*` /
|
|
4279
|
+
* `session.compacted` events. While set, a new prompt queues instead of
|
|
4280
|
+
* aborting (an abort would discard the in-progress summary).
|
|
4281
|
+
*/
|
|
4282
|
+
setCompacting(key, value) {
|
|
4283
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4284
|
+
if (!session?.isActive)
|
|
4285
|
+
return;
|
|
4286
|
+
if (session.isCompacting !== value)
|
|
4287
|
+
console.log(`[OpenCode] compacting=${value}`);
|
|
4288
|
+
session.isCompacting = value;
|
|
4289
|
+
}
|
|
4290
|
+
handleSessionError(key, eventSessionId, properties) {
|
|
4291
|
+
const errorMsg = getOpenCodeErrorMessage(properties.error);
|
|
4292
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4293
|
+
const isOwnSession = !eventSessionId || (session !== undefined && eventSessionId === session.sessionId);
|
|
4294
|
+
// A bare "Aborted" error is ALWAYS a bot-issued abort (question-cancel SIGINT
|
|
4295
|
+
// / `/esc` / provider-retry interrupt) — never surface it as "OpenCode error:
|
|
4296
|
+
// Aborted" (the reported third bogus message on the question-cancel path).
|
|
4297
|
+
// The turn DID end, so still close the sub-agent status for the own session.
|
|
4298
|
+
if (checkIsOpenCodeAbortError(errorMsg)) {
|
|
4299
|
+
console.log(`[OpenCode] Swallowed bot-issued abort (session.error "Aborted") for ${(0, sessionKey_1.keyToString)(key)}`);
|
|
4300
|
+
if (session?.isActive && isOwnSession) {
|
|
4301
|
+
// OpenCode may emit the terminal idle after this error. Keep its
|
|
4302
|
+
// one-shot shield armed until idle or the replacement's busy boundary.
|
|
4303
|
+
this.closeSubagentStatusOnParentTurnEnd(key);
|
|
4304
|
+
}
|
|
4305
|
+
return;
|
|
4306
|
+
}
|
|
4307
|
+
console.error(`[OpenCode] Session error:`, errorMsg);
|
|
4308
|
+
this.emit('output', key, `OpenCode error: ${errorMsg}`);
|
|
4309
|
+
// A PARENT-session error aborts the current turn/generation, so the
|
|
4310
|
+
// delegation the dedicated sub-agent status describes is no longer running:
|
|
4311
|
+
// close it defensively (mirrors the parent-idle / parent-finish closes) so
|
|
4312
|
+
// an error that never reaches a clean idle can't leave the "working" frame
|
|
4313
|
+
// and its self-re-arming tick wedged, flooding the topic (live 2026-08-03).
|
|
4314
|
+
// A CHILD (sub-agent) error must NOT close it — the parent delegation is
|
|
4315
|
+
// still in flight (guarded the same way as `handleSessionIdle`). If an
|
|
4316
|
+
// auto-retry re-delegates, a fresh `subagentStatus{active:true}` re-opens it.
|
|
4317
|
+
if (session?.isActive && isOwnSession) {
|
|
4318
|
+
this.closeSubagentStatusOnParentTurnEnd(key);
|
|
4319
|
+
}
|
|
4320
|
+
// Provider-side API error at the proxy boundary → emit `apiError`. transient
|
|
4321
|
+
// / usageLimit arm the auto-retry (the session stays active after
|
|
4322
|
+
// `session.error`, so the kick reuses it); `auth` (logged out / bad
|
|
4323
|
+
// credentials) is SURFACED as a pinned notice by the bot, never retried.
|
|
4324
|
+
// Unrecognised errors classify to `null` and emit nothing.
|
|
4325
|
+
//
|
|
4326
|
+
// Classified from the RAW payload, not from `errorMsg`: the surfaced text
|
|
4327
|
+
// collapses an unrecognised shape to a constant that carries no marker to
|
|
4328
|
+
// classify. The raw text stops here — the classifier returns only a class.
|
|
4329
|
+
const apiError = (0, apiErrorRetry_1.classifyAgentApiError)(getOpenCodeErrorClassificationText(properties.error), Date.now());
|
|
4330
|
+
if (apiError) {
|
|
4331
|
+
this.emit('apiError', key, apiError);
|
|
4332
|
+
}
|
|
4333
|
+
}
|
|
4334
|
+
handlePermissionAsked(_key, properties, directory) {
|
|
4335
|
+
console.log(`[OpenCode] Permission requested:`, JSON.stringify(properties));
|
|
4336
|
+
// Auto-approve all permissions (headless mode). Symmetry with the
|
|
4337
|
+
// claude adapter, which always passes `--dangerously-skip-permissions`
|
|
4338
|
+
// (plan D44, §10.2). Per-thread opt-out (S1) was deliberately rejected
|
|
4339
|
+
// — if it ever comes back, this is where it'd hook in.
|
|
4340
|
+
const requestId = (properties.requestID || properties.id);
|
|
4341
|
+
if (requestId) {
|
|
4342
|
+
// Permission state is instance-local, like questions: an approve sent
|
|
4343
|
+
// without the owning `?directory=` 404s silently and the agent hangs
|
|
4344
|
+
// on the permission forever.
|
|
4345
|
+
this.apiRequest('POST', buildDirectoryScopedPath(`/permission/${requestId}/reply`, directory), {
|
|
4346
|
+
reply: 'always',
|
|
4347
|
+
}).catch((e) => {
|
|
4348
|
+
console.error(`[OpenCode] Failed to reply to permission:`, e);
|
|
4349
|
+
});
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
/**
|
|
4353
|
+
* @description Handle question.asked events from OpenCode.
|
|
4354
|
+
* Stores the pending question and emits a 'question' event so the bot
|
|
4355
|
+
* can display it to the user with interactive buttons.
|
|
4356
|
+
*
|
|
4357
|
+
* Event properties: { id, sessionID, questions: QuestionInfo[], tool? }
|
|
4358
|
+
*/
|
|
4359
|
+
handleQuestionAsked(key, properties, directory) {
|
|
4360
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4361
|
+
if (!session?.isActive)
|
|
4362
|
+
return;
|
|
4363
|
+
const requestId = (properties.requestID || properties.id);
|
|
4364
|
+
const questions = properties.questions;
|
|
4365
|
+
console.log(`[OpenCode] Question asked (${requestId}, instance=${directory ?? 'default'}):`, JSON.stringify(properties).slice(0, 500));
|
|
4366
|
+
if (!requestId || !questions || questions.length === 0) {
|
|
4367
|
+
// No valid question — reply empty to unblock
|
|
4368
|
+
if (requestId) {
|
|
4369
|
+
this.apiRequest('POST', buildDirectoryScopedPath(`/question/${requestId}/reply`, directory), {
|
|
4370
|
+
answers: [['']],
|
|
4371
|
+
}).catch((e) => console.error(`[OpenCode] Failed to reply to question:`, e));
|
|
4372
|
+
}
|
|
4373
|
+
return;
|
|
4374
|
+
}
|
|
4375
|
+
// Store pending question so we can reply later when user answers.
|
|
4376
|
+
// `directory` rides along: the reply must target the instance that owns
|
|
4377
|
+
// the request, not the serve-cwd default (QuestionNotFoundError bug —
|
|
4378
|
+
// sessions live in whatever instance was current at their creation).
|
|
4379
|
+
session.pendingQuestion = { requestId, questions, directory };
|
|
4380
|
+
// Emit question event for the bot to display to user
|
|
4381
|
+
this.emit('question', key, session.pendingQuestion);
|
|
4382
|
+
}
|
|
4383
|
+
/**
|
|
4384
|
+
* @description Re-surface a question that was still open on the server when
|
|
4385
|
+
* the bot lost track of it — the reattach/resume path rebuilds the session
|
|
4386
|
+
* with `pendingQuestion: null`, so after ANY bot restart an unanswered
|
|
4387
|
+
* question would otherwise be forgotten and the topic hangs forever (the
|
|
4388
|
+
* break-out that turns a user message into the ANSWER never fires, and a
|
|
4389
|
+
* fresh prompt just queues behind the still-blocked turn).
|
|
4390
|
+
*
|
|
4391
|
+
* `GET /question?directory=<workDir>` returns the instance's live open
|
|
4392
|
+
* questions; we pick the entry owned by this session, rebuild
|
|
4393
|
+
* `session.pendingQuestion`, and emit `question` so the existing bot handler
|
|
4394
|
+
* re-posts the option buttons and repopulates (and persists) its pending map.
|
|
4395
|
+
* Reply id is the entry's top-level `id` (`que_…`), resolved by the pure
|
|
4396
|
+
* {@link getOpenQuestionForSession} helper.
|
|
4397
|
+
*
|
|
4398
|
+
* Best-effort: a read/parse failure is logged and swallowed — it must never
|
|
4399
|
+
* throw out of reattach. Only meaningful on the resume/reattach path; a
|
|
4400
|
+
* brand-new session (startSession) has no history and thus no open question,
|
|
4401
|
+
* so it is deliberately NOT called there.
|
|
4402
|
+
*/
|
|
4403
|
+
async restoreOpenQuestion(key, sessionId, workDir) {
|
|
4404
|
+
try {
|
|
4405
|
+
const response = await this.apiRequest('GET', buildDirectoryScopedPath('/question', workDir));
|
|
4406
|
+
const restored = (0, openCodeOpenQuestion_1.getOpenQuestionForSession)(response, sessionId, workDir);
|
|
4407
|
+
if (!restored)
|
|
4408
|
+
return;
|
|
4409
|
+
// The session may have been stopped/replaced between the GET and now.
|
|
4410
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4411
|
+
if (!session?.isActive || session.sessionId !== sessionId)
|
|
4412
|
+
return;
|
|
4413
|
+
session.pendingQuestion = restored;
|
|
4414
|
+
console.log(`[OpenCode] Restored open question ${restored.requestId} for ${(0, sessionKey_1.keyToString)(key)} on reattach`);
|
|
4415
|
+
this.emit('question', key, restored);
|
|
4416
|
+
}
|
|
4417
|
+
catch (e) {
|
|
4418
|
+
console.warn(`[OpenCode] restore open question failed:`, e instanceof Error ? e.message : e);
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
/**
|
|
4422
|
+
* @description Backstop predicate (G2): is this session's turn wedged behind
|
|
4423
|
+
* an open interactive question? The authoritative signal is the server's own
|
|
4424
|
+
* `GET /question?directory=<workDir>` returning an entry for this session —
|
|
4425
|
+
* a turn that is merely streaming text / running a normal tool / has a live
|
|
4426
|
+
* sub-agent has NO open question, so it is never reported wedged (no
|
|
4427
|
+
* regression: an arriving prompt still queues-and-is-picked-up for a healthy
|
|
4428
|
+
* busy turn). The bot consults this only when a fresh prompt would otherwise
|
|
4429
|
+
* queue behind a busy session with no known pending question; on `true` it
|
|
4430
|
+
* aborts the dead turn before forwarding instead of queueing forever.
|
|
4431
|
+
*
|
|
4432
|
+
* Best-effort: a read/parse failure returns `false` (don't abort on doubt).
|
|
4433
|
+
*/
|
|
4434
|
+
async checkIsWedgedOnQuestion(key) {
|
|
4435
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4436
|
+
if (!session?.isActive)
|
|
4437
|
+
return false;
|
|
4438
|
+
try {
|
|
4439
|
+
const response = await this.apiRequest('GET', buildDirectoryScopedPath('/question', session.workDir));
|
|
4440
|
+
return (0, openCodeOpenQuestion_1.getOpenQuestionForSession)(response, session.sessionId, session.workDir) !== null;
|
|
4441
|
+
}
|
|
4442
|
+
catch (e) {
|
|
4443
|
+
console.warn(`[OpenCode] wedged-question check failed:`, e instanceof Error ? e.message : e);
|
|
4444
|
+
return false;
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
/**
|
|
4448
|
+
* @description Reply to a pending question with user-selected answers.
|
|
4449
|
+
* Called by the bot when the user clicks an inline button or types a custom answer.
|
|
4450
|
+
*/
|
|
4451
|
+
answerQuestion(key, answers) {
|
|
4452
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4453
|
+
if (!session?.isActive || !session.pendingQuestion)
|
|
4454
|
+
return;
|
|
4455
|
+
const { requestId, directory } = session.pendingQuestion;
|
|
4456
|
+
session.pendingQuestion = null;
|
|
4457
|
+
// Audit S15 / #41: surface failures via `error` so the bot's
|
|
4458
|
+
// handleAgentError shows them in the thread, not just in console.
|
|
4459
|
+
this.apiRequest('POST', buildDirectoryScopedPath(`/question/${requestId}/reply`, directory), {
|
|
4460
|
+
answers,
|
|
4461
|
+
}).catch((e) => {
|
|
4462
|
+
console.error(`[OpenCode] Failed to reply to question:`, e);
|
|
4463
|
+
this.emit('error', key, e instanceof Error ? e : new Error(String(e)));
|
|
4464
|
+
});
|
|
4465
|
+
}
|
|
4466
|
+
/**
|
|
4467
|
+
* @description Reject a pending question server-side WITHOUT answering it —
|
|
4468
|
+
* called when the user ABANDONS the question (sends a fresh prompt instead of
|
|
4469
|
+
* answering, or the session is torn down while it is pending). Mirrors
|
|
4470
|
+
* {@link answerQuestion} but POSTs to `/question/:id/reject` (empty body)
|
|
4471
|
+
* instead of `/reply`. Without it the question stays "open" in the server's
|
|
4472
|
+
* registry and {@link restoreOpenQuestion} re-surfaces it on every reattach.
|
|
4473
|
+
* No-op if no active session / no pending question.
|
|
4474
|
+
*/
|
|
4475
|
+
rejectQuestion(key) {
|
|
4476
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4477
|
+
if (!session?.isActive || !session.pendingQuestion)
|
|
4478
|
+
return;
|
|
4479
|
+
const { requestId, directory } = session.pendingQuestion;
|
|
4480
|
+
session.pendingQuestion = null;
|
|
4481
|
+
// Same failure surfacing as answerQuestion: route errors through `error` so
|
|
4482
|
+
// the bot's handleAgentError shows them in the thread, not just console.
|
|
4483
|
+
this.apiRequest('POST', buildDirectoryScopedPath(`/question/${requestId}/reject`, directory), {}).catch((e) => {
|
|
4484
|
+
console.error(`[OpenCode] Failed to reject question:`, e);
|
|
4485
|
+
this.emit('error', key, e instanceof Error ? e : new Error(String(e)));
|
|
4486
|
+
});
|
|
4487
|
+
}
|
|
4488
|
+
flushOutput(key, isFinal = false) {
|
|
4489
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
4490
|
+
if (!session)
|
|
4491
|
+
return;
|
|
4492
|
+
if (session.outputTimer) {
|
|
4493
|
+
clearTimeout(session.outputTimer);
|
|
4494
|
+
session.outputTimer = null;
|
|
4495
|
+
}
|
|
4496
|
+
// Flush any pending status before flushing text output
|
|
4497
|
+
if (session.statusDebounceTimer) {
|
|
4498
|
+
clearTimeout(session.statusDebounceTimer);
|
|
4499
|
+
session.statusDebounceTimer = null;
|
|
4500
|
+
}
|
|
4501
|
+
if (session.pendingStatus) {
|
|
4502
|
+
this.emit('status', key, session.pendingStatus);
|
|
4503
|
+
session.pendingStatus = null;
|
|
4504
|
+
}
|
|
4505
|
+
// Reasoning end signals #2/#3: the turn flush fires from both the
|
|
4506
|
+
// `message.updated` finish and `session.idle`. Idempotent — a no-op if a
|
|
4507
|
+
// non-reasoning part already ended reasoning (signal #1).
|
|
4508
|
+
this.endReasoning(key, session);
|
|
4509
|
+
// Flush the CHILD (sub-agent) accumulator first (it streamed earlier than
|
|
4510
|
+
// the parent's closing text): clear its debounce and emit any unsent
|
|
4511
|
+
// marked tail before the reset below would drop it. The child idle event
|
|
4512
|
+
// routes here too (lineage), so a finished sub-agent flushes promptly.
|
|
4513
|
+
if (session.childOutputTimer) {
|
|
4514
|
+
clearTimeout(session.childOutputTimer);
|
|
4515
|
+
session.childOutputTimer = null;
|
|
4516
|
+
}
|
|
4517
|
+
this.emitChildResponseTail(key, session);
|
|
4518
|
+
this.emitResponseTail(key, session, isFinal);
|
|
4519
|
+
session.currentResponseText = '';
|
|
4520
|
+
session.lastEmittedLength = 0;
|
|
4521
|
+
session.isResponseCompactionSummary = false;
|
|
4522
|
+
session.childResponseText = '';
|
|
4523
|
+
session.childLastEmittedLength = 0;
|
|
4524
|
+
session.partTypes.clear();
|
|
4525
|
+
}
|
|
4526
|
+
/**
|
|
4527
|
+
* @description Defensive close of the dedicated sub-agent status when the
|
|
4528
|
+
* PARENT's own turn ends (its assistant message finishes, or its session goes
|
|
4529
|
+
* idle) so a dangling "working" indicator can't outlive the turn if the
|
|
4530
|
+
* terminal `task` part was missed. Strictly parent-only: a CHILD (sub-agent)
|
|
4531
|
+
* message finishing / going idle must NOT close it — the delegation is still
|
|
4532
|
+
* in flight (its child events route here via lineage). The bot no-ops the
|
|
4533
|
+
* close when nothing is open.
|
|
4534
|
+
*/
|
|
4535
|
+
closeSubagentStatusOnParentTurnEnd(key) {
|
|
4536
|
+
this.emit('subagentStatus', key, { active: false, title: null });
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
exports.OpenCodeAdapter = OpenCodeAdapter;
|
|
4540
|
+
//# sourceMappingURL=openCodeAdapter.js.map
|