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,4204 @@
|
|
|
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.ClaudeCliAdapter = exports.getNewPaneContent = exports.cleanOutput = exports.convertAnsiToMarkdown = exports.tmuxAsync = void 0;
|
|
37
|
+
exports.loadEffortPrefs = loadEffortPrefs;
|
|
38
|
+
exports.saveEffortPref = saveEffortPref;
|
|
39
|
+
exports.checkIsValidUuid = checkIsValidUuid;
|
|
40
|
+
exports.parseClaudeSessionIdFromCommand = parseClaudeSessionIdFromCommand;
|
|
41
|
+
exports.checkIsStatusOutput = checkIsStatusOutput;
|
|
42
|
+
exports.extractClaudeQuestion = extractClaudeQuestion;
|
|
43
|
+
exports.checkIsClaudeQuestionBlock = checkIsClaudeQuestionBlock;
|
|
44
|
+
exports.checkIsSelectorControlReply = checkIsSelectorControlReply;
|
|
45
|
+
exports.extractClaudeSurvey = extractClaudeSurvey;
|
|
46
|
+
exports.checkIsClaudeSurvey = checkIsClaudeSurvey;
|
|
47
|
+
exports.checkShouldDismissSurvey = checkShouldDismissSurvey;
|
|
48
|
+
exports.stripSurveyChromeLines = stripSurveyChromeLines;
|
|
49
|
+
exports.getClaudeSendKeysPlan = getClaudeSendKeysPlan;
|
|
50
|
+
exports.getClaudeReplyRoute = getClaudeReplyRoute;
|
|
51
|
+
exports.checkIsClaudeLoginPaste = checkIsClaudeLoginPaste;
|
|
52
|
+
exports.getLastSharpTableBlock = getLastSharpTableBlock;
|
|
53
|
+
exports.checkHasContentAfterLastSharpTable = checkHasContentAfterLastSharpTable;
|
|
54
|
+
exports.maskSharpTableLines = maskSharpTableLines;
|
|
55
|
+
exports.getTableStabilizationDecision = getTableStabilizationDecision;
|
|
56
|
+
exports.mergeAdjacentFences = mergeAdjacentFences;
|
|
57
|
+
exports.stripTuiElementsWithContext = stripTuiElementsWithContext;
|
|
58
|
+
exports.stripTuiElements = stripTuiElements;
|
|
59
|
+
exports.getClaudeProjectSlug = getClaudeProjectSlug;
|
|
60
|
+
exports.getClaudeProjectsRoot = getClaudeProjectsRoot;
|
|
61
|
+
exports.getClaudeTranscriptPath = getClaudeTranscriptPath;
|
|
62
|
+
exports.readRecentClaudeTurns = readRecentClaudeTurns;
|
|
63
|
+
exports.readClaudeReattachTranscript = readClaudeReattachTranscript;
|
|
64
|
+
exports.listClaudeSessionsForWorkDir = listClaudeSessionsForWorkDir;
|
|
65
|
+
exports.checkIsBareSlashCommand = checkIsBareSlashCommand;
|
|
66
|
+
exports.checkIsClaudeBusy = checkIsClaudeBusy;
|
|
67
|
+
exports.checkIsClaudeSessionBusy = checkIsClaudeSessionBusy;
|
|
68
|
+
exports.checkIsInputEchoFrame = checkIsInputEchoFrame;
|
|
69
|
+
exports.checkIsForwardedEcho = checkIsForwardedEcho;
|
|
70
|
+
exports.checkLooksUnsubmitted = checkLooksUnsubmitted;
|
|
71
|
+
exports.getClaudeAgentErrorLine = getClaudeAgentErrorLine;
|
|
72
|
+
exports.checkIsClaudePromptReady = checkIsClaudePromptReady;
|
|
73
|
+
exports.checkShouldAdvanceWatermark = checkShouldAdvanceWatermark;
|
|
74
|
+
exports.checkIsClaudeUninterruptible = checkIsClaudeUninterruptible;
|
|
75
|
+
exports.getResumeSeedDecision = getResumeSeedDecision;
|
|
76
|
+
const crypto_1 = require("crypto");
|
|
77
|
+
const events_1 = require("events");
|
|
78
|
+
const fs = __importStar(require("fs"));
|
|
79
|
+
const os = __importStar(require("os"));
|
|
80
|
+
const path = __importStar(require("path"));
|
|
81
|
+
const utils_1 = require("../utils");
|
|
82
|
+
const sessionKey_1 = require("../sessionKey");
|
|
83
|
+
const apiErrorRetry_1 = require("../apiErrorRetry");
|
|
84
|
+
const installManager_1 = require("../installManager");
|
|
85
|
+
const mcpConfig_1 = require("../mcpConfig");
|
|
86
|
+
const claudeCompactHook_1 = require("../utils/claudeCompactHook");
|
|
87
|
+
const claudePlatformFlags_1 = require("./claudePlatformFlags");
|
|
88
|
+
const state_1 = require("../state");
|
|
89
|
+
const threadContextPreamble_1 = require("../threadContextPreamble");
|
|
90
|
+
const claudeSessionTitle_1 = require("../utils/claudeSessionTitle");
|
|
91
|
+
const resolveBinary_1 = require("../utils/resolveBinary");
|
|
92
|
+
const claudeAuthLogin_1 = require("../utils/claudeAuthLogin");
|
|
93
|
+
const claudeScrapeShapes_1 = require("../utils/claudeScrapeShapes");
|
|
94
|
+
const progressLine_1 = require("../progressLine");
|
|
95
|
+
const serialQueue_1 = require("../utils/serialQueue");
|
|
96
|
+
const i18n_1 = require("../i18n");
|
|
97
|
+
const resumeContext_1 = require("../resumeContext");
|
|
98
|
+
const effortLevels_1 = require("../effortLevels");
|
|
99
|
+
const pollBackoff_1 = require("../utils/pollBackoff");
|
|
100
|
+
const tmuxExec_1 = require("../utils/tmuxExec");
|
|
101
|
+
Object.defineProperty(exports, "tmuxAsync", { enumerable: true, get: function () { return tmuxExec_1.tmuxAsync; } });
|
|
102
|
+
const ansiClean_1 = require("../utils/ansiClean");
|
|
103
|
+
Object.defineProperty(exports, "convertAnsiToMarkdown", { enumerable: true, get: function () { return ansiClean_1.convertAnsiToMarkdown; } });
|
|
104
|
+
Object.defineProperty(exports, "cleanOutput", { enumerable: true, get: function () { return ansiClean_1.cleanOutput; } });
|
|
105
|
+
const paneDiff_1 = require("../utils/paneDiff");
|
|
106
|
+
Object.defineProperty(exports, "getNewPaneContent", { enumerable: true, get: function () { return paneDiff_1.getNewPaneContent; } });
|
|
107
|
+
const paneResizeGuard_1 = require("../utils/paneResizeGuard");
|
|
108
|
+
const tmuxSessionName_1 = require("../utils/tmuxSessionName");
|
|
109
|
+
const effortStartupKeystroke_1 = require("../utils/effortStartupKeystroke");
|
|
110
|
+
const claudeRuntimeInfo_1 = require("../utils/claudeRuntimeInfo");
|
|
111
|
+
const displayVerbosity_1 = require("../utils/displayVerbosity");
|
|
112
|
+
const claudeSubagentTail_1 = require("../utils/claudeSubagentTail");
|
|
113
|
+
const recentRelayWindow_1 = require("../utils/recentRelayWindow");
|
|
114
|
+
const claudeChunkClassifier_1 = require("../utils/claudeChunkClassifier");
|
|
115
|
+
const claudeRelayRouting_1 = require("../utils/claudeRelayRouting");
|
|
116
|
+
/**
|
|
117
|
+
* @description Hard cap on resume flood-suppression polls. The normal exit is
|
|
118
|
+
* "pane non-empty and unchanged across 2 consecutive polls" (the restored
|
|
119
|
+
* transcript finished painting), but if the paint stutters indefinitely we
|
|
120
|
+
* force-exit after this many polls (≈ {@link basePollIntervalMs} × this) so seeding
|
|
121
|
+
* can never wedge a session into permanent silence. See {@link getResumeSeedDecision}.
|
|
122
|
+
*/
|
|
123
|
+
const resumeSeedMaxPolls = 40;
|
|
124
|
+
const claudePath = (0, resolveBinary_1.resolveClaudeBinary)();
|
|
125
|
+
/**
|
|
126
|
+
* @description Permission flags for EVERY bot-launched Claude session (start
|
|
127
|
+
* AND resume), forcing allow-all so the user never gets permission prompts.
|
|
128
|
+
*
|
|
129
|
+
* `--dangerously-skip-permissions` only sets bypass as the INITIAL mode and
|
|
130
|
+
* suppresses the "enter bypass?" confirm — it is NOT a runtime lock (shift+tab
|
|
131
|
+
* cycles bypass → auto → default). A live session ended up in a prompting,
|
|
132
|
+
* non-bypass mode despite the flag (live 2026-06-12); the bot never
|
|
133
|
+
* sends shift+tab itself, so the drift comes from Claude's own mode state, not
|
|
134
|
+
* the bot. `--permission-mode bypassPermissions` is the documented per-session
|
|
135
|
+
* override (verified present in v2.1.175 `--help`); passing it on every launch
|
|
136
|
+
* re-asserts bypass on each fresh start and resume — verified to land in
|
|
137
|
+
* "bypass permissions on" for both. Residual circuit-breakers (claude always
|
|
138
|
+
* asks for `rm -rf /` or `rm -rf ~`) are intentional and out of scope.
|
|
139
|
+
*/
|
|
140
|
+
const claudePermissionArgs = [
|
|
141
|
+
'--dangerously-skip-permissions',
|
|
142
|
+
'--permission-mode',
|
|
143
|
+
'bypassPermissions',
|
|
144
|
+
];
|
|
145
|
+
/**
|
|
146
|
+
* @description When set (`CLAUDE_SCRAPE_DEBUG=1`), the poll loop logs the FULL
|
|
147
|
+
* RAW and FILTERED chunk bodies — a forensic dump useful when debugging the
|
|
148
|
+
* scrape pipeline. Default OFF: those two sync stdout writes per changed poll
|
|
149
|
+
* (up to ~600KB each under load) were starving the event loop in prod, so we
|
|
150
|
+
* log only one-line size summaries instead. Read once at module init like the
|
|
151
|
+
* other env flags in the codebase (see `outputTrace.ts`).
|
|
152
|
+
*/
|
|
153
|
+
const isClaudeScrapeDebugEnabled = process.env.CLAUDE_SCRAPE_DEBUG === '1';
|
|
154
|
+
/**
|
|
155
|
+
* @description Max number of `.jsonl` transcripts to parse per folder.
|
|
156
|
+
*
|
|
157
|
+
* Parsing each transcript is the expensive step (stream the whole file to
|
|
158
|
+
* collect title/cwd/timestamp). We bound it to the most-recent N by mtime
|
|
159
|
+
* — the bot only ever shows the top {@link sessionsDisplayLimit}, so 30 is
|
|
160
|
+
* plenty of headroom even after the `recordedCwd === workDir` filter drops
|
|
161
|
+
* some entries.
|
|
162
|
+
*/
|
|
163
|
+
const sessionsParseLimit = 30;
|
|
164
|
+
/** Cap on a session title's length so the list stays readable in Telegram. */
|
|
165
|
+
const sessionTitleMaxLength = 60;
|
|
166
|
+
/**
|
|
167
|
+
* @description Per-thread Claude `/effort` choice (plan 2026-05-30-effort-command, D6).
|
|
168
|
+
*
|
|
169
|
+
* Claude itself persists `effortLevel` GLOBALLY in its own `settings.json`,
|
|
170
|
+
* so two threads driven by the same Linux user would otherwise overwrite
|
|
171
|
+
* each other's choice across sessions. We mirror the per-thread model-prefs
|
|
172
|
+
* pattern (see `openCodeAdapter.ts:modelStateFile`) — a tiny JSON map
|
|
173
|
+
* `{ "<chatId>:<threadId>": "<level>" }` under DATA_DIR, used for the
|
|
174
|
+
* banner / picker UI **only**. Live apply still goes through the TUI
|
|
175
|
+
* keystroke path, and claude clamps unsupported levels for the current
|
|
176
|
+
* model (plan D2).
|
|
177
|
+
*
|
|
178
|
+
* The file is best-effort: a corrupt copy is archived and reset (same
|
|
179
|
+
* scheme as `loadStoredSessions`), and write failures only log — they
|
|
180
|
+
* never block the user-facing `/effort` reply.
|
|
181
|
+
*/
|
|
182
|
+
const effortPrefsFile = path.join((0, state_1.resolveDataDir)(), '.claude-effort-prefs.json');
|
|
183
|
+
function loadEffortPrefs() {
|
|
184
|
+
try {
|
|
185
|
+
if (!fs.existsSync(effortPrefsFile))
|
|
186
|
+
return {};
|
|
187
|
+
const raw = fs.readFileSync(effortPrefsFile, 'utf-8');
|
|
188
|
+
const parsed = JSON.parse(raw);
|
|
189
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
190
|
+
? parsed
|
|
191
|
+
: {};
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
console.error(`[Claude] loadEffortPrefs failed:`, e instanceof Error ? e.message : e);
|
|
195
|
+
if (fs.existsSync(effortPrefsFile)) {
|
|
196
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
197
|
+
try {
|
|
198
|
+
fs.renameSync(effortPrefsFile, `${effortPrefsFile}.corrupted-${ts}`);
|
|
199
|
+
}
|
|
200
|
+
catch (re) {
|
|
201
|
+
console.warn(`[Claude] archive of corrupt effort prefs failed:`, re);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return {};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function saveEffortPref(key, level) {
|
|
208
|
+
try {
|
|
209
|
+
const data = loadEffortPrefs();
|
|
210
|
+
data[(0, sessionKey_1.keyToString)(key)] = level;
|
|
211
|
+
fs.writeFileSync(effortPrefsFile, JSON.stringify(data, null, 2));
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
console.error(`[Claude] saveEffortPref failed:`, e instanceof Error ? e.message : e);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* @description Tmux session-name prefix that namespaces Claude's sessions on
|
|
219
|
+
* the tmux server. The careful build/parse (negative chatId, strict per-half
|
|
220
|
+
* regex) is shared with other tmux-driven backends in `utils/tmuxSessionName`;
|
|
221
|
+
* these thin wrappers just bind the `'claude'` prefix.
|
|
222
|
+
*/
|
|
223
|
+
const claudeTmuxPrefix = 'claude';
|
|
224
|
+
/**
|
|
225
|
+
* @description Tmux session name for a `SessionKey`.
|
|
226
|
+
*
|
|
227
|
+
* Format: `claude-<chatId>-<threadId>`. Negative chat ids (forum supergroups
|
|
228
|
+
* are negative) keep their minus sign — tmux session names accept it. The
|
|
229
|
+
* format is `parse`-able back to `SessionKey` via {@link parseTmuxSessionName}.
|
|
230
|
+
*/
|
|
231
|
+
function buildTmuxSessionName(key) {
|
|
232
|
+
return (0, tmuxSessionName_1.buildTmuxSessionName)(claudeTmuxPrefix, key);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* @description Inverse of {@link buildTmuxSessionName}. Returns `null` for
|
|
236
|
+
* names that don't match our format (e.g. unrelated tmux sessions a user
|
|
237
|
+
* started by hand).
|
|
238
|
+
*
|
|
239
|
+
* Carefully handles negative chat ids: `claude--1001234-42` is `chatId=-1001234, threadId=42`.
|
|
240
|
+
*/
|
|
241
|
+
function parseTmuxSessionName(name) {
|
|
242
|
+
return (0, tmuxSessionName_1.parseTmuxSessionName)(claudeTmuxPrefix, name);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* @description Validate a UUID-shaped session id before it ever reaches a
|
|
246
|
+
* tmux command line. Defence-in-depth (audit S1): even though we now route
|
|
247
|
+
* every tmux call through argv (no shell interpolation), the UUID is later
|
|
248
|
+
* concatenated into the `shell-command` we hand to `tmux new-session`,
|
|
249
|
+
* which tmux execs via `$SHELL -c`. If a non-UUID value ever sneaks in
|
|
250
|
+
* (corrupted state.json, future user-facing `/resume <id>`), it would land
|
|
251
|
+
* in that shell. Rejecting up front keeps the surface tight.
|
|
252
|
+
*/
|
|
253
|
+
function checkIsValidUuid(s) {
|
|
254
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* @description Pure parser — pulls a `--session-id <uuid>` value out of a
|
|
258
|
+
* claude command line. Lives outside the adapter class so it can be
|
|
259
|
+
* unit-tested without booting tmux. Quoting in the input is flexible
|
|
260
|
+
* because `pane_start_command` can come back as `--session-id 'uuid'`,
|
|
261
|
+
* `--session-id "uuid"`, `--session-id=uuid`, or just `--session-id uuid`,
|
|
262
|
+
* depending on how the original shell-command was assembled. Returns the
|
|
263
|
+
* lowercase UUID, or `null` if no valid UUID is found.
|
|
264
|
+
*/
|
|
265
|
+
function parseClaudeSessionIdFromCommand(cmd) {
|
|
266
|
+
if (!cmd)
|
|
267
|
+
return null;
|
|
268
|
+
// Lookahead refuses to truncate a 37+ char "UUID-like" garbage tail to
|
|
269
|
+
// the first 36 chars: trailing hex must be followed by end-of-string,
|
|
270
|
+
// whitespace, or a quote. Production input comes from our own
|
|
271
|
+
// `pane_start_command`, but the anchor closes a defence-in-depth gap
|
|
272
|
+
// against future call sites that may pass weirder strings.
|
|
273
|
+
const match = cmd.match(/--session-id[\s'"=]+([0-9a-fA-F-]{36})(?=$|[\s'"])/);
|
|
274
|
+
if (!match)
|
|
275
|
+
return null;
|
|
276
|
+
const uuid = match[1].toLowerCase();
|
|
277
|
+
return checkIsValidUuid(uuid) ? uuid : null;
|
|
278
|
+
}
|
|
279
|
+
function normalizeToolCallLine(line) {
|
|
280
|
+
const trimmed = line.trim();
|
|
281
|
+
const toolPattern = /^(Bash|Read|Write|Edit|Glob|Grep|Task|TodoWrite|WebFetch|WebSearch)\s*\(/i;
|
|
282
|
+
const bulletToolPattern = /^([●○⏺])\s*(Bash|Read|Write|Edit|Glob|Grep|Task|TodoWrite|WebFetch|WebSearch)\s*\(/i;
|
|
283
|
+
const bulletMatch = trimmed.match(bulletToolPattern);
|
|
284
|
+
if (bulletMatch) {
|
|
285
|
+
const bullet = bulletMatch[1];
|
|
286
|
+
const rest = trimmed.slice(bulletMatch[1].length).trimStart();
|
|
287
|
+
const icon = bullet === '●' || bullet === '⏺' ? '⏳' : '✓';
|
|
288
|
+
return `${icon} ${rest}`;
|
|
289
|
+
}
|
|
290
|
+
const toolMatch = trimmed.match(toolPattern);
|
|
291
|
+
if (toolMatch) {
|
|
292
|
+
return `✓ ${trimmed}`;
|
|
293
|
+
}
|
|
294
|
+
return line;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* @description Check if output consists only of transient status lines (spinners, progress).
|
|
298
|
+
* Uses generic heuristics instead of hardcoded spinner chars/words,
|
|
299
|
+
* because Claude CLI can change its TUI symbols and wording at any time.
|
|
300
|
+
*
|
|
301
|
+
* Key insight: real Claude content is substantial (> 200 chars, multi-sentence).
|
|
302
|
+
* Status/progress is short, has few lines, and contains indicators like … or time/token stats.
|
|
303
|
+
*/
|
|
304
|
+
function checkIsStatusOutput(text) {
|
|
305
|
+
// The thinking-`short` collapse line the router emits ("💭 thought for {N}s" /
|
|
306
|
+
// "💭 думал {N} с", S5) is a DELIBERATE permanent artifact. Its short,
|
|
307
|
+
// glyph-led, sentence-less shape otherwise trips the heuristics below (and the
|
|
308
|
+
// EN form the `thought for \d` rule), so a standalone collapse chunk would be
|
|
309
|
+
// folded into the ephemeral status frame and lost. The 💭 marker is the bot's
|
|
310
|
+
// own collapse glyph — treat it as real permanent content.
|
|
311
|
+
if (text.trimStart().startsWith('💭'))
|
|
312
|
+
return false;
|
|
313
|
+
// A lone file-diff `NN +` change gutter (`40 +`, `3 +delta`) is PERMANENT
|
|
314
|
+
// output, not a transient spinner — its short, glyph-less shape otherwise
|
|
315
|
+
// trips the heuristics below and folds it into the ephemeral status frame, so
|
|
316
|
+
// it is never RECORDED into the relay window and its later re-render leaks
|
|
317
|
+
// unguarded (live diff-line leak 2026-06-24). Treating it as real output sends
|
|
318
|
+
// it down the `record` path so the re-render dedup (recentRelayWindow's
|
|
319
|
+
// `NN +`-gutter bypass) can suppress the repaint. Anchored to the gutter shape
|
|
320
|
+
// — `+1 done`, `3 lines changed`, `done` all stay status-eligible.
|
|
321
|
+
const gutterLines = text.split('\n').filter(line => line.trim() !== '');
|
|
322
|
+
if (gutterLines.length > 0 && gutterLines.every(line => claudeScrapeShapes_1.DIFF_CHANGE_GUTTER_RE.test(line))) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
// Real content is always substantial
|
|
326
|
+
if (text.length > 200)
|
|
327
|
+
return false;
|
|
328
|
+
const lines = text.split('\n').filter(l => l.trim());
|
|
329
|
+
if (lines.length === 0)
|
|
330
|
+
return false;
|
|
331
|
+
// Many non-empty lines = real content
|
|
332
|
+
if (lines.length > 3)
|
|
333
|
+
return false;
|
|
334
|
+
return lines.every(line => {
|
|
335
|
+
const trimmed = line.trim();
|
|
336
|
+
// The ANSWER bullet `●`/`⏺` followed by text is real OUTPUT, never a spinner —
|
|
337
|
+
// it is Claude's assistant-output marker, NOT a liveness glyph (the liveness
|
|
338
|
+
// set is `✻✽✶✢·*○`). Lumping it in with the spinner glyphs made a fully short
|
|
339
|
+
// answer like `● DONE` read as a glyph-led status frame and vanish entirely
|
|
340
|
+
// (live bug 2026-06-24). A bare bullet with no text stays status-eligible.
|
|
341
|
+
if (/^[●⏺]\s+\S/.test(trimmed))
|
|
342
|
+
return false;
|
|
343
|
+
// Tree structure / subagent progress lines (├─, └─, │, ─)
|
|
344
|
+
if (/^[├└│─]/.test(trimmed))
|
|
345
|
+
return true;
|
|
346
|
+
// Contains unicode ellipsis — universal spinner/progress indicator ("Nesting…", "Reading…", "Simmering…")
|
|
347
|
+
if (/…/.test(trimmed))
|
|
348
|
+
return true;
|
|
349
|
+
// Contains progress stats: time patterns (3m 36s), token counts (↓ 12.2k tokens), thought duration
|
|
350
|
+
if (/\d+[smh]\b.*[·↓]|↓\s*[\d.]+k?\s*tokens|thought for \d/i.test(trimmed))
|
|
351
|
+
return true;
|
|
352
|
+
// A short answer fragment that ends a sentence ("Done.", "OK.", "Found 3
|
|
353
|
+
// bugs.") is real content, not a spinner — spinners always carry a `…`, a
|
|
354
|
+
// glyph, or token stats (caught above). Don't swallow it into a status.
|
|
355
|
+
const isShortSentence = /[а-яёa-z]{2,}/i.test(trimmed) && /[.!?]$/.test(trimmed);
|
|
356
|
+
if (isShortSentence)
|
|
357
|
+
return false;
|
|
358
|
+
// Two or more significant words (3+ letters each) ANYWHERE = real prose, not a
|
|
359
|
+
// spinner icon. The words need NOT be adjacent — a short filler word / digits /
|
|
360
|
+
// punctuation between them (e.g. «Итог ПО миксу», "Решение ЗА тобой") must not
|
|
361
|
+
// defeat the count (live bug 2026-06-24: the old adjacency requirement read a
|
|
362
|
+
// real 3-word heading as word-less → status → swallowed). The glyph/`…`/stats/
|
|
363
|
+
// tree STATUS checks above run FIRST, so a genuine `✻ Compacting conversation…`
|
|
364
|
+
// (2 real words but `…`-bearing AND glyph-led) still reads as status.
|
|
365
|
+
const significantWordCount = (trimmed.match(/[а-яёa-z]{3,}/gi) ?? []).length;
|
|
366
|
+
if (significantWordCount >= 2)
|
|
367
|
+
return false;
|
|
368
|
+
// A short line with no sentence-like structure and < 2 real words — a lone
|
|
369
|
+
// spinner/icon/stat fragment.
|
|
370
|
+
if (trimmed.length < 40)
|
|
371
|
+
return true;
|
|
372
|
+
return false;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/** Box-drawing chars Claude's TUI wraps option/question lines in. */
|
|
376
|
+
const QUESTION_BORDER_REGEX = /^[│┃]\s?|\s*[│┃]\s*$/g;
|
|
377
|
+
/** Numbered option line, optionally cursor-highlighted, optionally boxed. */
|
|
378
|
+
const QUESTION_OPTION_REGEX = /^(❯\s*)?(\d{1,2})[.)]\s+(\S.*?)\s*$/;
|
|
379
|
+
/** Lines that are pure chrome (borders / blanks / nav hints), never prose. */
|
|
380
|
+
const QUESTION_CHROME_REGEX = /^[╭╮╰╯┌┐└┘├┤┬┴┼─━│┃\s]*$|Enter to select|esc to|↑↓|↑\/↓|to cycle|shift\+tab|to confirm|to submit|use arrow/i;
|
|
381
|
+
/**
|
|
382
|
+
* The "Notes: press n to add notes" affordance the side-by-side
|
|
383
|
+
* AskUserQuestion layout renders under the preview pane. Terminal-only (a
|
|
384
|
+
* Telegram user cannot press `n`), so it is question chrome on the scrape
|
|
385
|
+
* side AND dropped on the output side. ANCHORED whole-line (live leak
|
|
386
|
+
* 2026-06-10: it reached Telegram as naked text when the selector
|
|
387
|
+
* frame fell through to the plain-output path) — prose merely mentioning the
|
|
388
|
+
* phrase mid-sentence must survive.
|
|
389
|
+
*/
|
|
390
|
+
const QUESTION_NOTES_HINT_REGEX = /^\s*Notes: press n to add notes\s*$/i;
|
|
391
|
+
/**
|
|
392
|
+
* The UNNUMBERED "Chat about this" meta-row of the side-by-side
|
|
393
|
+
* AskUserQuestion layout (the single-column variant numbers it as a real
|
|
394
|
+
* option, e.g. `5. Chat about this`, which this regex deliberately does NOT
|
|
395
|
+
* match). A Telegram user gets the same affordance by simply typing free text
|
|
396
|
+
* (which breaks out of the selector as a fresh turn), so the row is chrome,
|
|
397
|
+
* not an option. ANCHORED whole-line, optional `❯` cursor — a prose sentence
|
|
398
|
+
* containing the phrase must survive.
|
|
399
|
+
*/
|
|
400
|
+
const QUESTION_CHAT_ABOUT_REGEX = /^\s*(?:❯\s*)?Chat about this\s*$/i;
|
|
401
|
+
/** A positive "this is an interactive prompt" signal near the option group. */
|
|
402
|
+
const QUESTION_SELECT_HINT_REGEX = /Enter to select|to select|↑↓|↑\/↓|use arrow|esc to/i;
|
|
403
|
+
const QUESTION_MIN_OPTIONS = 2;
|
|
404
|
+
/**
|
|
405
|
+
* Consecutive poll diffs with NO selector detected before a pending question is
|
|
406
|
+
* treated as resolved (`questionGone`). 2 = ignore a single transient scrape
|
|
407
|
+
* miss while the selector is still repainting; a genuine resolve stays absent
|
|
408
|
+
* across successive diffs. See {@link ClaudeSession.questionAbsentPolls}.
|
|
409
|
+
*/
|
|
410
|
+
const questionAbsentPollThreshold = 2;
|
|
411
|
+
/** How far below the last option to look for the "Enter to select" hint. */
|
|
412
|
+
const QUESTION_HINT_LOOKAHEAD = 4;
|
|
413
|
+
/**
|
|
414
|
+
* How far above a non-option line to look for another option when deciding
|
|
415
|
+
* whether the option group is still open. Real prompts interleave options
|
|
416
|
+
* with indented description sub-lines and full-width `────` separators (e.g.
|
|
417
|
+
* AskUserQuestion: option → description → option, or option → separator →
|
|
418
|
+
* option); a small look-back spans those gaps without merging an unrelated
|
|
419
|
+
* numbered list that sits further up the pane.
|
|
420
|
+
*/
|
|
421
|
+
const QUESTION_OPTION_LOOKBACK = 4;
|
|
422
|
+
/**
|
|
423
|
+
* Box corner chars. The upward option-group walk stops at a box edge so a
|
|
424
|
+
* numbered list in prose sitting just above the box can't merge into the
|
|
425
|
+
* options — the box top border separates the prompt from preceding prose.
|
|
426
|
+
*/
|
|
427
|
+
const QUESTION_BOX_EDGE_REGEX = /[╭╮╰╯]/;
|
|
428
|
+
/**
|
|
429
|
+
* Every box-drawing glyph a right-hand preview pane can put ON an option line
|
|
430
|
+
* in the side-by-side AskUserQuestion layout (options column left, per-option
|
|
431
|
+
* `preview` snippet boxed right):
|
|
432
|
+
*
|
|
433
|
+
* ❯ 1. winston ┌────────────────────────────┐
|
|
434
|
+
* 2. pino │ import winston from '…'; │
|
|
435
|
+
* 3. console │ │
|
|
436
|
+
* └────────────────────────────┘
|
|
437
|
+
*
|
|
438
|
+
* An option line is CUT at the first of these glyphs and only the left part
|
|
439
|
+
* is parsed — covers sharp AND rounded corner families plus double-line
|
|
440
|
+
* variants defensively (live bug #9, 2026-06-10: unparsed preview
|
|
441
|
+
* fragments polluted the labels / broke extraction and the question reached
|
|
442
|
+
* Telegram with ZERO options). Single-column frames carry none of these
|
|
443
|
+
* glyphs on option lines after {@link stripQuestionBoxBorder}, so the cut is
|
|
444
|
+
* a no-op there.
|
|
445
|
+
*/
|
|
446
|
+
const PREVIEW_BOX_GLYPH_CLASS = '┌┐└┘├┤┬┴┼─━│┃╭╮╰╯╔╗╚╝║═';
|
|
447
|
+
const PREVIEW_BOX_GLYPH_REGEX = new RegExp(`[${PREVIEW_BOX_GLYPH_CLASS}]`);
|
|
448
|
+
/** A line whose first non-space char is a box glyph = right-column preview content, never an option/description. */
|
|
449
|
+
const PREVIEW_PANE_FRAGMENT_REGEX = new RegExp(`^[${PREVIEW_BOX_GLYPH_CLASS}]`);
|
|
450
|
+
/**
|
|
451
|
+
* The ANSI-bold artifact `cleanOutput` leaves on the ❯-highlighted option of
|
|
452
|
+
* the side-by-side layout: the TUI bolds ` winston` (space included), and the
|
|
453
|
+
* bold→`*…*` conversion trims the span, yielding `❯ 1.*winston*` — no space
|
|
454
|
+
* after the dot, so {@link QUESTION_OPTION_REGEX} missed the line, the cursor
|
|
455
|
+
* was "gone", and the whole frame fell through to the plain-output path (the
|
|
456
|
+
* live zero-options leak 2026-06-10). Normalised back to
|
|
457
|
+
* `❯ 1. winston`. ANCHORED start-to-end: the whole remainder after `N.` must
|
|
458
|
+
* be ONE `*…*` span, so prose like `1.5 *important* note` can never match.
|
|
459
|
+
*/
|
|
460
|
+
const HIGHLIGHTED_OPTION_BOLD_ARTIFACT_REGEX = /^(❯\s*)?(\d{1,2})([.)])\s*\*([^*]+)\*$/;
|
|
461
|
+
/**
|
|
462
|
+
* How far below the last option to look for the strong `Enter to select`
|
|
463
|
+
* footer when the layout is POSITIVELY identified as side-by-side (an option
|
|
464
|
+
* line carried a preview-box fragment). The preview pane + the "Notes:" line
|
|
465
|
+
* + separator + "Chat about this" sit between the options and the footer, so
|
|
466
|
+
* the normal {@link QUESTION_HINT_LOOKAHEAD} cannot reach it. Only applied to
|
|
467
|
+
* side-by-side frames and only with the strong phrase — a prose numbered list
|
|
468
|
+
* has no box fragments on its lines, so this never widens the prose gate.
|
|
469
|
+
*/
|
|
470
|
+
const SIDE_BY_SIDE_FOOTER_LOOKAHEAD = 20;
|
|
471
|
+
/** The strong footer anchor required within the extended side-by-side window. */
|
|
472
|
+
const QUESTION_FOOTER_STRONG_REGEX = /Enter to select/i;
|
|
473
|
+
/** Minimum leading-space width for a sub-line to count as an option's description. */
|
|
474
|
+
const QUESTION_DESCRIPTION_MIN_INDENT = 4;
|
|
475
|
+
/** How far below the LAST option to collect its trailing description sub-lines. */
|
|
476
|
+
const QUESTION_DESCRIPTION_TRAILING_LOOKAHEAD = 3;
|
|
477
|
+
/**
|
|
478
|
+
* Header walk caps. A permission prompt's action box (`Bash command` + the
|
|
479
|
+
* command + a one-line description, or `Edit file …` + a diff) sits above the
|
|
480
|
+
* question line; the header walk now climbs through it (spanning blank lines)
|
|
481
|
+
* up to the box boundary. SCAN_LIMIT bounds the upward climb itself (runaway
|
|
482
|
+
* guard on a malformed pane); MAX_LINES/MAX_CHARS bound the RELAYED text — over
|
|
483
|
+
* the cap, {@link truncateQuestionHeader} keeps the identifying TOP and the
|
|
484
|
+
* question line at the BOTTOM and drops the middle. A Bash command is well
|
|
485
|
+
* under the cap → never truncated.
|
|
486
|
+
*/
|
|
487
|
+
const QUESTION_HEADER_SCAN_LIMIT = 200;
|
|
488
|
+
const QUESTION_HEADER_MAX_LINES = 26;
|
|
489
|
+
const QUESTION_HEADER_MAX_CHARS = 2000;
|
|
490
|
+
const QUESTION_HEADER_HEAD_LINES = 16;
|
|
491
|
+
const QUESTION_HEADER_TAIL_LINES = 6;
|
|
492
|
+
const QUESTION_HEADER_TRUNCATION_MARKER = '… (truncated — see terminal)';
|
|
493
|
+
/**
|
|
494
|
+
* AskUserQuestion category tab marker (`☐ Foo` / `☑ Bar`) — chrome that sits
|
|
495
|
+
* above the question line; a boundary for the header walk so it is never
|
|
496
|
+
* relayed (the side-by-side `☐` no-leak test). Anchored at line start.
|
|
497
|
+
*/
|
|
498
|
+
const QUESTION_TAB_MARKER_REGEX = /^[☐☑]/;
|
|
499
|
+
function stripQuestionBoxBorder(line) {
|
|
500
|
+
return line.replace(QUESTION_BORDER_REGEX, '');
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Bound an over-long captured header (an oversized Edit/Write diff box): keep
|
|
504
|
+
* the head (title + first lines — the most identifying part) and the tail (the
|
|
505
|
+
* question line sits at the bottom of the header), drop the middle behind one
|
|
506
|
+
* marker. A within-budget header passes through unchanged.
|
|
507
|
+
*/
|
|
508
|
+
function truncateQuestionHeader(headerLines) {
|
|
509
|
+
const totalChars = headerLines.reduce((sum, line) => sum + line.length + 1, 0);
|
|
510
|
+
if (headerLines.length <= QUESTION_HEADER_MAX_LINES && totalChars <= QUESTION_HEADER_MAX_CHARS) {
|
|
511
|
+
return headerLines;
|
|
512
|
+
}
|
|
513
|
+
return [
|
|
514
|
+
...headerLines.slice(0, QUESTION_HEADER_HEAD_LINES),
|
|
515
|
+
QUESTION_HEADER_TRUNCATION_MARKER,
|
|
516
|
+
...headerLines.slice(-QUESTION_HEADER_TAIL_LINES),
|
|
517
|
+
];
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* The left-column part of a side-by-side line: everything before the first
|
|
521
|
+
* preview-box glyph, trailing padding dropped. A line without box glyphs
|
|
522
|
+
* (single-column layout) passes through unchanged.
|
|
523
|
+
*/
|
|
524
|
+
function getPreviewPaneCutLeftPart(line) {
|
|
525
|
+
const cutIndex = line.search(PREVIEW_BOX_GLYPH_REGEX);
|
|
526
|
+
return (cutIndex === -1 ? line : line.slice(0, cutIndex)).trimEnd();
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Normalise a line for option matching: cut the right-hand preview-box
|
|
530
|
+
* fragment (side-by-side layout), then undo the highlighted-label bold
|
|
531
|
+
* artifact. A single-column line passes through unchanged.
|
|
532
|
+
*/
|
|
533
|
+
function getOptionLineCandidate(line) {
|
|
534
|
+
return getPreviewPaneCutLeftPart(line).replace(HIGHLIGHTED_OPTION_BOLD_ARTIFACT_REGEX, '$1$2$3 $4');
|
|
535
|
+
}
|
|
536
|
+
function checkIsOptionLine(line) {
|
|
537
|
+
return QUESTION_OPTION_REGEX.test(getOptionLineCandidate(line));
|
|
538
|
+
}
|
|
539
|
+
function getLineIndentWidth(line) {
|
|
540
|
+
return line.length - line.trimStart().length;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Whether a (border-stripped, untrimmed) line is an option's indented
|
|
544
|
+
* description sub-line. Conservative on purpose: must be non-blank, not
|
|
545
|
+
* question chrome, not right-column preview content, not itself an option,
|
|
546
|
+
* and indented at least {@link QUESTION_DESCRIPTION_MIN_INDENT} — the TUI
|
|
547
|
+
* indents descriptions under their option label.
|
|
548
|
+
*/
|
|
549
|
+
function checkIsOptionDescriptionLine(borderStrippedLine) {
|
|
550
|
+
const trimmed = borderStrippedLine.trim();
|
|
551
|
+
if (trimmed === '')
|
|
552
|
+
return false;
|
|
553
|
+
if (checkIsQuestionChrome(trimmed))
|
|
554
|
+
return false;
|
|
555
|
+
if (PREVIEW_PANE_FRAGMENT_REGEX.test(trimmed))
|
|
556
|
+
return false;
|
|
557
|
+
if (checkIsOptionLine(trimmed))
|
|
558
|
+
return false;
|
|
559
|
+
return getLineIndentWidth(borderStrippedLine) >= QUESTION_DESCRIPTION_MIN_INDENT;
|
|
560
|
+
}
|
|
561
|
+
/** Whether another option line sits within {@link QUESTION_OPTION_LOOKBACK} lines above `index`. */
|
|
562
|
+
function checkHasOptionAbove(lines, index) {
|
|
563
|
+
const top = Math.max(0, index - QUESTION_OPTION_LOOKBACK);
|
|
564
|
+
for (let i = index - 1; i >= top; i--) {
|
|
565
|
+
if (checkIsOptionLine(lines[i]))
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
function checkIsQuestionChrome(line) {
|
|
571
|
+
return (QUESTION_CHROME_REGEX.test(line) ||
|
|
572
|
+
QUESTION_NOTES_HINT_REGEX.test(line) ||
|
|
573
|
+
QUESTION_CHAT_ABOUT_REGEX.test(line));
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* @description Detect + extract the active interactive question/choice block
|
|
577
|
+
* from the FULL Claude pane, returning a durable rendering and a
|
|
578
|
+
* cursor-invariant signature, or `null` when the pane isn't confidently
|
|
579
|
+
* showing a question.
|
|
580
|
+
*
|
|
581
|
+
* Operates on the whole pane (not a poll diff) on purpose: moving the `❯`
|
|
582
|
+
* cursor repaints only the two changed option lines, so a signature taken
|
|
583
|
+
* from the diff would be a partial option set and wouldn't match the full
|
|
584
|
+
* box — the de-dup would fail and every keystroke would re-spam the thread.
|
|
585
|
+
* Reading the full option group makes the signature stable across cursor
|
|
586
|
+
* moves. We take the LAST option group (the active prompt sits at the bottom
|
|
587
|
+
* of the pane) and the header line(s) just above it.
|
|
588
|
+
*
|
|
589
|
+
* Conservative (plan §2026-05-30 / S2): requires a `❯` cursor on an option
|
|
590
|
+
* OR an "Enter to select"-style hint right below the group — a plain numbered
|
|
591
|
+
* list in prose has neither, so it returns `null` and falls through to the
|
|
592
|
+
* normal output path. The `❯`-highlighted option text is preserved (the old
|
|
593
|
+
* `stripTuiElements` path discarded every `^❯` line, losing the selection).
|
|
594
|
+
*
|
|
595
|
+
* Two layouts (plan §2026-06-09 question-ux / S4):
|
|
596
|
+
* - single-column — options with optional indented description sub-lines,
|
|
597
|
+
* which are ATTACHED to their option in the rendering (S1: previously
|
|
598
|
+
* discarded);
|
|
599
|
+
* - side-by-side (options WITH `preview` content) — each option line carries
|
|
600
|
+
* a right-hand preview-box fragment that is CUT off; the preview body is
|
|
601
|
+
* deliberately NOT relayed (Telegram gets labels + descriptions, the
|
|
602
|
+
* preview is a TUI-only nicety). See {@link PREVIEW_BOX_GLYPH_CLASS} and
|
|
603
|
+
* {@link HIGHLIGHTED_OPTION_BOLD_ARTIFACT_REGEX} for the live bug both
|
|
604
|
+
* rules close.
|
|
605
|
+
*/
|
|
606
|
+
function extractClaudeQuestion(text) {
|
|
607
|
+
const borderStrippedLines = text.split('\n').map(line => stripQuestionBoxBorder(line));
|
|
608
|
+
const lines = borderStrippedLines.map(line => line.trim());
|
|
609
|
+
// Bottom-most option line anchors the active prompt (it sits at the pane
|
|
610
|
+
// bottom). The option run is NOT contiguous — descriptions / separators sit
|
|
611
|
+
// between options — so walk up spanning any non-option line while another
|
|
612
|
+
// option is still within reach above; stop once it isn't (that's the header).
|
|
613
|
+
let end = -1;
|
|
614
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
615
|
+
if (checkIsOptionLine(lines[i])) {
|
|
616
|
+
end = i;
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (end === -1)
|
|
621
|
+
return null;
|
|
622
|
+
let start = end;
|
|
623
|
+
for (let i = end - 1; i >= 0; i--) {
|
|
624
|
+
if (checkIsOptionLine(lines[i])) {
|
|
625
|
+
start = i;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (QUESTION_BOX_EDGE_REGEX.test(lines[i]))
|
|
629
|
+
break;
|
|
630
|
+
if (!checkHasOptionAbove(lines, i))
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
// Collect options AND their indented description sub-lines (S1: the old
|
|
634
|
+
// flat `.filter(checkIsOptionLine)` discarded descriptions). A description
|
|
635
|
+
// attaches only while DIRECTLY under its option (or a previous description
|
|
636
|
+
// line of the same option) — chrome / separators / preview fragments end
|
|
637
|
+
// the run, so an indented stray further down can never mis-attach.
|
|
638
|
+
const options = [];
|
|
639
|
+
let isSideBySideLayout = false;
|
|
640
|
+
let isDirectlyUnderOption = false;
|
|
641
|
+
for (let i = start; i <= end; i++) {
|
|
642
|
+
const match = getOptionLineCandidate(lines[i]).match(QUESTION_OPTION_REGEX);
|
|
643
|
+
if (match) {
|
|
644
|
+
isSideBySideLayout = isSideBySideLayout || PREVIEW_BOX_GLYPH_REGEX.test(lines[i]);
|
|
645
|
+
options.push({
|
|
646
|
+
highlighted: Boolean(match[1]),
|
|
647
|
+
number: match[2],
|
|
648
|
+
label: match[3].trim(),
|
|
649
|
+
descriptionLines: [],
|
|
650
|
+
});
|
|
651
|
+
isDirectlyUnderOption = true;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (isDirectlyUnderOption && checkIsOptionDescriptionLine(borderStrippedLines[i])) {
|
|
655
|
+
// The cut keeps a description clean if a right-column preview fragment
|
|
656
|
+
// rides the same row (side-by-side); single-column lines are unchanged.
|
|
657
|
+
options[options.length - 1].descriptionLines.push(getPreviewPaneCutLeftPart(lines[i]));
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
isDirectlyUnderOption = false;
|
|
661
|
+
}
|
|
662
|
+
if (options.length < QUESTION_MIN_OPTIONS)
|
|
663
|
+
return null;
|
|
664
|
+
// The LAST option's description sub-line(s) sit BELOW `end`; collect the
|
|
665
|
+
// directly-adjacent qualifying run (bounded, stops at the first blank /
|
|
666
|
+
// chrome / preview-fragment line).
|
|
667
|
+
const trailingLimit = Math.min(lines.length - 1, end + QUESTION_DESCRIPTION_TRAILING_LOOKAHEAD);
|
|
668
|
+
for (let i = end + 1; i <= trailingLimit; i++) {
|
|
669
|
+
if (!checkIsOptionDescriptionLine(borderStrippedLines[i]))
|
|
670
|
+
break;
|
|
671
|
+
options[options.length - 1].descriptionLines.push(getPreviewPaneCutLeftPart(lines[i]));
|
|
672
|
+
}
|
|
673
|
+
// Positive interactive signal, or it's not a real choice prompt.
|
|
674
|
+
const hasCursor = options.some(option => option.highlighted);
|
|
675
|
+
if (!hasCursor) {
|
|
676
|
+
const tail = lines.slice(end + 1, end + 1 + QUESTION_HINT_LOOKAHEAD).join('\n');
|
|
677
|
+
if (!QUESTION_SELECT_HINT_REGEX.test(tail)) {
|
|
678
|
+
// Side-by-side only: the preview pane pushes the footer beyond the
|
|
679
|
+
// normal lookahead (e.g. the cursor sits on the unnumbered "Chat about
|
|
680
|
+
// this" meta-row, so no option is highlighted). Demand the STRONG
|
|
681
|
+
// footer phrase within the extended window; never applied to frames
|
|
682
|
+
// without preview fragments, so the prose gate stays as tight as before.
|
|
683
|
+
if (!isSideBySideLayout)
|
|
684
|
+
return null;
|
|
685
|
+
const extendedTail = lines
|
|
686
|
+
.slice(end + 1, end + 1 + SIDE_BY_SIDE_FOOTER_LOOKAHEAD)
|
|
687
|
+
.join('\n');
|
|
688
|
+
if (!QUESTION_FOOTER_STRONG_REGEX.test(extendedTail))
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
// Header: skip the blank gap directly above the options, then climb to the
|
|
693
|
+
// box boundary collecting every prose line. A permission prompt's action box
|
|
694
|
+
// (`Bash command` + the command + a one-line description) sits ABOVE a blank
|
|
695
|
+
// line that separates it from `Do you want to proceed?`, so the walk must
|
|
696
|
+
// SPAN whitespace-only chrome (kept as a blank separator) and stop only at
|
|
697
|
+
// NON-blank chrome — the full-width `────` divider (current layout) or a
|
|
698
|
+
// `╭│` box border (Edit/older layout) — which bounds the box and keeps the
|
|
699
|
+
// transcript prose above it out (regression-guarded by the `codebase` /
|
|
700
|
+
// `alpha.ts` no-leak tests). Bounded by QUESTION_HEADER_MAX_LINES so a
|
|
701
|
+
// malformed pane can't climb the whole scrollback.
|
|
702
|
+
let h = start - 1;
|
|
703
|
+
while (h >= 0 && checkIsQuestionChrome(lines[h]))
|
|
704
|
+
h--;
|
|
705
|
+
const headerLines = [];
|
|
706
|
+
while (h >= 0 && !checkIsOptionLine(lines[h]) && headerLines.length < QUESTION_HEADER_SCAN_LIMIT) {
|
|
707
|
+
const line = lines[h];
|
|
708
|
+
// AskUserQuestion category tab (`☐ Foo`) is chrome above the question — a
|
|
709
|
+
// boundary, never relayed.
|
|
710
|
+
if (QUESTION_TAB_MARKER_REGEX.test(line))
|
|
711
|
+
break;
|
|
712
|
+
if (checkIsQuestionChrome(line)) {
|
|
713
|
+
// Non-blank chrome (divider / box border / nav hint) is the box edge.
|
|
714
|
+
if (line !== '')
|
|
715
|
+
break;
|
|
716
|
+
// Blank line inside the box — keep it as a separator and keep climbing.
|
|
717
|
+
headerLines.unshift(line);
|
|
718
|
+
h--;
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
headerLines.unshift(line);
|
|
722
|
+
h--;
|
|
723
|
+
}
|
|
724
|
+
const header = truncateQuestionHeader(headerLines).join('\n').trim();
|
|
725
|
+
// Descriptions render indented under their option label, mirroring the
|
|
726
|
+
// OpenCode question body (`buildQuestionBodyLines`).
|
|
727
|
+
const renderedOptions = options
|
|
728
|
+
.map(option => {
|
|
729
|
+
const optionLine = `${option.highlighted ? '❯' : ' '} ${option.number}. ${option.label}`;
|
|
730
|
+
const descriptionLines = option.descriptionLines.map(line => ` ${line}`);
|
|
731
|
+
return [optionLine, ...descriptionLines].join('\n');
|
|
732
|
+
})
|
|
733
|
+
.join('\n');
|
|
734
|
+
// Labels only — descriptions and the cursor stay out so the signature is
|
|
735
|
+
// stable across cursor moves and description repaints (de-dup holds).
|
|
736
|
+
const signature = options.map(option => `${option.number}.${option.label}`).join('|');
|
|
737
|
+
return {
|
|
738
|
+
text: header ? `${header}\n\n${renderedOptions}` : renderedOptions,
|
|
739
|
+
signature,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/** Whether `text` is confidently showing an interactive question/choice block. */
|
|
743
|
+
function checkIsClaudeQuestionBlock(text) {
|
|
744
|
+
return extractClaudeQuestion(text) !== null;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* @description Classify a user reply sent WHILE a selector is on screen. A
|
|
748
|
+
* "control" reply is meant to drive the selector in place — a bare option
|
|
749
|
+
* number (1–2 digits) or a single `y`/`n` — so it must be typed straight into
|
|
750
|
+
* the TUI with no Escape. Anything else (a sentence, a new instruction) is a
|
|
751
|
+
* free-form message: the bot first sends Escape to cancel the selector, then
|
|
752
|
+
* forwards it as a fresh turn. Kept pure + exported so the routing decision is
|
|
753
|
+
* unit-testable without a live tmux session.
|
|
754
|
+
*/
|
|
755
|
+
function checkIsSelectorControlReply(text) {
|
|
756
|
+
const trimmed = text.trim();
|
|
757
|
+
return /^\d{1,2}$/.test(trimmed) || /^[yYnN]$/.test(trimmed);
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* @description The KNOWN survey header texts — a CLOSED alternation (a future
|
|
761
|
+
* rewording becomes variant #3 here), matched ANCHORED start-to-end of a line
|
|
762
|
+
* (after an optional leading `●`/`⏺` assistant bullet, before an optional
|
|
763
|
+
* trailing ` (optional)`), NEVER as a substring. The matched header text is
|
|
764
|
+
* captured (group 1) and feeds the survey's `header` + `signature`, so two
|
|
765
|
+
* variants never share a signature.
|
|
766
|
+
*
|
|
767
|
+
* WHY anchored, not substring: an earlier substring matcher false-fired on a
|
|
768
|
+
* user message that merely QUOTED the survey text in prose — it spammed the
|
|
769
|
+
* live topic with bogus surveys + duplicates. The header MUST be the WHOLE
|
|
770
|
+
* line, so a header embedded mid-sentence can never match. Do NOT loosen this
|
|
771
|
+
* to an open-ended `How .* Claude` prose pattern.
|
|
772
|
+
*
|
|
773
|
+
* WHY an alternation: Claude Code ≥2.1.19x reworded the survey ("How well is
|
|
774
|
+
* Claude following the instructions…") — the old exact-header match never
|
|
775
|
+
* fired, the undetected survey sat on the pane and SWALLOWED the Enter of the
|
|
776
|
+
* next forwarded prompt: the text stranded unsubmitted in the TUI input box
|
|
777
|
+
* and the topic looked hung for hours (live 2026-07-02).
|
|
778
|
+
*/
|
|
779
|
+
const CLAUDE_SURVEY_HEADER_REGEX = /^\s*[●⏺]?\s*(How is Claude doing this session\?|How well is Claude following the instructions you gave earlier in this conversation\?)(\s*\(optional\))?\s*$/;
|
|
780
|
+
/**
|
|
781
|
+
* The inline option row: `N: Label N: Label …` — every token is
|
|
782
|
+
* `digit: word`, ≥2 of them, and the WHOLE line is option tokens (anchored),
|
|
783
|
+
* so a prose line that merely contains `1: foo` can't pass.
|
|
784
|
+
*/
|
|
785
|
+
const CLAUDE_SURVEY_OPTION_ROW_REGEX = /^\s*(\d+:\s*[A-Za-z]+\s*)+$/;
|
|
786
|
+
/** Pulls each `digit: label` pair out of a validated option row. */
|
|
787
|
+
const CLAUDE_SURVEY_OPTION_TOKEN_REGEX = /(\d+):\s*([A-Za-z]+)/g;
|
|
788
|
+
const CLAUDE_SURVEY_MIN_OPTIONS = 2;
|
|
789
|
+
/**
|
|
790
|
+
* @description AIRTIGHT detector for the Claude CLI bare-digit survey. Returns
|
|
791
|
+
* a survey ONLY when BOTH hold:
|
|
792
|
+
*
|
|
793
|
+
* 1. Some line, after stripping an optional leading `● ` bullet, matches
|
|
794
|
+
* {@link CLAUDE_SURVEY_HEADER_REGEX} ANCHORED start-to-end (the WHOLE
|
|
795
|
+
* line) — so a header quoted mid-sentence is rejected.
|
|
796
|
+
* 2. The IMMEDIATELY-FOLLOWING non-empty line is an inline option row
|
|
797
|
+
* ({@link CLAUDE_SURVEY_OPTION_ROW_REGEX}) parsing to ≥2 options.
|
|
798
|
+
*
|
|
799
|
+
* Kept fully SEPARATE from {@link extractClaudeQuestion} (the per-line
|
|
800
|
+
* AskUserQuestion selector): this is a lighter fixed-shape prompt. The
|
|
801
|
+
* signature is header + the option digits/labels only (no volatile chrome), so
|
|
802
|
+
* the same survey across polls de-dups to a single emit.
|
|
803
|
+
*/
|
|
804
|
+
function extractClaudeSurvey(text) {
|
|
805
|
+
const lines = text.split('\n');
|
|
806
|
+
for (let i = 0; i < lines.length; i++) {
|
|
807
|
+
const headerMatch = CLAUDE_SURVEY_HEADER_REGEX.exec(lines[i]);
|
|
808
|
+
if (!headerMatch)
|
|
809
|
+
continue;
|
|
810
|
+
// The option row is the IMMEDIATELY-following non-empty line — skip only
|
|
811
|
+
// blank lines, never prose. If the next content line isn't an option row,
|
|
812
|
+
// this is not a survey (the header was probably quoted in prose).
|
|
813
|
+
let j = i + 1;
|
|
814
|
+
while (j < lines.length && lines[j].trim() === '')
|
|
815
|
+
j++;
|
|
816
|
+
if (j >= lines.length)
|
|
817
|
+
return null;
|
|
818
|
+
if (!CLAUDE_SURVEY_OPTION_ROW_REGEX.test(lines[j]))
|
|
819
|
+
return null;
|
|
820
|
+
const options = [];
|
|
821
|
+
for (const match of lines[j].matchAll(CLAUDE_SURVEY_OPTION_TOKEN_REGEX)) {
|
|
822
|
+
options.push({ digit: match[1], label: match[2] });
|
|
823
|
+
}
|
|
824
|
+
if (options.length < CLAUDE_SURVEY_MIN_OPTIONS)
|
|
825
|
+
return null;
|
|
826
|
+
const header = headerMatch[1];
|
|
827
|
+
const signature = `${header}::${options
|
|
828
|
+
.map(option => `${option.digit}:${option.label}`)
|
|
829
|
+
.join('|')}`;
|
|
830
|
+
return { header, options, signature };
|
|
831
|
+
}
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
/** Whether `text` is confidently showing a Claude CLI bare-digit survey. */
|
|
835
|
+
function checkIsClaudeSurvey(text) {
|
|
836
|
+
return extractClaudeSurvey(text) !== null;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* @description Whether the poll loop should AUTO-DISMISS the survey on screen
|
|
840
|
+
* this poll (S4). True only when a survey is present AND its signature differs
|
|
841
|
+
* from the one we last dismissed — the survey repaints every poll, so the
|
|
842
|
+
* signature dedup makes us send the dismiss keystroke exactly ONCE per
|
|
843
|
+
* appearance, and re-dismiss a genuinely new survey later (the caller clears the
|
|
844
|
+
* stored signature when the survey leaves the pane). Pure + exported so the
|
|
845
|
+
* once-per-appearance rule is unit-testable without a live session.
|
|
846
|
+
*/
|
|
847
|
+
function checkShouldDismissSurvey(surveySignature, lastDismissedSignature) {
|
|
848
|
+
return surveySignature !== null && surveySignature !== lastDismissedSignature;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* @description Drop the survey's header + option-row lines from a relay chunk
|
|
852
|
+
* (S4) so the auto-dismissed survey's chrome never leaks into the topic — those
|
|
853
|
+
* lines match no tool/spinner/chrome rule and would otherwise classify as prose
|
|
854
|
+
* and post as a stray message. The CALLER gates this on a survey actually being
|
|
855
|
+
* detected on the pane this poll, so a legitimate "1: Yes 2: No" list in normal
|
|
856
|
+
* agent output is never stripped. Pure + exported for unit testing.
|
|
857
|
+
*/
|
|
858
|
+
function stripSurveyChromeLines(text) {
|
|
859
|
+
return text
|
|
860
|
+
.split('\n')
|
|
861
|
+
.filter((line) => !CLAUDE_SURVEY_HEADER_REGEX.test(line) && !CLAUDE_SURVEY_OPTION_ROW_REGEX.test(line))
|
|
862
|
+
.join('\n');
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* @description Pure plan of the tmux send-keys steps for one `sendInput` call.
|
|
866
|
+
*
|
|
867
|
+
* Mirrors the three historical Enter branches (bare slash command → deferred
|
|
868
|
+
* Enter; short control reply → instant Enter; plain prompt → paste-race
|
|
869
|
+
* deferred Enter + verification). When `appendEnter === false` ONLY the literal
|
|
870
|
+
* keystrokes are sent — every Enter branch is skipped (for a keystroke that
|
|
871
|
+
* auto-submits on its own). `appendEnter` defaults to `true`, so every existing
|
|
872
|
+
* caller's plan is byte-for-byte unchanged.
|
|
873
|
+
*/
|
|
874
|
+
function getClaudeSendKeysPlan(input, appendEnter) {
|
|
875
|
+
const steps = ['literal'];
|
|
876
|
+
if (!appendEnter)
|
|
877
|
+
return steps;
|
|
878
|
+
if (checkIsBareSlashCommand(input)) {
|
|
879
|
+
steps.push('slashEnter');
|
|
880
|
+
}
|
|
881
|
+
else if (input.length < CLAUDE_PASTE_RACE_MIN_LENGTH) {
|
|
882
|
+
steps.push('instantEnter');
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
steps.push('verifiedEnter');
|
|
886
|
+
}
|
|
887
|
+
return steps;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* @description Decide how to route a user TEXT reply for a Claude thread,
|
|
891
|
+
* given which interactive prompts are currently pending. Pure so the
|
|
892
|
+
* precedence is unit-testable.
|
|
893
|
+
*
|
|
894
|
+
* Precedence (a real AskUserQuestion always wins):
|
|
895
|
+
* 1. A selector (AskUserQuestion) pending + a bare control reply
|
|
896
|
+
* (digit / y / n) → `'selector'` (drive the menu in place).
|
|
897
|
+
* 2. Else the `/login` "Paste code here" box is on screen → `'loginPaste'`:
|
|
898
|
+
* the OAuth code is a long free-form string, so route it VERBATIM into
|
|
899
|
+
* the box (see {@link checkIsClaudeLoginPaste}). Without this it falls to
|
|
900
|
+
* `'prompt'`, whose Escape cancels the login flow and whose preamble
|
|
901
|
+
* corrupts the code (live bug: "login can't be done via the bot").
|
|
902
|
+
* 3. Else → `'prompt'` (a fresh turn; free-form prose breaks out of any
|
|
903
|
+
* pending prompt, mirroring the existing selector break-out behavior).
|
|
904
|
+
*
|
|
905
|
+
* Claude's native session SURVEY is NOT routed here: the adapter auto-dismisses
|
|
906
|
+
* it (Escape) and never surfaces it to the user, so a bare digit typed during a
|
|
907
|
+
* survey window falls through to `'prompt'` (or `'selector'` when a real
|
|
908
|
+
* question is also up) — never silently dropped.
|
|
909
|
+
*/
|
|
910
|
+
function getClaudeReplyRoute(input) {
|
|
911
|
+
const isControlReply = checkIsSelectorControlReply(input.text);
|
|
912
|
+
if (input.isQuestionPending && isControlReply)
|
|
913
|
+
return 'selector';
|
|
914
|
+
if (input.isLoginPastePending)
|
|
915
|
+
return 'loginPaste';
|
|
916
|
+
return 'prompt';
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* @description Whether the captured pane shows Claude's `/login` OAuth
|
|
920
|
+
* "paste the code" step. After the user picks a login method Claude prints a
|
|
921
|
+
* sign-in URL and the input row `Paste code here if prompted >` (a plain `>`
|
|
922
|
+
* box, NOT the `❯` prompt or a numbered selector), then waits for the pasted
|
|
923
|
+
* `code#state` value. The marker string is stable (verified in the claude.exe
|
|
924
|
+
* string table, 2026-06-13). Used by {@link ClaudeCliAdapter.isLoginPastePending}
|
|
925
|
+
* so the bot routes the pasted code verbatim instead of as a fresh prompt.
|
|
926
|
+
* The marker regex is shared with the json-stream out-of-band `/login` flow
|
|
927
|
+
* ({@link claudeLoginPastePromptRe}) so the marker string has a single definition.
|
|
928
|
+
*/
|
|
929
|
+
/**
|
|
930
|
+
* Lines from the pane BOTTOM that count as the live input region. The real
|
|
931
|
+
* `/login` "paste code" prompt is the active bottom input row; matching the
|
|
932
|
+
* marker anywhere in the full pane false-fired whenever this repo's OWN source
|
|
933
|
+
* or docs (which quote the marker) rendered in a Claude TUI working on
|
|
934
|
+
* Charness — the bot then ate the user's next message as a one-time login
|
|
935
|
+
* code (live 2026-06-25). Anchoring to the tail excludes scrollback
|
|
936
|
+
* mentions while still catching a genuine login row.
|
|
937
|
+
*/
|
|
938
|
+
const loginPasteTailLineCount = 10;
|
|
939
|
+
function checkIsClaudeLoginPaste(paneText) {
|
|
940
|
+
const tail = paneText.split('\n').slice(-loginPasteTailLineCount).join('\n');
|
|
941
|
+
return claudeAuthLogin_1.claudeLoginPastePromptRe.test(tail);
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* A line-numbered diff row (` 88 + code`, ` 90 context`, bare ` 89`),
|
|
945
|
+
* recognisable WITHOUT its tool header — the fallback for a diff that arrived
|
|
946
|
+
* split from its header across two polls. Diff rows never occur in agent prose.
|
|
947
|
+
*/
|
|
948
|
+
const DIFF_ROW_RE = /^\s+\d+(?:\s|$)/;
|
|
949
|
+
/** Min consecutive diff rows for the header-less fallback to fence them. */
|
|
950
|
+
const DIFF_FALLBACK_MIN_ROWS = 2;
|
|
951
|
+
/**
|
|
952
|
+
* @description Recognise the four line shapes of a markdown table that Claude's
|
|
953
|
+
* TUI renders as a SHARP-corner box-drawing frame.
|
|
954
|
+
*
|
|
955
|
+
* WHY a dedicated detector: Claude's own UI **chrome** panels use ROUNDED
|
|
956
|
+
* corners (`╭ ╮ ╰ ╯`) — exactly what the chrome-drop filters in
|
|
957
|
+
* {@link stripTuiElementsWithContext} target by dropping any `│`/`─` line wider
|
|
958
|
+
* than 50 chars. A rendered markdown **table** uses SHARP corners
|
|
959
|
+
* (`┌ ┬ ┐ ├ ┼ ┤ └ ┴ ┘`), so a WIDE table body row (a long `│ … │`) was caught
|
|
960
|
+
* by that same width filter and dropped — the "header survives, body rows lost"
|
|
961
|
+
* bug (#10). Detecting sharp corners targets tables specifically and lets the
|
|
962
|
+
* table-collecting branch run BEFORE the chrome filters, so table rows can
|
|
963
|
+
* never be dropped while chrome (rounded) handling stays untouched.
|
|
964
|
+
*
|
|
965
|
+
* An optional leading `●/⏺ ` assistant bullet + indent is tolerated on the TOP
|
|
966
|
+
* border (Claude prints the bullet on the table's first line).
|
|
967
|
+
*/
|
|
968
|
+
const SHARP_TABLE_TOP_RE = /^\s*[●⏺]?\s*┌[─┬]+┐\s*$/;
|
|
969
|
+
const SHARP_TABLE_BOTTOM_RE = /^\s*└[─┴]+┘\s*$/;
|
|
970
|
+
const SHARP_TABLE_SEPARATOR_RE = /^\s*├[─┼]+┤\s*$/;
|
|
971
|
+
const SHARP_TABLE_CONTENT_RE = /^\s*│.*│\s*$/;
|
|
972
|
+
/** Strip a leading `●/⏺ ` assistant bullet so the collected box indent is uniform. */
|
|
973
|
+
const ASSISTANT_BULLET_PREFIX_RE = /^(\s*)[●⏺]\s/;
|
|
974
|
+
/** True iff `line` is the TOP border of a sharp-corner (markdown) table. */
|
|
975
|
+
function checkIsSharpTableTop(line) {
|
|
976
|
+
return SHARP_TABLE_TOP_RE.test(line);
|
|
977
|
+
}
|
|
978
|
+
/** True iff `line` is the BOTTOM border of a sharp-corner (markdown) table. */
|
|
979
|
+
function checkIsSharpTableBottom(line) {
|
|
980
|
+
return SHARP_TABLE_BOTTOM_RE.test(line);
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* True iff `line` is any line of a sharp-corner (markdown) table — top/bottom
|
|
984
|
+
* border, a `├─┼─┤` separator, or a `│ … │` content row.
|
|
985
|
+
*/
|
|
986
|
+
function checkIsSharpTableLine(line) {
|
|
987
|
+
return (checkIsSharpTableTop(line) ||
|
|
988
|
+
checkIsSharpTableBottom(line) ||
|
|
989
|
+
SHARP_TABLE_SEPARATOR_RE.test(line) ||
|
|
990
|
+
SHARP_TABLE_CONTENT_RE.test(line));
|
|
991
|
+
}
|
|
992
|
+
function getLeadingSpaceCount(line) {
|
|
993
|
+
return line.match(/^(\s*)/)[1].length;
|
|
994
|
+
}
|
|
995
|
+
/** Strip the largest leading-whitespace prefix common to all non-blank lines. */
|
|
996
|
+
function getDedented(lines) {
|
|
997
|
+
const indents = lines.filter(line => line.trim()).map(getLeadingSpaceCount);
|
|
998
|
+
if (indents.length === 0)
|
|
999
|
+
return lines;
|
|
1000
|
+
const min = Math.min(...indents);
|
|
1001
|
+
return lines.map(line => line.slice(min));
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* @description Wrap body lines in a ```` ``` ```` fence for `renderAgentHtml`
|
|
1005
|
+
* to turn into `<pre><code>`. Any run of 3+ backticks inside the body (e.g. a
|
|
1006
|
+
* diff of a markdown file with its own fences) is broken up with zero-width
|
|
1007
|
+
* spaces so it can't prematurely close our fence — the body is for reading,
|
|
1008
|
+
* not byte-exact copy.
|
|
1009
|
+
*/
|
|
1010
|
+
function getFenced(bodyLines) {
|
|
1011
|
+
const safe = bodyLines.map(line => line.replace(/`{3,}/g, run => run.split('').join('')));
|
|
1012
|
+
return ['```', ...safe, '```'];
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* @description Hard cap on streaming-table hold polls. The normal exit is a
|
|
1016
|
+
* done-signal — real prose now follows the table, or the turn went idle (see
|
|
1017
|
+
* {@link getTableStabilizationDecision}); if a pane is stuck mid-paint and the
|
|
1018
|
+
* turn never resolves, force-emit the latest version after this many CONSECUTIVE
|
|
1019
|
+
* unchanged-block polls so the table can never be swallowed forever. At
|
|
1020
|
+
* {@link basePollIntervalMs} (300ms) this is ≈9s of an unchanging held block
|
|
1021
|
+
* before the safety fires — far longer than a real table takes to settle.
|
|
1022
|
+
*/
|
|
1023
|
+
const maxTableHoldPolls = 30;
|
|
1024
|
+
/**
|
|
1025
|
+
* @description Locate the LAST contiguous sharp-corner (markdown) table block in
|
|
1026
|
+
* a pre-split pane snapshot: the block text plus the index of its LAST line, or
|
|
1027
|
+
* `null` when none is present. Mirrors the collection loop in
|
|
1028
|
+
* {@link stripTuiElementsWithContext} (a TOP border, then contiguous table lines
|
|
1029
|
+
* until the first non-table line, a BOTTOM border ending it inclusively). The
|
|
1030
|
+
* LAST block is taken because a streaming table sits at the bottom of the pane;
|
|
1031
|
+
* an already-finished earlier table was emitted on a prior poll. The end index
|
|
1032
|
+
* lets {@link checkHasContentAfterLastSharpTable} look at what follows the table
|
|
1033
|
+
* without re-scanning. Internal helper for the two callers below.
|
|
1034
|
+
*/
|
|
1035
|
+
function getLastSharpTableRange(lines) {
|
|
1036
|
+
let range = null;
|
|
1037
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1038
|
+
if (!checkIsSharpTableTop(lines[i]))
|
|
1039
|
+
continue;
|
|
1040
|
+
const tableLines = [lines[i]];
|
|
1041
|
+
let endIndex = i;
|
|
1042
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1043
|
+
if (!checkIsSharpTableLine(lines[j]))
|
|
1044
|
+
break;
|
|
1045
|
+
tableLines.push(lines[j]);
|
|
1046
|
+
endIndex = j;
|
|
1047
|
+
if (checkIsSharpTableBottom(lines[j]))
|
|
1048
|
+
break;
|
|
1049
|
+
}
|
|
1050
|
+
range = { block: tableLines.join('\n'), endIndex };
|
|
1051
|
+
// Keep scanning: a later top border means a more recent table further down.
|
|
1052
|
+
i = endIndex;
|
|
1053
|
+
}
|
|
1054
|
+
return range;
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* @description Find the LAST contiguous sharp-corner (markdown) table block in a
|
|
1058
|
+
* full pane snapshot, or `null` when none is present. Returns the raw block text
|
|
1059
|
+
* so the stabilizer can compare it across polls. Pure + exported for unit tests.
|
|
1060
|
+
*/
|
|
1061
|
+
function getLastSharpTableBlock(content) {
|
|
1062
|
+
return getLastSharpTableRange(content.split('\n'))?.block ?? null;
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* @description Whether any REAL prose line follows the last sharp-corner table in
|
|
1066
|
+
* the pane — the "the table is done, content moved past it" signal the stabilizer
|
|
1067
|
+
* uses to emit the held table BEFORE that trailing prose (preserving order).
|
|
1068
|
+
*
|
|
1069
|
+
* Deliberately CONSERVATIVE (errs toward `false`): a line after the table counts
|
|
1070
|
+
* as content only when it is none of the recognised non-content shapes —
|
|
1071
|
+
* blank/whitespace, another sharp-table line, TUI chrome
|
|
1072
|
+
* ({@link checkIsClaudeChromeLine} — covers the `❯ …` input box and its `───`
|
|
1073
|
+
* rules, box-drawing borders, the `⏵⏵ bypass permissions…` footer, nav hints),
|
|
1074
|
+
* any progress/spinner/sub-agent frame ({@link checkIsProgressChunk} per line),
|
|
1075
|
+
* a live spinner tick ({@link SPINNER_TICK_RE}), a `✻ … for Ns` post-thinking
|
|
1076
|
+
* trailer ({@link POST_THINKING_TRAILER_RE}), or a `Thinking for Ns…` header
|
|
1077
|
+
* ({@link THINKING_HEADER_RE}). Reuses the SAME predicates the relay already
|
|
1078
|
+
* trusts as chrome — it never invents a looser matcher — so it cannot
|
|
1079
|
+
* false-positive on the input box / spinner / trailers (the failure mode that
|
|
1080
|
+
* would emit a table prematurely). The turn-idle signal and the hold safety cap
|
|
1081
|
+
* are the backstops when this stays `false`. Pure + exported for unit tests.
|
|
1082
|
+
*/
|
|
1083
|
+
function checkHasContentAfterLastSharpTable(content) {
|
|
1084
|
+
const lines = content.split('\n');
|
|
1085
|
+
const range = getLastSharpTableRange(lines);
|
|
1086
|
+
if (range === null)
|
|
1087
|
+
return false;
|
|
1088
|
+
for (let i = range.endIndex + 1; i < lines.length; i++) {
|
|
1089
|
+
const line = lines[i];
|
|
1090
|
+
if (line.trim() === '')
|
|
1091
|
+
continue;
|
|
1092
|
+
if (checkIsSharpTableLine(line))
|
|
1093
|
+
continue;
|
|
1094
|
+
// The `❯` input box ends the transcript region: everything BELOW it (the
|
|
1095
|
+
// bypass-permissions footer, right-aligned token/usage counters, and corner
|
|
1096
|
+
// shortcut hints like `/rc` in Claude v2.1.201) is TUI footer chrome, never
|
|
1097
|
+
// transcript content. A real prose line after the table appears ABOVE the
|
|
1098
|
+
// input box, so once the scan reaches it, stop — nothing below is content.
|
|
1099
|
+
// WHY this is the real fix (live 2026-07-04): the `/rc` corner
|
|
1100
|
+
// hint is not matched by any chrome predicate, so the scan ran PAST the input
|
|
1101
|
+
// box and counted it as "content after the table" → `hasContentAfterTable`
|
|
1102
|
+
// stayed true through the whole render → RULE 2 emitted every byte-stable
|
|
1103
|
+
// growth stage → the wide table flooded as ~7 messages.
|
|
1104
|
+
if (/^\s*❯/.test(line))
|
|
1105
|
+
return false;
|
|
1106
|
+
if ((0, claudeScrapeShapes_1.checkIsClaudeChromeLine)(line))
|
|
1107
|
+
continue;
|
|
1108
|
+
if ((0, progressLine_1.checkIsProgressChunk)(line))
|
|
1109
|
+
continue;
|
|
1110
|
+
if (claudeScrapeShapes_1.SPINNER_TICK_RE.test(line))
|
|
1111
|
+
continue;
|
|
1112
|
+
if (claudeScrapeShapes_1.POST_THINKING_TRAILER_RE.test(line.trim()))
|
|
1113
|
+
continue;
|
|
1114
|
+
if (claudeScrapeShapes_1.THINKING_HEADER_RE.test(line))
|
|
1115
|
+
continue;
|
|
1116
|
+
// Survived every chrome predicate → a genuine prose line follows the table.
|
|
1117
|
+
return true;
|
|
1118
|
+
}
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* @description Drop every sharp-corner table line from a poll's delta text so a
|
|
1123
|
+
* mid-layout table frame never ships while the stabilizer is holding the table.
|
|
1124
|
+
* Only table-shaped lines are removed — surrounding prose streams as usual.
|
|
1125
|
+
* Pure + exported for unit tests.
|
|
1126
|
+
*/
|
|
1127
|
+
function maskSharpTableLines(deltaText) {
|
|
1128
|
+
return deltaText
|
|
1129
|
+
.split('\n')
|
|
1130
|
+
.filter(line => !checkIsSharpTableLine(line))
|
|
1131
|
+
.join('\n')
|
|
1132
|
+
.trim();
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* @description Decide what the poll loop does with a re-flowing sharp-corner
|
|
1136
|
+
* table. A held table is emitted ONLY when the turn is genuinely DONE — never on
|
|
1137
|
+
* a mere "byte-stable for one poll", because Claude streams a table in stages and
|
|
1138
|
+
* briefly PAUSES at an intermediate row count (e.g. 4 rows), which is byte-stable
|
|
1139
|
+
* across a poll yet not the final table. The old "stable one poll → emit" rule
|
|
1140
|
+
* shipped that 4-row intermediate, then the table grew to 6 rows and shipped
|
|
1141
|
+
* again → the topic got two tables (live incident 2026-06-15). Pure + exported
|
|
1142
|
+
* for unit tests. Rules, in order:
|
|
1143
|
+
*
|
|
1144
|
+
* 1. no table in the pane: flush whatever was held, once (table scrolled away /
|
|
1145
|
+
* message moved on); else no-op.
|
|
1146
|
+
* 2. real PROSE now follows the table (`hasContentAfterTable`) AND the table's
|
|
1147
|
+
* width has settled (`sameBlock` — byte-stable since the previous poll) → the
|
|
1148
|
+
* table is done; emit it BEFORE that trailing prose (the poll loop withholds
|
|
1149
|
+
* the prose this tick), so ordering is table-then-prose. A table still
|
|
1150
|
+
* re-flowing wider while prose is already on the pane HOLDS instead — only the
|
|
1151
|
+
* settled width ships (live flood 2026-07-04).
|
|
1152
|
+
* 3. the turn is IDLE (`isTurnIdle`) → emit promptly (covers a table-only
|
|
1153
|
+
* answer, which never gets trailing prose).
|
|
1154
|
+
* 4. otherwise HOLD — update the held block and advance `heldPolls` only while
|
|
1155
|
+
* the block is unchanged (a changed/new block resets the counter); past
|
|
1156
|
+
* {@link maxTableHoldPolls} force-emit so a never-settling pane can't swallow
|
|
1157
|
+
* the table forever. A still-busy table is held even when byte-identical
|
|
1158
|
+
* across polls — that is exactly the mid-stream pause.
|
|
1159
|
+
*/
|
|
1160
|
+
function getTableStabilizationDecision(input) {
|
|
1161
|
+
const { currentTable, streamingTable, hasContentAfterTable, isTurnIdle } = input;
|
|
1162
|
+
if (currentTable === null) {
|
|
1163
|
+
// RULE 1: the table left the pane — flush whatever was held, once.
|
|
1164
|
+
if (streamingTable) {
|
|
1165
|
+
return { kind: 'emit', block: streamingTable.block, nextStreamingTable: null };
|
|
1166
|
+
}
|
|
1167
|
+
return { kind: 'none', block: null, nextStreamingTable: null };
|
|
1168
|
+
}
|
|
1169
|
+
// Whether the table block is byte-stable since the previous poll (its WIDTH has
|
|
1170
|
+
// settled, not still re-flowing). Load-bearing for RULE 2 and the RULE 4 guard.
|
|
1171
|
+
const sameBlock = streamingTable !== null && currentTable === streamingTable.block;
|
|
1172
|
+
// RULE 2: real content follows the table → it is done; emit it BEFORE that
|
|
1173
|
+
// prose (the caller masks the prose out of this tick to keep ordering). BUT
|
|
1174
|
+
// only once the WIDTH has settled (`sameBlock`): a table still re-flowing wider
|
|
1175
|
+
// WHILE trailing prose is already on the pane must NOT ship an intermediate
|
|
1176
|
+
// width (live flood 2026-07-04 — each width shipped as its own
|
|
1177
|
+
// message). A changed (wider) block falls through to HOLD and re-checks next
|
|
1178
|
+
// poll; idle (RULE 3) and the safety cap still emit a final width.
|
|
1179
|
+
if (hasContentAfterTable && sameBlock) {
|
|
1180
|
+
return { kind: 'emit', block: currentTable, nextStreamingTable: null };
|
|
1181
|
+
}
|
|
1182
|
+
// RULE 3: the turn finished → emit promptly (a table-only answer never gets
|
|
1183
|
+
// trailing prose, so this is its done-signal).
|
|
1184
|
+
if (isTurnIdle) {
|
|
1185
|
+
return { kind: 'emit', block: currentTable, nextStreamingTable: null };
|
|
1186
|
+
}
|
|
1187
|
+
// RULE 4: still mid-turn (incl. a byte-stable mid-stream pause) → HOLD. Advance
|
|
1188
|
+
// the runaway guard only while the block is unchanged; a changed block restarts
|
|
1189
|
+
// the count (it is actively re-flowing, give it the full budget) — so a
|
|
1190
|
+
// continuously-widening table never hits the cap and can never force-emit a
|
|
1191
|
+
// partial width; only a FROZEN busy pane does.
|
|
1192
|
+
const heldPolls = (sameBlock ? streamingTable.heldPolls : 0) + 1;
|
|
1193
|
+
if (heldPolls > maxTableHoldPolls) {
|
|
1194
|
+
// SAFETY: a pane stuck mid-paint must never swallow the table — ship the
|
|
1195
|
+
// latest version we have and clear, rather than holding indefinitely.
|
|
1196
|
+
return { kind: 'emit', block: currentTable, nextStreamingTable: null };
|
|
1197
|
+
}
|
|
1198
|
+
return { kind: 'hold', block: null, nextStreamingTable: { block: currentTable, heldPolls } };
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* @description A status / summary line the TUI paints inside a tool-result
|
|
1202
|
+
* body that is chrome, not command output, and so must NEVER be fenced.
|
|
1203
|
+
* Narrowly anchored to the three confirmed literal shapes — extending this to
|
|
1204
|
+
* an arbitrary `…`-ending line would swallow real one-line stdout.
|
|
1205
|
+
*/
|
|
1206
|
+
function checkIsClaudeBodyStatusLine(line) {
|
|
1207
|
+
return (claudeScrapeShapes_1.TRANSIENT_TICK_RE.test(line) ||
|
|
1208
|
+
claudeScrapeShapes_1.COLLAPSE_MARKER_RE.test(line) ||
|
|
1209
|
+
claudeScrapeShapes_1.COMPLETION_SUMMARY_RE.test(line));
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* @description Split a tool-result body into the lines that should be fenced
|
|
1213
|
+
* (genuine stdout / diff / file content) and the status/summary lines that
|
|
1214
|
+
* must stay PLAIN. A {@link TRANSIENT_TICK_RE} tick (`Running…`/`Waiting…`)
|
|
1215
|
+
* followed by ANY real output line in the SAME body is stale — superseded by
|
|
1216
|
+
* the output the bot captured one frame later (the live case) — so it is
|
|
1217
|
+
* DROPPED, not even kept plain. Other status/summary lines are kept and
|
|
1218
|
+
* returned in `plain` (emitted after the fence). Genuine content goes to
|
|
1219
|
+
* `fenced` untouched.
|
|
1220
|
+
*/
|
|
1221
|
+
function splitStatusLinesFromBody(bodyLines) {
|
|
1222
|
+
const hasRealOutput = bodyLines.some(line => !checkIsClaudeBodyStatusLine(line));
|
|
1223
|
+
const fenced = [];
|
|
1224
|
+
const plain = [];
|
|
1225
|
+
for (const line of bodyLines) {
|
|
1226
|
+
if (!checkIsClaudeBodyStatusLine(line)) {
|
|
1227
|
+
fenced.push(line);
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
// Stale transient tick superseded by real output → drop it entirely.
|
|
1231
|
+
if (claudeScrapeShapes_1.TRANSIENT_TICK_RE.test(line) && hasRealOutput)
|
|
1232
|
+
continue;
|
|
1233
|
+
plain.push(line);
|
|
1234
|
+
}
|
|
1235
|
+
return { fenced, plain };
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* @description Fence a tool-result body while keeping its status/summary lines
|
|
1239
|
+
* (`Running…`, `… +N lines`, `Done (… tokens …)`) OUT of the fence as plain
|
|
1240
|
+
* text — see {@link splitStatusLinesFromBody}. Genuine content is dedented and
|
|
1241
|
+
* fenced; an empty-after-filtering body emits NO ```` ``` ```` fence. The
|
|
1242
|
+
* caller passes the already-dedented-and-prefixed body for the `output` kind
|
|
1243
|
+
* (the `⎿` line rides with it) so dedent is applied here over the full set.
|
|
1244
|
+
*/
|
|
1245
|
+
function getFencedBodyWithStatus(bodyLines) {
|
|
1246
|
+
const { fenced, plain } = splitStatusLinesFromBody(bodyLines);
|
|
1247
|
+
const result = [];
|
|
1248
|
+
if (fenced.length > 0)
|
|
1249
|
+
result.push(...getFenced(getDedented(fenced)));
|
|
1250
|
+
result.push(...plain.map(line => line.trim()));
|
|
1251
|
+
return result;
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* @description Wrap each code-producing tool's `⎿` result body in a code fence
|
|
1255
|
+
* (B2). Operates on the already echo-suppressed / chrome-filtered line array.
|
|
1256
|
+
* See {@link OUTPUT_TOOL_HEADER_RE} for the header→body classification.
|
|
1257
|
+
*
|
|
1258
|
+
* Stateful across the chunk AND across polls: a tool header sets the active
|
|
1259
|
+
* kind; a `⎿` result or its orphan indented continuation is fenced for that
|
|
1260
|
+
* kind; a solid non-tool line (prose, a `Thinking…` header) clears the kind so
|
|
1261
|
+
* thinking prose is never fenced. The kind is threaded across polls by the
|
|
1262
|
+
* caller — Claude's TUI redraws the whole pane each poll and the line-set diff
|
|
1263
|
+
* ({@link getNewPaneContent}) emits only NEW lines, so a slow command's output
|
|
1264
|
+
* (`yarn test`, a build) arrives in a later poll WITHOUT its `● Bash(…)` header
|
|
1265
|
+
* (suppressed as a duplicate). `incomingKind` carries the header poll's kind
|
|
1266
|
+
* forward; the returned `outgoingKind` is fed back next poll. This is tracked
|
|
1267
|
+
* via the clean emitted deltas, NOT by scanning the racy live pane (the spinner
|
|
1268
|
+
* repaints every tick, so a pane scan flickers between frames).
|
|
1269
|
+
*
|
|
1270
|
+
* An agent-authored ```` ``` ```` block is passed through untouched (and
|
|
1271
|
+
* suppresses the diff-row fallback inside it) so a real code block is never
|
|
1272
|
+
* double-wrapped.
|
|
1273
|
+
*/
|
|
1274
|
+
function fenceToolResultBodies(lines, incomingKind = null) {
|
|
1275
|
+
const out = [];
|
|
1276
|
+
let inAgentFence = false;
|
|
1277
|
+
let currentKind = incomingKind;
|
|
1278
|
+
let i = 0;
|
|
1279
|
+
while (i < lines.length) {
|
|
1280
|
+
const line = lines[i];
|
|
1281
|
+
if (claudeScrapeShapes_1.CODE_FENCE_LINE_RE.test(line)) {
|
|
1282
|
+
inAgentFence = !inAgentFence;
|
|
1283
|
+
out.push(line);
|
|
1284
|
+
i++;
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
if (inAgentFence) {
|
|
1288
|
+
out.push(line);
|
|
1289
|
+
i++;
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
if (claudeScrapeShapes_1.OUTPUT_TOOL_HEADER_RE.test(line)) {
|
|
1293
|
+
currentKind = 'output';
|
|
1294
|
+
out.push(line);
|
|
1295
|
+
i++;
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
if (claudeScrapeShapes_1.FILE_TOOL_HEADER_RE.test(line)) {
|
|
1299
|
+
currentKind = 'file';
|
|
1300
|
+
out.push(line);
|
|
1301
|
+
i++;
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
// Sub-agent task / compaction-bar progress lines: the bot collapses these,
|
|
1305
|
+
// so they must pass through un-fenced and they end any open tool body.
|
|
1306
|
+
if (claudeScrapeShapes_1.PROGRESS_PASSTHROUGH_RE.test(line)) {
|
|
1307
|
+
currentKind = null;
|
|
1308
|
+
out.push(line);
|
|
1309
|
+
i++;
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
const markerMatch = line.match(claudeScrapeShapes_1.TOOL_RESULT_MARKER_RE);
|
|
1313
|
+
if (markerMatch) {
|
|
1314
|
+
if (currentKind === null) {
|
|
1315
|
+
// Unknown owner (e.g. a thinking `⎿` body) — leave it as prose.
|
|
1316
|
+
out.push(line);
|
|
1317
|
+
i++;
|
|
1318
|
+
continue;
|
|
1319
|
+
}
|
|
1320
|
+
const markerIndent = markerMatch[1].length;
|
|
1321
|
+
const body = [];
|
|
1322
|
+
let j = i + 1;
|
|
1323
|
+
while (j < lines.length &&
|
|
1324
|
+
lines[j].trim() !== '' &&
|
|
1325
|
+
getLeadingSpaceCount(lines[j]) > markerIndent) {
|
|
1326
|
+
body.push(lines[j]);
|
|
1327
|
+
j++;
|
|
1328
|
+
}
|
|
1329
|
+
if (currentKind === 'output') {
|
|
1330
|
+
// `⎿`-content is stdout → drop the marker glyph (keep alignment), fence
|
|
1331
|
+
// it with the body, but keep any status/summary line plain (never fenced).
|
|
1332
|
+
out.push(...getFencedBodyWithStatus([line.replace('⎿', ' '), ...body]));
|
|
1333
|
+
}
|
|
1334
|
+
else {
|
|
1335
|
+
out.push(line); // summary line stays prose
|
|
1336
|
+
if (body.length > 0)
|
|
1337
|
+
out.push(...getFencedBodyWithStatus(body));
|
|
1338
|
+
}
|
|
1339
|
+
i = j;
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
// Orphan indented continuation of a known tool body (header was in a prior
|
|
1343
|
+
// poll, or the `⎿` summary already streamed): fence the indented run.
|
|
1344
|
+
if (currentKind !== null && /^\s/.test(line) && line.trim() !== '') {
|
|
1345
|
+
const block = [];
|
|
1346
|
+
let j = i;
|
|
1347
|
+
while (j < lines.length &&
|
|
1348
|
+
lines[j].trim() !== '' &&
|
|
1349
|
+
/^\s/.test(lines[j]) &&
|
|
1350
|
+
!claudeScrapeShapes_1.TOOL_RESULT_MARKER_RE.test(lines[j]) &&
|
|
1351
|
+
!claudeScrapeShapes_1.CODE_FENCE_LINE_RE.test(lines[j])) {
|
|
1352
|
+
block.push(lines[j]);
|
|
1353
|
+
j++;
|
|
1354
|
+
}
|
|
1355
|
+
out.push(...getFencedBodyWithStatus(block));
|
|
1356
|
+
i = j;
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
// Header-less diff-row run (cross-poll split with no known kind): fence on shape.
|
|
1360
|
+
if (currentKind === null && DIFF_ROW_RE.test(line)) {
|
|
1361
|
+
const block = [];
|
|
1362
|
+
let j = i;
|
|
1363
|
+
while (j < lines.length && DIFF_ROW_RE.test(lines[j])) {
|
|
1364
|
+
block.push(lines[j]);
|
|
1365
|
+
j++;
|
|
1366
|
+
}
|
|
1367
|
+
if (block.length >= DIFF_FALLBACK_MIN_ROWS) {
|
|
1368
|
+
out.push(...getFenced(getDedented(block)));
|
|
1369
|
+
}
|
|
1370
|
+
else {
|
|
1371
|
+
out.push(...block);
|
|
1372
|
+
}
|
|
1373
|
+
i = j;
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
// Any solid prose line ends the tool context (blanks leave it intact).
|
|
1377
|
+
if (line.trim() !== '')
|
|
1378
|
+
currentKind = null;
|
|
1379
|
+
out.push(line);
|
|
1380
|
+
i++;
|
|
1381
|
+
}
|
|
1382
|
+
return { out, outgoingKind: currentKind };
|
|
1383
|
+
}
|
|
1384
|
+
/** The language tag of a fence-OPEN line (`` ```ts `` → `ts`, bare `` ``` `` → ''). */
|
|
1385
|
+
function getFenceLanguage(fenceLine) {
|
|
1386
|
+
return fenceLine.replace(/^\s*```/, '').trim();
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* @description Merge two same-language fenced blocks separated by NOTHING but
|
|
1390
|
+
* blank line(s) into one continuous fence (#14). One logical tool output that
|
|
1391
|
+
* {@link fenceToolResultBodies} split into multiple `getFenced` runs renders as
|
|
1392
|
+
* two adjacent `<pre>` ({@link FENCE_REGEX} makes one per fence) — visually two
|
|
1393
|
+
* boxes for one output. A NON-blank line between the fences (a `● Bash(…)`
|
|
1394
|
+
* header, prose) BLOCKS the merge, so DIFFERENT tool calls stay separate; only
|
|
1395
|
+
* blank-separated same-language fences are joined, keeping one blank inside.
|
|
1396
|
+
* Languages must match (or both be empty) so a plain output block and a `ts`
|
|
1397
|
+
* block are never fused.
|
|
1398
|
+
*/
|
|
1399
|
+
function mergeAdjacentFences(lines) {
|
|
1400
|
+
const out = [...lines];
|
|
1401
|
+
let i = 0;
|
|
1402
|
+
while (i < out.length) {
|
|
1403
|
+
if (!claudeScrapeShapes_1.CODE_FENCE_LINE_RE.test(out[i])) {
|
|
1404
|
+
i++;
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
// `out[i]` opens a fence; find its closing delimiter.
|
|
1408
|
+
const closeIndex = findFenceClose(out, i);
|
|
1409
|
+
if (closeIndex === -1)
|
|
1410
|
+
break; // unbalanced — leave the tail untouched
|
|
1411
|
+
const nextOpenIndex = getNextOpenAfterBlanks(out, closeIndex + 1);
|
|
1412
|
+
if (nextOpenIndex !== -1 &&
|
|
1413
|
+
getFenceLanguage(out[nextOpenIndex]) === getFenceLanguage(out[i])) {
|
|
1414
|
+
// Drop the close + the re-open, keep a single blank line between bodies.
|
|
1415
|
+
out.splice(closeIndex, nextOpenIndex - closeIndex + 1, '');
|
|
1416
|
+
continue; // re-test from the SAME open fence (a third block may follow)
|
|
1417
|
+
}
|
|
1418
|
+
i = closeIndex + 1;
|
|
1419
|
+
}
|
|
1420
|
+
return out;
|
|
1421
|
+
}
|
|
1422
|
+
/** Index of the fence-CLOSE delimiter that matches the OPEN at `openIndex`. */
|
|
1423
|
+
function findFenceClose(lines, openIndex) {
|
|
1424
|
+
for (let j = openIndex + 1; j < lines.length; j++) {
|
|
1425
|
+
if (claudeScrapeShapes_1.CODE_FENCE_LINE_RE.test(lines[j]))
|
|
1426
|
+
return j;
|
|
1427
|
+
}
|
|
1428
|
+
return -1;
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Index of the next fence-OPEN delimiter reachable from `fromIndex` across ONLY
|
|
1432
|
+
* blank lines, or -1 if a non-blank line (a header / prose) intervenes first.
|
|
1433
|
+
*/
|
|
1434
|
+
function getNextOpenAfterBlanks(lines, fromIndex) {
|
|
1435
|
+
for (let j = fromIndex; j < lines.length; j++) {
|
|
1436
|
+
if (lines[j].trim() === '')
|
|
1437
|
+
continue;
|
|
1438
|
+
return claudeScrapeShapes_1.CODE_FENCE_LINE_RE.test(lines[j]) ? j : -1;
|
|
1439
|
+
}
|
|
1440
|
+
return -1;
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* @description Strip Claude TUI chrome from a pane diff and fence tool-result
|
|
1444
|
+
* bodies, returning the cleaned text AND the tool-result kind still open at the
|
|
1445
|
+
* end of the chunk. The caller (`pollOutput`) threads `toolKind` back in as the
|
|
1446
|
+
* next poll's `incomingKind` so a slow command's orphan output is fenced (B2).
|
|
1447
|
+
*/
|
|
1448
|
+
function stripTuiElementsWithContext(text, incomingKind = null) {
|
|
1449
|
+
const lines = text.split('\n');
|
|
1450
|
+
const filtered = [];
|
|
1451
|
+
// A submitted multi-line prompt renders as a `❯ <first line>` user-turn
|
|
1452
|
+
// block followed by space-indented continuation lines. We only dropped the
|
|
1453
|
+
// `❯` line, so the continuation (incl. literal ``` fences) leaked out as a
|
|
1454
|
+
// phantom "agent message" duplicating the user's own prompt. Skip the whole
|
|
1455
|
+
// echo block: continuation lines stay suppressed until a non-indented line
|
|
1456
|
+
// (the spinner, a blank, or the agent's own `●` output) ends it.
|
|
1457
|
+
let inUserTurnEcho = false;
|
|
1458
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1459
|
+
let line = lines[i];
|
|
1460
|
+
// B3: the `(ctrl+o …)` affordance is terminal-only — Telegram can't send
|
|
1461
|
+
// ctrl+o — so drop the parenthetical while keeping the rest of the line
|
|
1462
|
+
// (`… +2 lines`, `Thought for 4s`, `Read 50 lines`). Trailing-anchored, so
|
|
1463
|
+
// it never disturbs the leading-anchored echo / chrome detection below.
|
|
1464
|
+
line = line.replace(/\s*\(ctrl\+o[^)]*\)/gi, '');
|
|
1465
|
+
if (inUserTurnEcho) {
|
|
1466
|
+
if (/^\s+\S/.test(line))
|
|
1467
|
+
continue;
|
|
1468
|
+
inUserTurnEcho = false;
|
|
1469
|
+
}
|
|
1470
|
+
if (/^❯\s+\S/.test(line)) {
|
|
1471
|
+
inUserTurnEcho = true;
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
// Markdown table (sharp-corner box). Collect the whole frame and emit it
|
|
1475
|
+
// FENCED before the chrome-drop filters below — those filters delete any
|
|
1476
|
+
// `│`/`─` line wider than 50 chars, which used to wipe a wide table's body
|
|
1477
|
+
// rows (bug #10). Chrome uses ROUNDED corners and is left to those filters;
|
|
1478
|
+
// only the sharp-corner shape is intercepted here. A table split across
|
|
1479
|
+
// scrape chunks (no bottom border yet) still keeps every row it has so far.
|
|
1480
|
+
if (checkIsSharpTableTop(line)) {
|
|
1481
|
+
const tableBlock = [line];
|
|
1482
|
+
let endIndex = i;
|
|
1483
|
+
// Collect contiguous table lines; a bottom border ends the block
|
|
1484
|
+
// (inclusive), as does the first non-table line (a table still painting
|
|
1485
|
+
// across scrape chunks keeps every row it has so far).
|
|
1486
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1487
|
+
if (!checkIsSharpTableLine(lines[j]))
|
|
1488
|
+
break;
|
|
1489
|
+
tableBlock.push(lines[j]);
|
|
1490
|
+
endIndex = j;
|
|
1491
|
+
if (checkIsSharpTableBottom(lines[j]))
|
|
1492
|
+
break;
|
|
1493
|
+
}
|
|
1494
|
+
const unbulleted = tableBlock.map(tableLine => tableLine.replace(ASSISTANT_BULLET_PREFIX_RE, '$1 '));
|
|
1495
|
+
filtered.push(...getFenced(getDedented(unbulleted)));
|
|
1496
|
+
i = endIndex;
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
if (/^[─━]+$/.test(line.trim()))
|
|
1500
|
+
continue;
|
|
1501
|
+
if (/⏵⏵\s*(bypass permissions|accept edits)\s*(on|off)/i.test(line))
|
|
1502
|
+
continue;
|
|
1503
|
+
if (/^❯/.test(line))
|
|
1504
|
+
continue;
|
|
1505
|
+
// Ephemeral UI hint Claude prints under a turn ("⎿ Tip: Use Plan Mode…").
|
|
1506
|
+
// Require the `⎿` marker so plain prose starting with "Tip:" is NOT eaten.
|
|
1507
|
+
if (/^\s*⎿\s*Tip:\s/i.test(line))
|
|
1508
|
+
continue;
|
|
1509
|
+
// Transient frame the TUI repaints right after an Escape interrupt
|
|
1510
|
+
// (" ⎿ Interrupted · What should Claude do instead?"). The pane-diff
|
|
1511
|
+
// pipeline relayed it as a scary "agent is waiting" message even though
|
|
1512
|
+
// the next prompt is already being typed. Anchored to the exact TUI shape
|
|
1513
|
+
// (line-start, optional ⎿/whitespace, the literal phrase to line-end) so
|
|
1514
|
+
// prose merely mentioning the word "Interrupted" is NOT stripped.
|
|
1515
|
+
if (/^\s*(?:⎿\s*)?Interrupted · What should Claude do instead\?\s*$/.test(line))
|
|
1516
|
+
continue;
|
|
1517
|
+
if (/\(shift\+tab to cycle\)/i.test(line))
|
|
1518
|
+
continue;
|
|
1519
|
+
// Bottom-right corner shortcut hint (v2.1.201 `/rc`): right-aligned, it
|
|
1520
|
+
// scrapes as a lone whitespace-padded token line. Same shape the chrome
|
|
1521
|
+
// classifier drops (`CORNER_HINT_RE` in claudeScrapeShapes — the deliberate
|
|
1522
|
+
// strip/classifier parallel, keep in sync).
|
|
1523
|
+
if (claudeScrapeShapes_1.CORNER_HINT_RE.test(line))
|
|
1524
|
+
continue;
|
|
1525
|
+
if (/^[\s·✽✢✶✻⏵❯─━↵]+$/.test(line))
|
|
1526
|
+
continue;
|
|
1527
|
+
// S3 (N1.a) / S4 (N1.b): per-line strip of mid-chunk spinner ticks
|
|
1528
|
+
// and post-thinking trailers. These shapes used to slip through
|
|
1529
|
+
// `checkIsStatusOutput` (adapter side) and `checkIsProgressChunk`
|
|
1530
|
+
// (bot side) when they appeared mixed with real output in a single
|
|
1531
|
+
// poll diff (the live debug session).
|
|
1532
|
+
if (claudeScrapeShapes_1.SPINNER_TICK_RE.test(line))
|
|
1533
|
+
continue;
|
|
1534
|
+
if (claudeScrapeShapes_1.POST_THINKING_TRAILER_RE.test(line.trim()))
|
|
1535
|
+
continue;
|
|
1536
|
+
// S2 (render-flood 2026-07-04): a leading star-burst activity title with NO
|
|
1537
|
+
// stats anchor (`✻ Herding cats`) is transient spinner chrome the
|
|
1538
|
+
// stats-requiring SPINNER_TICK_RE above misses — drop it so it never ships
|
|
1539
|
+
// as answer content. Both relay paths (fast + verbosity-routed) funnel
|
|
1540
|
+
// through here, so this one drop covers them.
|
|
1541
|
+
if ((0, claudeScrapeShapes_1.checkIsBareSpinnerActivityLine)(line))
|
|
1542
|
+
continue;
|
|
1543
|
+
const trimmedLine = line.trim();
|
|
1544
|
+
const isToolCall = /^[●○⏺]?\s*(Bash|Read|Write|Edit|Glob|Grep|Task|TodoWrite|WebFetch|WebSearch)\s*\(/i.test(trimmedLine);
|
|
1545
|
+
if (!isToolCall && /ctrl\+c.*to interrupt/i.test(line))
|
|
1546
|
+
continue;
|
|
1547
|
+
if (/claude code has switched|native installer|Run.*install.*or see/i.test(line))
|
|
1548
|
+
continue;
|
|
1549
|
+
if (/^install`?\s*(or see)?/i.test(trimmedLine))
|
|
1550
|
+
continue;
|
|
1551
|
+
if (/docs\.anthropic\.com/i.test(line))
|
|
1552
|
+
continue;
|
|
1553
|
+
if (/more options\.?\s*$/i.test(trimmedLine) && trimmedLine.length < 20)
|
|
1554
|
+
continue;
|
|
1555
|
+
if (/^[╭─╮│╰╯\s]+$/.test(trimmedLine))
|
|
1556
|
+
continue;
|
|
1557
|
+
if (/^[▐▛▜▌▝▘█▀▄░▒▓\s]+$/.test(trimmedLine))
|
|
1558
|
+
continue;
|
|
1559
|
+
// Interactive question UI: tab bar navigation (← ☐ ... →)
|
|
1560
|
+
if (/^←.*→\s*$/.test(trimmedLine))
|
|
1561
|
+
continue;
|
|
1562
|
+
// Interactive question UI: selection/navigation hints
|
|
1563
|
+
if (/Enter to select/i.test(line))
|
|
1564
|
+
continue;
|
|
1565
|
+
// Interactive question UI: side-by-side AskUserQuestion chrome — the
|
|
1566
|
+
// "Notes: press n…" affordance and the unnumbered "Chat about this"
|
|
1567
|
+
// meta-row leaked as naked text when a selector frame fell through to
|
|
1568
|
+
// this path (live 2026-06-10). Both regexes are anchored
|
|
1569
|
+
// whole-line, so prose containing the phrases survives.
|
|
1570
|
+
if (QUESTION_NOTES_HINT_REGEX.test(line))
|
|
1571
|
+
continue;
|
|
1572
|
+
if (QUESTION_CHAT_ABOUT_REGEX.test(line))
|
|
1573
|
+
continue;
|
|
1574
|
+
if (/Recent activity|What's new|\/resume for more/i.test(line))
|
|
1575
|
+
continue;
|
|
1576
|
+
if (/Welcome\s*back/i.test(line))
|
|
1577
|
+
continue;
|
|
1578
|
+
if (/[╭─╮│╰╯]/.test(line) && trimmedLine.length > 50)
|
|
1579
|
+
continue;
|
|
1580
|
+
if (/^\s*│.*\d+[smh]\s+ago\s+/i.test(line))
|
|
1581
|
+
continue;
|
|
1582
|
+
if (/^\s*│.*[─]+\s*│\s*$/.test(line))
|
|
1583
|
+
continue;
|
|
1584
|
+
if (isToolCall) {
|
|
1585
|
+
line = normalizeToolCallLine(line);
|
|
1586
|
+
}
|
|
1587
|
+
filtered.push(line);
|
|
1588
|
+
}
|
|
1589
|
+
const { out, outgoingKind } = fenceToolResultBodies(filtered, incomingKind);
|
|
1590
|
+
const merged = mergeAdjacentFences(out);
|
|
1591
|
+
let result = merged.join('\n');
|
|
1592
|
+
result = result.replace(/\n{3,}/g, '\n\n');
|
|
1593
|
+
return { text: result.trim(), toolKind: outgoingKind };
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* @description Strip Claude TUI chrome and fence tool-result bodies (B2),
|
|
1597
|
+
* returning just the cleaned text. Thin wrapper over
|
|
1598
|
+
* {@link stripTuiElementsWithContext} for call sites (and tests) that don't
|
|
1599
|
+
* thread the cross-poll tool kind.
|
|
1600
|
+
*/
|
|
1601
|
+
function stripTuiElements(text, incomingKind = null) {
|
|
1602
|
+
return stripTuiElementsWithContext(text, incomingKind).text;
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* @description Turn an absolute workDir into Claude's transcript-folder slug.
|
|
1606
|
+
*
|
|
1607
|
+
* Claude stores per-project transcripts under
|
|
1608
|
+
* `~/.claude/projects/<slug>/<sessionUuid>.jsonl`, where the slug is the
|
|
1609
|
+
* absolute path with every non-alphanumeric character replaced by `-`
|
|
1610
|
+
* (e.g. `/home/user/src/telegramCode` → `-home-user-src-telegramCode`).
|
|
1611
|
+
*
|
|
1612
|
+
* The slug encoding is undocumented and could drift across Claude versions,
|
|
1613
|
+
* so it is used only to *locate* the candidate folder — the authoritative
|
|
1614
|
+
* correctness gate is the `recordedCwd === workDir` filter in
|
|
1615
|
+
* {@link listClaudeSessionsForWorkDir}.
|
|
1616
|
+
*/
|
|
1617
|
+
function getClaudeProjectSlug(workDir) {
|
|
1618
|
+
return workDir.replace(/[^a-zA-Z0-9]/g, '-');
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* @description Root of Claude's on-disk transcript store
|
|
1622
|
+
* (`~/.claude/projects`). Single source for every path built from it —
|
|
1623
|
+
* session listing, resume-context reads, and the sub-agent transcript scan.
|
|
1624
|
+
*/
|
|
1625
|
+
function getClaudeProjectsRoot() {
|
|
1626
|
+
return path.join(os.homedir(), '.claude', 'projects');
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* @description Absolute path of ONE session's transcript
|
|
1630
|
+
* (`<projectsRoot>/<slug>/<sessionId>.jsonl`).
|
|
1631
|
+
*
|
|
1632
|
+
* Single source for the layout: both Claude backends resolve it for the
|
|
1633
|
+
* watermark advance, the runtime-info read, the resume-context turns, and the
|
|
1634
|
+
* reattach recap — a hand-built copy in any of them would silently read nothing
|
|
1635
|
+
* if the layout ever drifts.
|
|
1636
|
+
*/
|
|
1637
|
+
function getClaudeTranscriptPath(workDir, sessionId) {
|
|
1638
|
+
return path.join(getClaudeProjectsRoot(), getClaudeProjectSlug(workDir), `${sessionId}.jsonl`);
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* @description Read the byte range `[startOffset..endOffset)` of a file as
|
|
1642
|
+
* UTF-8. The sub-agent transcript tail uses it to fetch only the bytes
|
|
1643
|
+
* appended since the previous poll instead of re-reading whole files. A short
|
|
1644
|
+
* read (file truncated between stat and read) returns just the bytes
|
|
1645
|
+
* available — the JSONL line parser skips any torn tail.
|
|
1646
|
+
*/
|
|
1647
|
+
async function readFileSlice(filePath, startOffset, endOffset) {
|
|
1648
|
+
const fileHandle = await fs.promises.open(filePath, 'r');
|
|
1649
|
+
try {
|
|
1650
|
+
const length = endOffset - startOffset;
|
|
1651
|
+
const buffer = Buffer.alloc(length);
|
|
1652
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, length, startOffset);
|
|
1653
|
+
return buffer.subarray(0, bytesRead).toString('utf-8');
|
|
1654
|
+
}
|
|
1655
|
+
finally {
|
|
1656
|
+
await fileHandle.close();
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* @description Is this fs error a plain "path does not exist" (`ENOENT`)?
|
|
1661
|
+
* Cast-free narrowing via the same record guard the JSONL parsers use.
|
|
1662
|
+
*/
|
|
1663
|
+
function checkIsMissingPathError(error) {
|
|
1664
|
+
return checkIsRecord(error) && error.code === 'ENOENT';
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* @description Narrow an untyped `JSON.parse` result to a plain object so its
|
|
1668
|
+
* fields can be read with `typeof` guards instead of casts (parse-boundary
|
|
1669
|
+
* pattern for untrusted on-disk data; mirrors `isRecord` in `state.ts`).
|
|
1670
|
+
*/
|
|
1671
|
+
function checkIsRecord(value) {
|
|
1672
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* @description Stream-parse a single Claude `.jsonl` transcript, pulling out
|
|
1676
|
+
* just the fields we need to render and gate a resumable session. Reads the
|
|
1677
|
+
* whole file synchronously (the caller already bounded the count to
|
|
1678
|
+
* {@link sessionsParseLimit}) and tolerates malformed lines.
|
|
1679
|
+
*
|
|
1680
|
+
* A `.jsonl` can carry many `summary` entries (one per branch ever summarised
|
|
1681
|
+
* in this project file). The one describing THIS conversation is the summary
|
|
1682
|
+
* whose `leafUuid` points at a message `uuid` that lives in this file, so we
|
|
1683
|
+
* collect message uuids + all (leafUuid → summary) pairs, then match.
|
|
1684
|
+
*/
|
|
1685
|
+
function parseClaudeTranscript(filePath) {
|
|
1686
|
+
const result = {
|
|
1687
|
+
recordedCwd: null,
|
|
1688
|
+
summaryTitle: null,
|
|
1689
|
+
firstMeaningfulPrompt: null,
|
|
1690
|
+
firstTimestamp: null,
|
|
1691
|
+
};
|
|
1692
|
+
let raw;
|
|
1693
|
+
try {
|
|
1694
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
1695
|
+
}
|
|
1696
|
+
catch (e) {
|
|
1697
|
+
console.warn(`[Claude] failed to read transcript ${filePath}:`, e instanceof Error ? e.message : e);
|
|
1698
|
+
return result;
|
|
1699
|
+
}
|
|
1700
|
+
const messageUuids = new Set();
|
|
1701
|
+
const summaryByLeaf = new Map();
|
|
1702
|
+
for (const line of raw.split('\n')) {
|
|
1703
|
+
const trimmed = line.trim();
|
|
1704
|
+
if (!trimmed)
|
|
1705
|
+
continue;
|
|
1706
|
+
let entry;
|
|
1707
|
+
try {
|
|
1708
|
+
entry = JSON.parse(trimmed);
|
|
1709
|
+
}
|
|
1710
|
+
catch {
|
|
1711
|
+
continue; // tolerate a half-written trailing line
|
|
1712
|
+
}
|
|
1713
|
+
if (!checkIsRecord(entry))
|
|
1714
|
+
continue;
|
|
1715
|
+
if (typeof entry.uuid === 'string')
|
|
1716
|
+
messageUuids.add(entry.uuid);
|
|
1717
|
+
if (entry.type === 'summary' && typeof entry.summary === 'string' && typeof entry.leafUuid === 'string') {
|
|
1718
|
+
summaryByLeaf.set(entry.leafUuid, entry.summary);
|
|
1719
|
+
}
|
|
1720
|
+
if (result.recordedCwd === null && typeof entry.cwd === 'string') {
|
|
1721
|
+
result.recordedCwd = entry.cwd;
|
|
1722
|
+
}
|
|
1723
|
+
if (result.firstTimestamp === null && typeof entry.timestamp === 'string') {
|
|
1724
|
+
result.firstTimestamp = entry.timestamp;
|
|
1725
|
+
}
|
|
1726
|
+
if (entry.type === 'user' && result.firstMeaningfulPrompt === null) {
|
|
1727
|
+
const userText = extractUserText(entry.message);
|
|
1728
|
+
if (userText) {
|
|
1729
|
+
const candidate = (0, claudeSessionTitle_1.getSessionTitleCandidate)(userText);
|
|
1730
|
+
if (candidate)
|
|
1731
|
+
result.firstMeaningfulPrompt = candidate;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
for (const [leafUuid, summary] of summaryByLeaf) {
|
|
1736
|
+
if (messageUuids.has(leafUuid)) {
|
|
1737
|
+
result.summaryTitle = summary;
|
|
1738
|
+
break;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
return result;
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* @description Best-effort extraction of plain text from a transcript's
|
|
1745
|
+
* `user` message. Claude writes the message either as a bare string or as
|
|
1746
|
+
* an array of content blocks (`{ type: 'text', text }`). Returns the first
|
|
1747
|
+
* non-empty text found, or `null`.
|
|
1748
|
+
*/
|
|
1749
|
+
function extractUserText(message) {
|
|
1750
|
+
if (typeof message === 'string')
|
|
1751
|
+
return message.trim() || null;
|
|
1752
|
+
if (!checkIsRecord(message))
|
|
1753
|
+
return null;
|
|
1754
|
+
const content = message.content;
|
|
1755
|
+
if (typeof content === 'string')
|
|
1756
|
+
return content.trim() || null;
|
|
1757
|
+
if (Array.isArray(content)) {
|
|
1758
|
+
for (const block of content) {
|
|
1759
|
+
if (checkIsRecord(block) && block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
|
|
1760
|
+
return block.text.trim();
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
return null;
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* @description Best-effort extraction of plain text from a transcript's
|
|
1768
|
+
* `assistant` message. Unlike a user message, an assistant `message.content`
|
|
1769
|
+
* is always the block array; we concatenate every `{ type: 'text' }` block
|
|
1770
|
+
* (one assistant turn can interleave several) and IGNORE `tool_use` /
|
|
1771
|
+
* `thinking` / other block kinds so the resume context shows prose, not a
|
|
1772
|
+
* tool-call dump. Returns the joined text, or `null` if no renderable text.
|
|
1773
|
+
*/
|
|
1774
|
+
function extractAssistantText(message) {
|
|
1775
|
+
if (!checkIsRecord(message))
|
|
1776
|
+
return null;
|
|
1777
|
+
const content = message.content;
|
|
1778
|
+
// Some assistant entries store a bare string (rare); accept it too.
|
|
1779
|
+
if (typeof content === 'string')
|
|
1780
|
+
return content.trim() || null;
|
|
1781
|
+
if (!Array.isArray(content))
|
|
1782
|
+
return null;
|
|
1783
|
+
const textBlocks = [];
|
|
1784
|
+
for (const block of content) {
|
|
1785
|
+
if (checkIsRecord(block) && block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
|
|
1786
|
+
textBlocks.push(block.text.trim());
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
if (textBlocks.length === 0)
|
|
1790
|
+
return null;
|
|
1791
|
+
return textBlocks.join('\n\n');
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* @description Parse ONE Claude `.jsonl` transcript line into a renderable
|
|
1795
|
+
* conversational turn, or `null` for a meta / tool-only / empty / malformed
|
|
1796
|
+
* line. Shared by {@link readRecentClaudeTurns} (the resume-context / recap turn
|
|
1797
|
+
* body) and {@link readClaudeReattachTranscript} (the missed-message count) so
|
|
1798
|
+
* both apply the EXACT same renderability rule: a `type:'user'` /
|
|
1799
|
+
* `type:'assistant'` entry whose message yields non-empty text (via
|
|
1800
|
+
* {@link extractUserText} / {@link extractAssistantText}); `summary` and other
|
|
1801
|
+
* meta lines, and assistant entries that are tool_use-only, yield `null`. Never
|
|
1802
|
+
* throws — a torn / half-written line (offset landing mid-record) fails
|
|
1803
|
+
* `JSON.parse` and yields `null`.
|
|
1804
|
+
*/
|
|
1805
|
+
function parseClaudeTurnLine(line) {
|
|
1806
|
+
const trimmed = line.trim();
|
|
1807
|
+
if (!trimmed)
|
|
1808
|
+
return null;
|
|
1809
|
+
let entry;
|
|
1810
|
+
try {
|
|
1811
|
+
entry = JSON.parse(trimmed);
|
|
1812
|
+
}
|
|
1813
|
+
catch {
|
|
1814
|
+
return null; // tolerate a half-written / torn line
|
|
1815
|
+
}
|
|
1816
|
+
if (!checkIsRecord(entry))
|
|
1817
|
+
return null;
|
|
1818
|
+
if (entry.type === 'user') {
|
|
1819
|
+
const text = extractUserText(entry.message);
|
|
1820
|
+
return text ? { role: 'user', text } : null;
|
|
1821
|
+
}
|
|
1822
|
+
if (entry.type === 'assistant') {
|
|
1823
|
+
const text = extractAssistantText(entry.message);
|
|
1824
|
+
return text ? { role: 'assistant', text } : null;
|
|
1825
|
+
}
|
|
1826
|
+
return null;
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* @description Read the last `limit` conversational turns (user/assistant
|
|
1830
|
+
* messages with renderable text, oldest→newest) from a Claude `.jsonl`
|
|
1831
|
+
* transcript, for the resume context block.
|
|
1832
|
+
*
|
|
1833
|
+
* Exported and pure (filesystem-only) so it is unit-testable against a temp
|
|
1834
|
+
* `.jsonl`. Streams the whole file via {@link parseClaudeTurnLine}, keeping only
|
|
1835
|
+
* the last `limit` in a rolling window so a huge transcript stays cheap.
|
|
1836
|
+
* Tolerates a half-written trailing line; a missing/unreadable file → `[]`.
|
|
1837
|
+
*/
|
|
1838
|
+
function readRecentClaudeTurns(filePath, limit) {
|
|
1839
|
+
let raw;
|
|
1840
|
+
try {
|
|
1841
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
1842
|
+
}
|
|
1843
|
+
catch {
|
|
1844
|
+
return []; // transcript not on disk (unknown UUID / pruned) → no context
|
|
1845
|
+
}
|
|
1846
|
+
const turns = [];
|
|
1847
|
+
for (const line of raw.split('\n')) {
|
|
1848
|
+
const turn = parseClaudeTurnLine(line);
|
|
1849
|
+
if (turn)
|
|
1850
|
+
turns.push(turn);
|
|
1851
|
+
// Bound the window: drop the oldest once we exceed `limit` so a long
|
|
1852
|
+
// transcript never accumulates more than `limit` turns in memory.
|
|
1853
|
+
if (turns.length > limit)
|
|
1854
|
+
turns.shift();
|
|
1855
|
+
}
|
|
1856
|
+
return turns;
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* @description Combined transcript read for the silent-reattach recap: ONE
|
|
1860
|
+
* `fs.readFileSync` yields BOTH the missed-message count and the recap turn
|
|
1861
|
+
* body, so the transcript is not read twice.
|
|
1862
|
+
*
|
|
1863
|
+
* - `missedCount` — renderable `type:'assistant'` turns appended AFTER byte
|
|
1864
|
+
* `offset` (the seen-watermark = transcript size at the last turn end), counted
|
|
1865
|
+
* over the `[offset, EOF)` byte tail. A torn leading line (offset mid-record)
|
|
1866
|
+
* fails to parse and is skipped. `offset >= size` → `0` (nothing appended /
|
|
1867
|
+
* transcript truncated or rewritten smaller).
|
|
1868
|
+
* - `turns` — the last `limit` renderable turns of the WHOLE session (NOT just
|
|
1869
|
+
* the missed region), identical to {@link readRecentClaudeTurns}.
|
|
1870
|
+
* - `headOffset` — the transcript's CURRENT byte size (`buffer.length`), i.e.
|
|
1871
|
+
* the watermark value meaning "everything currently on disk is accounted for".
|
|
1872
|
+
* The reattach recap advances the persisted watermark to it for idempotency.
|
|
1873
|
+
* Absent on an unreadable file (head unknown → the caller omits the watermark).
|
|
1874
|
+
*
|
|
1875
|
+
* Exported and pure (filesystem-only) so it is unit-testable. `fs.readFileSync`
|
|
1876
|
+
* reads the whole file in one call (it handles short reads internally), so a
|
|
1877
|
+
* large `[offset, EOF)` tail never loses its last lines to a partial `read` —
|
|
1878
|
+
* the silent undercount a manual `Buffer.alloc` + `fs.readSync` would hit on
|
|
1879
|
+
* exactly the long-downtime case this feature targets. A missing/unreadable
|
|
1880
|
+
* file → `{ missedCount: 0, turns: [] }` (no `headOffset`).
|
|
1881
|
+
*/
|
|
1882
|
+
function readClaudeReattachTranscript(filePath, offset, limit) {
|
|
1883
|
+
let buffer;
|
|
1884
|
+
try {
|
|
1885
|
+
buffer = fs.readFileSync(filePath);
|
|
1886
|
+
}
|
|
1887
|
+
catch {
|
|
1888
|
+
return { missedCount: 0, turns: [] }; // transcript not on disk → nothing to recap
|
|
1889
|
+
}
|
|
1890
|
+
// Recap body: last `limit` renderable turns of the WHOLE session.
|
|
1891
|
+
const turns = [];
|
|
1892
|
+
for (const line of buffer.toString('utf-8').split('\n')) {
|
|
1893
|
+
const turn = parseClaudeTurnLine(line);
|
|
1894
|
+
if (turn)
|
|
1895
|
+
turns.push(turn);
|
|
1896
|
+
if (turns.length > limit)
|
|
1897
|
+
turns.shift();
|
|
1898
|
+
}
|
|
1899
|
+
// Missed count: renderable assistant turns in the [offset, EOF) byte tail.
|
|
1900
|
+
const start = Math.min(Math.max(0, offset), buffer.length);
|
|
1901
|
+
let missedCount = 0;
|
|
1902
|
+
for (const line of buffer.subarray(start).toString('utf-8').split('\n')) {
|
|
1903
|
+
const turn = parseClaudeTurnLine(line);
|
|
1904
|
+
if (turn?.role === 'assistant')
|
|
1905
|
+
missedCount += 1;
|
|
1906
|
+
}
|
|
1907
|
+
return { missedCount, turns, headOffset: buffer.length };
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* @description List resumable Claude sessions for a workDir by reading the
|
|
1911
|
+
* real `~/.claude/projects/<slug>/*.jsonl` transcripts.
|
|
1912
|
+
*
|
|
1913
|
+
* Exported and pure (filesystem-only, no tmux/adapter boot) so it can be
|
|
1914
|
+
* unit-tested against a temp `projectsRoot`. Steps:
|
|
1915
|
+
*
|
|
1916
|
+
* 1. Resolve `<projectsRoot>/<slug>`; if missing → `[]`.
|
|
1917
|
+
* 2. Take the {@link sessionsParseLimit} most-recently-modified `.jsonl`.
|
|
1918
|
+
* 3. Parse each; KEEP only files whose recorded cwd === `workDir`
|
|
1919
|
+
* (the load-bearing correctness gate — slug collisions / version drift).
|
|
1920
|
+
* 4. id = filename stem, MUST pass {@link checkIsValidUuid} (else skip).
|
|
1921
|
+
* 5. title = summaryTitle ?? firstMeaningfulPrompt ?? id (trimmed, capped).
|
|
1922
|
+
* `summaryTitle` is legacy (current Claude no longer writes it); the
|
|
1923
|
+
* primary source is the first REAL prompt topic
|
|
1924
|
+
* ({@link getSessionTitleCandidate}) — preamble/marker stripped, tooling
|
|
1925
|
+
* noise skipped — NOT the last (often noise) prompt. A session with no
|
|
1926
|
+
* meaningful prompt (all turns were command echoes / noise) falls back to
|
|
1927
|
+
* the bare id. updatedAt = file mtime; createdAt = first timestamp ?? mtime.
|
|
1928
|
+
*
|
|
1929
|
+
* Result is newest-first by mtime.
|
|
1930
|
+
*/
|
|
1931
|
+
function listClaudeSessionsForWorkDir(projectsRoot, workDir) {
|
|
1932
|
+
const slugDir = path.join(projectsRoot, getClaudeProjectSlug(workDir));
|
|
1933
|
+
let entries;
|
|
1934
|
+
try {
|
|
1935
|
+
entries = fs.readdirSync(slugDir);
|
|
1936
|
+
}
|
|
1937
|
+
catch {
|
|
1938
|
+
return []; // folder doesn't exist yet → no Claude session ran here
|
|
1939
|
+
}
|
|
1940
|
+
const candidates = entries
|
|
1941
|
+
.filter(name => name.endsWith('.jsonl'))
|
|
1942
|
+
.map(name => {
|
|
1943
|
+
const fullPath = path.join(slugDir, name);
|
|
1944
|
+
let mtimeMs;
|
|
1945
|
+
try {
|
|
1946
|
+
mtimeMs = fs.statSync(fullPath).mtimeMs;
|
|
1947
|
+
}
|
|
1948
|
+
catch {
|
|
1949
|
+
mtimeMs = 0;
|
|
1950
|
+
}
|
|
1951
|
+
return { name, fullPath, mtimeMs };
|
|
1952
|
+
})
|
|
1953
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
1954
|
+
.slice(0, sessionsParseLimit);
|
|
1955
|
+
const sessions = [];
|
|
1956
|
+
for (const candidate of candidates) {
|
|
1957
|
+
const id = candidate.name.slice(0, -'.jsonl'.length);
|
|
1958
|
+
if (!checkIsValidUuid(id))
|
|
1959
|
+
continue;
|
|
1960
|
+
const parsed = parseClaudeTranscript(candidate.fullPath);
|
|
1961
|
+
if (parsed.recordedCwd !== workDir)
|
|
1962
|
+
continue;
|
|
1963
|
+
const rawTitle = parsed.summaryTitle ?? parsed.firstMeaningfulPrompt ?? id;
|
|
1964
|
+
const title = rawTitle.trim().slice(0, sessionTitleMaxLength) || id;
|
|
1965
|
+
const updatedAt = new Date(candidate.mtimeMs);
|
|
1966
|
+
const createdAt = parsed.firstTimestamp ? new Date(parsed.firstTimestamp) : updatedAt;
|
|
1967
|
+
sessions.push({ id, title, createdAt, updatedAt });
|
|
1968
|
+
}
|
|
1969
|
+
return sessions;
|
|
1970
|
+
}
|
|
1971
|
+
// `shellSingleQuote` moved to `utils/tmuxExec` (shared with the terminal
|
|
1972
|
+
// backend); it was private here and is imported above.
|
|
1973
|
+
/**
|
|
1974
|
+
* @description A bare slash command (e.g. `/compact`, `/clear`, `/context`) —
|
|
1975
|
+
* a `/` followed only by letters/hyphens with NO argument. Typing one opens
|
|
1976
|
+
* Claude's command-autocomplete popup; an argument (a space) closes it. We
|
|
1977
|
+
* use this to decide whether the trailing `Enter` needs to wait for the popup
|
|
1978
|
+
* to settle (see {@link CLAUDE_SLASH_ENTER_DELAY_MS}).
|
|
1979
|
+
*/
|
|
1980
|
+
const BARE_SLASH_COMMAND_RE = /^\/[a-zA-Z][a-zA-Z-]*$/;
|
|
1981
|
+
/**
|
|
1982
|
+
* @description Whether `input` is a bare slash command (matches
|
|
1983
|
+
* {@link BARE_SLASH_COMMAND_RE} after trimming). Exported so the
|
|
1984
|
+
* popup-settle decision is unit-testable without a live tmux session.
|
|
1985
|
+
*/
|
|
1986
|
+
function checkIsBareSlashCommand(input) {
|
|
1987
|
+
return BARE_SLASH_COMMAND_RE.test(input.trim());
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* @description Delay between typing a bare slash command and pressing Enter.
|
|
1991
|
+
*
|
|
1992
|
+
* Claude's TUI opens an autocomplete popup the instant `/` is typed. Sending
|
|
1993
|
+
* Enter the same tick the popup is still rendering accepts the highlighted
|
|
1994
|
+
* suggestion instead of RUNNING the command, so e.g. `/compact` silently
|
|
1995
|
+
* no-ops. Letting the popup settle first makes Enter execute the command.
|
|
1996
|
+
* Commands with arguments (`/model sonnet`) don't need this — the space
|
|
1997
|
+
* already dismissed the popup.
|
|
1998
|
+
*/
|
|
1999
|
+
const CLAUDE_SLASH_ENTER_DELAY_MS = 250;
|
|
2000
|
+
/**
|
|
2001
|
+
* @description Delay between the literal-text burst and the Enter that submits
|
|
2002
|
+
* a plain (non-slash) prompt.
|
|
2003
|
+
*
|
|
2004
|
+
* Claude's TUI aggregates a fast burst of input as a paste; an Enter that lands
|
|
2005
|
+
* inside that aggregation window (observed: tens of ms after a 100+ char burst)
|
|
2006
|
+
* is absorbed as a paste newline instead of submitting, leaving the prompt
|
|
2007
|
+
* typed-but-unsubmitted in the input box (B5, live-reproduced ~1 in 4). Sending
|
|
2008
|
+
* the text and the Enter back-to-back on the serial queue is exactly that race.
|
|
2009
|
+
* Deferring the Enter past the window makes it register as a submit. Kept small
|
|
2010
|
+
* so the prompt still feels instant; the slash-command delay is larger because
|
|
2011
|
+
* it solves a different problem (popup settle), not this one.
|
|
2012
|
+
*/
|
|
2013
|
+
const CLAUDE_TEXT_ENTER_DELAY_MS = 80;
|
|
2014
|
+
/**
|
|
2015
|
+
* @description Inputs shorter than this skip the deferred Enter + verification
|
|
2016
|
+
* entirely: a few characters (`y`, `n`, an option digit) cannot trigger the
|
|
2017
|
+
* TUI's paste aggregation, and these control replies were instant before B5 —
|
|
2018
|
+
* keep them instant.
|
|
2019
|
+
*/
|
|
2020
|
+
const CLAUDE_PASTE_RACE_MIN_LENGTH = 8;
|
|
2021
|
+
/**
|
|
2022
|
+
* @description How long to wait after the submit-Enter before capturing the
|
|
2023
|
+
* pane to verify the prompt actually submitted (B5 post-Enter verification).
|
|
2024
|
+
* Must outlast the TUI repaint that follows a real submit (the input box clears
|
|
2025
|
+
* and Claude either goes busy or echoes the user turn). One capture only; if it
|
|
2026
|
+
* still looks unsubmitted we re-send Enter exactly once (no infinite retries).
|
|
2027
|
+
*/
|
|
2028
|
+
const CLAUDE_ENTER_VERIFY_DELAY_MS = 600;
|
|
2029
|
+
/**
|
|
2030
|
+
* @description How many trailing pane lines form Claude's live footer/status
|
|
2031
|
+
* region. The `esc to interrupt` busy hint renders ONLY here — on the
|
|
2032
|
+
* `⏵⏵ bypass permissions…` footer line, or the spinner status line a row or two
|
|
2033
|
+
* above the input box. `checkIsClaudeBusy` reads `session.lastContent`, which is
|
|
2034
|
+
* the FULL `capture-pane -S -2000` scrollback; bounding the busy match to this
|
|
2035
|
+
* tail stops a transcript that merely TALKS about "esc to interrupt" from
|
|
2036
|
+
* pinning the session busy forever. Live 2026-06-29: the bot's own dev topic
|
|
2037
|
+
* (its conversation is full of the phrase) froze a "🔧 working" status frame
|
|
2038
|
+
* because the regex matched scrollback prose, not the live footer — the genuine
|
|
2039
|
+
* footer sat on the bottom line while the nearest prose hit was 17 lines above.
|
|
2040
|
+
*/
|
|
2041
|
+
const CLAUDE_BUSY_FOOTER_TAIL_LINES = 8;
|
|
2042
|
+
/**
|
|
2043
|
+
* @description The busy hint as Claude's footer/spinner CHROME renders it:
|
|
2044
|
+
* bracketed by the `·` hint separator (`… · esc to interrupt · ← for agents`)
|
|
2045
|
+
* or the spinner-stats parens (`(12s · ↓ tokens · esc to interrupt)` /
|
|
2046
|
+
* `(esc to interrupt)`), never as bare prose. Requiring that bracketing keeps a
|
|
2047
|
+
* transcript line like `const RE = /esc to interrupt/i` or "the footer shows esc
|
|
2048
|
+
* to interrupt" — which can land in the inspected tail of an idle dev topic —
|
|
2049
|
+
* from reading as busy. Matched against CLEANED pane text (ANSI stripped) so the
|
|
2050
|
+
* marker is never split mid-word.
|
|
2051
|
+
*/
|
|
2052
|
+
const CLAUDE_BUSY_FOOTER_RE = /[·(]\s*esc to interrupt|esc to interrupt\s*[·)]/i;
|
|
2053
|
+
/**
|
|
2054
|
+
* @description Whether Claude is mid-turn, judged from a captured pane. A
|
|
2055
|
+
* selector/permission prompt shows `Esc to cancel`, NOT `esc to interrupt`, so
|
|
2056
|
+
* it reads as idle here (correct — we only wait out a running turn). Only the
|
|
2057
|
+
* footer tail is inspected ({@link CLAUDE_BUSY_FOOTER_TAIL_LINES}) and the hint
|
|
2058
|
+
* must carry footer chrome ({@link CLAUDE_BUSY_FOOTER_RE}), so scrollback prose
|
|
2059
|
+
* mentioning the phrase can't false-positive. Exported so the busy detection is
|
|
2060
|
+
* unit-testable without a live tmux session.
|
|
2061
|
+
*/
|
|
2062
|
+
function checkIsClaudeBusy(paneText) {
|
|
2063
|
+
const footer = paneText.replace(/\s+$/, '').split('\n').slice(-CLAUDE_BUSY_FOOTER_TAIL_LINES);
|
|
2064
|
+
return footer.some((line) => CLAUDE_BUSY_FOOTER_RE.test(line));
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* @description Sync, in-memory "is this session mid-turn?" decision for the
|
|
2068
|
+
* scheduler's wait-for-idle loop ({@link AgentAdapter.checkIsBusy}). Reuses the
|
|
2069
|
+
* SAME pane predicate {@link interruptAndWaitIdle} polls
|
|
2070
|
+
* ({@link checkIsClaudeBusy} on the `esc to interrupt` footer), evaluated
|
|
2071
|
+
* against the session's last CLEANED pane content instead of a fresh `tmux`
|
|
2072
|
+
* call — so it stays synchronous and adds no tmux load. The cleaned text is
|
|
2073
|
+
* load-bearing: the raw `-e` capture carries ANSI/SGR runs that can split the
|
|
2074
|
+
* footer marker mid-word and make the regex miss (the predicate's other
|
|
2075
|
+
* callers read plain no-`-e` captures for the same reason). A dead/missing
|
|
2076
|
+
* session is never busy. Exported pure so the gate is unit-testable without a
|
|
2077
|
+
* live session.
|
|
2078
|
+
*/
|
|
2079
|
+
function checkIsClaudeSessionBusy(args) {
|
|
2080
|
+
return args.isActive && checkIsClaudeBusy(args.lastContent);
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* @description Markers that a frame carries real agent content (a tool header
|
|
2084
|
+
* `●`/`⏺`, a tool-result `⎿`, or a ``` ``` ``` code fence). Their presence vetoes
|
|
2085
|
+
* the input-echo classification in {@link checkIsInputEchoFrame}.
|
|
2086
|
+
*/
|
|
2087
|
+
const CONTENT_MARKER_RE = /^(?:[●⏺]|⎿|```)/;
|
|
2088
|
+
/**
|
|
2089
|
+
* @description Whether a cleaned output frame is nothing but the TUI input box
|
|
2090
|
+
* echoing typed text (`❯ <draft>`). The poll can catch the input row between
|
|
2091
|
+
* the literal-text keystrokes and the deferred submit Enter (B5 window), and
|
|
2092
|
+
* that draft echo is pure noise in Telegram — the user already sees their own
|
|
2093
|
+
* message (B7).
|
|
2094
|
+
*
|
|
2095
|
+
* A frame is an echo when its FIRST non-empty line starts with `❯` AND no line
|
|
2096
|
+
* carries a content marker (`●`, `⎿`, or a ``` ``` ``` fence). The old
|
|
2097
|
+
* predicate required EVERY line to start with `❯`, but a long draft wraps in
|
|
2098
|
+
* the input box so only the first row carries `❯` and the continuation rows are
|
|
2099
|
+
* plain — those frames leaked into the topic (B7b). Anchoring on the first line
|
|
2100
|
+
* + a content-marker veto keeps a `❯` inside real tool output (e.g. a pane
|
|
2101
|
+
* capture printed by a Bash command) classified as content, not an echo.
|
|
2102
|
+
*
|
|
2103
|
+
* MAINTENANCE — Claude Code's TUI rendering drifts between versions, back and
|
|
2104
|
+
* forth (the prompt glyph, the echo indentation, the content markers — e.g. the
|
|
2105
|
+
* `●`→`⏺` bullet change noted in `utils/claudeScrapeShapes.ts`). This detector
|
|
2106
|
+
* must tolerate ALL known formatting variants, not assume the current one: when
|
|
2107
|
+
* an echo starts leaking into the topic, WIDEN the predicate to also cover the
|
|
2108
|
+
* new shape (keep the old variants), don't swap one shape for another.
|
|
2109
|
+
*/
|
|
2110
|
+
function checkIsInputEchoFrame(frameText) {
|
|
2111
|
+
const contentLines = frameText
|
|
2112
|
+
.split('\n')
|
|
2113
|
+
.map((line) => line.trim())
|
|
2114
|
+
.filter(Boolean);
|
|
2115
|
+
if (contentLines.length === 0)
|
|
2116
|
+
return false;
|
|
2117
|
+
if (!contentLines[0].startsWith('❯'))
|
|
2118
|
+
return false;
|
|
2119
|
+
return !contentLines.some((line) => CONTENT_MARKER_RE.test(line));
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* @description Min normalized chars a frame must share with the last forwarded
|
|
2123
|
+
* text before the content gate calls it an echo. Keeps a short reply that merely
|
|
2124
|
+
* coincides with a word of the prompt from being eaten — only a real echo of the
|
|
2125
|
+
* substance is suppressed. Below it, only the deterministic preamble signature
|
|
2126
|
+
* (which the agent never produces itself) can suppress.
|
|
2127
|
+
*/
|
|
2128
|
+
const claudeEchoMinMatchChars = 16;
|
|
2129
|
+
/**
|
|
2130
|
+
* @description How long after a {@link ClaudeCliAdapter.sendInput} the content
|
|
2131
|
+
* echo gate stays armed. Claude echoes the typed text right at submit, so a short
|
|
2132
|
+
* window catches every echo variant while bounding false positives: a much later
|
|
2133
|
+
* real reply that happens to repeat prompt text is outside the window and kept.
|
|
2134
|
+
*/
|
|
2135
|
+
const claudeInputEchoContentWindowMs = 30_000;
|
|
2136
|
+
/**
|
|
2137
|
+
* @description Collapse a frame / forwarded prompt to a whitespace-insensitive,
|
|
2138
|
+
* case-folded form for echo comparison. The TUI wraps a long input across rows
|
|
2139
|
+
* and pads differently between redraws, so runs of whitespace (incl. newlines)
|
|
2140
|
+
* flatten to a single space — the words survive, the layout noise does not.
|
|
2141
|
+
*/
|
|
2142
|
+
function normalizeEchoText(text) {
|
|
2143
|
+
return text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* @description CONTENT-based echo gate (S1), complementing the shape-only
|
|
2147
|
+
* {@link checkIsInputEchoFrame}. The bot types the user's prompt into the Claude
|
|
2148
|
+
* TUI and Claude echoes it back in the pane; when that echo carries NO `❯`
|
|
2149
|
+
* (a wrapped continuation row, the `[Telegram thread context]` preamble, or the
|
|
2150
|
+
* tail of a long voice transcript) the shape gate misses it and it leaks into
|
|
2151
|
+
* the topic as a ghost "bot message" (live 2026-06-28).
|
|
2152
|
+
*
|
|
2153
|
+
* A cleaned frame is the user's own echo when EITHER:
|
|
2154
|
+
* - it carries the deterministic thread-context preamble signature (the
|
|
2155
|
+
* `[Telegram thread context]` header — the agent never emits that itself), OR
|
|
2156
|
+
* - its normalized text is a contiguous slice of the normalized forwarded text
|
|
2157
|
+
* (a prefix, suffix, or interior run) of at least
|
|
2158
|
+
* {@link claudeEchoMinMatchChars} — i.e. the whole frame is nothing but a
|
|
2159
|
+
* chunk of what we just typed.
|
|
2160
|
+
*
|
|
2161
|
+
* The containment DIRECTION is load-bearing: the FRAME must be contained in the
|
|
2162
|
+
* forwarded text, never the reverse — so a genuine reply that QUOTES part of the
|
|
2163
|
+
* prompt and then adds an answer is NOT a substring of the prompt and is kept.
|
|
2164
|
+
* The caller additionally gates this on a short post-forward time window.
|
|
2165
|
+
*
|
|
2166
|
+
* WIDENED (live 2026-07-06): the poll can catch the just-submitted
|
|
2167
|
+
* echo's wrapped row GLUED to the new turn's first spinner tick in ONE frame
|
|
2168
|
+
* (`<echo row>\n\n* Twisting…`). The glued chrome is not part of the forwarded
|
|
2169
|
+
* text, so whole-frame containment failed and the row leaked as a permanent
|
|
2170
|
+
* message. When the whole frame misses, transient-shaped lines (per-line
|
|
2171
|
+
* {@link checkIsStatusOutput}) are dropped and the remaining content is
|
|
2172
|
+
* re-tested — only a frame whose real content is nothing but a slice of the
|
|
2173
|
+
* forwarded text is suppressed, so an answer glued to a spinner is still kept.
|
|
2174
|
+
*
|
|
2175
|
+
* Pure + exported so every observed echo variant is unit-testable. MAINTENANCE:
|
|
2176
|
+
* like {@link checkIsInputEchoFrame}, WIDEN (never swap) when a new echo shape
|
|
2177
|
+
* leaks — keep the old variants.
|
|
2178
|
+
*/
|
|
2179
|
+
function checkIsForwardedEcho(frameText, forwardedText) {
|
|
2180
|
+
const frame = normalizeEchoText(frameText);
|
|
2181
|
+
if (!frame)
|
|
2182
|
+
return false;
|
|
2183
|
+
if (frame.includes(normalizeEchoText(threadContextPreamble_1.threadContextPreambleHeader)))
|
|
2184
|
+
return true;
|
|
2185
|
+
const forwarded = normalizeEchoText(forwardedText);
|
|
2186
|
+
if (!forwarded)
|
|
2187
|
+
return false;
|
|
2188
|
+
if (frame.length >= claudeEchoMinMatchChars && forwarded.includes(frame))
|
|
2189
|
+
return true;
|
|
2190
|
+
// Glued-chrome widen: drop transient/spinner-shaped lines, keep real content
|
|
2191
|
+
// lines, and re-run the SAME containment on the remainder. A frame with no
|
|
2192
|
+
// content lines left is pure status — the status flow owns it, not the gate.
|
|
2193
|
+
const contentLines = frameText
|
|
2194
|
+
.split('\n')
|
|
2195
|
+
.filter((line) => line.trim() && !checkIsStatusOutput(line));
|
|
2196
|
+
if (contentLines.length === 0)
|
|
2197
|
+
return false;
|
|
2198
|
+
const residual = normalizeEchoText(contentLines.join('\n'));
|
|
2199
|
+
if (residual === frame)
|
|
2200
|
+
return false; // nothing was dropped — already tested above
|
|
2201
|
+
return residual.length >= claudeEchoMinMatchChars && forwarded.includes(residual);
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* @description Min chars of the typed prompt that must still sit in the live
|
|
2205
|
+
* input box for {@link checkLooksUnsubmitted} to call it unsubmitted. A short
|
|
2206
|
+
* prefix (vs the whole prompt) is enough — the input box shows only the first
|
|
2207
|
+
* wrapped line of a long prompt — while guarding against a one-glyph false
|
|
2208
|
+
* match. Below this length we compare the full trimmed text.
|
|
2209
|
+
*/
|
|
2210
|
+
const CLAUDE_INPUT_MATCH_PREFIX_LEN = 12;
|
|
2211
|
+
/**
|
|
2212
|
+
* @description Whether a captured pane looks like the typed prompt is still
|
|
2213
|
+
* sitting UNSUBMITTED in Claude's live input box (the B5 paste-race symptom).
|
|
2214
|
+
*
|
|
2215
|
+
* Submission predicate (the load-bearing "did it submit?" check): a prompt has
|
|
2216
|
+
* submitted iff EITHER Claude went busy (`esc to interrupt` in the footer — the
|
|
2217
|
+
* turn started) OR the live input box at the bottom is empty (`❯ ` with no
|
|
2218
|
+
* text — Claude consumed the input and is idle again, e.g. a fast/no-op turn).
|
|
2219
|
+
* It looks UNSUBMITTED only when Claude is idle AND the last `❯`-led input row
|
|
2220
|
+
* still carries the typed text. We deliberately key on the LAST `❯` line: a
|
|
2221
|
+
* SUBMITTED prompt echoes as a `❯ <text>` user-turn block in scrollback, but
|
|
2222
|
+
* the live input box below it is the final `❯` line and is empty — so matching
|
|
2223
|
+
* the last one ignores the harmless scrollback echo.
|
|
2224
|
+
*
|
|
2225
|
+
* Tradeoff (locked in plan B5): if a single verification capture is ambiguous
|
|
2226
|
+
* we err toward reporting unsubmitted and let the caller retry Enter once — a
|
|
2227
|
+
* duplicate Enter on an already-submitted prompt is a harmless no-op in the
|
|
2228
|
+
* Claude TUI (submits an empty input = ignored), whereas a missed submit silently
|
|
2229
|
+
* loses the user's message.
|
|
2230
|
+
*
|
|
2231
|
+
* Exported so the predicate is unit-testable without a live tmux session.
|
|
2232
|
+
*/
|
|
2233
|
+
function checkLooksUnsubmitted(paneText, typedText) {
|
|
2234
|
+
if (checkIsClaudeBusy(paneText))
|
|
2235
|
+
return false;
|
|
2236
|
+
const typed = typedText.trim();
|
|
2237
|
+
if (!typed)
|
|
2238
|
+
return false;
|
|
2239
|
+
// The input box renders the first visual line of the prompt; compare against
|
|
2240
|
+
// the prompt's first line so a multi-line paste doesn't fail to match.
|
|
2241
|
+
const firstLine = typed.split('\n', 1)[0].trim();
|
|
2242
|
+
if (!firstLine)
|
|
2243
|
+
return false;
|
|
2244
|
+
const lines = paneText.split('\n');
|
|
2245
|
+
let lastInputRow = null;
|
|
2246
|
+
for (const line of lines) {
|
|
2247
|
+
const match = line.match(/^\s*❯\s?(.*)$/);
|
|
2248
|
+
if (match)
|
|
2249
|
+
lastInputRow = match[1].trim();
|
|
2250
|
+
}
|
|
2251
|
+
// No input box at all (e.g. a selector/question is on screen) → not our case.
|
|
2252
|
+
if (lastInputRow === null)
|
|
2253
|
+
return false;
|
|
2254
|
+
if (lastInputRow === '')
|
|
2255
|
+
return false;
|
|
2256
|
+
const needle = firstLine.length <= CLAUDE_INPUT_MATCH_PREFIX_LEN
|
|
2257
|
+
? firstLine
|
|
2258
|
+
: firstLine.slice(0, CLAUDE_INPUT_MATCH_PREFIX_LEN);
|
|
2259
|
+
return lastInputRow.startsWith(needle);
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* @description Boot-time lifecycle gates Claude can paint before the real input
|
|
2263
|
+
* box is usable: the "Press Enter to continue" / login-success gate, and the
|
|
2264
|
+
* bypass-permissions warning. `handleAutoLifecycle` dismisses both; the S7
|
|
2265
|
+
* readiness predicate treats them as "not ready yet". Module-level so both the
|
|
2266
|
+
* auto-lifecycle handlers and {@link checkIsClaudePromptReady} share one source
|
|
2267
|
+
* of truth (no duplicated regexes).
|
|
2268
|
+
*/
|
|
2269
|
+
const CLAUDE_AUTO_ENTER_PATTERNS = [
|
|
2270
|
+
/Press Enter to continue/i,
|
|
2271
|
+
/Login successful\. Press Enter/i,
|
|
2272
|
+
];
|
|
2273
|
+
const CLAUDE_BYPASS_WARNING_RE = /WARNING.*Bypass|Bypass.*Permissions/i;
|
|
2274
|
+
const CLAUDE_BYPASS_ACCEPT_RE = /Yes,?\s*I\s*accept/i;
|
|
2275
|
+
/** The `API Error:`-at-line-start error row shape (detector case (a)). */
|
|
2276
|
+
const CLAUDE_API_ERROR_START_RE = /^\s*API Error:/;
|
|
2277
|
+
/** The `API Error:` substring, for a `⎿` result row that carries it (case (b)). */
|
|
2278
|
+
const CLAUDE_API_ERROR_SUBSTR_RE = /API Error:/;
|
|
2279
|
+
/**
|
|
2280
|
+
* @description A `⎿` result row whose content LEADS with an auth phrase — Claude's
|
|
2281
|
+
* canonical logged-out render (`⎿ Not logged in · Please run /login`,
|
|
2282
|
+
* `⎿ Please run /login · API Error: 401 …`). The phrase is anchored right after
|
|
2283
|
+
* the `⎿` marker (case (c)) — see the false-positive note on
|
|
2284
|
+
* {@link getClaudeAgentErrorLine}.
|
|
2285
|
+
*/
|
|
2286
|
+
const CLAUDE_AUTH_ROW_RE = /^\s*⎿\s*(?:not logged in|please run \/login|invalid authentication credentials)\b/i;
|
|
2287
|
+
/**
|
|
2288
|
+
* @description A row whose CONTENT leads with a usage/session-limit phrase — the
|
|
2289
|
+
* render behind `You've hit your session limit · resets 10:50pm (UTC)`, which
|
|
2290
|
+
* carries no `API Error:` marker and so matched none of the shapes (a)–(c). The
|
|
2291
|
+
* optional leading glyph covers the `⎿` result marker and the `●`/`⏺` output
|
|
2292
|
+
* bullet; up to three punctuation-free words may precede the phrase because the
|
|
2293
|
+
* live wording leads with a subject ("You've hit …"). That bounded, punctuation-
|
|
2294
|
+
* free lead-in is the false-positive guard (case (d)): a QUOTED phrase inside a
|
|
2295
|
+
* tool result always carries a path / colon / bracket first (`⎿ log:224:[Claude]
|
|
2296
|
+
* … session limit`), so it can never reach the phrase. The phrase alternation
|
|
2297
|
+
* itself is shared with {@link classifyAgentApiError} via
|
|
2298
|
+
* {@link usageLimitPhraseSource} so the detector and the classifier cannot drift.
|
|
2299
|
+
*/
|
|
2300
|
+
const CLAUDE_LIMIT_ROW_RE = new RegExp(String.raw `^\s*(?:[⎿●⏺]\s*)?(?:[A-Za-z][A-Za-z'’]*[ ]){0,3}(?:${apiErrorRetry_1.usageLimitPhraseSource})`, 'i');
|
|
2301
|
+
/**
|
|
2302
|
+
* @description SECOND required signal for case (d): the row must also READ like a
|
|
2303
|
+
* terminal limit error, not like the agent's own prose ABOUT limits. Case (d) is
|
|
2304
|
+
* the only shape with no glyph/`API Error:` anchor, so the lead-in alone let any
|
|
2305
|
+
* answer line that merely STARTS with a limit phrase fire a bogus episode
|
|
2306
|
+
* (`Session limits reset weekly for every plan.` / `Usage limits apply per 5-hour
|
|
2307
|
+
* window.` armed a 60-min wait and pushed a "continue" nudge into a healthy
|
|
2308
|
+
* topic — trivially reachable in a topic where the agent discusses limits, e.g.
|
|
2309
|
+
* this feature itself). Every real row carries one of these markers
|
|
2310
|
+
* (`… limit · resets 10:50pm (UTC)`, `… limit reached ∙ resets 3am`); the bare
|
|
2311
|
+
* verb prose uses ("limits reset weekly") is deliberately NOT in the list.
|
|
2312
|
+
*/
|
|
2313
|
+
const CLAUDE_LIMIT_ROW_SHAPE_RE = /\b(?:reached|exceeded|resets|resetting|try again|too low)\b|[·∙]/i;
|
|
2314
|
+
/**
|
|
2315
|
+
* @description Find the ONE line in a scraped pane that is a terminal
|
|
2316
|
+
* provider / auth error, or `null` if none. `content` is `cleanOutput(raw)`,
|
|
2317
|
+
* which RETAINS the `⎿` result marker. Three accepted shapes:
|
|
2318
|
+
* a) `API Error: …` at line start — the classic provider row
|
|
2319
|
+
* (`API Error: … (not your usage limit) · Rate limited`).
|
|
2320
|
+
* b) a `⎿` result row that also contains `API Error:`
|
|
2321
|
+
* (`⎿ overloaded · API Error: 429` — the render the old start-anchored gate
|
|
2322
|
+
* MISSED, which is why a `⎿`-prefixed rate-limit never auto-retried).
|
|
2323
|
+
* c) a `⎿` result row whose content LEADS with an auth phrase
|
|
2324
|
+
* (`⎿ Not logged in · Please run /login` — the logged-out render).
|
|
2325
|
+
* d) a row whose content LEADS with a usage/session-limit phrase AND reads like
|
|
2326
|
+
* an error ({@link CLAUDE_LIMIT_ROW_SHAPE_RE}) — `You've hit your session
|
|
2327
|
+
* limit · resets 10:50pm (UTC)` carries no `API Error:` marker at all, so
|
|
2328
|
+
* (a)–(c) all missed it.
|
|
2329
|
+
* FALSE-POSITIVE GUARD: prose never starts a line with the `⎿` glyph nor with
|
|
2330
|
+
* `API Error:`. But TOOL results (Bash/Read/Grep output) ARE rendered by the TUI
|
|
2331
|
+
* under a `⎿` marker, so a result row that QUOTES an auth phrase deeper in the
|
|
2332
|
+
* line (the agent grepping the bot's own logs/source, or a `gh`/`npm` "not
|
|
2333
|
+
* logged in" line) would fire case (c) if we matched the phrase anywhere. So (c)
|
|
2334
|
+
* — and (d), for the same reason — anchor the phrase to the row START: a real
|
|
2335
|
+
* error row leads with it; a quote embeds it after other text (live 2026-07-03).
|
|
2336
|
+
* The returned line is fed VERBATIM to
|
|
2337
|
+
* {@link classifyAgentApiError}. Exported for unit testing without a live tmux
|
|
2338
|
+
* pane.
|
|
2339
|
+
*/
|
|
2340
|
+
function getClaudeAgentErrorLine(content) {
|
|
2341
|
+
for (const line of content.split('\n')) {
|
|
2342
|
+
if (CLAUDE_API_ERROR_START_RE.test(line))
|
|
2343
|
+
return line;
|
|
2344
|
+
if (CLAUDE_AUTH_ROW_RE.test(line))
|
|
2345
|
+
return line;
|
|
2346
|
+
if (CLAUDE_LIMIT_ROW_RE.test(line) && CLAUDE_LIMIT_ROW_SHAPE_RE.test(line))
|
|
2347
|
+
return line;
|
|
2348
|
+
if (claudeScrapeShapes_1.TOOL_RESULT_MARKER_RE.test(line) && CLAUDE_API_ERROR_SUBSTR_RE.test(line))
|
|
2349
|
+
return line;
|
|
2350
|
+
}
|
|
2351
|
+
return null;
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* @description Matches the TUI's live input box prompt row (`❯` led, optional
|
|
2355
|
+
* draft text after it). Its presence in a captured pane is our signal that the
|
|
2356
|
+
* Claude TUI has finished booting its banner and is ready to receive typed
|
|
2357
|
+
* input — before that, the pane shows only the boot banner with no `❯` row.
|
|
2358
|
+
*/
|
|
2359
|
+
const CLAUDE_INPUT_BOX_RE = /^\s*❯/m;
|
|
2360
|
+
/**
|
|
2361
|
+
* @description Whether a captured pane shows the Claude TUI has finished
|
|
2362
|
+
* booting and is ready to accept a typed slash command (S7 readiness signal).
|
|
2363
|
+
*
|
|
2364
|
+
* Ready means: the live input box `❯` is on screen AND no boot-time lifecycle
|
|
2365
|
+
* gate is still up — neither the "Press Enter to continue" / login gate nor the
|
|
2366
|
+
* bypass-permissions warning, both of which `handleAutoLifecycle` dismisses
|
|
2367
|
+
* over the next polls. We do NOT require idle: a fresh spawn is idle, and
|
|
2368
|
+
* gating on "not busy" would only delay the re-apply past the box first
|
|
2369
|
+
* appearing. Keeping the predicate to "input box up, no lifecycle gate" matches
|
|
2370
|
+
* the single observable moment the box first accepts keys. Exported for unit
|
|
2371
|
+
* testing without a live tmux pane.
|
|
2372
|
+
*/
|
|
2373
|
+
function checkIsClaudePromptReady(paneText) {
|
|
2374
|
+
if (!CLAUDE_INPUT_BOX_RE.test(paneText))
|
|
2375
|
+
return false;
|
|
2376
|
+
if (CLAUDE_AUTO_ENTER_PATTERNS.some((pattern) => pattern.test(paneText)))
|
|
2377
|
+
return false;
|
|
2378
|
+
const hasBypassWarning = CLAUDE_BYPASS_WARNING_RE.test(paneText);
|
|
2379
|
+
const hasBypassAccept = CLAUDE_BYPASS_ACCEPT_RE.test(paneText);
|
|
2380
|
+
if (hasBypassWarning && hasBypassAccept)
|
|
2381
|
+
return false;
|
|
2382
|
+
return true;
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* @description Pure decision for the seen-watermark advance: persist the
|
|
2386
|
+
* transcript's current EOF as "relayed live up to here" whenever the input box
|
|
2387
|
+
* is ready AND the file grew past the last persisted offset — busy or idle.
|
|
2388
|
+
*
|
|
2389
|
+
* S7 dropped the old `!isBusy` gate. A live Claude adapter relays the pane on
|
|
2390
|
+
* EVERY poll, so EOF-on-growth already means "shown up to here" mid-turn, not
|
|
2391
|
+
* only at turn end; the old idle-only advance left the watermark at the previous
|
|
2392
|
+
* turn end all through a multi-message turn, so a mid-turn restart re-counted
|
|
2393
|
+
* every already-relayed message as a false "missed N" (live 2026-07-04). The
|
|
2394
|
+
* `isReady` gate stays (don't advance before the box is up / while a boot or
|
|
2395
|
+
* lifecycle gate is on screen — that isn't a settled relay), and the growth
|
|
2396
|
+
* guard (`eof > lastOffset`) stays so idle metadata rewrites cost no write and a
|
|
2397
|
+
* smaller rewrite never rewinds the monotonic anchor. `isBusy` is accepted so
|
|
2398
|
+
* the caller passes the poll's busy flag and the truth table documents that busy
|
|
2399
|
+
* no longer blocks; it is intentionally unread.
|
|
2400
|
+
*
|
|
2401
|
+
* Exported so the truth table is unit-testable without a live tmux;
|
|
2402
|
+
* {@link ClaudeCliAdapter.advanceClaudeWatermarkIfGrown} is the thin fs+writer
|
|
2403
|
+
* wrapper. Callers pass `lastOffset = session.lastWatermarkOffset ?? -1` so a
|
|
2404
|
+
* never-advanced session writes on its first ready poll.
|
|
2405
|
+
*/
|
|
2406
|
+
function checkShouldAdvanceWatermark(args) {
|
|
2407
|
+
return args.isReady && args.eof > args.lastOffset;
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* @description Markers of a live sub-agent (Task tool) on the pane. While a
|
|
2411
|
+
* sub-agent runs the TUI shows a task line led by `◯` (U+25EF LARGE CIRCLE —
|
|
2412
|
+
* NOT the spinner glyph `○` U+25CB used elsewhere in this file), e.g.
|
|
2413
|
+
* `◯ general-purpose <task> 7s`, and the footer gains a `↓ to manage` hint.
|
|
2414
|
+
* Either is enough.
|
|
2415
|
+
*/
|
|
2416
|
+
const CLAUDE_SUBAGENT_TASK_RE = /^\s*◯\s+\S/m;
|
|
2417
|
+
const CLAUDE_SUBAGENT_MANAGE_RE = /↓ to manage/;
|
|
2418
|
+
/**
|
|
2419
|
+
* @description Phrase the TUI shows while compacting context (manual `/compact`
|
|
2420
|
+
* or auto), e.g. `✶ Compacting conversation… (57s · ↑ 3.1k tokens)`. A bare
|
|
2421
|
+
* substring match on the phrase is enough here: this only gates the
|
|
2422
|
+
* interrupt decision (don't Escape during compaction), so it deliberately
|
|
2423
|
+
* stays looser than the anchored {@link COMPACT_LINE_RE} in `progressLine.ts`
|
|
2424
|
+
* (which must be line-exact to safely collapse a redraw burst).
|
|
2425
|
+
*/
|
|
2426
|
+
const CLAUDE_COMPACTING_RE = /Compacting conversation/i;
|
|
2427
|
+
/**
|
|
2428
|
+
* @description Whether Claude is doing work that must NOT be interrupted: a
|
|
2429
|
+
* running sub-agent (Escape would kill the child) or an in-flight compaction
|
|
2430
|
+
* (Escape would abort it and lose the summary). In these states the caller
|
|
2431
|
+
* forwards the prompt without Escape so it queues behind the current turn and
|
|
2432
|
+
* runs once the work finishes. Exported for unit testing without a live tmux.
|
|
2433
|
+
*/
|
|
2434
|
+
function checkIsClaudeUninterruptible(paneText) {
|
|
2435
|
+
return (CLAUDE_COMPACTING_RE.test(paneText) ||
|
|
2436
|
+
CLAUDE_SUBAGENT_TASK_RE.test(paneText) ||
|
|
2437
|
+
CLAUDE_SUBAGENT_MANAGE_RE.test(paneText));
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* @description Poll cadence while waiting for an interrupt to land. Each tick
|
|
2441
|
+
* is a cheap read-only `tmux capture-pane`.
|
|
2442
|
+
*/
|
|
2443
|
+
const CLAUDE_INTERRUPT_POLL_MS = 100;
|
|
2444
|
+
/**
|
|
2445
|
+
* @description Upper bound on how long we wait for Claude to leave the busy
|
|
2446
|
+
* state after Escape before forwarding the prompt anyway. Escape reliably
|
|
2447
|
+
* interrupts a *thinking* turn within well under a second (observed
|
|
2448
|
+
* ~200 ms–1 s, vs the old 120 ms fixed guess that raced and let the prompt
|
|
2449
|
+
* queue). It does NOT interrupt an in-flight tool/subprocess — those run to
|
|
2450
|
+
* this timeout and then forward (the message queues behind the tool, which is
|
|
2451
|
+
* unavoidable, but is never dropped). So the bound is sized to cover
|
|
2452
|
+
* thinking-interrupt latency, not to wait out a running tool.
|
|
2453
|
+
*/
|
|
2454
|
+
const CLAUDE_INTERRUPT_TIMEOUT_MS = 3000;
|
|
2455
|
+
/**
|
|
2456
|
+
* @description Decide whether to stay in resume flood-suppression mode or exit.
|
|
2457
|
+
*
|
|
2458
|
+
* While `keepSeeding` is true, `pollOutput` advances the baseline but emits no
|
|
2459
|
+
* conversation text, so the entire restored transcript Claude repaints on
|
|
2460
|
+
* `--resume` is swallowed instead of dumped into the topic. We exit (and let
|
|
2461
|
+
* the NEXT poll's diff be genuine new output) when either:
|
|
2462
|
+
*
|
|
2463
|
+
* - the pane is non-empty AND unchanged across two consecutive polls (the
|
|
2464
|
+
* restored transcript finished painting — the common case), or
|
|
2465
|
+
* - the hard cap {@link resumeSeedMaxPolls} is hit (paint stuttered forever;
|
|
2466
|
+
* force-exit so a session can never be wedged into permanent silence).
|
|
2467
|
+
*
|
|
2468
|
+
* Pure + exported so the swallow → exit-on-stable → emit-new transition is
|
|
2469
|
+
* unit-testable without a live tmux pane.
|
|
2470
|
+
*/
|
|
2471
|
+
function getResumeSeedDecision(state) {
|
|
2472
|
+
const reachedCap = state.polls + 1 >= resumeSeedMaxPolls;
|
|
2473
|
+
const stableNonEmpty = state.content !== '' && state.content === state.prevContent;
|
|
2474
|
+
return { keepSeeding: !reachedCap && !stableNonEmpty };
|
|
2475
|
+
}
|
|
2476
|
+
class ClaudeCliAdapter extends events_1.EventEmitter {
|
|
2477
|
+
name = 'claude';
|
|
2478
|
+
label = 'Claude Code';
|
|
2479
|
+
outputsDeltas = true;
|
|
2480
|
+
// The TUI prints its own banner to the topic — the bot suppresses `agent.ready`
|
|
2481
|
+
// and keeps the typing loader up until that first output (see AgentAdapter).
|
|
2482
|
+
selfGreetsOnStart = true;
|
|
2483
|
+
/**
|
|
2484
|
+
* Map of serialised `SessionKey` (`"<chatId>:<threadId>"`) → live session.
|
|
2485
|
+
* Keyed by string rather than `SessionKey` object so map lookups work — JS
|
|
2486
|
+
* Map compares object identity, not structural equality.
|
|
2487
|
+
*/
|
|
2488
|
+
sessions = new Map();
|
|
2489
|
+
/**
|
|
2490
|
+
* Per-thread display-prefs reader, injected by the bot at boot via
|
|
2491
|
+
* `createAdapter.registerDisplayPrefsReader` (S4; same idiom as the OpenCode
|
|
2492
|
+
* adapter). Consulted on the poll hot path: the relay branches each chunk's
|
|
2493
|
+
* tool / panel segments on `toolResults` (S4), and the transcript scan
|
|
2494
|
+
* fast-forwards (non-`full` `subagent`) or streams (`full`). `null` until
|
|
2495
|
+
* wired → reads fall back to all-fields-`minimal`.
|
|
2496
|
+
*/
|
|
2497
|
+
displayPrefsReader = null;
|
|
2498
|
+
/** @description Inject the per-thread display-prefs reader (see the field's JSDoc). */
|
|
2499
|
+
setDisplayPrefsReader(reader) {
|
|
2500
|
+
this.displayPrefsReader = reader;
|
|
2501
|
+
}
|
|
2502
|
+
/** Per-thread locale reader, injected at boot via `createAdapter.registerThreadLocaleReader`. */
|
|
2503
|
+
threadLocaleReader = null;
|
|
2504
|
+
/** @description Inject the per-thread locale reader (for adapter-side `t(...)` calls). */
|
|
2505
|
+
setThreadLocaleReader(reader) {
|
|
2506
|
+
this.threadLocaleReader = reader;
|
|
2507
|
+
}
|
|
2508
|
+
/** @description Run `fn` inside the thread's locale context so `t(...)` resolves correctly. */
|
|
2509
|
+
tl(key, fn) {
|
|
2510
|
+
return (0, i18n_1.runWithLocale)(this.threadLocaleReader?.(key) ?? i18n_1.defaultLocale, fn);
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Per-thread seen-watermark writer, injected by the bot at boot via
|
|
2514
|
+
* `createAdapter.registerSeenWatermarkWriter`. Called on every idle+ready poll
|
|
2515
|
+
* whose transcript EOF grew past the last persisted offset (via
|
|
2516
|
+
* {@link ClaudeCliAdapter.advanceClaudeWatermarkIfGrown}) to persist the new
|
|
2517
|
+
* EOF. `null` until wired → {@link advanceSeenWatermark} is a no-op.
|
|
2518
|
+
*/
|
|
2519
|
+
seenWatermarkWriter = null;
|
|
2520
|
+
/** @description Inject the per-thread seen-watermark writer (see the field's JSDoc). */
|
|
2521
|
+
setSeenWatermarkWriter(writer) {
|
|
2522
|
+
this.seenWatermarkWriter = writer;
|
|
2523
|
+
}
|
|
2524
|
+
/** @description Advance the persisted seen-watermark for `key` (no-op until the
|
|
2525
|
+
* writer is wired). */
|
|
2526
|
+
advanceSeenWatermark(key, watermark) {
|
|
2527
|
+
this.seenWatermarkWriter?.(key, watermark);
|
|
2528
|
+
}
|
|
2529
|
+
/**
|
|
2530
|
+
* @description Advance the seen-watermark to the transcript's current EOF when
|
|
2531
|
+
* the input box is ready and the file grew since the last write (S7) — the
|
|
2532
|
+
* fs+writer wrapper around {@link checkShouldAdvanceWatermark}. Called every
|
|
2533
|
+
* non-seeding poll with the poll's `isBusy` / `isReady`; it early-returns only
|
|
2534
|
+
* on a NOT-ready poll BEFORE the `fs.stat` (a boot / lifecycle-gate frame isn't
|
|
2535
|
+
* a settled relay). S7 dropped the busy early-return so a BUSY-but-ready poll
|
|
2536
|
+
* mid-turn advances too — the pane is relayed every poll, so tracking EOF as it
|
|
2537
|
+
* grows keeps the watermark at "relayed up to here" instead of stranding it at
|
|
2538
|
+
* the previous turn end (the mid-turn false-"missed N" fix). It still writes
|
|
2539
|
+
* only when EOF actually grew — so idle metadata churn (Claude rewrites
|
|
2540
|
+
* `last-prompt`/`mode`/… for hours) costs no more than one bounded write per
|
|
2541
|
+
* real growth, and a smaller rewrite never rewinds the in-memory anchor
|
|
2542
|
+
* (monotonic). Best-effort — a transcript not yet on disk (UUID just born)
|
|
2543
|
+
* leaves the watermark where it was, the next poll re-tries. The path resolves
|
|
2544
|
+
* the same way {@link getRecentTurns} / {@link getReattachRecap} do.
|
|
2545
|
+
*/
|
|
2546
|
+
advanceClaudeWatermarkIfGrown(key, session, isBusy, isReady) {
|
|
2547
|
+
if (!isReady)
|
|
2548
|
+
return; // skip the fs stat before the input box is up (not a relay)
|
|
2549
|
+
const transcriptPath = getClaudeTranscriptPath(session.workDir, session.claudeSessionId);
|
|
2550
|
+
let eof;
|
|
2551
|
+
try {
|
|
2552
|
+
eof = fs.statSync(transcriptPath).size;
|
|
2553
|
+
}
|
|
2554
|
+
catch {
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
const shouldAdvance = checkShouldAdvanceWatermark({
|
|
2558
|
+
isBusy,
|
|
2559
|
+
isReady,
|
|
2560
|
+
eof,
|
|
2561
|
+
lastOffset: session.lastWatermarkOffset ?? -1,
|
|
2562
|
+
});
|
|
2563
|
+
if (!shouldAdvance)
|
|
2564
|
+
return;
|
|
2565
|
+
this.advanceSeenWatermark(key, { sessionId: session.claudeSessionId, claudeTranscriptOffset: eof });
|
|
2566
|
+
session.lastWatermarkOffset = eof;
|
|
2567
|
+
}
|
|
2568
|
+
/** @description Resolve the thread's full display prefs, defaulting every
|
|
2569
|
+
* field to `minimal` for any read before the bot wires the reader at boot. */
|
|
2570
|
+
getDisplayPrefs(key) {
|
|
2571
|
+
return this.displayPrefsReader?.(key) ?? (0, displayVerbosity_1.getDefaultDisplayPrefs)();
|
|
2572
|
+
}
|
|
2573
|
+
/** @description Resolve the thread's `/subagent` mode (consulted by the
|
|
2574
|
+
* transcript-tail scan), via the full prefs reader. */
|
|
2575
|
+
getSubagentMode(key) {
|
|
2576
|
+
return this.getDisplayPrefs(key).subagent;
|
|
2577
|
+
}
|
|
2578
|
+
createSession(params) {
|
|
2579
|
+
return {
|
|
2580
|
+
...params,
|
|
2581
|
+
queue: (0, serialQueue_1.createSerialQueue)(),
|
|
2582
|
+
pollTimer: null,
|
|
2583
|
+
lastContent: '',
|
|
2584
|
+
// One-shot auto-retry guard — every fresh session (start / resume /
|
|
2585
|
+
// adopt) begins un-armed; armed on the first retryable `API Error:` line.
|
|
2586
|
+
handledApiError: false,
|
|
2587
|
+
lastStatusText: '',
|
|
2588
|
+
lastForwardedText: '',
|
|
2589
|
+
lastForwardedTextAt: 0,
|
|
2590
|
+
lastQuestionSignature: '',
|
|
2591
|
+
questionAbsentPolls: 0,
|
|
2592
|
+
lastSurveySignature: '',
|
|
2593
|
+
autoEnterTimer: null,
|
|
2594
|
+
autoAcceptOuterTimer: null,
|
|
2595
|
+
autoAcceptInnerTimer: null,
|
|
2596
|
+
isPolling: false,
|
|
2597
|
+
openToolKind: null,
|
|
2598
|
+
// Seeding is armed explicitly by `resumeSession` after createSession;
|
|
2599
|
+
// a fresh `startSession` / reattach never floods, so it stays off here.
|
|
2600
|
+
resumeSeeding: false,
|
|
2601
|
+
resumeSeedPolls: 0,
|
|
2602
|
+
resumeSeedPrevContent: '',
|
|
2603
|
+
lastRawCapture: '',
|
|
2604
|
+
// S2: every fresh session (start / resume / adopt all flow through here)
|
|
2605
|
+
// begins at base cadence — this IS the "reset on session start/resume".
|
|
2606
|
+
// No timer is armed yet, so an explicit resetPollCadence would be a no-op.
|
|
2607
|
+
currentPollDelayMs: pollBackoff_1.basePollIntervalMs,
|
|
2608
|
+
unchangedPollStreak: 0,
|
|
2609
|
+
// Seed the pane-change clock to "now" so a freshly-created/adopted session
|
|
2610
|
+
// (its first poll hasn't run yet) never reads as 30s-static immediately.
|
|
2611
|
+
lastContentChangeAt: Date.now(),
|
|
2612
|
+
// S7: armed explicitly by `applyStoredEffortOnSpawn` after a fresh
|
|
2613
|
+
// start/resume spawn; adopt/reattach leaves it null (no re-apply).
|
|
2614
|
+
pendingEffortReapply: null,
|
|
2615
|
+
// S3 seen-watermark anchor: no advance yet → the first idle+ready poll
|
|
2616
|
+
// writes the current transcript EOF. Fresh per session object; cross-restart
|
|
2617
|
+
// correctness is protected by the recap's PRE-adopt snapshot, not by seeding.
|
|
2618
|
+
lastWatermarkOffset: undefined,
|
|
2619
|
+
// Every fresh session object (start / resume / adopt all flow through
|
|
2620
|
+
// here) begins with an EMPTY relay window — this IS the "reset on
|
|
2621
|
+
// session start"; stop deletes the session and the window with it.
|
|
2622
|
+
recentRelayWindow: (0, recentRelayWindow_1.createRecentRelayWindow)(),
|
|
2623
|
+
// S3: fresh tail state per session — its first scan seeds offsets to
|
|
2624
|
+
// EOF, so resume/adopt never replays an old sub-agent's transcript.
|
|
2625
|
+
subagentTail: (0, claudeSubagentTail_1.createSubagentTailState)(),
|
|
2626
|
+
// S4: fresh cross-poll classifier context per session — the all-closed
|
|
2627
|
+
// start, so a fresh session never inherits a prior session's open block.
|
|
2628
|
+
chunkContext: (0, claudeChunkClassifier_1.createInitialChunkContext)(),
|
|
2629
|
+
// Resize guard: the first successful size query of this session records
|
|
2630
|
+
// the baseline; only a CHANGE after that suppresses (never the first read).
|
|
2631
|
+
lastPaneSize: null,
|
|
2632
|
+
isResizeSettling: false,
|
|
2633
|
+
resizeSettlePolls: 0,
|
|
2634
|
+
// No table held at session start (start / resume / adopt all flow through
|
|
2635
|
+
// here) — this IS the "reset on session start"; stop deletes it with the
|
|
2636
|
+
// session. The resume-seed exit clears it again (see `pollOutput`).
|
|
2637
|
+
streamingTable: null,
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
enqueueTmux(session, fn) {
|
|
2641
|
+
return session.queue.run(fn);
|
|
2642
|
+
}
|
|
2643
|
+
enqueueTmuxBestEffort(session, fn) {
|
|
2644
|
+
void this.enqueueTmux(session, fn).catch((e) => {
|
|
2645
|
+
console.warn(`[Claude] tmux operation failed:`, e instanceof Error ? e.message : e);
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
async stopSessionInternal(key) {
|
|
2649
|
+
const k = (0, sessionKey_1.keyToString)(key);
|
|
2650
|
+
const session = this.sessions.get(k);
|
|
2651
|
+
if (!session)
|
|
2652
|
+
return;
|
|
2653
|
+
console.log(`[Claude] Stopping session for ${k}`);
|
|
2654
|
+
session.isActive = false;
|
|
2655
|
+
// pollTimer is now a `setTimeout` handle (not interval), but
|
|
2656
|
+
// clearTimeout safely handles either type.
|
|
2657
|
+
if (session.pollTimer) {
|
|
2658
|
+
clearTimeout(session.pollTimer);
|
|
2659
|
+
session.pollTimer = null;
|
|
2660
|
+
}
|
|
2661
|
+
// Audit S9 / #10: cancel pending auto-Enter / auto-Accept callbacks
|
|
2662
|
+
// so they don't land in a replacement session.
|
|
2663
|
+
if (session.autoEnterTimer) {
|
|
2664
|
+
clearTimeout(session.autoEnterTimer);
|
|
2665
|
+
session.autoEnterTimer = null;
|
|
2666
|
+
}
|
|
2667
|
+
if (session.autoAcceptOuterTimer) {
|
|
2668
|
+
clearTimeout(session.autoAcceptOuterTimer);
|
|
2669
|
+
session.autoAcceptOuterTimer = null;
|
|
2670
|
+
}
|
|
2671
|
+
if (session.autoAcceptInnerTimer) {
|
|
2672
|
+
clearTimeout(session.autoAcceptInnerTimer);
|
|
2673
|
+
session.autoAcceptInnerTimer = null;
|
|
2674
|
+
}
|
|
2675
|
+
const killPromise = this.enqueueTmux(session, () => (0, tmuxExec_1.tmuxAsync)('kill-session', '-t', (0, tmuxExec_1.getTmuxSessionTarget)(session.sessionName)));
|
|
2676
|
+
// Remove the tmp MCP files we wrote on startSession — claude inlines
|
|
2677
|
+
// their content into the session at boot, so once tmux is killed they
|
|
2678
|
+
// serve no purpose and would just leak secrets on disk (plan §13.18).
|
|
2679
|
+
(0, mcpConfig_1.cleanupMcpTempFiles)({ key, dataDir: (0, state_1.resolveDataDir)() });
|
|
2680
|
+
this.sessions.delete(k);
|
|
2681
|
+
this.emit('stopped', key);
|
|
2682
|
+
await killPromise;
|
|
2683
|
+
}
|
|
2684
|
+
/**
|
|
2685
|
+
* @description Schedule the next `pollOutput` for a session. Audit S9 /
|
|
2686
|
+
* #37: replaces `setInterval` so a slow `tmux capture-pane` cannot
|
|
2687
|
+
* cause overlapping invocations. The handle is stored back on
|
|
2688
|
+
* `session.pollTimer` so `stopSession` can cancel the next tick.
|
|
2689
|
+
*/
|
|
2690
|
+
schedulePoll(key, session) {
|
|
2691
|
+
if (!session.isActive)
|
|
2692
|
+
return;
|
|
2693
|
+
session.pollTimer = setTimeout(() => {
|
|
2694
|
+
void (async () => {
|
|
2695
|
+
if (!session.isActive)
|
|
2696
|
+
return;
|
|
2697
|
+
if (session.isPolling) {
|
|
2698
|
+
// Previous poll still in-flight; skip this tick and reschedule.
|
|
2699
|
+
this.schedulePoll(key, session);
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
session.isPolling = true;
|
|
2703
|
+
try {
|
|
2704
|
+
await this.pollOutput(key);
|
|
2705
|
+
// S3: tail the session's on-disk sub-agent transcripts on the same
|
|
2706
|
+
// tick cadence. Self-guarding (catches everything internally), so a
|
|
2707
|
+
// scan problem can never break the pane polling.
|
|
2708
|
+
await this.scanSubagentTranscripts(key, session);
|
|
2709
|
+
}
|
|
2710
|
+
finally {
|
|
2711
|
+
session.isPolling = false;
|
|
2712
|
+
this.schedulePoll(key, session);
|
|
2713
|
+
}
|
|
2714
|
+
})();
|
|
2715
|
+
}, session.currentPollDelayMs);
|
|
2716
|
+
}
|
|
2717
|
+
/**
|
|
2718
|
+
* @description Per-poll-tick scan of the session's on-disk sub-agent
|
|
2719
|
+
* transcripts (`/subagent` on Claude, plan 2026-06-11 S3). Claude writes
|
|
2720
|
+
* each Task-tool child transcript to
|
|
2721
|
+
* `<projectsRoot>/<slug>/<claudeSessionId>/subagents/agent-<agentId>.jsonl`;
|
|
2722
|
+
* the pure `claudeSubagentTail` helpers own all decisions — first-scan EOF
|
|
2723
|
+
* seeding (no backlog replay on resume/adopt), compact-mode fast-forward
|
|
2724
|
+
* without reading, and assistant-text-block extraction (child thinking /
|
|
2725
|
+
* tool_use never rendered). In `full` mode every appended text block is
|
|
2726
|
+
* emitted as a marked output (`meta.isSubagent`) which the bot renders as a
|
|
2727
|
+
* standalone "🤖 ⤷" message outside the parent's continuation chain.
|
|
2728
|
+
*
|
|
2729
|
+
* A missing dir (`ENOENT` — no sub-agent yet, or an older claude version)
|
|
2730
|
+
* short-circuits cheaply but still runs the empty scan, so the first-scan
|
|
2731
|
+
* flag flips and a transcript created later streams from byte 0. Any other
|
|
2732
|
+
* error is logged and swallowed here — this method must never reject, or
|
|
2733
|
+
* the rejection would escape `schedulePoll`'s void'd poll chain.
|
|
2734
|
+
*/
|
|
2735
|
+
async scanSubagentTranscripts(key, session) {
|
|
2736
|
+
try {
|
|
2737
|
+
const subagentsDir = path.join(getClaudeProjectsRoot(), getClaudeProjectSlug(session.workDir), session.claudeSessionId, 'subagents');
|
|
2738
|
+
let entries = [];
|
|
2739
|
+
try {
|
|
2740
|
+
entries = await fs.promises.readdir(subagentsDir);
|
|
2741
|
+
}
|
|
2742
|
+
catch (e) {
|
|
2743
|
+
if (!checkIsMissingPathError(e))
|
|
2744
|
+
throw e;
|
|
2745
|
+
}
|
|
2746
|
+
const scannedFiles = [];
|
|
2747
|
+
for (const fileName of entries.filter(claudeSubagentTail_1.checkIsSubagentTranscriptName).sort()) {
|
|
2748
|
+
try {
|
|
2749
|
+
const stats = await fs.promises.stat(path.join(subagentsDir, fileName));
|
|
2750
|
+
scannedFiles.push({ fileName, sizeBytes: stats.size });
|
|
2751
|
+
}
|
|
2752
|
+
catch {
|
|
2753
|
+
// File vanished between readdir and stat — picked up next tick.
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
const reads = (0, claudeSubagentTail_1.getSubagentTailReads)(session.subagentTail, scannedFiles, this.getSubagentMode(key));
|
|
2757
|
+
for (const read of reads) {
|
|
2758
|
+
if (!session.isActive)
|
|
2759
|
+
return;
|
|
2760
|
+
// Per-read guard: one file's failed read (vanished mid-read) loses only
|
|
2761
|
+
// ITS already-advanced range (drop-over-resend), not the sibling files'
|
|
2762
|
+
// ranges the outer catch would otherwise abort with it.
|
|
2763
|
+
try {
|
|
2764
|
+
const appendedText = await readFileSlice(path.join(subagentsDir, read.fileName), read.startOffset, read.endOffset);
|
|
2765
|
+
for (const text of (0, claudeSubagentTail_1.extractAppendedSubagentTexts)(session.subagentTail, read.fileName, appendedText)) {
|
|
2766
|
+
const meta = { isSubagent: true };
|
|
2767
|
+
this.emit('output', key, text, meta);
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
catch (e) {
|
|
2771
|
+
console.warn(`[Claude] sub-agent transcript read failed (${read.fileName}):`, e instanceof Error ? e.message : e);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
catch (e) {
|
|
2776
|
+
console.warn(`[Claude] sub-agent transcript scan failed:`, e instanceof Error ? e.message : e);
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
/**
|
|
2780
|
+
* @description Snap a session's poll cadence back to base (S2). Called on
|
|
2781
|
+
* every explicit write (keystroke / signal) and on session start/resume so a
|
|
2782
|
+
* fresh prompt never waits up to {@link maxPollIntervalMs} for the next
|
|
2783
|
+
* capture — user-visible latency after a write must stay at base cadence.
|
|
2784
|
+
*
|
|
2785
|
+
* If a poll timer is already pending we re-arm it at the base delay so the
|
|
2786
|
+
* backed-off idle timer can't keep the next capture far in the future. The
|
|
2787
|
+
* existing {@link ClaudeSession.isPolling} guard is respected: a poll already
|
|
2788
|
+
* in flight will reschedule itself from the (now reset) delay, so we never
|
|
2789
|
+
* double-poll.
|
|
2790
|
+
*/
|
|
2791
|
+
resetPollCadence(key, session) {
|
|
2792
|
+
session.currentPollDelayMs = pollBackoff_1.basePollIntervalMs;
|
|
2793
|
+
session.unchangedPollStreak = 0;
|
|
2794
|
+
if (!session.isActive || session.isPolling)
|
|
2795
|
+
return;
|
|
2796
|
+
if (session.pollTimer) {
|
|
2797
|
+
clearTimeout(session.pollTimer);
|
|
2798
|
+
session.pollTimer = null;
|
|
2799
|
+
this.schedulePoll(key, session);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
async startSession(key, workDir, args, sessionId) {
|
|
2803
|
+
await this.stopSessionInternal(key);
|
|
2804
|
+
if (!(0, installManager_1.checkIsInstalled)('claude')) {
|
|
2805
|
+
this.emit('output', key, 'Installing Claude Code...');
|
|
2806
|
+
await (0, installManager_1.installTool)('claude');
|
|
2807
|
+
}
|
|
2808
|
+
const sessionName = buildTmuxSessionName(key);
|
|
2809
|
+
// If the bot didn't provide a UUID, mint one ourselves. The plan owns
|
|
2810
|
+
// generation in bot.ts so it can be persisted in state.json (D14), but
|
|
2811
|
+
// until §11 Этап 3 wires that up we mint here as a safe default.
|
|
2812
|
+
const claudeSessionId = sessionId || (0, crypto_1.randomUUID)();
|
|
2813
|
+
if (!checkIsValidUuid(claudeSessionId)) {
|
|
2814
|
+
// Caller-supplied UUID — refuse anything that doesn't look like one.
|
|
2815
|
+
// See `checkIsValidUuid` for the reasoning (audit S1).
|
|
2816
|
+
// Audit S10 / #16: throw instead of emit+return so callers can
|
|
2817
|
+
// distinguish "did not start" from "started, will fail async".
|
|
2818
|
+
throw new Error(`Invalid sessionId: ${claudeSessionId}`);
|
|
2819
|
+
}
|
|
2820
|
+
if (args && !(0, tmuxExec_1.checkArgsAreSafe)(args)) {
|
|
2821
|
+
throw new Error('Args contain control characters');
|
|
2822
|
+
}
|
|
2823
|
+
console.log(`[Claude] Starting tmux session ${sessionName} in ${workDir} ` +
|
|
2824
|
+
`(sessionId=${claudeSessionId})${args ? ` with args: ${args}` : ''}`);
|
|
2825
|
+
// Make sure no stale session with the same name is lingering.
|
|
2826
|
+
await (0, tmuxExec_1.tmuxAsync)('kill-session', '-t', (0, tmuxExec_1.getTmuxSessionTarget)(sessionName));
|
|
2827
|
+
// Build the claude command line as an argv list, then assemble the final
|
|
2828
|
+
// shell-command for tmux by single-quoting every element. tmux execs the
|
|
2829
|
+
// trailing `shell-command` argument via `$SHELL -c` (audit S1 / #1, #2):
|
|
2830
|
+
// there is no argv-only path for `new-session`, so the only defence is
|
|
2831
|
+
// to ensure no user-controlled string can break out of single quotes.
|
|
2832
|
+
// `shellSingleQuote` handles embedded single quotes via the standard
|
|
2833
|
+
// `'\\''` close-reopen idiom.
|
|
2834
|
+
//
|
|
2835
|
+
// --session-id <uuid> assigns the UUID to the NEW session so we can later
|
|
2836
|
+
// resume by UUID (plan §13.1). --dangerously-skip-permissions stays
|
|
2837
|
+
// hardcoded by D44 (symmetry with opencode auto-approve).
|
|
2838
|
+
//
|
|
2839
|
+
// MCP servers come from up to four sources (user/group/project/thread,
|
|
2840
|
+
// plan §19); user + project are auto-loaded by Claude from cwd, the
|
|
2841
|
+
// other two reach Claude through repeated `--mcp-config` flags. The flag
|
|
2842
|
+
// values point at tmp files because the bot expands `${VAR}` env-var
|
|
2843
|
+
// placeholders itself before handing the config off (plan §13.18, T2).
|
|
2844
|
+
// The `--settings` file carries the PreCompact hook that gives an
|
|
2845
|
+
// overflow-triggered compaction the bot's summary guidance.
|
|
2846
|
+
const mcpFlagsArr = await (0, mcpConfig_1.prepareMcpFlags)({ key, dataDir: (0, state_1.resolveDataDir)() });
|
|
2847
|
+
const claudeArgv = [
|
|
2848
|
+
claudePath,
|
|
2849
|
+
// Followed by an option on purpose: `--disallowedTools` takes several values.
|
|
2850
|
+
...(0, claudePlatformFlags_1.getClaudePlatformFlags)(key),
|
|
2851
|
+
...claudePermissionArgs,
|
|
2852
|
+
'--session-id', claudeSessionId,
|
|
2853
|
+
...mcpFlagsArr,
|
|
2854
|
+
...(0, claudeCompactHook_1.prepareClaudeCompactHookFlags)((0, state_1.resolveDataDir)()),
|
|
2855
|
+
...(args ? [args] : []),
|
|
2856
|
+
];
|
|
2857
|
+
const claudeShellCmd = claudeArgv.map(tmuxExec_1.shellSingleQuote).join(' ');
|
|
2858
|
+
try {
|
|
2859
|
+
// `-c <workDir>` sets the new session's start directory, avoiding a
|
|
2860
|
+
// preceding `cd && …` chain (which would have to be shell-quoted too).
|
|
2861
|
+
await (0, tmuxExec_1.tmuxOrThrowAsync)('new-session', '-d', '-s', sessionName, '-x', '300', '-y', '50', '-c', workDir, claudeShellCmd);
|
|
2862
|
+
console.log(`[Claude] tmux session created`);
|
|
2863
|
+
}
|
|
2864
|
+
catch (e) {
|
|
2865
|
+
console.error(`[Claude] Failed to create tmux session:`, e);
|
|
2866
|
+
// Audit S9 / #14: even when the spawn fails, tmux can leave a
|
|
2867
|
+
// half-built session and we wrote MCP tmp files we don't want to
|
|
2868
|
+
// leak. Best-effort cleanup before bubbling up the error.
|
|
2869
|
+
await (0, tmuxExec_1.tmuxAsync)('kill-session', '-t', (0, tmuxExec_1.getTmuxSessionTarget)(sessionName));
|
|
2870
|
+
(0, mcpConfig_1.cleanupMcpTempFiles)({ key, dataDir: (0, state_1.resolveDataDir)() });
|
|
2871
|
+
// Audit S10 / #16: throw so the caller's `await startSession()`
|
|
2872
|
+
// sees the failure and skips registering the binding.
|
|
2873
|
+
throw new Error(`Failed to start Claude session: ${e instanceof Error ? e.message : String(e)}`);
|
|
2874
|
+
}
|
|
2875
|
+
const session = this.createSession({
|
|
2876
|
+
key,
|
|
2877
|
+
workDir,
|
|
2878
|
+
sessionName,
|
|
2879
|
+
claudeSessionId,
|
|
2880
|
+
isActive: true,
|
|
2881
|
+
handledAutoEnter: false,
|
|
2882
|
+
handledAutoAccept: false,
|
|
2883
|
+
});
|
|
2884
|
+
this.sessions.set((0, sessionKey_1.keyToString)(key), session);
|
|
2885
|
+
this.schedulePoll(key, session);
|
|
2886
|
+
// S7: ARM the thread's stored /effort for re-apply once the TUI input box
|
|
2887
|
+
// is actually ready (consumed in the poll loop) — NOT typed now, while the
|
|
2888
|
+
// pane is still painting its boot banner. Fresh spawn only — see
|
|
2889
|
+
// applyStoredEffortOnSpawn.
|
|
2890
|
+
this.applyStoredEffortOnSpawn(key);
|
|
2891
|
+
this.emit('started', key);
|
|
2892
|
+
}
|
|
2893
|
+
stopSession(key) {
|
|
2894
|
+
void this.stopSessionInternal(key).catch((e) => {
|
|
2895
|
+
console.warn(`[Claude] stopSession failed:`, e instanceof Error ? e.message : e);
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
checkIsActive(key) {
|
|
2899
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2900
|
+
return session?.isActive ?? false;
|
|
2901
|
+
}
|
|
2902
|
+
checkIsBusy(key) {
|
|
2903
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2904
|
+
if (!session)
|
|
2905
|
+
return false;
|
|
2906
|
+
return checkIsClaudeSessionBusy({ isActive: session.isActive, lastContent: session.lastContent });
|
|
2907
|
+
}
|
|
2908
|
+
async getRuntimeInfo(key) {
|
|
2909
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2910
|
+
if (!session?.isActive) {
|
|
2911
|
+
return { version: null, model: null, contextWindowTokens: null, contextUsedTokens: null };
|
|
2912
|
+
}
|
|
2913
|
+
const filePath = getClaudeTranscriptPath(session.workDir, session.claudeSessionId);
|
|
2914
|
+
try {
|
|
2915
|
+
return await (0, claudeRuntimeInfo_1.readClaudeRuntimeInfo)(filePath);
|
|
2916
|
+
}
|
|
2917
|
+
catch {
|
|
2918
|
+
return { version: null, model: null, contextWindowTokens: null, contextUsedTokens: null };
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
/**
|
|
2922
|
+
* @description Ms since the TUI pane last actually changed (raw `capture-pane`
|
|
2923
|
+
* differed), or `null` when there is no live session for this thread. Feeds
|
|
2924
|
+
* the bot's S2 pane-static idle net: a genuinely working agent repaints the
|
|
2925
|
+
* pane every second, so a large value means the agent is idle even if the
|
|
2926
|
+
* footer busy signal is stuck. `null` is never treated as idle.
|
|
2927
|
+
*/
|
|
2928
|
+
getMsSincePaneChange(key) {
|
|
2929
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2930
|
+
if (!session?.isActive)
|
|
2931
|
+
return null;
|
|
2932
|
+
return Date.now() - session.lastContentChangeAt;
|
|
2933
|
+
}
|
|
2934
|
+
sendInput(key, input, options) {
|
|
2935
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2936
|
+
if (!session?.isActive) {
|
|
2937
|
+
console.log(`[Claude] sendInput: no active session for ${(0, sessionKey_1.keyToString)(key)}`);
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
const appendEnter = options?.appendEnter ?? true;
|
|
2941
|
+
console.log(`[Claude] sendInput: "${input}"${appendEnter ? '' : ' (no Enter)'}`);
|
|
2942
|
+
// S1: remember what we just typed (every path — forward, `/c continue`,
|
|
2943
|
+
// buffered replay, control replies) so the content-based echo gate can
|
|
2944
|
+
// recognise Claude's pane echo of it, even when the echo carries no `❯`.
|
|
2945
|
+
session.lastForwardedText = input;
|
|
2946
|
+
session.lastForwardedTextAt = Date.now();
|
|
2947
|
+
this.resetPollCadence(key, session);
|
|
2948
|
+
// The ordered keystroke plan is pure (getClaudeSendKeysPlan) so the "no
|
|
2949
|
+
// Enter" decision is unit-testable; here we just execute each step.
|
|
2950
|
+
const steps = getClaudeSendKeysPlan(input, appendEnter);
|
|
2951
|
+
for (const step of steps) {
|
|
2952
|
+
if (step === 'literal') {
|
|
2953
|
+
// Argv-based send-keys: tmux never invokes a shell here, so user-typed
|
|
2954
|
+
// `$(...)` / backticks are delivered to claude's stdin as literal
|
|
2955
|
+
// bytes. The previous implementation used `execSync` with a shell
|
|
2956
|
+
// template; `JSON.stringify(input)` wraps the text in double quotes,
|
|
2957
|
+
// and `/bin/sh` happily expands `$(...)` inside double quotes BEFORE
|
|
2958
|
+
// tmux ever sees the keys — that was the RCE flagged by audit S1 / #1.
|
|
2959
|
+
//
|
|
2960
|
+
// `-l` tells tmux to treat the next argument as literal keys, not as
|
|
2961
|
+
// tmux special-key names (so the user typing the word "Enter" wouldn't
|
|
2962
|
+
// be rewritten to a newline). A separate call adds the actual Enter.
|
|
2963
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
2964
|
+
if (!session.isActive)
|
|
2965
|
+
return '';
|
|
2966
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), '-l', input);
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
else if (step === 'slashEnter') {
|
|
2970
|
+
// Bare slash commands (`/compact`, `/clear`, …) open Claude's command
|
|
2971
|
+
// autocomplete popup; an Enter fired the same instant accepts the popup
|
|
2972
|
+
// highlight instead of running the command (so it silently no-ops). Defer
|
|
2973
|
+
// the Enter so the popup settles first. Re-check the session is still
|
|
2974
|
+
// alive when the timer fires — it may have been stopped meanwhile.
|
|
2975
|
+
const sessionName = session.sessionName;
|
|
2976
|
+
setTimeout(() => {
|
|
2977
|
+
const current = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
2978
|
+
if (!current?.isActive || current.sessionName !== sessionName)
|
|
2979
|
+
return;
|
|
2980
|
+
this.enqueueTmuxBestEffort(current, async () => {
|
|
2981
|
+
if (!current.isActive)
|
|
2982
|
+
return '';
|
|
2983
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(sessionName), 'Enter');
|
|
2984
|
+
});
|
|
2985
|
+
}, CLAUDE_SLASH_ENTER_DELAY_MS);
|
|
2986
|
+
}
|
|
2987
|
+
else if (step === 'verifiedEnter') {
|
|
2988
|
+
// Plain prompt: defer the Enter past the TUI's paste-aggregation window
|
|
2989
|
+
// (see CLAUDE_TEXT_ENTER_DELAY_MS) so it submits instead of being absorbed
|
|
2990
|
+
// as a paste newline. The delay runs INSIDE the queued fn (not a bare
|
|
2991
|
+
// setTimeout): the per-session queue is the ordering guarantee, so any op
|
|
2992
|
+
// enqueued later — the next prompt's text, an interrupt Escape from
|
|
2993
|
+
// startup-prompt replay — physically cannot land between this text and
|
|
2994
|
+
// its submit Enter. An 80ms hold of THIS session's queue is harmless
|
|
2995
|
+
// (polls run every 300ms); other sessions' queues are independent.
|
|
2996
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
2997
|
+
if (!session.isActive)
|
|
2998
|
+
return '';
|
|
2999
|
+
await (0, utils_1.sleep)(CLAUDE_TEXT_ENTER_DELAY_MS);
|
|
3000
|
+
if (!session.isActive)
|
|
3001
|
+
return '';
|
|
3002
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'Enter');
|
|
3003
|
+
});
|
|
3004
|
+
this.scheduleEnterVerification(key, session.sessionName, input);
|
|
3005
|
+
}
|
|
3006
|
+
else {
|
|
3007
|
+
// Short control replies (y/n/option digits) can't trigger paste
|
|
3008
|
+
// aggregation — submit instantly, no verification capture.
|
|
3009
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
3010
|
+
if (!session.isActive)
|
|
3011
|
+
return '';
|
|
3012
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'Enter');
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
/**
|
|
3018
|
+
* @description Post-Enter verification for a plain prompt (B5). After a short
|
|
3019
|
+
* settle, capture the pane through the session queue and — if the prompt still
|
|
3020
|
+
* looks unsubmitted (see {@link checkLooksUnsubmitted}) — re-send Enter exactly
|
|
3021
|
+
* ONCE. No further retries: a duplicate Enter on an already-submitted prompt is
|
|
3022
|
+
* a harmless no-op, but an unbounded retry loop on a genuinely-stuck pane would
|
|
3023
|
+
* spam keystrokes. Same staleness guards as the deferred Enter.
|
|
3024
|
+
*/
|
|
3025
|
+
scheduleEnterVerification(key, sessionName, typedText) {
|
|
3026
|
+
setTimeout(() => {
|
|
3027
|
+
const current = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3028
|
+
if (!current?.isActive || current.sessionName !== sessionName)
|
|
3029
|
+
return;
|
|
3030
|
+
void this.enqueueTmux(current, () => (0, tmuxExec_1.tmuxAsync)('capture-pane', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(sessionName), '-p'))
|
|
3031
|
+
.then((pane) => {
|
|
3032
|
+
const live = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3033
|
+
if (!live?.isActive || live.sessionName !== sessionName)
|
|
3034
|
+
return;
|
|
3035
|
+
if (!checkLooksUnsubmitted(pane, typedText))
|
|
3036
|
+
return;
|
|
3037
|
+
console.log(`[Claude] Enter retry (paste-race)`);
|
|
3038
|
+
this.enqueueTmuxBestEffort(live, async () => {
|
|
3039
|
+
if (!live.isActive)
|
|
3040
|
+
return '';
|
|
3041
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(sessionName), 'Enter');
|
|
3042
|
+
});
|
|
3043
|
+
})
|
|
3044
|
+
.catch(() => {
|
|
3045
|
+
/* capture failures are non-fatal: the next poll still drives the UI */
|
|
3046
|
+
});
|
|
3047
|
+
}, CLAUDE_ENTER_VERIFY_DELAY_MS);
|
|
3048
|
+
}
|
|
3049
|
+
sendSignal(key, signal) {
|
|
3050
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3051
|
+
if (!session?.isActive)
|
|
3052
|
+
return;
|
|
3053
|
+
this.resetPollCadence(key, session);
|
|
3054
|
+
if (signal === 'SIGINT') {
|
|
3055
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
3056
|
+
if (!session.isActive)
|
|
3057
|
+
return '';
|
|
3058
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'C-c');
|
|
3059
|
+
});
|
|
3060
|
+
console.log(`[Claude] sent Ctrl+C`);
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
sendEnter(key) {
|
|
3064
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3065
|
+
if (!session?.isActive)
|
|
3066
|
+
return;
|
|
3067
|
+
this.resetPollCadence(key, session);
|
|
3068
|
+
console.log(`[Claude] sendEnter`);
|
|
3069
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
3070
|
+
if (!session.isActive)
|
|
3071
|
+
return '';
|
|
3072
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'Enter');
|
|
3073
|
+
});
|
|
3074
|
+
}
|
|
3075
|
+
sendArrow(key, direction) {
|
|
3076
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3077
|
+
if (!session?.isActive)
|
|
3078
|
+
return;
|
|
3079
|
+
this.resetPollCadence(key, session);
|
|
3080
|
+
console.log(`[Claude] sendArrow: ${direction}`);
|
|
3081
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
3082
|
+
if (!session.isActive)
|
|
3083
|
+
return '';
|
|
3084
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), direction);
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
sendTab(key) {
|
|
3088
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3089
|
+
if (!session?.isActive)
|
|
3090
|
+
return;
|
|
3091
|
+
this.resetPollCadence(key, session);
|
|
3092
|
+
console.log(`[Claude] sendTab`);
|
|
3093
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
3094
|
+
if (!session.isActive)
|
|
3095
|
+
return '';
|
|
3096
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'Tab');
|
|
3097
|
+
});
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* @description S4: auto-dismiss the native Claude session-feedback survey by
|
|
3101
|
+
* sending a single Escape into the pane (Escape clears the survey overlay the
|
|
3102
|
+
* same way it dismisses a selector). Fire-and-forget through the session queue,
|
|
3103
|
+
* like {@link sendEscape}; the caller's {@link checkShouldDismissSurvey}
|
|
3104
|
+
* signature dedup guarantees it fires once per appearance. Never surfaced to
|
|
3105
|
+
* the topic — the survey must not occupy input / disrupt the scrape.
|
|
3106
|
+
*/
|
|
3107
|
+
dismissClaudeSurvey(session) {
|
|
3108
|
+
console.log('[Claude] auto-dismissing session survey (Escape)');
|
|
3109
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
3110
|
+
if (!session.isActive)
|
|
3111
|
+
return '';
|
|
3112
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'Escape');
|
|
3113
|
+
});
|
|
3114
|
+
}
|
|
3115
|
+
// Fire-and-forget single Escape keystroke (interrupt the current turn /
|
|
3116
|
+
// dismiss a selector). Deliberately distinct from `interruptAndWaitIdle`:
|
|
3117
|
+
// this is a raw one-shot key, NOT a wait-for-idle interrupt.
|
|
3118
|
+
sendEscape(key) {
|
|
3119
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3120
|
+
if (!session?.isActive)
|
|
3121
|
+
return;
|
|
3122
|
+
this.resetPollCadence(key, session);
|
|
3123
|
+
console.log(`[Claude] sendEscape`);
|
|
3124
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
3125
|
+
if (!session.isActive)
|
|
3126
|
+
return '';
|
|
3127
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'Escape');
|
|
3128
|
+
});
|
|
3129
|
+
}
|
|
3130
|
+
/**
|
|
3131
|
+
* @description Interrupt the current turn, then wait until Claude is
|
|
3132
|
+
* actually idle before resolving, so the caller can type a fresh prompt
|
|
3133
|
+
* without it being queued behind the running turn.
|
|
3134
|
+
*
|
|
3135
|
+
* Sends a single Escape — which both cancels an on-screen selector AND
|
|
3136
|
+
* breaks Claude out of the "busy" state — then polls the pane footer until
|
|
3137
|
+
* the `esc to interrupt` hint clears. A fixed post-Escape delay was
|
|
3138
|
+
* unreliable: heavy extended-thinking turns take longer than the old 120 ms
|
|
3139
|
+
* to tear down, so the prompt landed while Claude was still busy and got
|
|
3140
|
+
* queued (the "voice message arrives before the interrupt, then the agent
|
|
3141
|
+
* waits" bug). On timeout we forward anyway rather than drop the message.
|
|
3142
|
+
*
|
|
3143
|
+
* Exception: if Claude is running a sub-agent or compacting context, we do
|
|
3144
|
+
* NOT interrupt — Escape would kill the child / abort the compaction. We
|
|
3145
|
+
* return without Escape so the caller's prompt queues behind the current
|
|
3146
|
+
* turn and runs once that work finishes.
|
|
3147
|
+
*/
|
|
3148
|
+
async interruptAndWaitIdle(key) {
|
|
3149
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3150
|
+
if (!session?.isActive)
|
|
3151
|
+
return;
|
|
3152
|
+
this.resetPollCadence(key, session);
|
|
3153
|
+
const paneBefore = await this.enqueueTmux(session, () => (0, tmuxExec_1.tmuxAsync)('capture-pane', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), '-p'));
|
|
3154
|
+
if (!session.isActive)
|
|
3155
|
+
return;
|
|
3156
|
+
if (checkIsClaudeUninterruptible(paneBefore)) {
|
|
3157
|
+
console.log(`[Claude] sub-agent/compaction in progress — queueing prompt, not interrupting`);
|
|
3158
|
+
return;
|
|
3159
|
+
}
|
|
3160
|
+
console.log(`[Claude] sendEscape (interrupt)`);
|
|
3161
|
+
await this.enqueueTmux(session, () => (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'Escape'));
|
|
3162
|
+
if (!session.isActive)
|
|
3163
|
+
return;
|
|
3164
|
+
const deadline = Date.now() + CLAUDE_INTERRUPT_TIMEOUT_MS;
|
|
3165
|
+
while (Date.now() < deadline) {
|
|
3166
|
+
await new Promise((resolve) => setTimeout(resolve, CLAUDE_INTERRUPT_POLL_MS));
|
|
3167
|
+
const current = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3168
|
+
if (!current?.isActive)
|
|
3169
|
+
return;
|
|
3170
|
+
const pane = await this.enqueueTmux(current, () => (0, tmuxExec_1.tmuxAsync)('capture-pane', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(current.sessionName), '-p'));
|
|
3171
|
+
if (!current.isActive)
|
|
3172
|
+
return;
|
|
3173
|
+
if (!checkIsClaudeBusy(pane)) {
|
|
3174
|
+
console.log(`[Claude] interrupt landed — idle, forwarding prompt`);
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
console.log(`[Claude] interrupt: still busy after ${CLAUDE_INTERRUPT_TIMEOUT_MS}ms, forwarding anyway`);
|
|
3179
|
+
}
|
|
3180
|
+
/**
|
|
3181
|
+
* @description Whether a selector is currently on screen. Backed by the same
|
|
3182
|
+
* `lastQuestionSignature` the output pump sets when it scrapes a question
|
|
3183
|
+
* block (see the `extractClaudeQuestion` call in the poll loop) — non-empty
|
|
3184
|
+
* means a question is being shown and awaiting an answer.
|
|
3185
|
+
*/
|
|
3186
|
+
isQuestionPending(key) {
|
|
3187
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3188
|
+
return Boolean(session?.isActive && session.lastQuestionSignature);
|
|
3189
|
+
}
|
|
3190
|
+
/**
|
|
3191
|
+
* @description Whether Claude's `/login` OAuth "paste the code" box is on
|
|
3192
|
+
* screen right now, read live off the last captured pane ({@link checkIsClaudeLoginPaste}).
|
|
3193
|
+
* No signature/de-dup is needed — nothing is emitted; this only answers
|
|
3194
|
+
* "should the next text reply be typed verbatim into the paste box?" when a
|
|
3195
|
+
* message arrives, so the bot skips the prompt path (Escape + preamble) that
|
|
3196
|
+
* would otherwise cancel the login and corrupt the code.
|
|
3197
|
+
*/
|
|
3198
|
+
isLoginPastePending(key) {
|
|
3199
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3200
|
+
return Boolean(session?.isActive && checkIsClaudeLoginPaste(session.lastContent));
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* @description For Claude CLI, model switching is done via the /model slash command.
|
|
3204
|
+
* Sends "/model <modelId>" as input to the tmux session. Returns `null`
|
|
3205
|
+
* on success (best-effort: claude doesn't ack the change synchronously).
|
|
3206
|
+
* Audit S10 / #39: unified signature with OpenCode adapter.
|
|
3207
|
+
*
|
|
3208
|
+
* Unlike OpenCode, Claude has NO model-pref persistence — model switching is
|
|
3209
|
+
* a TUI keystroke with no on-disk pref to replay at next start. So with no
|
|
3210
|
+
* active session there is nothing to do but refuse, otherwise `sendInput`
|
|
3211
|
+
* silently no-ops and the caller would report a false "model set" success
|
|
3212
|
+
* (reachable today via the ungated numeric-pick path).
|
|
3213
|
+
*/
|
|
3214
|
+
async setModel(key, modelId) {
|
|
3215
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3216
|
+
if (!session?.isActive)
|
|
3217
|
+
return (0, i18n_1.t)('model.start_agent_first');
|
|
3218
|
+
this.sendInput(key, `/model ${modelId}`);
|
|
3219
|
+
// No explicit /effort pref → (re)apply the bot's default for the newly
|
|
3220
|
+
// picked model (claude self-clamps unsupported levels). An explicit pick
|
|
3221
|
+
// is left alone — claude carries it across the model switch.
|
|
3222
|
+
if (this.getEffort(key) === null)
|
|
3223
|
+
this.sendInput(key, `/effort ${effortLevels_1.defaultEffortLevel}`);
|
|
3224
|
+
return null;
|
|
3225
|
+
}
|
|
3226
|
+
getCurrentModel(_key) {
|
|
3227
|
+
return null;
|
|
3228
|
+
}
|
|
3229
|
+
/**
|
|
3230
|
+
* @description Set the reasoning effort for this thread by typing claude's
|
|
3231
|
+
* native `/effort <level>` slash command into the running TUI.
|
|
3232
|
+
*
|
|
3233
|
+
* Plan 2026-05-30-effort-command / S3, D2/D6:
|
|
3234
|
+
*
|
|
3235
|
+
* - **Validation** is against the canonical Claude set (`getClaudeAvailableLevels`),
|
|
3236
|
+
* not per-model: the adapter can't read claude's live model after a
|
|
3237
|
+
* `/model` switch (`getCurrentModel` returns `null`), so we trust claude
|
|
3238
|
+
* to clamp an unsupported level for the actual model down to its nearest
|
|
3239
|
+
* supported one. Caller already filtered against the same canonical list
|
|
3240
|
+
* when building the picker.
|
|
3241
|
+
* - **Apply** is best-effort via {@link sendInput} (the keystroke path used
|
|
3242
|
+
* by `setModel`). Returns `null` immediately — claude's TUI doesn't
|
|
3243
|
+
* acknowledge the change synchronously and we don't poll-and-wait for it.
|
|
3244
|
+
* - **Persist** to the per-thread prefs file so the banner / `/effort`
|
|
3245
|
+
* picker survives a bot restart (claude's own settings.json is global).
|
|
3246
|
+
*
|
|
3247
|
+
* Returns a short notice instead of `null` when the session isn't running
|
|
3248
|
+
* (we still persist the choice so the next /claude picks it up in the
|
|
3249
|
+
* banner; live apply happens once the agent actually exists).
|
|
3250
|
+
*/
|
|
3251
|
+
async setEffort(key, level) {
|
|
3252
|
+
if (!(0, effortLevels_1.checkIsClaudeEffortLevel)(level)) {
|
|
3253
|
+
return (0, i18n_1.t)('effort.invalid_level', {
|
|
3254
|
+
level,
|
|
3255
|
+
valid: (0, effortLevels_1.getClaudeAvailableLevels)().join(', '),
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3258
|
+
// Persist first — the menu/banner must reflect the user's choice even
|
|
3259
|
+
// when there is no running session yet (D6).
|
|
3260
|
+
saveEffortPref(key, level);
|
|
3261
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3262
|
+
if (!session?.isActive) {
|
|
3263
|
+
// Soft notice: choice is recorded, but live apply is deferred until a
|
|
3264
|
+
// session exists. Bot surfaces this distinctly from a hard error so
|
|
3265
|
+
// the user knows to start an agent.
|
|
3266
|
+
return (0, i18n_1.t)('effort.start_agent_first');
|
|
3267
|
+
}
|
|
3268
|
+
this.sendInput(key, `/effort ${level}`);
|
|
3269
|
+
return null;
|
|
3270
|
+
}
|
|
3271
|
+
/**
|
|
3272
|
+
* @description Last bot-set effort for this thread, or `null` if none.
|
|
3273
|
+
*
|
|
3274
|
+
* Reads from the on-disk prefs file (not an in-memory cache): the file is
|
|
3275
|
+
* tiny and the read happens only on menu / banner refresh paths, so a
|
|
3276
|
+
* cache would just be drift surface. Whatever claude itself stores in
|
|
3277
|
+
* its global `settings.json` is invisible to us by design (D2).
|
|
3278
|
+
*/
|
|
3279
|
+
getEffort(key) {
|
|
3280
|
+
const prefs = loadEffortPrefs();
|
|
3281
|
+
return prefs[(0, sessionKey_1.keyToString)(key)] ?? null;
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* @description ARM the thread's stored `/effort` choice for re-apply on a
|
|
3285
|
+
* FRESH TUI spawn (S7) — deferred, not typed immediately. claude persists
|
|
3286
|
+
* effort GLOBALLY in its own settings.json, so a fresh `startSession` /
|
|
3287
|
+
* `resumeSession` would otherwise inherit the last globally-set level —
|
|
3288
|
+
* possibly chosen in another topic.
|
|
3289
|
+
*
|
|
3290
|
+
* Timing is the whole point: a fresh tmux session returns the instant
|
|
3291
|
+
* `new-session` succeeds, while the claude TUI is still painting its boot
|
|
3292
|
+
* banner — typing into it then interleaves keystrokes with the paint and the
|
|
3293
|
+
* command sits unsubmitted (live bug 2026-06-05). So we do NOT type here; we
|
|
3294
|
+
* stash the level on `session.pendingEffortReapply` and let the poll loop
|
|
3295
|
+
* consume it the FIRST time the pane shows a ready input box
|
|
3296
|
+
* ({@link checkIsClaudePromptReady}). That readiness moment is strictly after
|
|
3297
|
+
* the banner paints and strictly before the box can echo a buffered prompt,
|
|
3298
|
+
* giving the required ordering (effort BEFORE buffered prompts) via the same
|
|
3299
|
+
* serial tmux queue.
|
|
3300
|
+
*
|
|
3301
|
+
* Called at the END of `startSession` / `resumeSession`. NOT called from
|
|
3302
|
+
* `adoptExistingTmuxSession` — that claude process survived the restart with
|
|
3303
|
+
* its in-TUI effort intact and may be mid-turn, so re-applying would be both
|
|
3304
|
+
* unnecessary and risk interleaving with a running stream.
|
|
3305
|
+
*
|
|
3306
|
+
* No stored pref → the bot's default (`defaultEffortLevel` = xhigh) is armed
|
|
3307
|
+
* instead of claude's own default, so a no-pref spawn types `/effort xhigh`
|
|
3308
|
+
* (claude clamps per model — same as a manual `/effort` or an explicit pref).
|
|
3309
|
+
* An explicit per-thread pref still wins and is armed verbatim.
|
|
3310
|
+
*/
|
|
3311
|
+
applyStoredEffortOnSpawn(key) {
|
|
3312
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3313
|
+
if (!session)
|
|
3314
|
+
return;
|
|
3315
|
+
const level = this.getEffort(key) ?? effortLevels_1.defaultEffortLevel;
|
|
3316
|
+
if (!(0, effortStartupKeystroke_1.getEffortStartupKeystroke)(level))
|
|
3317
|
+
return;
|
|
3318
|
+
session.pendingEffortReapply = level;
|
|
3319
|
+
}
|
|
3320
|
+
/**
|
|
3321
|
+
* @description S7 consumption point: once the poll loop sees the TUI input
|
|
3322
|
+
* box is up and no boot gate is left, type the armed `/effort <level>` ONCE
|
|
3323
|
+
* and clear the pending flag (one-shot, can't fire twice). Reuses the exact
|
|
3324
|
+
* keystroke path a manual `/effort` uses ({@link sendInput} with its
|
|
3325
|
+
* bare-slash deferred-Enter handling) so there is no duplicate keystroke
|
|
3326
|
+
* logic. Enqueuing onto the session's serial tmux queue at this ready moment
|
|
3327
|
+
* guarantees the keystroke lands in a usable input box (not the boot paint)
|
|
3328
|
+
* and ahead of any buffered prompt the bot replays onto the same queue.
|
|
3329
|
+
*/
|
|
3330
|
+
consumePendingEffortReapply(session) {
|
|
3331
|
+
const keystroke = (0, effortStartupKeystroke_1.getEffortStartupKeystroke)(session.pendingEffortReapply);
|
|
3332
|
+
if (!keystroke)
|
|
3333
|
+
return;
|
|
3334
|
+
session.pendingEffortReapply = null;
|
|
3335
|
+
this.sendInput(session.key, keystroke);
|
|
3336
|
+
}
|
|
3337
|
+
/**
|
|
3338
|
+
* @description Effort levels offered by the `/effort` picker for Claude.
|
|
3339
|
+
*
|
|
3340
|
+
* Returns the canonical set unconditionally (plan D2). The `key` argument
|
|
3341
|
+
* is accepted for interface symmetry with OpenCode (whose set depends on
|
|
3342
|
+
* the per-thread current model) but is intentionally unused here.
|
|
3343
|
+
*/
|
|
3344
|
+
async getAvailableEffortLevels(_key) {
|
|
3345
|
+
return (0, effortLevels_1.getClaudeAvailableLevels)();
|
|
3346
|
+
}
|
|
3347
|
+
getFullOutput(key, lines = 500) {
|
|
3348
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3349
|
+
if (!session?.isActive)
|
|
3350
|
+
return null;
|
|
3351
|
+
return session.lastContent.split('\n').slice(-lines).join('\n') || null;
|
|
3352
|
+
}
|
|
3353
|
+
/**
|
|
3354
|
+
* @description Expose the Claude `--session-id` UUID for a live session.
|
|
3355
|
+
* The bot calls this right after `startSession()` so the UUID can be
|
|
3356
|
+
* persisted in state.json and reused on later resumes (plan §13.1, D14).
|
|
3357
|
+
*/
|
|
3358
|
+
getClaudeSessionId(key) {
|
|
3359
|
+
return this.sessions.get((0, sessionKey_1.keyToString)(key))?.claudeSessionId ?? null;
|
|
3360
|
+
}
|
|
3361
|
+
// Lists REAL resumable sessions from `~/.claude/projects/<cwd>/*.jsonl`,
|
|
3362
|
+
// not a bot-private registry. This makes a conversation started by hand
|
|
3363
|
+
// on the laptop in `workDir` resumable from the bound Telegram thread.
|
|
3364
|
+
// `key` is unused: Claude scopes transcripts by folder, not by thread.
|
|
3365
|
+
async getSessions(_key, workDir) {
|
|
3366
|
+
return listClaudeSessionsForWorkDir(getClaudeProjectsRoot(), workDir);
|
|
3367
|
+
}
|
|
3368
|
+
/**
|
|
3369
|
+
* @description Read the last `limit` conversational turns of a resumable
|
|
3370
|
+
* Claude session for the resume context block. Resolves the same
|
|
3371
|
+
* `~/.claude/projects/<slug>/<sessionId>.jsonl` path the session listing uses
|
|
3372
|
+
* (`key` is unused — Claude scopes transcripts by folder, not by thread) and
|
|
3373
|
+
* delegates to the pure {@link readRecentClaudeTurns}. A missing transcript
|
|
3374
|
+
* (unknown / pruned UUID) yields `[]`, so the caller posts no context block.
|
|
3375
|
+
*/
|
|
3376
|
+
async getRecentTurns(_key, workDir, sessionId, limit) {
|
|
3377
|
+
const filePath = getClaudeTranscriptPath(workDir, sessionId);
|
|
3378
|
+
return readRecentClaudeTurns(filePath, limit);
|
|
3379
|
+
}
|
|
3380
|
+
/**
|
|
3381
|
+
* @description Assemble the silent-reattach recap for Claude from the on-disk
|
|
3382
|
+
* transcript via ONE {@link readClaudeReattachTranscript} read: the
|
|
3383
|
+
* missed-message count is the assistant turns appended after the watermark
|
|
3384
|
+
* byte offset, the body is the last {@link resumeContextTurnLimit} turns, and
|
|
3385
|
+
* `isActive` is the best-effort in-memory busy flag (the just-adopted session
|
|
3386
|
+
* seeds `lastContent` from the current pane, so an `esc to interrupt` footer
|
|
3387
|
+
* reads as still-working). The offset is trusted only when the watermark's
|
|
3388
|
+
* `sessionId` matches the session being recapped — a stale watermark from a
|
|
3389
|
+
* different session (`/new`, `/sessions` resume keep the agent row) is treated
|
|
3390
|
+
* as unknown. A `null`/mismatched/offset-less watermark yields
|
|
3391
|
+
* `isWatermarkKnown: false` and `missedCount: 0` (the fallback recap), while
|
|
3392
|
+
* the body still shows the last turns.
|
|
3393
|
+
*/
|
|
3394
|
+
async getReattachRecap(key, workDir, sessionId, watermark) {
|
|
3395
|
+
const filePath = getClaudeTranscriptPath(workDir, sessionId);
|
|
3396
|
+
const offset = watermark?.claudeTranscriptOffset;
|
|
3397
|
+
const isWatermarkKnown = typeof offset === 'number' && watermark?.sessionId === sessionId;
|
|
3398
|
+
// ONE read serves both the count and the body. When the watermark is unknown
|
|
3399
|
+
// we still want the turn body, so read from 0 and drop the count below.
|
|
3400
|
+
const { missedCount, turns, headOffset } = readClaudeReattachTranscript(filePath, offset ?? 0, resumeContext_1.resumeContextTurnLimit);
|
|
3401
|
+
const isActive = this.checkIsBusy(key);
|
|
3402
|
+
// Head watermark = the transcript's current size, scoped to THIS session.
|
|
3403
|
+
// Omitted when the file was unreadable (head unknown → no idempotent advance).
|
|
3404
|
+
const headWatermark = headOffset === undefined ? undefined : { sessionId, claudeTranscriptOffset: headOffset };
|
|
3405
|
+
return { missedCount: isWatermarkKnown ? missedCount : 0, turns, isWatermarkKnown, isActive, headWatermark };
|
|
3406
|
+
}
|
|
3407
|
+
/**
|
|
3408
|
+
* @description Resume a Claude session by UUID.
|
|
3409
|
+
*
|
|
3410
|
+
* Two fixes vs. the legacy implementation:
|
|
3411
|
+
*
|
|
3412
|
+
* 1. **Required `workDir`.** The old `resumeSession` fell back to
|
|
3413
|
+
* `process.env.WORK_DIR || '/workspace'`, which is wrong as soon as the
|
|
3414
|
+
* bot manages multiple folders. The bot now passes the correct workDir
|
|
3415
|
+
* from the thread binding.
|
|
3416
|
+
*
|
|
3417
|
+
* 2. **Use `--resume <uuid>` instead of `--resume` with no argument.** The
|
|
3418
|
+
* no-arg form opens an interactive picker that can't be driven from a
|
|
3419
|
+
* headless tmux pane, which manifested as a silent hang (see plan
|
|
3420
|
+
* §13.1, fix to claudeCliAdapter.ts:455). If Claude can't find the UUID
|
|
3421
|
+
* (history pruned, different machine), it just starts a new session;
|
|
3422
|
+
* we surface that to the user (T8 in plan §16.3).
|
|
3423
|
+
*/
|
|
3424
|
+
async resumeSession(key, workDir, sessionId, options) {
|
|
3425
|
+
await this.stopSessionInternal(key);
|
|
3426
|
+
if (!(0, installManager_1.checkIsInstalled)('claude')) {
|
|
3427
|
+
this.emit('output', key, 'Installing Claude Code...');
|
|
3428
|
+
await (0, installManager_1.installTool)('claude');
|
|
3429
|
+
}
|
|
3430
|
+
if (!checkIsValidUuid(sessionId)) {
|
|
3431
|
+
throw new Error(`Invalid sessionId: ${sessionId}`);
|
|
3432
|
+
}
|
|
3433
|
+
const sessionName = buildTmuxSessionName(key);
|
|
3434
|
+
console.log(`[Claude] Resuming session ${sessionId} in ${workDir} for ${(0, sessionKey_1.keyToString)(key)}`);
|
|
3435
|
+
await (0, tmuxExec_1.tmuxAsync)('kill-session', '-t', (0, tmuxExec_1.getTmuxSessionTarget)(sessionName));
|
|
3436
|
+
// Pass the UUID explicitly. If it's unknown to claude, it'll just print a
|
|
3437
|
+
// notice and start fresh — better than hanging on a picker. MCP flags
|
|
3438
|
+
// are re-applied here so a resumed session sees the same servers as a
|
|
3439
|
+
// fresh one would (plan §19). Argv-style shell-quoting mirrors
|
|
3440
|
+
// `startSession` (audit S1).
|
|
3441
|
+
const mcpFlagsArr = await (0, mcpConfig_1.prepareMcpFlags)({ key, dataDir: (0, state_1.resolveDataDir)() });
|
|
3442
|
+
const claudeArgv = [
|
|
3443
|
+
claudePath,
|
|
3444
|
+
// Followed by an option on purpose: `--disallowedTools` takes several values.
|
|
3445
|
+
...(0, claudePlatformFlags_1.getClaudePlatformFlags)(key),
|
|
3446
|
+
...claudePermissionArgs,
|
|
3447
|
+
'--resume', sessionId,
|
|
3448
|
+
...mcpFlagsArr,
|
|
3449
|
+
...(0, claudeCompactHook_1.prepareClaudeCompactHookFlags)((0, state_1.resolveDataDir)()),
|
|
3450
|
+
];
|
|
3451
|
+
const claudeShellCmd = claudeArgv.map(tmuxExec_1.shellSingleQuote).join(' ');
|
|
3452
|
+
try {
|
|
3453
|
+
await (0, tmuxExec_1.tmuxOrThrowAsync)('new-session', '-d', '-s', sessionName, '-x', '300', '-y', '50', '-c', workDir, claudeShellCmd);
|
|
3454
|
+
}
|
|
3455
|
+
catch (e) {
|
|
3456
|
+
console.error(`[Claude] Failed to resume session:`, e);
|
|
3457
|
+
await (0, tmuxExec_1.tmuxAsync)('kill-session', '-t', (0, tmuxExec_1.getTmuxSessionTarget)(sessionName));
|
|
3458
|
+
(0, mcpConfig_1.cleanupMcpTempFiles)({ key, dataDir: (0, state_1.resolveDataDir)() });
|
|
3459
|
+
throw new Error(`Failed to resume Claude session: ${e instanceof Error ? e.message : String(e)}`);
|
|
3460
|
+
}
|
|
3461
|
+
const claudeSession = this.createSession({
|
|
3462
|
+
key,
|
|
3463
|
+
workDir,
|
|
3464
|
+
sessionName,
|
|
3465
|
+
claudeSessionId: sessionId,
|
|
3466
|
+
isActive: true,
|
|
3467
|
+
handledAutoEnter: false,
|
|
3468
|
+
handledAutoAccept: false,
|
|
3469
|
+
});
|
|
3470
|
+
// Arm flood-suppression BEFORE the first poll: Claude repaints the whole
|
|
3471
|
+
// restored transcript into the pane over the next polls, which would
|
|
3472
|
+
// otherwise be relayed as one giant dump. Seeding swallows that text while
|
|
3473
|
+
// still advancing the baseline (see `pollOutput` + `getResumeSeedDecision`).
|
|
3474
|
+
claudeSession.resumeSeeding = true;
|
|
3475
|
+
this.sessions.set((0, sessionKey_1.keyToString)(key), claudeSession);
|
|
3476
|
+
this.schedulePoll(key, claudeSession);
|
|
3477
|
+
// S7: ARM the stored /effort for re-apply on this fresh resume spawn too
|
|
3478
|
+
// (claude's global effort state could be from another topic). Deferred to
|
|
3479
|
+
// the poll-loop readiness point — same as startSession; the resume seeding
|
|
3480
|
+
// repaint must finish before the input box is ready, which the readiness
|
|
3481
|
+
// predicate already requires. See applyStoredEffortOnSpawn.
|
|
3482
|
+
this.applyStoredEffortOnSpawn(key);
|
|
3483
|
+
this.emit('started', key);
|
|
3484
|
+
// Post the short last-N-turn context block in place of the flood. ONLY on
|
|
3485
|
+
// the explicit user resume (`/sessions` pick) — a silent re-attach must
|
|
3486
|
+
// stay quiet (see ResumeSessionOptions). The `.jsonl` is independent of
|
|
3487
|
+
// the pane, so this can run immediately (it does not wait for seeding to
|
|
3488
|
+
// finish). Best-effort: a read failure or empty history simply posts no
|
|
3489
|
+
// extra block — the normal "resumed" reply stands.
|
|
3490
|
+
if (options?.isWithRecentContext) {
|
|
3491
|
+
try {
|
|
3492
|
+
const turns = await this.getRecentTurns(key, workDir, sessionId, resumeContext_1.resumeContextTurnLimit);
|
|
3493
|
+
const rendered = (0, resumeContext_1.formatResumeContext)(turns);
|
|
3494
|
+
// Complete one-shot block — post instantly, never via the dm draft
|
|
3495
|
+
// channel (its typing animation would "draw" this already-whole text).
|
|
3496
|
+
if (rendered)
|
|
3497
|
+
this.emit('output', key, rendered, { isComplete: true });
|
|
3498
|
+
}
|
|
3499
|
+
catch (e) {
|
|
3500
|
+
console.warn(`[Claude] resume context block failed:`, e instanceof Error ? e.message : e);
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
/**
|
|
3505
|
+
* @description Pick up tmux sessions that outlived the bot process.
|
|
3506
|
+
*
|
|
3507
|
+
* Called by `bot.ts` on startup, BEFORE `bot.launch()`. Scans
|
|
3508
|
+
* `tmux list-sessions` for names matching our `claude-<chatId>-<threadId>`
|
|
3509
|
+
* convention. For each match, returns the parsed key + tmux session name —
|
|
3510
|
+
* the bot then decides which ones to re-adopt (must have a live binding in
|
|
3511
|
+
* state.json) and which are orphans to garbage-collect (plan §10.2 / §13.19, E1).
|
|
3512
|
+
*
|
|
3513
|
+
* This method does NOT itself adopt sessions: it has no knowledge of state.json
|
|
3514
|
+
* or which sessions are still bound. The actual re-attach is done by the bot
|
|
3515
|
+
* calling {@link adoptExistingTmuxSession} for each key it wants to keep.
|
|
3516
|
+
*
|
|
3517
|
+
* Returns an empty array if tmux isn't installed or no matching sessions exist.
|
|
3518
|
+
*/
|
|
3519
|
+
async listExistingTmuxSessions() {
|
|
3520
|
+
const raw = await (0, tmuxExec_1.tmuxAsync)('list-sessions', '-F', '#{session_name}');
|
|
3521
|
+
if (!raw)
|
|
3522
|
+
return [];
|
|
3523
|
+
const names = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
3524
|
+
const result = [];
|
|
3525
|
+
for (const name of names) {
|
|
3526
|
+
const key = parseTmuxSessionName(name);
|
|
3527
|
+
if (key)
|
|
3528
|
+
result.push({ key, sessionName: name });
|
|
3529
|
+
}
|
|
3530
|
+
return result;
|
|
3531
|
+
}
|
|
3532
|
+
/**
|
|
3533
|
+
* @description Recover the `--session-id <uuid>` flag from a live tmux
|
|
3534
|
+
* session's start command.
|
|
3535
|
+
*
|
|
3536
|
+
* Used by the bot's reattach loop to reconcile state with reality when
|
|
3537
|
+
* `state.agents[key]` is missing a `claudeSessionId` (or names a
|
|
3538
|
+
* different adapter) but a `claude-<chatId>-<threadId>` tmux session is
|
|
3539
|
+
* still running. Previously such cases were treated as orphans and the
|
|
3540
|
+
* tmux session was killed, throwing away the user's live work.
|
|
3541
|
+
*
|
|
3542
|
+
* Implementation: read `pane_start_command` for the session's first
|
|
3543
|
+
* pane and parse the UUID out via {@link parseClaudeSessionIdFromCommand}.
|
|
3544
|
+
* `pane_start_command` survives across restarts because tmux keeps the
|
|
3545
|
+
* original command line for the pane.
|
|
3546
|
+
*
|
|
3547
|
+
* Returns `null` if no UUID can be recovered (caller falls back to
|
|
3548
|
+
* killing the session as an orphan).
|
|
3549
|
+
*/
|
|
3550
|
+
async recoverSessionIdFromTmux(sessionName) {
|
|
3551
|
+
const sessions = await (0, tmuxExec_1.tmuxAsync)('list-sessions', '-F', '#{session_name}');
|
|
3552
|
+
if (!sessions.split('\n').includes(sessionName))
|
|
3553
|
+
return null;
|
|
3554
|
+
const cmd = await (0, tmuxExec_1.tmuxAsync)('display-message', '-p', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(sessionName), '#{pane_start_command}');
|
|
3555
|
+
if (!cmd)
|
|
3556
|
+
return null;
|
|
3557
|
+
return parseClaudeSessionIdFromCommand(cmd);
|
|
3558
|
+
}
|
|
3559
|
+
/**
|
|
3560
|
+
* @description Adopt a tmux session that survived a bot restart.
|
|
3561
|
+
*
|
|
3562
|
+
* The bot calls this after `listExistingTmuxSessions()` for each
|
|
3563
|
+
* `(key, sessionName)` pair it wants to keep alive. Restores the in-memory
|
|
3564
|
+
* `ClaudeSession` and resumes polling so output flows back to Telegram.
|
|
3565
|
+
*
|
|
3566
|
+
* `workDir` and `claudeSessionId` come from state.json (the bot keeps a
|
|
3567
|
+
* binding `(key → subdir, claudeSessionId)`). If we ever lose them, the
|
|
3568
|
+
* caller should kill the tmux session as an orphan instead.
|
|
3569
|
+
*
|
|
3570
|
+
* Returns `true` on success, `false` if the tmux session disappeared between
|
|
3571
|
+
* the `list` call and now (race with manual `tmux kill-session`).
|
|
3572
|
+
*/
|
|
3573
|
+
async adoptExistingTmuxSession(key, sessionName, workDir, claudeSessionId) {
|
|
3574
|
+
const sessions = await (0, tmuxExec_1.tmuxAsync)('list-sessions', '-F', '#{session_name}');
|
|
3575
|
+
if (!sessions.split('\n').includes(sessionName)) {
|
|
3576
|
+
console.log(`[Claude] adopt: tmux session ${sessionName} no longer exists`);
|
|
3577
|
+
return false;
|
|
3578
|
+
}
|
|
3579
|
+
// Audit S9 / #15: confirm the tmux session has a live child process,
|
|
3580
|
+
// not just an empty pane. With `remain-on-exit` semantics or a crashed
|
|
3581
|
+
// claude, a session can exist but produce no output forever; adopting
|
|
3582
|
+
// it would silently swallow further user input.
|
|
3583
|
+
const panesRaw = await (0, tmuxExec_1.tmuxAsync)('list-panes', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(sessionName), '-F', '#{pane_pid}');
|
|
3584
|
+
const pids = panesRaw.split('\n').map(s => s.trim()).filter(s => /^\d+$/.test(s));
|
|
3585
|
+
const anyAlive = pids.some(pidStr => {
|
|
3586
|
+
try {
|
|
3587
|
+
process.kill(Number(pidStr), 0);
|
|
3588
|
+
return true;
|
|
3589
|
+
}
|
|
3590
|
+
catch {
|
|
3591
|
+
return false;
|
|
3592
|
+
}
|
|
3593
|
+
});
|
|
3594
|
+
if (!anyAlive) {
|
|
3595
|
+
console.log(`[Claude] adopt: ${sessionName} has no live child process, killing as zombie`);
|
|
3596
|
+
await (0, tmuxExec_1.tmuxAsync)('kill-session', '-t', (0, tmuxExec_1.getTmuxSessionTarget)(sessionName));
|
|
3597
|
+
return false;
|
|
3598
|
+
}
|
|
3599
|
+
const k = (0, sessionKey_1.keyToString)(key);
|
|
3600
|
+
// If we already have a tracked session for this key, leave it alone.
|
|
3601
|
+
if (this.sessions.has(k)) {
|
|
3602
|
+
console.log(`[Claude] adopt: already tracking ${k}, skipping`);
|
|
3603
|
+
return true;
|
|
3604
|
+
}
|
|
3605
|
+
console.log(`[Claude] adopt: re-attaching to ${sessionName} in ${workDir}`);
|
|
3606
|
+
const session = this.createSession({
|
|
3607
|
+
key,
|
|
3608
|
+
workDir,
|
|
3609
|
+
sessionName,
|
|
3610
|
+
claudeSessionId,
|
|
3611
|
+
isActive: true,
|
|
3612
|
+
handledAutoEnter: true, // don't try to auto-Enter on a session that's already past startup
|
|
3613
|
+
handledAutoAccept: true, // same — bypass-permissions was accepted on the original launch
|
|
3614
|
+
});
|
|
3615
|
+
// Seed `lastContent` with the current pane snapshot **before** the
|
|
3616
|
+
// first poll fires. Without this, the bot's first `pollOutput` after
|
|
3617
|
+
// re-adoption would diff a ~2000-line scrollback (hours of stale
|
|
3618
|
+
// conversation that survived the restart inside tmux) against `''`
|
|
3619
|
+
// — `getNewPaneContent('', x) === x` — and emit every line of it to
|
|
3620
|
+
// Telegram as if it were brand new output. Symptom: user restarts
|
|
3621
|
+
// the bot, types a fresh message in an existing thread, and the
|
|
3622
|
+
// thread gets flooded with answers from previous sessions before
|
|
3623
|
+
// the new answer arrives.
|
|
3624
|
+
//
|
|
3625
|
+
// The capture uses the SAME flags as `pollOutput` (`-e -S -2000`)
|
|
3626
|
+
// so the seed and the next poll's snapshot are produced by the
|
|
3627
|
+
// same code path; otherwise edge differences in ANSI handling or
|
|
3628
|
+
// scrollback depth would re-introduce phantom "new" lines on the
|
|
3629
|
+
// first diff. Best-effort — if `capture-pane` fails the seed stays
|
|
3630
|
+
// empty and we fall back to the pre-fix (noisy) behaviour, which
|
|
3631
|
+
// is still better than refusing to adopt.
|
|
3632
|
+
const initialRaw = await this.enqueueTmux(session, () => (0, tmuxExec_1.tmuxAsync)('capture-pane', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(sessionName), '-p', '-e', '-S', '-2000'));
|
|
3633
|
+
if (initialRaw) {
|
|
3634
|
+
session.lastContent = (0, ansiClean_1.cleanOutput)(initialRaw);
|
|
3635
|
+
// S1: seed the raw baseline too so the first poll after adoption — which
|
|
3636
|
+
// captures the same idle pane — skips the redundant `cleanOutput`.
|
|
3637
|
+
session.lastRawCapture = initialRaw;
|
|
3638
|
+
// Seed the relay window too (live incident 2026-06-24): the
|
|
3639
|
+
// baseline above suppresses re-appearing PROSE, but a SETTLED table already
|
|
3640
|
+
// on the pane is deduped ONLY at the BLOCK level (`emitStabilizedTable`).
|
|
3641
|
+
// Without this seed the window is empty, so the first post-adopt poll
|
|
3642
|
+
// re-emits the whole table once per restart (~1100 chars). Recording the
|
|
3643
|
+
// pane + the settled table's own block signature makes that re-render hit
|
|
3644
|
+
// `checkBlockAlreadyRelayed === true` and be suppressed.
|
|
3645
|
+
(0, recentRelayWindow_1.seedRelayWindowFromPane)(session.recentRelayWindow, session.lastContent, getLastSharpTableBlock);
|
|
3646
|
+
}
|
|
3647
|
+
this.sessions.set(k, session);
|
|
3648
|
+
this.schedulePoll(key, session);
|
|
3649
|
+
this.emit('started', key);
|
|
3650
|
+
return true;
|
|
3651
|
+
}
|
|
3652
|
+
/**
|
|
3653
|
+
* @description Kill a tmux session by name without touching adapter state.
|
|
3654
|
+
*
|
|
3655
|
+
* The bot uses this on startup to garbage-collect orphan tmux sessions
|
|
3656
|
+
* (`claude-<chatId>-<threadId>` names with no corresponding binding in
|
|
3657
|
+
* state.json). See plan §10.2 / §13.19.
|
|
3658
|
+
*/
|
|
3659
|
+
async killOrphanTmuxSession(sessionName) {
|
|
3660
|
+
console.log(`[Claude] kill orphan tmux session: ${sessionName}`);
|
|
3661
|
+
await (0, tmuxExec_1.tmuxAsync)('kill-session', '-t', (0, tmuxExec_1.getTmuxSessionTarget)(sessionName));
|
|
3662
|
+
}
|
|
3663
|
+
// Exposed for tests (see §11 Этап 7, R10): keeps the tmux-name parsing
|
|
3664
|
+
// logic unit-testable without instantiating the adapter (which would try to
|
|
3665
|
+
// auto-install claude on construction).
|
|
3666
|
+
static parseTmuxSessionName = parseTmuxSessionName;
|
|
3667
|
+
static buildTmuxSessionName = buildTmuxSessionName;
|
|
3668
|
+
async probeSessionAlive(session) {
|
|
3669
|
+
const sessions = await this.enqueueTmux(session, () => (0, tmuxExec_1.tmuxAsync)('list-sessions', '-F', '#{session_name}'));
|
|
3670
|
+
return sessions.split('\n').includes(session.sessionName);
|
|
3671
|
+
}
|
|
3672
|
+
async pollOutput(key) {
|
|
3673
|
+
const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
|
|
3674
|
+
if (!session?.isActive)
|
|
3675
|
+
return;
|
|
3676
|
+
// Audit S9 / #38: a burst of more than 200 lines between two polls
|
|
3677
|
+
// would slide off the top of the capture window before the next poll
|
|
3678
|
+
// saw it, breaking the diff and causing duplicate "new" emissions.
|
|
3679
|
+
// 2000 lines comfortably covers a long Claude tool-call block; we
|
|
3680
|
+
// still diff against `session.lastContent` in memory so the larger
|
|
3681
|
+
// capture doesn't grow the output we send to Telegram.
|
|
3682
|
+
const raw = await this.enqueueTmux(session, () => (0, tmuxExec_1.tmuxAsync)('capture-pane', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), '-p', '-e', '-S', '-2000'));
|
|
3683
|
+
if (!session.isActive)
|
|
3684
|
+
return;
|
|
3685
|
+
if (!raw) {
|
|
3686
|
+
const alive = await this.probeSessionAlive(session);
|
|
3687
|
+
if (!session.isActive)
|
|
3688
|
+
return;
|
|
3689
|
+
if (!alive) {
|
|
3690
|
+
console.log(`[Claude] Session died, cleaning up`);
|
|
3691
|
+
await this.stopSessionInternal(key);
|
|
3692
|
+
this.emit('closed', key);
|
|
3693
|
+
}
|
|
3694
|
+
return;
|
|
3695
|
+
}
|
|
3696
|
+
// Pane-RESIZE guard (live incident 2026-07-02): an
|
|
3697
|
+
// interactive `tmux attach` resizes the window to the client terminal (and
|
|
3698
|
+
// detach restores the `-x/-y` default); tmux re-wraps the WHOLE scrollback
|
|
3699
|
+
// at the new width, so the line-SET diff below would see ~every line as
|
|
3700
|
+
// "new" and relay ragged fragments of OLD conversation (the relay window
|
|
3701
|
+
// can't match re-wrapped lines, and its short-line exemption lets clusters
|
|
3702
|
+
// of short old lines through on every flap). Query the size AFTER the
|
|
3703
|
+
// capture (same-poll order is what makes the race-free direction hold: a
|
|
3704
|
+
// resize landing between the two calls is detected on THIS poll, before
|
|
3705
|
+
// its giant diff ships) and, on a change, swallow the repaint — advance
|
|
3706
|
+
// the baseline, emit nothing — until the capture settles. Decision logic
|
|
3707
|
+
// is pure: `utils/paneResizeGuard.ts`.
|
|
3708
|
+
const paneSizeRaw = await this.enqueueTmux(session, () => (0, tmuxExec_1.tmuxAsync)('display-message', '-p', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), '#{pane_width}x#{pane_height}'));
|
|
3709
|
+
if (!session.isActive)
|
|
3710
|
+
return;
|
|
3711
|
+
const currentPaneSize = (0, paneResizeGuard_1.parsePaneSize)(paneSizeRaw);
|
|
3712
|
+
const resizeDecision = (0, paneResizeGuard_1.getPaneResizeGuardDecision)({
|
|
3713
|
+
lastSize: session.lastPaneSize,
|
|
3714
|
+
currentSize: currentPaneSize,
|
|
3715
|
+
isSettling: session.isResizeSettling,
|
|
3716
|
+
settlePolls: session.resizeSettlePolls,
|
|
3717
|
+
isRawChanged: raw !== session.lastRawCapture,
|
|
3718
|
+
});
|
|
3719
|
+
if (currentPaneSize !== null && session.lastPaneSize !== null && currentPaneSize !== session.lastPaneSize) {
|
|
3720
|
+
console.log(`[Claude] pane resized (${session.lastPaneSize} → ${currentPaneSize}) — suppressing scrape, reseeding baseline`);
|
|
3721
|
+
}
|
|
3722
|
+
if (currentPaneSize !== null)
|
|
3723
|
+
session.lastPaneSize = currentPaneSize;
|
|
3724
|
+
if (session.isResizeSettling && !resizeDecision.nextIsSettling) {
|
|
3725
|
+
console.log(`[Claude] pane resize settled after ${session.resizeSettlePolls} suppressed polls`);
|
|
3726
|
+
}
|
|
3727
|
+
session.isResizeSettling = resizeDecision.nextIsSettling;
|
|
3728
|
+
session.resizeSettlePolls = resizeDecision.nextSettlePolls;
|
|
3729
|
+
if (resizeDecision.action === 'suppress') {
|
|
3730
|
+
if (raw !== session.lastRawCapture) {
|
|
3731
|
+
session.lastRawCapture = raw;
|
|
3732
|
+
session.lastContent = (0, ansiClean_1.cleanOutput)(raw);
|
|
3733
|
+
// The pane genuinely changed — keep the 30s pane-static liveness net honest.
|
|
3734
|
+
session.lastContentChangeAt = Date.now();
|
|
3735
|
+
}
|
|
3736
|
+
return;
|
|
3737
|
+
}
|
|
3738
|
+
// S1: when the raw capture is byte-identical to the previous poll's, the
|
|
3739
|
+
// pane did not change, so the cleaned content is identical too — skip the
|
|
3740
|
+
// ~15-regex `cleanOutput` pass and reuse the cached result. Runs AFTER the
|
|
3741
|
+
// empty-raw death probe above (an empty capture short-circuits there and
|
|
3742
|
+
// never reaches the skip). All downstream branches behave bit-for-bit the
|
|
3743
|
+
// same: the resume-seeding "unchanged across 2 polls" exit still sees its
|
|
3744
|
+
// no-change signal, and the `content !== lastContent` compare still no-ops.
|
|
3745
|
+
const isRawChanged = raw !== session.lastRawCapture;
|
|
3746
|
+
let content;
|
|
3747
|
+
if (isRawChanged) {
|
|
3748
|
+
session.lastRawCapture = raw;
|
|
3749
|
+
// Pane changed → reset the idle clock the bot's 30s pane-static net reads.
|
|
3750
|
+
session.lastContentChangeAt = Date.now();
|
|
3751
|
+
content = (0, ansiClean_1.cleanOutput)(raw);
|
|
3752
|
+
}
|
|
3753
|
+
else {
|
|
3754
|
+
content = session.lastContent;
|
|
3755
|
+
}
|
|
3756
|
+
// S2: grow the poll delay while the pane stays idle, snap back to base on
|
|
3757
|
+
// any change. A change always resets the cadence so streaming latency is
|
|
3758
|
+
// unaffected; only no-activity panes slow down (toward maxPollIntervalMs).
|
|
3759
|
+
const nextCadence = (0, pollBackoff_1.getNextPollDelay)({
|
|
3760
|
+
isChanged: isRawChanged,
|
|
3761
|
+
currentDelayMs: session.currentPollDelayMs,
|
|
3762
|
+
unchangedStreak: session.unchangedPollStreak,
|
|
3763
|
+
});
|
|
3764
|
+
session.currentPollDelayMs = nextCadence.delayMs;
|
|
3765
|
+
session.unchangedPollStreak = nextCadence.unchangedStreak;
|
|
3766
|
+
// Resume flood-suppression: while seeding, Claude is repainting the whole
|
|
3767
|
+
// restored transcript into the pane. We advance the baseline every poll but
|
|
3768
|
+
// emit NO conversation text, so once the pane stops growing the baseline
|
|
3769
|
+
// equals the full restored transcript and later diffs are genuine new
|
|
3770
|
+
// output. This runs on EVERY poll (even when `content === lastContent`),
|
|
3771
|
+
// because "unchanged across 2 polls" — the exit signal — is exactly the
|
|
3772
|
+
// no-change case. Lifecycle auto-handling KEEPS running (suppress text, not
|
|
3773
|
+
// the "Press Enter" / bypass-permissions prompts a resume may re-show).
|
|
3774
|
+
if (session.resumeSeeding) {
|
|
3775
|
+
const decision = getResumeSeedDecision({
|
|
3776
|
+
content,
|
|
3777
|
+
prevContent: session.resumeSeedPrevContent,
|
|
3778
|
+
polls: session.resumeSeedPolls,
|
|
3779
|
+
});
|
|
3780
|
+
session.lastContent = content;
|
|
3781
|
+
session.resumeSeedPrevContent = content;
|
|
3782
|
+
session.resumeSeedPolls += 1;
|
|
3783
|
+
this.handleAutoLifecycle(session, content, (0, paneDiff_1.getNewPaneContent)('', content).text);
|
|
3784
|
+
if (!decision.keepSeeding) {
|
|
3785
|
+
session.resumeSeeding = false;
|
|
3786
|
+
// The relay window must start EMPTY here: seeding SWALLOWED the
|
|
3787
|
+
// restored transcript (it was never relayed this lifetime), and the
|
|
3788
|
+
// window only ever suppresses content it actually relayed. Seeding
|
|
3789
|
+
// emits no output so nothing was recorded — the reset keeps that
|
|
3790
|
+
// invariant explicit rather than incidental.
|
|
3791
|
+
session.recentRelayWindow.reset();
|
|
3792
|
+
// Same invariant for the table stabilizer: seeding swallowed any
|
|
3793
|
+
// restored table, so nothing should carry over into the live phase.
|
|
3794
|
+
session.streamingTable = null;
|
|
3795
|
+
console.log(`[Claude] resume seeding done after ${session.resumeSeedPolls} polls`);
|
|
3796
|
+
}
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
// S7: re-apply the thread's stored /effort the FIRST poll the TUI input box
|
|
3800
|
+
// is up (and no boot gate left). Runs every non-seeding poll, incl. unchanged
|
|
3801
|
+
// ones — the box can appear on a poll whose content didn't otherwise change.
|
|
3802
|
+
// Cheap early-out below when nothing is armed; `consumePendingEffortReapply`
|
|
3803
|
+
// clears the flag so it fires exactly once. Must precede any buffered-prompt
|
|
3804
|
+
// replay, which the bot only starts after the input box exists — they share
|
|
3805
|
+
// the serial tmux queue, and this enqueues first.
|
|
3806
|
+
if (session.pendingEffortReapply && checkIsClaudePromptReady(content)) {
|
|
3807
|
+
this.consumePendingEffortReapply(session);
|
|
3808
|
+
}
|
|
3809
|
+
// S7 seen-watermark advance: on EVERY ready poll whose transcript EOF grew
|
|
3810
|
+
// (busy OR idle) move the persisted watermark to the current EOF. Because a
|
|
3811
|
+
// live Claude adapter relays the pane on every poll, growth means "relayed
|
|
3812
|
+
// up to here" mid-turn — so the watermark tracks what the user already saw
|
|
3813
|
+
// instead of stranding at the previous turn end, which made a mid-turn
|
|
3814
|
+
// restart re-count the whole in-flight turn as a false "missed N" (live
|
|
3815
|
+
// 2026-07-04). Cheap: two regex tests every poll; the fs stat + write fire
|
|
3816
|
+
// only on a ready poll, and the write only when EOF actually grew. Lets a
|
|
3817
|
+
// later bot restart count only the assistant turns it genuinely missed
|
|
3818
|
+
// during downtime (the reattach recap).
|
|
3819
|
+
const isBusy = checkIsClaudeSessionBusy({ isActive: session.isActive, lastContent: content });
|
|
3820
|
+
this.advanceClaudeWatermarkIfGrown(key, session, isBusy, checkIsClaudePromptReady(content));
|
|
3821
|
+
// S1 idle-poll table flush: the change branch below never runs on a
|
|
3822
|
+
// byte-unchanged poll, so a table that is the LAST thing painted before the
|
|
3823
|
+
// pane freezes is flushed here instead (never silently drop it). The flush
|
|
3824
|
+
// is gated on the SAME done-signals as the change branch — real prose after
|
|
3825
|
+
// the table OR the turn going idle — so a mid-stream PAUSE (the pane briefly
|
|
3826
|
+
// frozen at an intermediate row count while still busy) is held, not emitted
|
|
3827
|
+
// (live incident 2026-06-15). A frozen-but-busy table rides the held-poll
|
|
3828
|
+
// safety cap; a frozen idle table emits via the idle signal.
|
|
3829
|
+
if (content === session.lastContent && session.streamingTable) {
|
|
3830
|
+
const idleDecision = getTableStabilizationDecision({
|
|
3831
|
+
currentTable: getLastSharpTableBlock(content),
|
|
3832
|
+
streamingTable: session.streamingTable,
|
|
3833
|
+
hasContentAfterTable: checkHasContentAfterLastSharpTable(content),
|
|
3834
|
+
isTurnIdle: !checkIsClaudeSessionBusy({ isActive: session.isActive, lastContent: content }),
|
|
3835
|
+
});
|
|
3836
|
+
session.streamingTable = idleDecision.nextStreamingTable;
|
|
3837
|
+
if (idleDecision.kind === 'emit' && idleDecision.block) {
|
|
3838
|
+
this.emitStabilizedTable(key, session, idleDecision.block);
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
if (content !== session.lastContent) {
|
|
3842
|
+
const previousPane = session.lastContent;
|
|
3843
|
+
const diff = (0, paneDiff_1.getNewPaneContent)(previousPane, content);
|
|
3844
|
+
const startsNewParagraph = diff.startsNewParagraph;
|
|
3845
|
+
session.lastContent = content;
|
|
3846
|
+
// S1 streaming-table stabilizer (live incident 2026-06-11): a wide
|
|
3847
|
+
// markdown table re-flows its column widths as longer cells stream in, so
|
|
3848
|
+
// every layout is byte-distinct and the line-SET diff marks the whole
|
|
3849
|
+
// table "new" each poll — it would ship once per layout (empty → 1 row →
|
|
3850
|
+
// full), the final full frame often dropped under the coalescer's
|
|
3851
|
+
// debounce / a 429. Operate on the FULL live `content` (not the diff):
|
|
3852
|
+
// hold a still-laying-out table (masking its lines out of THIS poll's
|
|
3853
|
+
// delta so no intermediate frame ships), and emit the complete table only
|
|
3854
|
+
// when it is DONE — real prose now follows it, or the turn went idle, or
|
|
3855
|
+
// the safety cap fired (NOT on a mid-stream pause; live incident
|
|
3856
|
+
// 2026-06-15). Runs BEFORE the normal emit so a done table is its own
|
|
3857
|
+
// fenced block, ahead of any trailing prose in this same delta.
|
|
3858
|
+
const tableDecision = getTableStabilizationDecision({
|
|
3859
|
+
currentTable: getLastSharpTableBlock(content),
|
|
3860
|
+
streamingTable: session.streamingTable,
|
|
3861
|
+
hasContentAfterTable: checkHasContentAfterLastSharpTable(content),
|
|
3862
|
+
isTurnIdle: !checkIsClaudeSessionBusy({ isActive: session.isActive, lastContent: content }),
|
|
3863
|
+
});
|
|
3864
|
+
session.streamingTable = tableDecision.nextStreamingTable;
|
|
3865
|
+
// While a table is held OR being emitted separately, its lines must not
|
|
3866
|
+
// also ride in this poll's prose delta — mask them out either way.
|
|
3867
|
+
let newPart = diff.text;
|
|
3868
|
+
if (tableDecision.kind !== 'none') {
|
|
3869
|
+
newPart = maskSharpTableLines(newPart);
|
|
3870
|
+
}
|
|
3871
|
+
if (tableDecision.kind === 'emit' && tableDecision.block) {
|
|
3872
|
+
this.emitStabilizedTable(key, session, tableDecision.block);
|
|
3873
|
+
}
|
|
3874
|
+
if (newPart) {
|
|
3875
|
+
// S4: full body only when explicitly debugging the scrape pipeline;
|
|
3876
|
+
// default is a one-line size summary (the sync stdout write of up to
|
|
3877
|
+
// ~600KB per changed poll was starving the event loop in prod).
|
|
3878
|
+
console.log(isClaudeScrapeDebugEnabled
|
|
3879
|
+
? `[Claude] RAW output (${newPart.length}):\n---\n${newPart}\n---`
|
|
3880
|
+
: `[Claude] RAW output (${newPart.length} chars)`);
|
|
3881
|
+
// Interactive question/choice prompts are detected on the FULL pane
|
|
3882
|
+
// (not this diff): moving the `❯` cursor repaints only the changed
|
|
3883
|
+
// option lines, so a signature from the diff would be a partial
|
|
3884
|
+
// option set and the de-dup would re-spam the thread on every
|
|
3885
|
+
// keystroke. Reading the whole option group keeps the signature
|
|
3886
|
+
// stable across cursor moves. Deliver once as durable output — not a
|
|
3887
|
+
// transient status frame that gets deleted — and suppress repaints.
|
|
3888
|
+
const question = extractClaudeQuestion(content);
|
|
3889
|
+
if (question) {
|
|
3890
|
+
// A real AskUserQuestion selector takes PRECEDENCE over a survey: a
|
|
3891
|
+
// digit reply must drive the selector, not be eaten by a stale
|
|
3892
|
+
// survey signal. Clear the survey de-dup so it can't shadow the
|
|
3893
|
+
// question's digit routing.
|
|
3894
|
+
session.lastSurveySignature = '';
|
|
3895
|
+
// Detected → reset the absence counter (any prior transient null was a
|
|
3896
|
+
// flicker, not a resolve).
|
|
3897
|
+
session.questionAbsentPolls = 0;
|
|
3898
|
+
if (question.signature !== session.lastQuestionSignature) {
|
|
3899
|
+
session.lastQuestionSignature = question.signature;
|
|
3900
|
+
session.lastStatusText = '';
|
|
3901
|
+
session.openToolKind = null; // a question ends any prior tool output
|
|
3902
|
+
// `isQuestion`: the bot sends this as its OWN pinnable message and
|
|
3903
|
+
// pins it so the muted topic fires a notification.
|
|
3904
|
+
this.emit('output', key, `${question.text}\n\n${this.tl(key, () => (0, i18n_1.t)('agent.question_hint'))}`, { isQuestion: true });
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
else {
|
|
3908
|
+
// No selector THIS diff. While a question is pending, a single miss is
|
|
3909
|
+
// usually the selector mid-repaint, NOT a resolve — debounce so we
|
|
3910
|
+
// don't fire `questionGone` (premature unpin) then re-emit a duplicate
|
|
3911
|
+
// question + notification next poll once it re-appears. Only resolve
|
|
3912
|
+
// after it's been absent for `questionAbsentPollThreshold` consecutive
|
|
3913
|
+
// diffs; clearing the signature only THEN (so an identical question
|
|
3914
|
+
// asked again later is still delivered, not swallowed).
|
|
3915
|
+
if (session.lastQuestionSignature) {
|
|
3916
|
+
session.questionAbsentPolls += 1;
|
|
3917
|
+
if (session.questionAbsentPolls >= questionAbsentPollThreshold) {
|
|
3918
|
+
this.emit('questionGone', key);
|
|
3919
|
+
session.lastQuestionSignature = '';
|
|
3920
|
+
session.questionAbsentPolls = 0;
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
else {
|
|
3924
|
+
session.questionAbsentPolls = 0;
|
|
3925
|
+
}
|
|
3926
|
+
// S4: AUTO-DISMISS the native Claude session-feedback survey (the
|
|
3927
|
+
// periodic bare-digit prompt — either known header wording, see
|
|
3928
|
+
// CLAUDE_SURVEY_HEADER_REGEX). It lands on the pane mid-turn,
|
|
3929
|
+
// occupies the input box and disrupts the scrape (it contributed to
|
|
3930
|
+
// the "status never appeared" hang, live 2026-06-29; an UNDETECTED
|
|
3931
|
+
// wording swallowed the next prompt's Enter and hung the topic, live
|
|
3932
|
+
// 2026-07-02). Never surface it to the topic — send a one-shot Escape
|
|
3933
|
+
// to clear it. The signature (header + option digits/labels, no
|
|
3934
|
+
// volatile chrome) is stable across the survey's per-poll repaints, so
|
|
3935
|
+
// we dismiss ONCE per appearance; cleared when it leaves the pane so a
|
|
3936
|
+
// genuinely new survey later is dismissed again. While a survey is on
|
|
3937
|
+
// the pane its header/option lines are ALSO stripped from the relay
|
|
3938
|
+
// chunk below (gated on this `survey`), so the dismissed survey's
|
|
3939
|
+
// chrome never leaks into the topic as a stray prose message.
|
|
3940
|
+
const survey = extractClaudeSurvey(content);
|
|
3941
|
+
if (survey) {
|
|
3942
|
+
if (checkShouldDismissSurvey(survey.signature, session.lastSurveySignature)) {
|
|
3943
|
+
session.lastSurveySignature = survey.signature;
|
|
3944
|
+
this.dismissClaudeSurvey(session);
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
else {
|
|
3948
|
+
session.lastSurveySignature = '';
|
|
3949
|
+
}
|
|
3950
|
+
// Long-horizon re-render dedup (live incident 2026-06-10, plan
|
|
3951
|
+
// 2026-06-10-claude-stale-rescrape-dedup): the per-poll diff above
|
|
3952
|
+
// only knows the PREVIOUS capture, so when the TUI re-renders
|
|
3953
|
+
// hours-old scrollback (full repaint / scroll-through / resize) the
|
|
3954
|
+
// redrawn lines arrive here as "new" and would be re-relayed as a
|
|
3955
|
+
// duplicate flood. Drop every line already relayed to this topic;
|
|
3956
|
+
// when nothing survives, skip the poll's emit entirely.
|
|
3957
|
+
let relayablePart = (0, recentRelayWindow_1.getRelayDedupedChunk)(session.recentRelayWindow, newPart);
|
|
3958
|
+
if (relayablePart && survey) {
|
|
3959
|
+
// S4: a survey is on the pane this poll — drop its header/option
|
|
3960
|
+
// lines so the auto-dismissed survey's chrome never leaks (it would
|
|
3961
|
+
// otherwise classify as prose). Gated on a detected `survey` so a
|
|
3962
|
+
// legitimate "1: x 2: y" list in normal output is never touched.
|
|
3963
|
+
relayablePart = stripSurveyChromeLines(relayablePart);
|
|
3964
|
+
}
|
|
3965
|
+
if (!relayablePart) {
|
|
3966
|
+
console.log(`[Claude] already-relayed re-render suppressed (${newPart.length} chars)`);
|
|
3967
|
+
}
|
|
3968
|
+
else {
|
|
3969
|
+
// S4 verbosity relay: classify the chunk (pure, cheap), then route
|
|
3970
|
+
// its tool / panel segments per the thread's `/tool_results` pref.
|
|
3971
|
+
// The classifier threads fence/block context across polls (mirrors
|
|
3972
|
+
// `session.openToolKind` for orphan bodies whose header streamed in
|
|
3973
|
+
// an earlier poll). FAST PATH (regression anchor): all-`full`
|
|
3974
|
+
// tool+thinking prefs AND no panel-preview segment → the pre-S4
|
|
3975
|
+
// direct strip→emit runs BYTE-IDENTICALLY on the original chunk.
|
|
3976
|
+
const prefs = this.getDisplayPrefs(key);
|
|
3977
|
+
const classification = (0, claudeChunkClassifier_1.classifyClaudeChunk)(relayablePart, session.chunkContext);
|
|
3978
|
+
session.chunkContext = classification.outgoingContext;
|
|
3979
|
+
const isFastPath = (0, claudeRelayRouting_1.checkIsClaudeRelayFastPath)(classification.segments, prefs.toolResults, prefs.thinking);
|
|
3980
|
+
let stripInput;
|
|
3981
|
+
let activityLine = null;
|
|
3982
|
+
if (isFastPath) {
|
|
3983
|
+
// Pre-S4 path, unchanged: feed the ORIGINAL chunk to the stripper
|
|
3984
|
+
// (it is the chrome/fence backstop). Provably byte-identical.
|
|
3985
|
+
stripInput = relayablePart;
|
|
3986
|
+
}
|
|
3987
|
+
else {
|
|
3988
|
+
// QUIET PATH: keep prose + thinking verbatim, route tool bodies
|
|
3989
|
+
// (full keep / short truncate / minimal fold) and ALWAYS fold a
|
|
3990
|
+
// sub-agent panel preview into the rolling status frame.
|
|
3991
|
+
const routed = this.tl(key, () => (0, claudeRelayRouting_1.routeClaudeChunkSegments)(classification.segments, prefs.toolResults, prefs.thinking, (toolLabel) => (0, i18n_1.t)('toolResults.activity_status', { tool: toolLabel || (0, i18n_1.t)('toolResults.activity_fallback') }), () => (0, i18n_1.t)('subagent.panel_fold_status'), () => (0, i18n_1.t)('thinking.live'), (seconds) => (0, i18n_1.t)('thinking.thoughtForSeconds', { seconds }), (0, i18n_1.t)('toolResults.truncated_footer')));
|
|
3992
|
+
stripInput = routed.keptText;
|
|
3993
|
+
activityLine = routed.activityLine;
|
|
3994
|
+
}
|
|
3995
|
+
// Thread the tool-result kind across polls: the owning `● Bash(…)`
|
|
3996
|
+
// header of a slow command's output streamed in an earlier poll (the
|
|
3997
|
+
// line-set diff drops it as a duplicate), so `session.openToolKind`
|
|
3998
|
+
// carries it forward to fence the orphan `⎿` body (B2). Tracked via
|
|
3999
|
+
// the clean deltas, not a scan of the racy live pane.
|
|
4000
|
+
const stripped = stripTuiElementsWithContext(stripInput, session.openToolKind);
|
|
4001
|
+
session.openToolKind = stripped.toolKind;
|
|
4002
|
+
const cleanedOutput = stripped.text;
|
|
4003
|
+
// B7 shape gate (the `❯` input-box draft) FIRST; then the S1
|
|
4004
|
+
// content gate for the no-`❯` echo variants (preamble / wrapped
|
|
4005
|
+
// continuation / voice-transcript tail), armed only briefly after a
|
|
4006
|
+
// send so a later real reply that repeats prompt words is never eaten.
|
|
4007
|
+
const isShapeEcho = checkIsInputEchoFrame(cleanedOutput);
|
|
4008
|
+
const isContentEcho = !isShapeEcho &&
|
|
4009
|
+
Date.now() - session.lastForwardedTextAt < claudeInputEchoContentWindowMs &&
|
|
4010
|
+
checkIsForwardedEcho(cleanedOutput, session.lastForwardedText);
|
|
4011
|
+
if (cleanedOutput && (isShapeEcho || isContentEcho)) {
|
|
4012
|
+
// The frame is just Claude echoing the user's own typed input —
|
|
4013
|
+
// relaying it reads as a ghost message in the topic.
|
|
4014
|
+
console.log(`[Claude] input-echo frame filtered${isContentEcho ? ' (content match)' : ''}`);
|
|
4015
|
+
}
|
|
4016
|
+
else if (cleanedOutput) {
|
|
4017
|
+
// S4: gated full body (see the RAW dump above for the rationale).
|
|
4018
|
+
console.log(isClaudeScrapeDebugEnabled
|
|
4019
|
+
? `[Claude] FILTERED output (${cleanedOutput.length}):\n---\n${cleanedOutput}\n---`
|
|
4020
|
+
: `[Claude] FILTERED output (${cleanedOutput.length} chars)`);
|
|
4021
|
+
if (checkIsStatusOutput(cleanedOutput)) {
|
|
4022
|
+
// S6 (c): when this same poll ALSO folded a verbosity activity
|
|
4023
|
+
// (🔧 tool / 🤖 sub-agent / ☁️ thinking), that routed line is the
|
|
4024
|
+
// INTENTIONAL minimal-mode indicator — prefer it over the
|
|
4025
|
+
// incidental transient so the rolling status reads cleanly
|
|
4026
|
+
// (the transient must not shadow the routed activity). Real
|
|
4027
|
+
// permanent output is the `else` below and still always wins.
|
|
4028
|
+
const statusText = activityLine ?? cleanedOutput;
|
|
4029
|
+
// Deduplicate spinner updates: normalize spinner character and compare
|
|
4030
|
+
const dedupKey = activityLine ?? cleanedOutput.replace(/^[✻✽✶✢·*●○⏺]\s*/gm, '');
|
|
4031
|
+
if (dedupKey !== session.lastStatusText) {
|
|
4032
|
+
session.lastStatusText = dedupKey;
|
|
4033
|
+
this.emit('status', key, statusText);
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
else {
|
|
4037
|
+
session.lastStatusText = '';
|
|
4038
|
+
// Record in the PANE-line domain (pre-strip): a future
|
|
4039
|
+
// re-render arrives as pane lines, so the window must store
|
|
4040
|
+
// the same shape the filter above queries. Permanent output
|
|
4041
|
+
// only — status frames roll/are edited in place and never
|
|
4042
|
+
// flood as separate messages.
|
|
4043
|
+
session.recentRelayWindow.record(relayablePart);
|
|
4044
|
+
// Carry the dropped leading paragraph break out-of-band (S2): the
|
|
4045
|
+
// bot rebuilds the `\n\n` separator only when APPENDING this prose
|
|
4046
|
+
// chunk to the pending buffer / live draft — never at a message
|
|
4047
|
+
// start. Out-of-band so the pipeline's trims can't strip it.
|
|
4048
|
+
this.emit('output', key, cleanedOutput, { startsNewParagraph });
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
else if (activityLine) {
|
|
4052
|
+
// Quiet path folded every routable segment into the status frame
|
|
4053
|
+
// (e.g. `minimal` tool calls / a sub-agent panel preview) — keep
|
|
4054
|
+
// the user informed with one rolling activity line, deduped like
|
|
4055
|
+
// the spinner-status path above so the frame doesn't churn.
|
|
4056
|
+
if (activityLine !== session.lastStatusText) {
|
|
4057
|
+
session.lastStatusText = activityLine;
|
|
4058
|
+
this.emit('status', key, activityLine);
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
else {
|
|
4062
|
+
console.log(`[Claude] Output filtered out completely`);
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
this.handleAutoLifecycle(session, content, newPart);
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
/**
|
|
4071
|
+
* @description Emit a settled streaming table (S1) as ONE fenced `output`.
|
|
4072
|
+
*
|
|
4073
|
+
* Routes the held block through the SAME fence path as the inline table path
|
|
4074
|
+
* ({@link stripTuiElementsWithContext} fences a sharp-corner table) so the
|
|
4075
|
+
* `<pre>` rendering is byte-identical to today — only the TIMING/dedup change.
|
|
4076
|
+
* Records the raw block in {@link RecentRelayWindow} in the PANE-line domain
|
|
4077
|
+
* (pre-strip) so a later full repaint of the same table is suppressed by the
|
|
4078
|
+
* existing re-render dedup, exactly like the normal permanent-output path.
|
|
4079
|
+
* Emitted with `startsNewParagraph: true` — a table is its own visual block,
|
|
4080
|
+
* so it must not glue onto preceding prose in the append/draft join.
|
|
4081
|
+
*/
|
|
4082
|
+
emitStabilizedTable(key, session, block) {
|
|
4083
|
+
// Root-cause dedup (flood 2026-06-16): the table block was RECORDED into the
|
|
4084
|
+
// relay window but never CHECKED before emit, and table lines are masked out
|
|
4085
|
+
// of the prose delta before `getRelayDedupedChunk` runs — so the long-horizon
|
|
4086
|
+
// re-render guard structurally never saw a re-printed/looped table. Each
|
|
4087
|
+
// re-render → one new message (a single topic flooded with ~500 byte-identical
|
|
4088
|
+
// copies). Check the window at the BLOCK level (border rows / tiny cells fall
|
|
4089
|
+
// under the per-line gate, so a per-line check would miss them) in the SAME
|
|
4090
|
+
// pre-strip pane-line domain `record` uses. An identical block → skip the
|
|
4091
|
+
// emit (suppressed exactly like a re-rendered prose chunk); a CHANGED table is
|
|
4092
|
+
// a different signature → still emits. Still record() to keep the window warm.
|
|
4093
|
+
const isAlreadyRelayed = session.recentRelayWindow.checkBlockAlreadyRelayed(block);
|
|
4094
|
+
const fenced = stripTuiElementsWithContext(block, session.openToolKind);
|
|
4095
|
+
session.openToolKind = fenced.toolKind;
|
|
4096
|
+
if (!fenced.text)
|
|
4097
|
+
return;
|
|
4098
|
+
session.recentRelayWindow.record(block);
|
|
4099
|
+
if (isAlreadyRelayed) {
|
|
4100
|
+
console.log(`[Claude] already-relayed table re-render suppressed (${block.length} chars)`);
|
|
4101
|
+
return;
|
|
4102
|
+
}
|
|
4103
|
+
session.lastStatusText = '';
|
|
4104
|
+
this.emit('output', key, fenced.text, { startsNewParagraph: true });
|
|
4105
|
+
}
|
|
4106
|
+
/**
|
|
4107
|
+
* @description Auto-handle the two lifecycle prompts Claude can show — the
|
|
4108
|
+
* "Press Enter to continue" gate and the bypass-permissions warning — by
|
|
4109
|
+
* issuing the matching keystrokes. Extracted from {@link pollOutput} so the
|
|
4110
|
+
* resume-seeding path (which suppresses conversation TEXT) can still drive
|
|
4111
|
+
* these prompts; suppression must hide chatter, not the lifecycle.
|
|
4112
|
+
*
|
|
4113
|
+
* Also the detection seam for a terminal provider error / logged-out row
|
|
4114
|
+
* ({@link getClaudeAgentErrorLine}: `API Error: …`, `⎿ … · API Error: …`, or
|
|
4115
|
+
* `⎿ … Please run /login`): when it first classifies, emit one `apiError`
|
|
4116
|
+
* event — the trigger for the auto-retry (transient / usageLimit) OR the
|
|
4117
|
+
* surfaced logged-out notice (auth). Detection scans the NEW pane delta only
|
|
4118
|
+
* (never the full pane / arbitrary conversation text), so a recognized error
|
|
4119
|
+
* row fires exactly once — when it first renders.
|
|
4120
|
+
*
|
|
4121
|
+
* A large new chunk (`newPart.length > 50`) means real conversation moved on,
|
|
4122
|
+
* so the one-shot `handled*` guards are reset to re-arm for a later prompt.
|
|
4123
|
+
*/
|
|
4124
|
+
handleAutoLifecycle(session, content, newPart) {
|
|
4125
|
+
if (newPart.length > 50) {
|
|
4126
|
+
session.handledAutoEnter = false;
|
|
4127
|
+
session.handledAutoAccept = false;
|
|
4128
|
+
session.handledApiError = false;
|
|
4129
|
+
}
|
|
4130
|
+
if (!session.handledApiError) {
|
|
4131
|
+
// Scan the NEW delta, NOT the full pane: a logged-out `⎿ … /login` (or
|
|
4132
|
+
// `API Error:`) row lingers in the scrollback long after the user re-logs
|
|
4133
|
+
// in, and the guard above re-arms on any >50-char redraw during the login
|
|
4134
|
+
// flow — scanning `content` re-fired `apiError('auth')` on every poll, so
|
|
4135
|
+
// the recovery clear (first real output) and this re-fire oscillated:
|
|
4136
|
+
// re-pinning "logged out, run /login" AFTER a successful login and burying
|
|
4137
|
+
// the real answer (live 2026-07-03). The line-SET pane diff
|
|
4138
|
+
// (getNewPaneContent) puts the row in `newPart` only on its FIRST
|
|
4139
|
+
// appearance → one fire per genuine logout episode. The resume-seed caller
|
|
4140
|
+
// passes the full pane AS newPart, so an adopted logged-out session still
|
|
4141
|
+
// surfaces once.
|
|
4142
|
+
const apiErrorLine = getClaudeAgentErrorLine(newPart);
|
|
4143
|
+
if (apiErrorLine) {
|
|
4144
|
+
const apiError = (0, apiErrorRetry_1.classifyAgentApiError)(apiErrorLine, Date.now());
|
|
4145
|
+
if (apiError) {
|
|
4146
|
+
session.handledApiError = true;
|
|
4147
|
+
console.log(`[Claude] API error detected (${apiError.kind}): ${apiErrorLine.trim()}`);
|
|
4148
|
+
this.emit('apiError', session.key, apiError);
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
if (!session.handledAutoEnter && this.checkNeedsAutoEnter(content)) {
|
|
4153
|
+
session.handledAutoEnter = true;
|
|
4154
|
+
console.log(`[Claude] Auto-pressing Enter`);
|
|
4155
|
+
session.autoEnterTimer = setTimeout(() => {
|
|
4156
|
+
session.autoEnterTimer = null;
|
|
4157
|
+
if (!session.isActive)
|
|
4158
|
+
return;
|
|
4159
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
4160
|
+
if (!session.isActive)
|
|
4161
|
+
return '';
|
|
4162
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'Enter');
|
|
4163
|
+
});
|
|
4164
|
+
}, 300);
|
|
4165
|
+
}
|
|
4166
|
+
if (!session.handledAutoAccept && this.checkNeedsAutoAccept(content)) {
|
|
4167
|
+
session.handledAutoAccept = true;
|
|
4168
|
+
console.log(`[Claude] Auto-accepting bypass permissions`);
|
|
4169
|
+
session.autoAcceptOuterTimer = setTimeout(() => {
|
|
4170
|
+
session.autoAcceptOuterTimer = null;
|
|
4171
|
+
if (!session.isActive)
|
|
4172
|
+
return;
|
|
4173
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
4174
|
+
if (!session.isActive)
|
|
4175
|
+
return '';
|
|
4176
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'Down');
|
|
4177
|
+
});
|
|
4178
|
+
session.autoAcceptInnerTimer = setTimeout(() => {
|
|
4179
|
+
session.autoAcceptInnerTimer = null;
|
|
4180
|
+
if (!session.isActive)
|
|
4181
|
+
return;
|
|
4182
|
+
this.enqueueTmuxBestEffort(session, async () => {
|
|
4183
|
+
if (!session.isActive)
|
|
4184
|
+
return '';
|
|
4185
|
+
return (0, tmuxExec_1.tmuxAsync)('send-keys', '-t', (0, tmuxExec_1.getTmuxPaneTarget)(session.sessionName), 'Enter');
|
|
4186
|
+
});
|
|
4187
|
+
}, 100);
|
|
4188
|
+
}, 300);
|
|
4189
|
+
}
|
|
4190
|
+
}
|
|
4191
|
+
checkNeedsAutoEnter(content) {
|
|
4192
|
+
return CLAUDE_AUTO_ENTER_PATTERNS.some(pattern => pattern.test(content));
|
|
4193
|
+
}
|
|
4194
|
+
checkNeedsAutoAccept(content) {
|
|
4195
|
+
const hasWarning = CLAUDE_BYPASS_WARNING_RE.test(content);
|
|
4196
|
+
const hasAccept = CLAUDE_BYPASS_ACCEPT_RE.test(content);
|
|
4197
|
+
if (hasWarning || hasAccept) {
|
|
4198
|
+
console.log(`[Claude] checkNeedsAutoAccept: warning=${hasWarning}, accept=${hasAccept}`);
|
|
4199
|
+
}
|
|
4200
|
+
return hasWarning && hasAccept;
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4203
|
+
exports.ClaudeCliAdapter = ClaudeCliAdapter;
|
|
4204
|
+
//# sourceMappingURL=claudeCliAdapter.js.map
|