@trenchwork/coder 1.3.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/LICENSE +16 -0
- package/README.md +173 -0
- package/agents/trenchwork-code.rules.json +199 -0
- package/dist/bin/deepseek.d.ts +3 -0
- package/dist/bin/deepseek.d.ts.map +1 -0
- package/dist/bin/deepseek.js +23 -0
- package/dist/bin/deepseek.js.map +1 -0
- package/dist/capabilities/baseCapability.d.ts +72 -0
- package/dist/capabilities/baseCapability.d.ts.map +1 -0
- package/dist/capabilities/baseCapability.js +183 -0
- package/dist/capabilities/baseCapability.js.map +1 -0
- package/dist/capabilities/bashCapability.d.ts +13 -0
- package/dist/capabilities/bashCapability.d.ts.map +1 -0
- package/dist/capabilities/bashCapability.js +24 -0
- package/dist/capabilities/bashCapability.js.map +1 -0
- package/dist/capabilities/editCapability.d.ts +17 -0
- package/dist/capabilities/editCapability.d.ts.map +1 -0
- package/dist/capabilities/editCapability.js +27 -0
- package/dist/capabilities/editCapability.js.map +1 -0
- package/dist/capabilities/enhancedGitCapability.d.ts +7 -0
- package/dist/capabilities/enhancedGitCapability.d.ts.map +1 -0
- package/dist/capabilities/enhancedGitCapability.js +220 -0
- package/dist/capabilities/enhancedGitCapability.js.map +1 -0
- package/dist/capabilities/filesystemCapability.d.ts +13 -0
- package/dist/capabilities/filesystemCapability.d.ts.map +1 -0
- package/dist/capabilities/filesystemCapability.js +24 -0
- package/dist/capabilities/filesystemCapability.js.map +1 -0
- package/dist/capabilities/gitHistoryCapability.d.ts +6 -0
- package/dist/capabilities/gitHistoryCapability.d.ts.map +1 -0
- package/dist/capabilities/gitHistoryCapability.js +184 -0
- package/dist/capabilities/gitHistoryCapability.js.map +1 -0
- package/dist/capabilities/hitlCapability.d.ts +18 -0
- package/dist/capabilities/hitlCapability.d.ts.map +1 -0
- package/dist/capabilities/hitlCapability.js +29 -0
- package/dist/capabilities/hitlCapability.js.map +1 -0
- package/dist/capabilities/index.d.ts +11 -0
- package/dist/capabilities/index.d.ts.map +1 -0
- package/dist/capabilities/index.js +16 -0
- package/dist/capabilities/index.js.map +1 -0
- package/dist/capabilities/memoryCapability.d.ts +10 -0
- package/dist/capabilities/memoryCapability.d.ts.map +1 -0
- package/dist/capabilities/memoryCapability.js +22 -0
- package/dist/capabilities/memoryCapability.js.map +1 -0
- package/dist/capabilities/notebookCapability.d.ts +6 -0
- package/dist/capabilities/notebookCapability.d.ts.map +1 -0
- package/dist/capabilities/notebookCapability.js +17 -0
- package/dist/capabilities/notebookCapability.js.map +1 -0
- package/dist/capabilities/searchCapability.d.ts +19 -0
- package/dist/capabilities/searchCapability.d.ts.map +1 -0
- package/dist/capabilities/searchCapability.js +29 -0
- package/dist/capabilities/searchCapability.js.map +1 -0
- package/dist/capabilities/skillCapability.d.ts +6 -0
- package/dist/capabilities/skillCapability.d.ts.map +1 -0
- package/dist/capabilities/skillCapability.js +17 -0
- package/dist/capabilities/skillCapability.js.map +1 -0
- package/dist/capabilities/todoCapability.d.ts +11 -0
- package/dist/capabilities/todoCapability.d.ts.map +1 -0
- package/dist/capabilities/todoCapability.js +22 -0
- package/dist/capabilities/todoCapability.js.map +1 -0
- package/dist/capabilities/toolManifest.d.ts +3 -0
- package/dist/capabilities/toolManifest.d.ts.map +1 -0
- package/dist/capabilities/toolManifest.js +163 -0
- package/dist/capabilities/toolManifest.js.map +1 -0
- package/dist/capabilities/toolRegistry.d.ts +25 -0
- package/dist/capabilities/toolRegistry.d.ts.map +1 -0
- package/dist/capabilities/toolRegistry.js +150 -0
- package/dist/capabilities/toolRegistry.js.map +1 -0
- package/dist/capabilities/unifiedCodingCapability.d.ts +62 -0
- package/dist/capabilities/unifiedCodingCapability.d.ts.map +1 -0
- package/dist/capabilities/unifiedCodingCapability.js +790 -0
- package/dist/capabilities/unifiedCodingCapability.js.map +1 -0
- package/dist/capabilities/webCapability.d.ts +23 -0
- package/dist/capabilities/webCapability.d.ts.map +1 -0
- package/dist/capabilities/webCapability.js +33 -0
- package/dist/capabilities/webCapability.js.map +1 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +181 -0
- package/dist/config.js.map +1 -0
- package/dist/contracts/agent-profiles.schema.json +25 -0
- package/dist/contracts/agent-schemas.json +158 -0
- package/dist/contracts/models.schema.json +9 -0
- package/dist/contracts/module-schema.json +367 -0
- package/dist/contracts/schemas/agent-profile.schema.json +157 -0
- package/dist/contracts/schemas/agent-rules.schema.json +238 -0
- package/dist/contracts/schemas/agent-schemas.schema.json +528 -0
- package/dist/contracts/schemas/agent.schema.json +90 -0
- package/dist/contracts/schemas/tool-selection.schema.json +174 -0
- package/dist/contracts/tools.schema.json +42 -0
- package/dist/contracts/unified-schema.json +40 -0
- package/dist/contracts/v1/agent.d.ts +225 -0
- package/dist/contracts/v1/agent.d.ts.map +1 -0
- package/dist/contracts/v1/agent.js +8 -0
- package/dist/contracts/v1/agent.js.map +1 -0
- package/dist/contracts/v1/agentProfileManifest.d.ts +60 -0
- package/dist/contracts/v1/agentProfileManifest.d.ts.map +1 -0
- package/dist/contracts/v1/agentProfileManifest.js +9 -0
- package/dist/contracts/v1/agentProfileManifest.js.map +1 -0
- package/dist/contracts/v1/agentRules.d.ts +60 -0
- package/dist/contracts/v1/agentRules.d.ts.map +1 -0
- package/dist/contracts/v1/agentRules.js +10 -0
- package/dist/contracts/v1/agentRules.js.map +1 -0
- package/dist/contracts/v1/provider.d.ts +149 -0
- package/dist/contracts/v1/provider.d.ts.map +1 -0
- package/dist/contracts/v1/provider.js +7 -0
- package/dist/contracts/v1/provider.js.map +1 -0
- package/dist/contracts/v1/tool.d.ts +136 -0
- package/dist/contracts/v1/tool.d.ts.map +1 -0
- package/dist/contracts/v1/tool.js +7 -0
- package/dist/contracts/v1/tool.js.map +1 -0
- package/dist/contracts/v1/toolAccess.d.ts +43 -0
- package/dist/contracts/v1/toolAccess.d.ts.map +1 -0
- package/dist/contracts/v1/toolAccess.js +9 -0
- package/dist/contracts/v1/toolAccess.js.map +1 -0
- package/dist/core/adversarial.d.ts +38 -0
- package/dist/core/adversarial.d.ts.map +1 -0
- package/dist/core/adversarial.js +106 -0
- package/dist/core/adversarial.js.map +1 -0
- package/dist/core/adversarialCorrection.d.ts +22 -0
- package/dist/core/adversarialCorrection.d.ts.map +1 -0
- package/dist/core/adversarialCorrection.js +25 -0
- package/dist/core/adversarialCorrection.js.map +1 -0
- package/dist/core/agent.d.ts +331 -0
- package/dist/core/agent.d.ts.map +1 -0
- package/dist/core/agent.js +1637 -0
- package/dist/core/agent.js.map +1 -0
- package/dist/core/agentProfileManifest.d.ts +3 -0
- package/dist/core/agentProfileManifest.d.ts.map +1 -0
- package/dist/core/agentProfileManifest.js +188 -0
- package/dist/core/agentProfileManifest.js.map +1 -0
- package/dist/core/agentProfiles.d.ts +22 -0
- package/dist/core/agentProfiles.d.ts.map +1 -0
- package/dist/core/agentProfiles.js +35 -0
- package/dist/core/agentProfiles.js.map +1 -0
- package/dist/core/agentRegistry.d.ts +111 -0
- package/dist/core/agentRegistry.d.ts.map +1 -0
- package/dist/core/agentRegistry.js +229 -0
- package/dist/core/agentRegistry.js.map +1 -0
- package/dist/core/agentRulebook.d.ts +11 -0
- package/dist/core/agentRulebook.d.ts.map +1 -0
- package/dist/core/agentRulebook.js +136 -0
- package/dist/core/agentRulebook.js.map +1 -0
- package/dist/core/agentSchemaLoader.d.ts +131 -0
- package/dist/core/agentSchemaLoader.d.ts.map +1 -0
- package/dist/core/agentSchemaLoader.js +235 -0
- package/dist/core/agentSchemaLoader.js.map +1 -0
- package/dist/core/aiErrorFixer.d.ts +57 -0
- package/dist/core/aiErrorFixer.d.ts.map +1 -0
- package/dist/core/aiErrorFixer.js +214 -0
- package/dist/core/aiErrorFixer.js.map +1 -0
- package/dist/core/bashCommandGuidance.d.ts +16 -0
- package/dist/core/bashCommandGuidance.d.ts.map +1 -0
- package/dist/core/bashCommandGuidance.js +40 -0
- package/dist/core/bashCommandGuidance.js.map +1 -0
- package/dist/core/compactionNote.d.ts +13 -0
- package/dist/core/compactionNote.d.ts.map +1 -0
- package/dist/core/compactionNote.js +13 -0
- package/dist/core/compactionNote.js.map +1 -0
- package/dist/core/constants.d.ts +31 -0
- package/dist/core/constants.d.ts.map +1 -0
- package/dist/core/constants.js +62 -0
- package/dist/core/constants.js.map +1 -0
- package/dist/core/contextManager.d.ts +271 -0
- package/dist/core/contextManager.d.ts.map +1 -0
- package/dist/core/contextManager.js +1076 -0
- package/dist/core/contextManager.js.map +1 -0
- package/dist/core/contextUsage.d.ts +28 -0
- package/dist/core/contextUsage.d.ts.map +1 -0
- package/dist/core/contextUsage.js +62 -0
- package/dist/core/contextUsage.js.map +1 -0
- package/dist/core/contextWindow.d.ts +42 -0
- package/dist/core/contextWindow.d.ts.map +1 -0
- package/dist/core/contextWindow.js +127 -0
- package/dist/core/contextWindow.js.map +1 -0
- package/dist/core/customCommands.d.ts +19 -0
- package/dist/core/customCommands.d.ts.map +1 -0
- package/dist/core/customCommands.js +85 -0
- package/dist/core/customCommands.js.map +1 -0
- package/dist/core/diffPanel.d.ts +30 -0
- package/dist/core/diffPanel.d.ts.map +1 -0
- package/dist/core/diffPanel.js +48 -0
- package/dist/core/diffPanel.js.map +1 -0
- package/dist/core/errorClassification.d.ts +44 -0
- package/dist/core/errorClassification.d.ts.map +1 -0
- package/dist/core/errorClassification.js +333 -0
- package/dist/core/errorClassification.js.map +1 -0
- package/dist/core/errors/apiKeyErrors.d.ts +11 -0
- package/dist/core/errors/apiKeyErrors.d.ts.map +1 -0
- package/dist/core/errors/apiKeyErrors.js +159 -0
- package/dist/core/errors/apiKeyErrors.js.map +1 -0
- package/dist/core/errors/errorTypes.d.ts +111 -0
- package/dist/core/errors/errorTypes.d.ts.map +1 -0
- package/dist/core/errors/errorTypes.js +345 -0
- package/dist/core/errors/errorTypes.js.map +1 -0
- package/dist/core/errors/index.d.ts +50 -0
- package/dist/core/errors/index.d.ts.map +1 -0
- package/dist/core/errors/index.js +156 -0
- package/dist/core/errors/index.js.map +1 -0
- package/dist/core/errors/networkErrors.d.ts +14 -0
- package/dist/core/errors/networkErrors.d.ts.map +1 -0
- package/dist/core/errors/networkErrors.js +53 -0
- package/dist/core/errors/networkErrors.js.map +1 -0
- package/dist/core/errors/safetyValidator.d.ts +109 -0
- package/dist/core/errors/safetyValidator.d.ts.map +1 -0
- package/dist/core/errors/safetyValidator.js +272 -0
- package/dist/core/errors/safetyValidator.js.map +1 -0
- package/dist/core/errors.d.ts +4 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +33 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/failureRegistry.d.ts +30 -0
- package/dist/core/failureRegistry.d.ts.map +1 -0
- package/dist/core/failureRegistry.js +74 -0
- package/dist/core/failureRegistry.js.map +1 -0
- package/dist/core/fileMentions.d.ts +40 -0
- package/dist/core/fileMentions.d.ts.map +1 -0
- package/dist/core/fileMentions.js +136 -0
- package/dist/core/fileMentions.js.map +1 -0
- package/dist/core/finalResponseFormatter.d.ts +10 -0
- package/dist/core/finalResponseFormatter.d.ts.map +1 -0
- package/dist/core/finalResponseFormatter.js +14 -0
- package/dist/core/finalResponseFormatter.js.map +1 -0
- package/dist/core/guardrails.d.ts +146 -0
- package/dist/core/guardrails.d.ts.map +1 -0
- package/dist/core/guardrails.js +361 -0
- package/dist/core/guardrails.js.map +1 -0
- package/dist/core/hitl.d.ts +119 -0
- package/dist/core/hitl.d.ts.map +1 -0
- package/dist/core/hitl.js +396 -0
- package/dist/core/hitl.js.map +1 -0
- package/dist/core/hooks.d.ts +95 -0
- package/dist/core/hooks.d.ts.map +1 -0
- package/dist/core/hooks.js +236 -0
- package/dist/core/hooks.js.map +1 -0
- package/dist/core/hostedAuth.d.ts +88 -0
- package/dist/core/hostedAuth.d.ts.map +1 -0
- package/dist/core/hostedAuth.js +219 -0
- package/dist/core/hostedAuth.js.map +1 -0
- package/dist/core/inputProtection.d.ts +122 -0
- package/dist/core/inputProtection.d.ts.map +1 -0
- package/dist/core/inputProtection.js +422 -0
- package/dist/core/inputProtection.js.map +1 -0
- package/dist/core/modelDiscovery.d.ts +102 -0
- package/dist/core/modelDiscovery.d.ts.map +1 -0
- package/dist/core/modelDiscovery.js +416 -0
- package/dist/core/modelDiscovery.js.map +1 -0
- package/dist/core/multilinePasteHandler.d.ts +35 -0
- package/dist/core/multilinePasteHandler.d.ts.map +1 -0
- package/dist/core/multilinePasteHandler.js +81 -0
- package/dist/core/multilinePasteHandler.js.map +1 -0
- package/dist/core/permissionMode.d.ts +40 -0
- package/dist/core/permissionMode.d.ts.map +1 -0
- package/dist/core/permissionMode.js +86 -0
- package/dist/core/permissionMode.js.map +1 -0
- package/dist/core/postWriteDiagnostics.d.ts +32 -0
- package/dist/core/postWriteDiagnostics.d.ts.map +1 -0
- package/dist/core/postWriteDiagnostics.js +127 -0
- package/dist/core/postWriteDiagnostics.js.map +1 -0
- package/dist/core/preferences.d.ts +66 -0
- package/dist/core/preferences.d.ts.map +1 -0
- package/dist/core/preferences.js +310 -0
- package/dist/core/preferences.js.map +1 -0
- package/dist/core/quota.d.ts +61 -0
- package/dist/core/quota.d.ts.map +1 -0
- package/dist/core/quota.js +104 -0
- package/dist/core/quota.js.map +1 -0
- package/dist/core/quotaErrors.d.ts +42 -0
- package/dist/core/quotaErrors.d.ts.map +1 -0
- package/dist/core/quotaErrors.js +86 -0
- package/dist/core/quotaErrors.js.map +1 -0
- package/dist/core/refusalDetection.d.ts +2 -0
- package/dist/core/refusalDetection.d.ts.map +1 -0
- package/dist/core/refusalDetection.js +51 -0
- package/dist/core/refusalDetection.js.map +1 -0
- package/dist/core/relativeTime.d.ts +8 -0
- package/dist/core/relativeTime.d.ts.map +1 -0
- package/dist/core/relativeTime.js +29 -0
- package/dist/core/relativeTime.js.map +1 -0
- package/dist/core/resultVerification.d.ts +48 -0
- package/dist/core/resultVerification.d.ts.map +1 -0
- package/dist/core/resultVerification.js +127 -0
- package/dist/core/resultVerification.js.map +1 -0
- package/dist/core/rewind.d.ts +14 -0
- package/dist/core/rewind.d.ts.map +1 -0
- package/dist/core/rewind.js +25 -0
- package/dist/core/rewind.js.map +1 -0
- package/dist/core/schemaValidator.d.ts +49 -0
- package/dist/core/schemaValidator.d.ts.map +1 -0
- package/dist/core/schemaValidator.js +234 -0
- package/dist/core/schemaValidator.js.map +1 -0
- package/dist/core/secretStore.d.ts +59 -0
- package/dist/core/secretStore.d.ts.map +1 -0
- package/dist/core/secretStore.js +278 -0
- package/dist/core/secretStore.js.map +1 -0
- package/dist/core/sessionStorage.d.ts +10 -0
- package/dist/core/sessionStorage.d.ts.map +1 -0
- package/dist/core/sessionStorage.js +46 -0
- package/dist/core/sessionStorage.js.map +1 -0
- package/dist/core/sessionStore.d.ts +35 -0
- package/dist/core/sessionStore.d.ts.map +1 -0
- package/dist/core/sessionStore.js +190 -0
- package/dist/core/sessionStore.js.map +1 -0
- package/dist/core/shutdown.d.ts +34 -0
- package/dist/core/shutdown.d.ts.map +1 -0
- package/dist/core/shutdown.js +186 -0
- package/dist/core/shutdown.js.map +1 -0
- package/dist/core/slashCommands.d.ts +38 -0
- package/dist/core/slashCommands.d.ts.map +1 -0
- package/dist/core/slashCommands.js +72 -0
- package/dist/core/slashCommands.js.map +1 -0
- package/dist/core/subAgentNote.d.ts +15 -0
- package/dist/core/subAgentNote.d.ts.map +1 -0
- package/dist/core/subAgentNote.js +16 -0
- package/dist/core/subAgentNote.js.map +1 -0
- package/dist/core/sudoPasswordManager.d.ts +52 -0
- package/dist/core/sudoPasswordManager.d.ts.map +1 -0
- package/dist/core/sudoPasswordManager.js +115 -0
- package/dist/core/sudoPasswordManager.js.map +1 -0
- package/dist/core/taskCompletionDetector.d.ts +117 -0
- package/dist/core/taskCompletionDetector.d.ts.map +1 -0
- package/dist/core/taskCompletionDetector.js +532 -0
- package/dist/core/taskCompletionDetector.js.map +1 -0
- package/dist/core/testFailureMonitor.d.ts +67 -0
- package/dist/core/testFailureMonitor.d.ts.map +1 -0
- package/dist/core/testFailureMonitor.js +262 -0
- package/dist/core/testFailureMonitor.js.map +1 -0
- package/dist/core/thinkingVerbs.d.ts +31 -0
- package/dist/core/thinkingVerbs.d.ts.map +1 -0
- package/dist/core/thinkingVerbs.js +58 -0
- package/dist/core/thinkingVerbs.js.map +1 -0
- package/dist/core/toolPreconditions.d.ts +34 -0
- package/dist/core/toolPreconditions.d.ts.map +1 -0
- package/dist/core/toolPreconditions.js +242 -0
- package/dist/core/toolPreconditions.js.map +1 -0
- package/dist/core/toolRuntime.d.ts +193 -0
- package/dist/core/toolRuntime.d.ts.map +1 -0
- package/dist/core/toolRuntime.js +526 -0
- package/dist/core/toolRuntime.js.map +1 -0
- package/dist/core/turnGovernor.d.ts +63 -0
- package/dist/core/turnGovernor.d.ts.map +1 -0
- package/dist/core/turnGovernor.js +94 -0
- package/dist/core/turnGovernor.js.map +1 -0
- package/dist/core/types/utilityTypes.d.ts +183 -0
- package/dist/core/types/utilityTypes.d.ts.map +1 -0
- package/dist/core/types/utilityTypes.js +273 -0
- package/dist/core/types/utilityTypes.js.map +1 -0
- package/dist/core/types.d.ts +334 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +76 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/updateChecker.d.ts +148 -0
- package/dist/core/updateChecker.d.ts.map +1 -0
- package/dist/core/updateChecker.js +605 -0
- package/dist/core/updateChecker.js.map +1 -0
- package/dist/core/usage.d.ts +28 -0
- package/dist/core/usage.d.ts.map +1 -0
- package/dist/core/usage.js +77 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/headless/interactiveShell.d.ts +47 -0
- package/dist/headless/interactiveShell.d.ts.map +1 -0
- package/dist/headless/interactiveShell.js +2495 -0
- package/dist/headless/interactiveShell.js.map +1 -0
- package/dist/leanAgent.d.ts +73 -0
- package/dist/leanAgent.d.ts.map +1 -0
- package/dist/leanAgent.js +177 -0
- package/dist/leanAgent.js.map +1 -0
- package/dist/plugins/providers/deepseek/index.d.ts +12 -0
- package/dist/plugins/providers/deepseek/index.d.ts.map +1 -0
- package/dist/plugins/providers/deepseek/index.js +123 -0
- package/dist/plugins/providers/deepseek/index.js.map +1 -0
- package/dist/plugins/providers/index.d.ts +2 -0
- package/dist/plugins/providers/index.d.ts.map +1 -0
- package/dist/plugins/providers/index.js +10 -0
- package/dist/plugins/providers/index.js.map +1 -0
- package/dist/providers/baseProvider.d.ts +140 -0
- package/dist/providers/baseProvider.d.ts.map +1 -0
- package/dist/providers/baseProvider.js +230 -0
- package/dist/providers/baseProvider.js.map +1 -0
- package/dist/providers/openaiChatCompletionsProvider.d.ts +70 -0
- package/dist/providers/openaiChatCompletionsProvider.d.ts.map +1 -0
- package/dist/providers/openaiChatCompletionsProvider.js +971 -0
- package/dist/providers/openaiChatCompletionsProvider.js.map +1 -0
- package/dist/providers/providerFactory.d.ts +22 -0
- package/dist/providers/providerFactory.d.ts.map +1 -0
- package/dist/providers/providerFactory.js +25 -0
- package/dist/providers/providerFactory.js.map +1 -0
- package/dist/providers/resilientProvider.d.ts +96 -0
- package/dist/providers/resilientProvider.d.ts.map +1 -0
- package/dist/providers/resilientProvider.js +251 -0
- package/dist/providers/resilientProvider.js.map +1 -0
- package/dist/runtime/agentController.d.ts +137 -0
- package/dist/runtime/agentController.d.ts.map +1 -0
- package/dist/runtime/agentController.js +784 -0
- package/dist/runtime/agentController.js.map +1 -0
- package/dist/runtime/agentHost.d.ts +61 -0
- package/dist/runtime/agentHost.d.ts.map +1 -0
- package/dist/runtime/agentHost.js +158 -0
- package/dist/runtime/agentHost.js.map +1 -0
- package/dist/runtime/agentSession.d.ts +49 -0
- package/dist/runtime/agentSession.d.ts.map +1 -0
- package/dist/runtime/agentSession.js +218 -0
- package/dist/runtime/agentSession.js.map +1 -0
- package/dist/runtime/agentSpawningWiring.d.ts +32 -0
- package/dist/runtime/agentSpawningWiring.d.ts.map +1 -0
- package/dist/runtime/agentSpawningWiring.js +114 -0
- package/dist/runtime/agentSpawningWiring.js.map +1 -0
- package/dist/runtime/node.d.ts +7 -0
- package/dist/runtime/node.d.ts.map +1 -0
- package/dist/runtime/node.js +50 -0
- package/dist/runtime/node.js.map +1 -0
- package/dist/runtime/universal.d.ts +18 -0
- package/dist/runtime/universal.d.ts.map +1 -0
- package/dist/runtime/universal.js +21 -0
- package/dist/runtime/universal.js.map +1 -0
- package/dist/shell/liveStatus.d.ts +27 -0
- package/dist/shell/liveStatus.d.ts.map +1 -0
- package/dist/shell/liveStatus.js +53 -0
- package/dist/shell/liveStatus.js.map +1 -0
- package/dist/shell/systemPrompt.d.ts +12 -0
- package/dist/shell/systemPrompt.d.ts.map +1 -0
- package/dist/shell/systemPrompt.js +16 -0
- package/dist/shell/systemPrompt.js.map +1 -0
- package/dist/shell/toolPresentation.d.ts +54 -0
- package/dist/shell/toolPresentation.d.ts.map +1 -0
- package/dist/shell/toolPresentation.js +334 -0
- package/dist/shell/toolPresentation.js.map +1 -0
- package/dist/tools/bashTools.d.ts +11 -0
- package/dist/tools/bashTools.d.ts.map +1 -0
- package/dist/tools/bashTools.js +785 -0
- package/dist/tools/bashTools.js.map +1 -0
- package/dist/tools/diffUtils.d.ts +43 -0
- package/dist/tools/diffUtils.d.ts.map +1 -0
- package/dist/tools/diffUtils.js +607 -0
- package/dist/tools/diffUtils.js.map +1 -0
- package/dist/tools/editTools.d.ts +29 -0
- package/dist/tools/editTools.d.ts.map +1 -0
- package/dist/tools/editTools.js +792 -0
- package/dist/tools/editTools.js.map +1 -0
- package/dist/tools/fileChangeTracker.d.ts +47 -0
- package/dist/tools/fileChangeTracker.d.ts.map +1 -0
- package/dist/tools/fileChangeTracker.js +154 -0
- package/dist/tools/fileChangeTracker.js.map +1 -0
- package/dist/tools/fileReadTracker.d.ts +69 -0
- package/dist/tools/fileReadTracker.d.ts.map +1 -0
- package/dist/tools/fileReadTracker.js +213 -0
- package/dist/tools/fileReadTracker.js.map +1 -0
- package/dist/tools/fileTools.d.ts +3 -0
- package/dist/tools/fileTools.d.ts.map +1 -0
- package/dist/tools/fileTools.js +389 -0
- package/dist/tools/fileTools.js.map +1 -0
- package/dist/tools/grepTools.d.ts +3 -0
- package/dist/tools/grepTools.d.ts.map +1 -0
- package/dist/tools/grepTools.js +137 -0
- package/dist/tools/grepTools.js.map +1 -0
- package/dist/tools/hitlTools.d.ts +7 -0
- package/dist/tools/hitlTools.d.ts.map +1 -0
- package/dist/tools/hitlTools.js +185 -0
- package/dist/tools/hitlTools.js.map +1 -0
- package/dist/tools/memoryTools.d.ts +27 -0
- package/dist/tools/memoryTools.d.ts.map +1 -0
- package/dist/tools/memoryTools.js +197 -0
- package/dist/tools/memoryTools.js.map +1 -0
- package/dist/tools/notebookTools.d.ts +20 -0
- package/dist/tools/notebookTools.d.ts.map +1 -0
- package/dist/tools/notebookTools.js +140 -0
- package/dist/tools/notebookTools.js.map +1 -0
- package/dist/tools/searchTools.d.ts +12 -0
- package/dist/tools/searchTools.d.ts.map +1 -0
- package/dist/tools/searchTools.js +414 -0
- package/dist/tools/searchTools.js.map +1 -0
- package/dist/tools/skillTools.d.ts +24 -0
- package/dist/tools/skillTools.d.ts.map +1 -0
- package/dist/tools/skillTools.js +140 -0
- package/dist/tools/skillTools.js.map +1 -0
- package/dist/tools/todoTools.d.ts +23 -0
- package/dist/tools/todoTools.d.ts.map +1 -0
- package/dist/tools/todoTools.js +120 -0
- package/dist/tools/todoTools.js.map +1 -0
- package/dist/tools/webTools.d.ts +26 -0
- package/dist/tools/webTools.d.ts.map +1 -0
- package/dist/tools/webTools.js +467 -0
- package/dist/tools/webTools.js.map +1 -0
- package/dist/ui/ink/App.d.ts +53 -0
- package/dist/ui/ink/App.d.ts.map +1 -0
- package/dist/ui/ink/App.js +13 -0
- package/dist/ui/ink/App.js.map +1 -0
- package/dist/ui/ink/ChatStatic.d.ts +30 -0
- package/dist/ui/ink/ChatStatic.d.ts.map +1 -0
- package/dist/ui/ink/ChatStatic.js +83 -0
- package/dist/ui/ink/ChatStatic.js.map +1 -0
- package/dist/ui/ink/InkPromptController.d.ts +321 -0
- package/dist/ui/ink/InkPromptController.d.ts.map +1 -0
- package/dist/ui/ink/InkPromptController.js +667 -0
- package/dist/ui/ink/InkPromptController.js.map +1 -0
- package/dist/ui/ink/Menu.d.ts +21 -0
- package/dist/ui/ink/Menu.d.ts.map +1 -0
- package/dist/ui/ink/Menu.js +61 -0
- package/dist/ui/ink/Menu.js.map +1 -0
- package/dist/ui/ink/Prompt.d.ts +47 -0
- package/dist/ui/ink/Prompt.d.ts.map +1 -0
- package/dist/ui/ink/Prompt.js +571 -0
- package/dist/ui/ink/Prompt.js.map +1 -0
- package/dist/ui/ink/StatusLine.d.ts +35 -0
- package/dist/ui/ink/StatusLine.d.ts.map +1 -0
- package/dist/ui/ink/StatusLine.js +66 -0
- package/dist/ui/ink/StatusLine.js.map +1 -0
- package/dist/ui/ink/pasteBuffer.d.ts +44 -0
- package/dist/ui/ink/pasteBuffer.d.ts.map +1 -0
- package/dist/ui/ink/pasteBuffer.js +73 -0
- package/dist/ui/ink/pasteBuffer.js.map +1 -0
- package/dist/ui/theme.d.ts +351 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +435 -0
- package/dist/ui/theme.js.map +1 -0
- package/dist/utils/analytics.d.ts +2 -0
- package/dist/utils/analytics.d.ts.map +1 -0
- package/dist/utils/analytics.js +51 -0
- package/dist/utils/analytics.js.map +1 -0
- package/dist/utils/asyncUtils.d.ts +95 -0
- package/dist/utils/asyncUtils.d.ts.map +1 -0
- package/dist/utils/asyncUtils.js +286 -0
- package/dist/utils/asyncUtils.js.map +1 -0
- package/dist/utils/debugLogger.d.ts +6 -0
- package/dist/utils/debugLogger.d.ts.map +1 -0
- package/dist/utils/debugLogger.js +39 -0
- package/dist/utils/debugLogger.js.map +1 -0
- package/dist/utils/errorUtils.d.ts +12 -0
- package/dist/utils/errorUtils.d.ts.map +1 -0
- package/dist/utils/errorUtils.js +83 -0
- package/dist/utils/errorUtils.js.map +1 -0
- package/dist/utils/frontmatter.d.ts +10 -0
- package/dist/utils/frontmatter.d.ts.map +1 -0
- package/dist/utils/frontmatter.js +78 -0
- package/dist/utils/frontmatter.js.map +1 -0
- package/dist/utils/packageInfo.d.ts +14 -0
- package/dist/utils/packageInfo.d.ts.map +1 -0
- package/dist/utils/packageInfo.js +45 -0
- package/dist/utils/packageInfo.js.map +1 -0
- package/dist/utils/planFormatter.d.ts +34 -0
- package/dist/utils/planFormatter.d.ts.map +1 -0
- package/dist/utils/planFormatter.js +141 -0
- package/dist/utils/planFormatter.js.map +1 -0
- package/dist/utils/securityUtils.d.ts +132 -0
- package/dist/utils/securityUtils.d.ts.map +1 -0
- package/dist/utils/securityUtils.js +324 -0
- package/dist/utils/securityUtils.js.map +1 -0
- package/dist/utils/statusReporter.d.ts +6 -0
- package/dist/utils/statusReporter.d.ts.map +1 -0
- package/dist/utils/statusReporter.js +26 -0
- package/dist/utils/statusReporter.js.map +1 -0
- package/dist/workspace.d.ts +8 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +135 -0
- package/dist/workspace.js.map +1 -0
- package/dist/workspace.validator.d.ts +49 -0
- package/dist/workspace.validator.d.ts.map +1 -0
- package/dist/workspace.validator.js +215 -0
- package/dist/workspace.validator.js.map +1 -0
- package/package.json +116 -0
- package/scripts/postinstall.cjs +56 -0
|
@@ -0,0 +1,2495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Shell - Full interactive CLI experience with rich UI.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* agi # Start interactive shell
|
|
6
|
+
* agi "initial prompt" # Start with initial prompt
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Rich terminal UI with status bar
|
|
10
|
+
* - Command history
|
|
11
|
+
* - Streaming responses
|
|
12
|
+
* - Tool execution display
|
|
13
|
+
* - Ctrl+C to interrupt
|
|
14
|
+
*/
|
|
15
|
+
import { stdin, stdout, exit } from 'node:process';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { resolve, dirname, relative } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { exec as childExec } from 'node:child_process';
|
|
20
|
+
import { promisify } from 'node:util';
|
|
21
|
+
import chalk from 'chalk';
|
|
22
|
+
import { getHITL, hitlEvents } from '../core/hitl.js';
|
|
23
|
+
// Connector imports removed — CLI is local-only, no GitHub gate.
|
|
24
|
+
// Stub functions (antiTermination removed)
|
|
25
|
+
const initializeProtection = (_config) => { };
|
|
26
|
+
const enterCriticalSection = (_name) => { };
|
|
27
|
+
const exitCriticalSection = (_name) => { };
|
|
28
|
+
// Import real shutdown handler for reliable Ctrl+C handling
|
|
29
|
+
import { authorizedShutdown, installSignalHandlers, onShutdown } from '../core/shutdown.js';
|
|
30
|
+
import { resolveProfileConfig } from '../config.js';
|
|
31
|
+
import { createAgentController } from '../runtime/agentController.js';
|
|
32
|
+
import { expandFileMentions, listWorkspaceFiles } from '../core/fileMentions.js';
|
|
33
|
+
import { resolveWorkspaceCaptureOptions, buildWorkspaceContext } from '../workspace.js';
|
|
34
|
+
import { loadAllSecrets, listSecretDefinitions, setSecretValue, getSecretValue, getSecretDefinition, classifyKeyEntry } from '../core/secretStore.js';
|
|
35
|
+
import { resolveKeyMode, keyModeLine, setPreferOwnKeys, clearHostedSession, loginViaLoopback } from '../core/hostedAuth.js';
|
|
36
|
+
import { appendMemoryNote } from '../tools/memoryTools.js';
|
|
37
|
+
import { recordDeepSeekUsage, getUsage, TAVILY_MONTHLY_FREE, TAVILY_ONE_TIME_BONUS } from '../core/usage.js';
|
|
38
|
+
import { listSessions, loadSessionById, saveSessionSnapshot } from '../core/sessionStore.js';
|
|
39
|
+
import { relativeTime } from '../core/relativeTime.js';
|
|
40
|
+
import { getModelContextInfo } from '../core/contextWindow.js';
|
|
41
|
+
import { computeContextUsage, formatTokenCount } from '../core/contextUsage.js';
|
|
42
|
+
import { getChangedFiles, revertAllChanges, hasChangesToRevert } from '../tools/fileChangeTracker.js';
|
|
43
|
+
import { renderChangePanel } from '../core/diffPanel.js';
|
|
44
|
+
import { rewindPreviewLines, rewindResultLine } from '../core/rewind.js';
|
|
45
|
+
import { formatCompactionNote } from '../core/compactionNote.js';
|
|
46
|
+
import { formatSubAgentStart, formatSubAgentComplete } from '../core/subAgentNote.js';
|
|
47
|
+
import { getConfiguredProviders, getProvidersStatus, quickCheckProviders, getCachedDiscoveredModels, sortModelsByPriority } from '../core/modelDiscovery.js';
|
|
48
|
+
import { saveModelPreference } from '../core/preferences.js';
|
|
49
|
+
import { setDebugMode, debugSnippet } from '../utils/debugLogger.js';
|
|
50
|
+
const exec = promisify(childExec);
|
|
51
|
+
import { ensureNextSteps } from '../core/finalResponseFormatter.js';
|
|
52
|
+
import { getTaskCompletionDetector, detectFailingTestOrBuild } from '../core/taskCompletionDetector.js';
|
|
53
|
+
import { TurnGovernor, pendingTodos, nextTodoPrompt } from '../core/turnGovernor.js';
|
|
54
|
+
import { FailureRegistry } from '../core/failureRegistry.js';
|
|
55
|
+
import { buildAdversarialCorrectionPrompt, MAX_ADVERSARIAL_CORRECTIONS } from '../core/adversarialCorrection.js';
|
|
56
|
+
import { getCurrentTodos } from '../tools/todoTools.js';
|
|
57
|
+
import { checkForUpdates, performBackgroundUpdate } from '../core/updateChecker.js';
|
|
58
|
+
import { startNewRun } from '../tools/fileChangeTracker.js';
|
|
59
|
+
import { onSudoPasswordNeeded, offSudoPasswordNeeded, provideSudoPassword } from '../core/sudoPasswordManager.js';
|
|
60
|
+
import { reportStatus, setStatusSink } from '../utils/statusReporter.js';
|
|
61
|
+
import { isSafetyRefusal } from '../core/refusalDetection.js';
|
|
62
|
+
import { formatToolCall, toolActivityLabel, formatToolResult, formatToolError } from '../shell/toolPresentation.js';
|
|
63
|
+
// Tool-result display (ANSI stripping, summarisation, the `⎿` block) now lives
|
|
64
|
+
// in ../shell/toolPresentation.ts — the shell just emits the formatted strings.
|
|
65
|
+
// Timeout constants for regular prompt processing (reasoning models like DeepSeek)
|
|
66
|
+
const PROMPT_REASONING_TIMEOUT_MS = 60 * 1000; // 60 seconds max for reasoning-only without action
|
|
67
|
+
// Per-step timeout: how long we'll wait for the *next* event before
|
|
68
|
+
// declaring the stream stuck and bailing out. Set generously (10 min) so
|
|
69
|
+
// long-running tool calls (a build, a slow `npm install`, etc.) don't
|
|
70
|
+
// trip it, but short enough that a dead provider / network drop doesn't
|
|
71
|
+
// leave the user staring at a forever-spinner with Ctrl+C as their only
|
|
72
|
+
// escape. iterateWithTimeout resets this per-event, so it only fires on
|
|
73
|
+
// genuine inactivity. Override with TRENCHWORK_STEP_TIMEOUT_MS for tests.
|
|
74
|
+
const PROMPT_STEP_TIMEOUT_MS = (() => {
|
|
75
|
+
const env = process.env['TRENCHWORK_STEP_TIMEOUT_MS'];
|
|
76
|
+
const parsed = env ? Number(env) : NaN;
|
|
77
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
78
|
+
return parsed;
|
|
79
|
+
return 10 * 60 * 1000;
|
|
80
|
+
})();
|
|
81
|
+
const HITL_TOOL_PREFIX = 'HITL_';
|
|
82
|
+
const isHitlToolName = (toolName) => toolName.startsWith(HITL_TOOL_PREFIX);
|
|
83
|
+
/**
|
|
84
|
+
* Iterate over an async iterator with a timeout per iteration.
|
|
85
|
+
* If no event is received within the timeout, yields a special timeout marker.
|
|
86
|
+
* Emits timeout markers without aborting the underlying iterator.
|
|
87
|
+
* Pass Infinity to disable timeouts entirely.
|
|
88
|
+
*/
|
|
89
|
+
async function* iterateWithTimeout(iterator, timeoutMs, onTimeout) {
|
|
90
|
+
const asyncIterator = iterator[Symbol.asyncIterator]();
|
|
91
|
+
let pending = null;
|
|
92
|
+
let done = false;
|
|
93
|
+
// If timeout is Infinity or not a positive finite number, disable timeout entirely
|
|
94
|
+
const timeoutDisabled = !Number.isFinite(timeoutMs) || timeoutMs <= 0;
|
|
95
|
+
try {
|
|
96
|
+
while (true) {
|
|
97
|
+
if (!pending) {
|
|
98
|
+
pending = asyncIterator.next();
|
|
99
|
+
}
|
|
100
|
+
let result;
|
|
101
|
+
if (timeoutDisabled) {
|
|
102
|
+
// No timeout - just wait for the next value
|
|
103
|
+
result = await pending;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// Race between pending result and timeout
|
|
107
|
+
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve({ __timeout: true }), timeoutMs));
|
|
108
|
+
result = await Promise.race([pending, timeoutPromise]);
|
|
109
|
+
}
|
|
110
|
+
if ('__timeout' in result) {
|
|
111
|
+
onTimeout?.();
|
|
112
|
+
yield result;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
pending = null;
|
|
116
|
+
if (result.done) {
|
|
117
|
+
done = true;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
yield result.value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
if (!done && typeof asyncIterator.return === 'function') {
|
|
125
|
+
try {
|
|
126
|
+
await asyncIterator.return(undefined);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Ignore return errors
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
let cachedVersion = null;
|
|
135
|
+
// Get version from package.json
|
|
136
|
+
function getVersion() {
|
|
137
|
+
if (cachedVersion)
|
|
138
|
+
return cachedVersion;
|
|
139
|
+
try {
|
|
140
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
141
|
+
const pkgPath = resolve(dirname(__filename), '../../package.json');
|
|
142
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
143
|
+
cachedVersion = pkg.version || '0.0.0';
|
|
144
|
+
return cachedVersion;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return '0.0.0';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Inner content of the welcome box (plain, no border/colour). */
|
|
151
|
+
function welcomeBodyLines(input) {
|
|
152
|
+
const title = input.version ? `✻ Welcome to Trenchwork Coder ${input.version}` : '✻ Welcome to Trenchwork Coder';
|
|
153
|
+
const body = [title, ''];
|
|
154
|
+
const mode = input.keyMode ?? (input.hasApiKey ? 'own' : 'none');
|
|
155
|
+
if (mode === 'hosted') {
|
|
156
|
+
// Signed in — running on hosted keys. The mode line names the account so
|
|
157
|
+
// it's unmistakable this is NOT the user's own key.
|
|
158
|
+
body.push(input.keyModeLine ?? 'Signed in · using hosted keys');
|
|
159
|
+
}
|
|
160
|
+
else if (mode === 'own') {
|
|
161
|
+
body.push(`${input.model} · ${input.provider}`, `Key: ${input.maskedKey} · /help for commands`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
body.push('⚠ No DeepSeek API key configured', '', '/login Sign in with Google for hosted keys', '', 'Or bring your own:', ' /key sk-… DeepSeek (required) · platform.deepseek.com', ' /key tvly-… Tavily web search (optional) · tavily.com');
|
|
165
|
+
}
|
|
166
|
+
if (input.cwd)
|
|
167
|
+
body.push(`cwd: ${input.cwd}`);
|
|
168
|
+
return body;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Wrap content lines in a Claude-Code-style rounded box (╭╮╰╯). `paint`
|
|
172
|
+
* colours an already-padded content cell; `border` colours the frame. Both
|
|
173
|
+
* default to identity so the pure version stays ANSI-free.
|
|
174
|
+
*/
|
|
175
|
+
function roundedBox(content, paint = (s) => s, border = (s) => s) {
|
|
176
|
+
const width = Math.min(content.reduce((m, c) => Math.max(m, c.length), 0), 72);
|
|
177
|
+
const pad = (c) => c + ' '.repeat(Math.max(0, width - c.length));
|
|
178
|
+
const rule = '─'.repeat(width + 2);
|
|
179
|
+
return [
|
|
180
|
+
border(`╭${rule}╮`),
|
|
181
|
+
...content.map((c) => `${border('│')} ${paint(pad(c))} ${border('│')}`),
|
|
182
|
+
border(`╰${rule}╯`),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Compose the lines shown when the interactive shell opens. Deliberately NOT a
|
|
187
|
+
* marketing splash — bare `trenchwork` opens straight into the chat (like
|
|
188
|
+
* `claude`); this is the load-bearing welcome: a sparkle, the name, and either
|
|
189
|
+
* how to set a key or the active model + masked key, inside a rounded box that
|
|
190
|
+
* mirrors Claude Code's. Pure (no chalk/ANSI, no I/O) so the "no marketing
|
|
191
|
+
* splash, key guidance kept" contract is unit-testable without a PTY. The live
|
|
192
|
+
* renderer colourises equivalent content; this is the source of truth for
|
|
193
|
+
* WHICH lines appear.
|
|
194
|
+
*/
|
|
195
|
+
export function composeWelcomeLines(input) {
|
|
196
|
+
return ['', ...(input.updateLines ?? []), ...roundedBox(welcomeBodyLines(input)), ''];
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Run the fully interactive shell with rich UI.
|
|
200
|
+
*/
|
|
201
|
+
export async function runInteractiveShell(options) {
|
|
202
|
+
// Install signal handlers FIRST for reliable Ctrl+C handling
|
|
203
|
+
installSignalHandlers();
|
|
204
|
+
// Initialize protection systems
|
|
205
|
+
initializeProtection({
|
|
206
|
+
interceptSignals: true,
|
|
207
|
+
monitorResources: true,
|
|
208
|
+
armorExceptions: true,
|
|
209
|
+
enableWatchdog: true,
|
|
210
|
+
verbose: process.env['TRENCHWORK_DEBUG'] === '1',
|
|
211
|
+
});
|
|
212
|
+
// The CLI is interactive-only. There is no piped / one-shot / headless
|
|
213
|
+
// mode — every session runs through the Ink renderer against a live
|
|
214
|
+
// terminal. If stdin or stdout isn't a TTY, fail fast with a clear
|
|
215
|
+
// message rather than emitting unrenderable escape sequences into a
|
|
216
|
+
// pipe.
|
|
217
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
218
|
+
reportStatus('trenchwork requires an interactive terminal. Run it directly in a TTY (no pipes, no shell redirection).');
|
|
219
|
+
exit(1);
|
|
220
|
+
}
|
|
221
|
+
loadAllSecrets();
|
|
222
|
+
// argv intentionally unused — the bin is shell-only. Any tokens after
|
|
223
|
+
// `trenchwork` are ignored on purpose; configuration lives in /secrets,
|
|
224
|
+
// /model, /auto, etc. The options.argv field stays only because tests
|
|
225
|
+
// pass it; it does not affect runtime.
|
|
226
|
+
void options;
|
|
227
|
+
const profile = resolveProfile();
|
|
228
|
+
const workingDir = process.cwd();
|
|
229
|
+
const workspaceOptions = resolveWorkspaceCaptureOptions(process.env);
|
|
230
|
+
const workspaceContext = buildWorkspaceContext(workingDir, workspaceOptions);
|
|
231
|
+
// Resolve profile config for model info
|
|
232
|
+
const profileConfig = resolveProfileConfig(profile, workspaceContext);
|
|
233
|
+
// Create agent controller
|
|
234
|
+
const controller = await createAgentController({
|
|
235
|
+
profile,
|
|
236
|
+
workingDir,
|
|
237
|
+
workspaceContext,
|
|
238
|
+
env: process.env,
|
|
239
|
+
});
|
|
240
|
+
// Create the interactive shell instance
|
|
241
|
+
const shell = new InteractiveShell(controller, profile, profileConfig, workingDir);
|
|
242
|
+
await shell.run();
|
|
243
|
+
}
|
|
244
|
+
class InteractiveShell {
|
|
245
|
+
controller;
|
|
246
|
+
profile;
|
|
247
|
+
profileConfig;
|
|
248
|
+
workingDir;
|
|
249
|
+
// The shell holds an `IPromptController`-shaped value. The CLI has a
|
|
250
|
+
// single renderer — Ink, via InkPromptController. `any` here keeps
|
|
251
|
+
// existing call signatures unchanged; the interface declares the
|
|
252
|
+
// same surface but TS would otherwise insist we touch every call
|
|
253
|
+
// site to declare nullability.
|
|
254
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
255
|
+
promptController = null;
|
|
256
|
+
isProcessing = false;
|
|
257
|
+
// Full result + tool of the last tool-result that was TRUNCATED for display,
|
|
258
|
+
// so Ctrl+O can expand it (null when the last result fit, or none yet).
|
|
259
|
+
lastExpandableResult = null;
|
|
260
|
+
// Newer npm version detected at startup (null if up to date / unchecked).
|
|
261
|
+
pendingUpdate = null;
|
|
262
|
+
shouldExit = false;
|
|
263
|
+
pendingPrompts = [];
|
|
264
|
+
debugEnabled = false;
|
|
265
|
+
// Stable id for THIS run's persisted session, so each autosave updates the
|
|
266
|
+
// same snapshot in place. Assigned from the first saveSessionSnapshot; set
|
|
267
|
+
// to a resumed session's id after /resume so the restored thread continues.
|
|
268
|
+
sessionId = null;
|
|
269
|
+
// Real input-token count from the provider's last response (= tokens
|
|
270
|
+
// currently occupying the context window). Drives the accurate "% context
|
|
271
|
+
// left" chrome indicator and the /context view.
|
|
272
|
+
lastInputTokens = null;
|
|
273
|
+
ctrlCCount = 0;
|
|
274
|
+
lastCtrlCTime = 0;
|
|
275
|
+
// Set when the user Ctrl+C interrupts a run; suppresses the auto-continue
|
|
276
|
+
// re-launch in the finally block of processPrompt so the agent doesn't
|
|
277
|
+
// immediately resume the work the user just cancelled. Cleared when the
|
|
278
|
+
// user submits a fresh prompt.
|
|
279
|
+
userInterruptedRun = false;
|
|
280
|
+
cachedProviders = null;
|
|
281
|
+
secretInputMode = {
|
|
282
|
+
active: false,
|
|
283
|
+
secretId: null,
|
|
284
|
+
queue: [],
|
|
285
|
+
};
|
|
286
|
+
pendingModelSwitch = null;
|
|
287
|
+
currentResponseBuffer = '';
|
|
288
|
+
// The turn's final assistant text, captured BEFORE currentResponseBuffer is
|
|
289
|
+
// cleared on message.complete. The auto-continue refusal/completion/governor
|
|
290
|
+
// reads run in the `finally`, AFTER that clear, so reading the buffer there saw
|
|
291
|
+
// '' and blinded them (completion detection + safety-refusal both need the
|
|
292
|
+
// text). This mirrors the buffer's content but is never cleared mid-turn.
|
|
293
|
+
finalResponseText = '';
|
|
294
|
+
// Store original prompt for auto-continuation
|
|
295
|
+
originalPromptForAutoContinue = null;
|
|
296
|
+
// (Pinned prompt removed per request — field intentionally absent.)
|
|
297
|
+
// Bounds + stall-detects the auto-continue loop per user request, and drives
|
|
298
|
+
// continuation from the live TODO plan (see src/core/turnGovernor.ts). Reset
|
|
299
|
+
// when a fresh user prompt arrives.
|
|
300
|
+
autoGovernor = new TurnGovernor();
|
|
301
|
+
// Remembers recurring error signatures across auto-continue turns so the
|
|
302
|
+
// agent stops re-trying the same dead end (see src/core/failureRegistry.ts).
|
|
303
|
+
failureRegistry = new FailureRegistry();
|
|
304
|
+
// Adversarial auto-correction: how many bounded re-fixes the reviewer has
|
|
305
|
+
// triggered for the CURRENT user request (capped). Reset on a fresh prompt;
|
|
306
|
+
// the findings themselves are a per-turn local in processPrompt.
|
|
307
|
+
adversarialCorrectionCount = 0;
|
|
308
|
+
constructor(controller, profile, profileConfig, workingDir) {
|
|
309
|
+
this.controller = controller;
|
|
310
|
+
this.profile = profile;
|
|
311
|
+
this.profileConfig = profileConfig;
|
|
312
|
+
this.workingDir = workingDir;
|
|
313
|
+
// Pre-fetch provider status in background
|
|
314
|
+
void this.fetchProviders();
|
|
315
|
+
}
|
|
316
|
+
async fetchProviders() {
|
|
317
|
+
try {
|
|
318
|
+
this.cachedProviders = await quickCheckProviders();
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
this.cachedProviders = [];
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
validateRequiredApiKeys() {
|
|
325
|
+
const missingKeys = [];
|
|
326
|
+
// Check DeepSeek API key (required)
|
|
327
|
+
if (!getSecretValue('DEEPSEEK_API_KEY')) {
|
|
328
|
+
missingKeys.push('DEEPSEEK_API_KEY');
|
|
329
|
+
}
|
|
330
|
+
// Prompt for missing keys directly without showing warning
|
|
331
|
+
if (missingKeys.length > 0 && this.promptController) {
|
|
332
|
+
// Queue all missing keys for input
|
|
333
|
+
this.secretInputMode.queue = missingKeys.slice(1); // Rest of the keys
|
|
334
|
+
const first = missingKeys[0];
|
|
335
|
+
if (first) {
|
|
336
|
+
// Set secret mode immediately to mask input
|
|
337
|
+
this.secretInputMode.active = true;
|
|
338
|
+
this.secretInputMode.secretId = first;
|
|
339
|
+
this.promptController.setSecretMode(true);
|
|
340
|
+
// Show the inline panel with instructions
|
|
341
|
+
const secrets = listSecretDefinitions();
|
|
342
|
+
const secret = secrets.find(s => s.id === first);
|
|
343
|
+
if (secret && this.promptController.supportsInlinePanel()) {
|
|
344
|
+
const lines = [
|
|
345
|
+
chalk.bold.hex('#ece6da')(`Set ${secret.label}`),
|
|
346
|
+
chalk.dim(secret.description),
|
|
347
|
+
'',
|
|
348
|
+
chalk.dim('Enter value (or press Enter to skip)'),
|
|
349
|
+
];
|
|
350
|
+
this.promptController.setInlinePanel(lines);
|
|
351
|
+
this.promptController.setStatusMessage(`Enter ${secret.label}...`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
queuePrompt(prompt) {
|
|
357
|
+
this.pendingPrompts.push(prompt);
|
|
358
|
+
}
|
|
359
|
+
async run() {
|
|
360
|
+
// createPromptController returns the Ink-backed controller
|
|
361
|
+
// (src/ui/ink/InkPromptController.ts) — the only renderer. The
|
|
362
|
+
// dynamic import keeps React/Ink off the cold-start path until
|
|
363
|
+
// the interactive shell actually starts.
|
|
364
|
+
const { createPromptController } = await import('../ui/ink/InkPromptController.js');
|
|
365
|
+
this.promptController = await createPromptController(stdin, stdout, {
|
|
366
|
+
onSubmit: (text) => this.handleSubmit(text),
|
|
367
|
+
onQueue: (text) => this.queuePrompt(text),
|
|
368
|
+
onInterrupt: () => this.handleInterrupt(),
|
|
369
|
+
onExit: () => this.handleExit(),
|
|
370
|
+
onCtrlC: (info) => this.handleCtrlC(info),
|
|
371
|
+
onToggleAutoContinue: () => this.handleAutoContinueToggle(),
|
|
372
|
+
onToggleHITL: () => this.handleHITLToggle(),
|
|
373
|
+
onCyclePermissionMode: (mode) => this.handlePermissionModeChange(mode),
|
|
374
|
+
onExpandToolResult: () => this.handleExpandToolResult(),
|
|
375
|
+
// Esc interrupts a running turn (handleInterrupt no-ops when idle), so
|
|
376
|
+
// the spinner's "esc to interrupt" is real. Ctrl+C still works too.
|
|
377
|
+
onEscape: () => this.handleInterrupt(),
|
|
378
|
+
onShowShortcuts: () => this.showKeyboardShortcuts(),
|
|
379
|
+
onDismissPanel: () => this.dismissInlinePanel(),
|
|
380
|
+
});
|
|
381
|
+
// Register cleanup callback for graceful shutdown
|
|
382
|
+
onShutdown(() => {
|
|
383
|
+
this.shouldExit = true;
|
|
384
|
+
this.promptController?.stop();
|
|
385
|
+
setStatusSink(null);
|
|
386
|
+
});
|
|
387
|
+
setStatusSink((message) => this.promptController?.setStatusMessage(message));
|
|
388
|
+
// Hand the terminal off to the HITL prompt while it's open: suspend
|
|
389
|
+
// prompt rendering and detach our keypress handler so arrow keys aren't
|
|
390
|
+
// double-consumed. Restore both when the prompt closes so the next turn's
|
|
391
|
+
// input works correctly.
|
|
392
|
+
const onHitlOpen = () => {
|
|
393
|
+
const r = this.promptController?.getRenderer();
|
|
394
|
+
if (!r)
|
|
395
|
+
return;
|
|
396
|
+
try {
|
|
397
|
+
r.suspendPromptRendering();
|
|
398
|
+
}
|
|
399
|
+
catch { /* ignore */ }
|
|
400
|
+
try {
|
|
401
|
+
r.suspendInputCapture();
|
|
402
|
+
}
|
|
403
|
+
catch { /* ignore */ }
|
|
404
|
+
};
|
|
405
|
+
const onHitlClose = () => {
|
|
406
|
+
const r = this.promptController?.getRenderer();
|
|
407
|
+
if (!r)
|
|
408
|
+
return;
|
|
409
|
+
try {
|
|
410
|
+
r.resumeInputCapture();
|
|
411
|
+
}
|
|
412
|
+
catch { /* ignore */ }
|
|
413
|
+
try {
|
|
414
|
+
r.resumePromptRendering(true);
|
|
415
|
+
}
|
|
416
|
+
catch { /* ignore */ }
|
|
417
|
+
};
|
|
418
|
+
hitlEvents.on('prompt-open', onHitlOpen);
|
|
419
|
+
hitlEvents.on('prompt-close', onHitlClose);
|
|
420
|
+
onShutdown(() => {
|
|
421
|
+
hitlEvents.removeListener('prompt-open', onHitlOpen);
|
|
422
|
+
hitlEvents.removeListener('prompt-close', onHitlClose);
|
|
423
|
+
});
|
|
424
|
+
// Start the UI
|
|
425
|
+
this.promptController.start();
|
|
426
|
+
this.applyDebugState(this.debugEnabled);
|
|
427
|
+
// Build the @-mention completion file list (bounded walk; new files appear
|
|
428
|
+
// on the next launch). Best-effort — a scan failure must not block the UI.
|
|
429
|
+
try {
|
|
430
|
+
this.promptController.setCompletionFiles(listWorkspaceFiles(this.workingDir));
|
|
431
|
+
}
|
|
432
|
+
catch { /* ignore — completion is a convenience */ }
|
|
433
|
+
// Set up sudo password prompt handler
|
|
434
|
+
this.setupSudoPasswordHandler();
|
|
435
|
+
// Set initial status
|
|
436
|
+
this.promptController.setChromeMeta({
|
|
437
|
+
directory: this.workingDir,
|
|
438
|
+
});
|
|
439
|
+
// Show welcome message
|
|
440
|
+
await this.showWelcome();
|
|
441
|
+
// Pinned prompt loading removed — feature stripped per request.
|
|
442
|
+
// TEST SEAM (guarded; never active unless TRENCHWORK_TEST_FORCE_BUSY_MS + SKIP_AUTH):
|
|
443
|
+
// Lets PTY E2E harness drive the exact live follow-up queue paths
|
|
444
|
+
// (handleSubmit during isProcessing, transient queued UI, drain) with
|
|
445
|
+
// real binary + real keystrokes, no LLM key or network required.
|
|
446
|
+
const forceBusyMs = Number(process.env['TRENCHWORK_TEST_FORCE_BUSY_MS'] || '0');
|
|
447
|
+
if (forceBusyMs > 0) {
|
|
448
|
+
this.isProcessing = true;
|
|
449
|
+
this.promptController?.setStreaming(true);
|
|
450
|
+
this.promptController?.setActivityMessage('TEST BUSY (seam for queue E2E)');
|
|
451
|
+
setTimeout(() => {
|
|
452
|
+
this.isProcessing = false;
|
|
453
|
+
this.promptController?.setStreaming(false);
|
|
454
|
+
this.promptController?.setActivityMessage(null);
|
|
455
|
+
this.promptController?.forceRender();
|
|
456
|
+
// Explicitly drain here (exercises the real drain + processPrompt path
|
|
457
|
+
// even though the fake "run" had no controller.send). Pending items
|
|
458
|
+
// will hit the normal early guard + error path, but the queue/dequeue
|
|
459
|
+
// logic itself runs for the test assertions.
|
|
460
|
+
if (this.pendingPrompts.length > 0 && !this.shouldExit) {
|
|
461
|
+
const next = this.pendingPrompts.shift();
|
|
462
|
+
if (next) {
|
|
463
|
+
const r = this.promptController?.getRenderer();
|
|
464
|
+
r?.setFollowUpQueueMode(false);
|
|
465
|
+
r?.addUserHistoryItem(next);
|
|
466
|
+
r?.setQueuedPrompts(this.pendingPrompts.slice());
|
|
467
|
+
void this.processPrompt(next).catch(() => { });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}, forceBusyMs);
|
|
471
|
+
}
|
|
472
|
+
// Process any queued prompts
|
|
473
|
+
if (this.pendingPrompts.length > 0) {
|
|
474
|
+
const prompts = this.pendingPrompts.splice(0);
|
|
475
|
+
for (const prompt of prompts) {
|
|
476
|
+
await this.processPrompt(prompt);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Keep running until exit
|
|
480
|
+
await this.waitForExit();
|
|
481
|
+
}
|
|
482
|
+
async showWelcome() {
|
|
483
|
+
const renderer = this.promptController?.getRenderer();
|
|
484
|
+
if (!renderer)
|
|
485
|
+
return;
|
|
486
|
+
const version = getVersion();
|
|
487
|
+
// Append to existing terminal history — do not clear scrollback.
|
|
488
|
+
// Check if DeepSeek API key is set
|
|
489
|
+
const apiKey = process.env.DEEPSEEK_API_KEY?.trim() || '';
|
|
490
|
+
const hasApiKey = apiKey.length > 0;
|
|
491
|
+
// Mask API key: show first 4 and last 4 chars
|
|
492
|
+
const maskApiKey = (key) => {
|
|
493
|
+
if (key.length <= 12)
|
|
494
|
+
return key.slice(0, 3) + '...' + key.slice(-3);
|
|
495
|
+
return key.slice(0, 6) + '...' + key.slice(-4);
|
|
496
|
+
};
|
|
497
|
+
// Update check runs for everyone (no account required), with a hard
|
|
498
|
+
// race-timeout so a slow registry never delays the banner.
|
|
499
|
+
const updateLines = [];
|
|
500
|
+
const updatePromise = Promise.race([
|
|
501
|
+
checkForUpdates(version).catch(() => null),
|
|
502
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
|
|
503
|
+
]);
|
|
504
|
+
// Resolve the update check BEFORE composing the welcome lines — the
|
|
505
|
+
// previous order built welcomeLines with `...updateLines` (the array
|
|
506
|
+
// was empty at that point) and only populated updateLines afterwards,
|
|
507
|
+
// so the upgrade banner literally never rendered. Bug shipped before
|
|
508
|
+
// the scoped-package rename made the check return wrong data anyway.
|
|
509
|
+
const updateInfo = await updatePromise;
|
|
510
|
+
if (updateInfo?.updateAvailable) {
|
|
511
|
+
// Detect + OFFER (don't force) — the user applies it in-shell with
|
|
512
|
+
// /update. Auto-installing on every startup ran `npm i -g` without
|
|
513
|
+
// consent and could fail silently; making it user-initiated is clearer.
|
|
514
|
+
this.pendingUpdate = updateInfo;
|
|
515
|
+
updateLines.push(chalk.cyan(' ⬆ ') +
|
|
516
|
+
chalk.dim('Update available: ') +
|
|
517
|
+
chalk.yellow(`v${updateInfo.current}`) +
|
|
518
|
+
chalk.dim(' → ') +
|
|
519
|
+
chalk.green(`v${updateInfo.latest}`) +
|
|
520
|
+
chalk.dim(' · type ') + chalk.hex('#ffb142')('/update') + chalk.dim(' to upgrade'));
|
|
521
|
+
}
|
|
522
|
+
// Clean, minimal welcome — a sparkle + the essentials in a rounded box,
|
|
523
|
+
// mirroring Claude Code. The pure composeWelcomeLines() is the contract for
|
|
524
|
+
// WHICH lines appear; here we draw the same box with brand colour.
|
|
525
|
+
const flare = chalk.hex('#ff6a1f');
|
|
526
|
+
const wire = chalk.hex('#3a362e');
|
|
527
|
+
const keyStatus = resolveKeyMode();
|
|
528
|
+
const body = welcomeBodyLines({
|
|
529
|
+
hasApiKey,
|
|
530
|
+
maskedKey: hasApiKey ? maskApiKey(apiKey) : '',
|
|
531
|
+
model: this.profileConfig.model,
|
|
532
|
+
provider: this.profileConfig.provider,
|
|
533
|
+
cwd: this.workingDir,
|
|
534
|
+
keyMode: keyStatus.mode,
|
|
535
|
+
keyModeLine: keyModeLine(keyStatus),
|
|
536
|
+
version: `v${version}`,
|
|
537
|
+
});
|
|
538
|
+
const boxed = roundedBox(body, (cell) => cell.replace('✻', flare('✻')), (s) => wire(s));
|
|
539
|
+
const welcomeContent = ['', ...updateLines, ...boxed, ''].join('\n');
|
|
540
|
+
// Use renderer event system instead of direct stdout writes
|
|
541
|
+
renderer.addEvent('banner', welcomeContent);
|
|
542
|
+
// Update renderer meta with model info
|
|
543
|
+
this.promptController?.setModelContext({
|
|
544
|
+
model: this.profileConfig.model,
|
|
545
|
+
provider: this.profileConfig.provider,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Kick off `npm install -g <pkg>@latest` in a background process. When it
|
|
550
|
+
* completes, surface a renderer event so the user sees the result without
|
|
551
|
+
* any blocking. The running CLI keeps the old code — the new version is
|
|
552
|
+
* picked up on next launch.
|
|
553
|
+
*/
|
|
554
|
+
/**
|
|
555
|
+
* /update — re-check npm for a newer version (so it works on demand, not
|
|
556
|
+
* just from the startup notice) and, if one exists, upgrade in-shell. The
|
|
557
|
+
* install runs in the background and the new version takes effect on the
|
|
558
|
+
* next launch (a running Node process can't hot-swap its own global pkg).
|
|
559
|
+
*/
|
|
560
|
+
async handleUpdateCommand() {
|
|
561
|
+
const renderer = this.promptController?.getRenderer();
|
|
562
|
+
this.promptController?.setStatusMessage('Checking npm for updates…');
|
|
563
|
+
const info = await checkForUpdates(getVersion(), true).catch(() => null); // force a fresh check
|
|
564
|
+
this.promptController?.setStatusMessage(null);
|
|
565
|
+
if (!info) {
|
|
566
|
+
renderer?.addEvent('system', chalk.dim('Could not reach npm to check for updates. Try again, or run: npm i -g @trenchwork/coder@latest'));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (!info.updateAvailable) {
|
|
570
|
+
renderer?.addEvent('system', chalk.dim(`You're on the latest version (v${info.current}).`));
|
|
571
|
+
this.pendingUpdate = null;
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
renderer?.addEvent('system', chalk.cyan('⬆ ') + chalk.dim('Updating ') + chalk.yellow(`v${info.current}`) +
|
|
575
|
+
chalk.dim(' → ') + chalk.green(`v${info.latest}`) + chalk.dim('…'));
|
|
576
|
+
this.pendingUpdate = null;
|
|
577
|
+
this.runBackgroundUpdate(info);
|
|
578
|
+
}
|
|
579
|
+
runBackgroundUpdate(info) {
|
|
580
|
+
const renderer = this.promptController?.getRenderer();
|
|
581
|
+
void performBackgroundUpdate(info, (msg) => {
|
|
582
|
+
try {
|
|
583
|
+
renderer?.addEvent('system', msg);
|
|
584
|
+
}
|
|
585
|
+
catch { /* ignore */ }
|
|
586
|
+
}).then((res) => {
|
|
587
|
+
if (!res.started)
|
|
588
|
+
return;
|
|
589
|
+
try {
|
|
590
|
+
renderer?.addEvent('system', chalk.green(`✓ Update installer launched for v${info.latest}. `) +
|
|
591
|
+
chalk.dim('Exit and reopen the CLI to use the new version.'));
|
|
592
|
+
}
|
|
593
|
+
catch { /* ignore */ }
|
|
594
|
+
}).catch(() => { });
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Set up handler for sudo password prompts from bash tool execution.
|
|
598
|
+
* When a sudo command needs a password, this prompts the user securely.
|
|
599
|
+
*/
|
|
600
|
+
sudoPasswordHandler = null;
|
|
601
|
+
setupSudoPasswordHandler() {
|
|
602
|
+
this.sudoPasswordHandler = async () => {
|
|
603
|
+
const renderer = this.promptController?.getRenderer();
|
|
604
|
+
if (!renderer) {
|
|
605
|
+
provideSudoPassword(null);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
// Show password prompt
|
|
610
|
+
renderer.addEvent('system', chalk.yellow('Sudo password required'));
|
|
611
|
+
renderer.setSecretMode(true);
|
|
612
|
+
renderer.clearBuffer();
|
|
613
|
+
// Capture password input
|
|
614
|
+
const password = await renderer.captureInput({ allowEmpty: false, trim: true, resetBuffer: true });
|
|
615
|
+
// Hide password mode
|
|
616
|
+
renderer.setSecretMode(false);
|
|
617
|
+
if (password) {
|
|
618
|
+
provideSudoPassword(password);
|
|
619
|
+
renderer.addEvent('system', chalk.green('✓ Password provided'));
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
provideSudoPassword(null);
|
|
623
|
+
renderer.addEvent('system', chalk.yellow('Sudo cancelled'));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
renderer.setSecretMode(false);
|
|
628
|
+
provideSudoPassword(null);
|
|
629
|
+
reportStatus('Password prompt cancelled');
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
onSudoPasswordNeeded(this.sudoPasswordHandler);
|
|
633
|
+
}
|
|
634
|
+
cleanupSudoPasswordHandler() {
|
|
635
|
+
if (this.sudoPasswordHandler) {
|
|
636
|
+
offSudoPasswordNeeded(this.sudoPasswordHandler);
|
|
637
|
+
this.sudoPasswordHandler = null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
applyDebugState(enabled, statusMessage) {
|
|
641
|
+
this.debugEnabled = enabled;
|
|
642
|
+
setDebugMode(enabled);
|
|
643
|
+
this.promptController?.setDebugMode(enabled);
|
|
644
|
+
// Show transient status message instead of chat banner
|
|
645
|
+
if (statusMessage) {
|
|
646
|
+
this.promptController?.setStatusMessage(statusMessage);
|
|
647
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
describeEventForDebug(event) {
|
|
651
|
+
switch (event.type) {
|
|
652
|
+
case 'message.start':
|
|
653
|
+
return 'message.start';
|
|
654
|
+
case 'message.delta': {
|
|
655
|
+
const snippet = debugSnippet(event.content);
|
|
656
|
+
return snippet ? `message.delta → ${snippet}` : 'message.delta (empty)';
|
|
657
|
+
}
|
|
658
|
+
case 'message.complete': {
|
|
659
|
+
const snippet = debugSnippet(event.content);
|
|
660
|
+
return snippet
|
|
661
|
+
? `message.complete → ${snippet} (${event.elapsedMs}ms)`
|
|
662
|
+
: `message.complete (${event.elapsedMs}ms)`;
|
|
663
|
+
}
|
|
664
|
+
case 'tool.start':
|
|
665
|
+
return `tool.start ${event.toolName}`;
|
|
666
|
+
case 'tool.complete': {
|
|
667
|
+
const snippet = debugSnippet(event.result);
|
|
668
|
+
return snippet
|
|
669
|
+
? `tool.complete ${event.toolName} → ${snippet}`
|
|
670
|
+
: `tool.complete ${event.toolName}`;
|
|
671
|
+
}
|
|
672
|
+
case 'tool.error':
|
|
673
|
+
return `tool.error ${event.toolName} → ${event.error}`;
|
|
674
|
+
case 'edit.explanation': {
|
|
675
|
+
const snippet = debugSnippet(event.content);
|
|
676
|
+
return snippet ? `edit.explanation → ${snippet}` : 'edit.explanation';
|
|
677
|
+
}
|
|
678
|
+
case 'error':
|
|
679
|
+
return `error → ${event.error}`;
|
|
680
|
+
case 'usage': {
|
|
681
|
+
const parts = [];
|
|
682
|
+
if (event.inputTokens != null)
|
|
683
|
+
parts.push(`in:${event.inputTokens}`);
|
|
684
|
+
if (event.outputTokens != null)
|
|
685
|
+
parts.push(`out:${event.outputTokens}`);
|
|
686
|
+
if (event.totalTokens != null)
|
|
687
|
+
parts.push(`total:${event.totalTokens}`);
|
|
688
|
+
return `usage ${parts.length ? parts.join(', ') : '(no tokens)'}`;
|
|
689
|
+
}
|
|
690
|
+
default:
|
|
691
|
+
return event.type;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
handleDebugCommand(arg) {
|
|
695
|
+
const normalized = arg?.toLowerCase();
|
|
696
|
+
// /debug alone - toggle
|
|
697
|
+
if (!normalized) {
|
|
698
|
+
const targetState = !this.debugEnabled;
|
|
699
|
+
this.applyDebugState(targetState, `Debug ${targetState ? 'on' : 'off'}`);
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
// /debug status - show current state
|
|
703
|
+
if (normalized === 'status') {
|
|
704
|
+
this.promptController?.setStatusMessage(`Debug is ${this.debugEnabled ? 'on' : 'off'}`);
|
|
705
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
// /debug on|enable
|
|
709
|
+
if (normalized === 'on' || normalized === 'enable') {
|
|
710
|
+
if (this.debugEnabled) {
|
|
711
|
+
this.promptController?.setStatusMessage('Debug already on');
|
|
712
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
this.applyDebugState(true, 'Debug on');
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
// /debug off|disable
|
|
719
|
+
if (normalized === 'off' || normalized === 'disable') {
|
|
720
|
+
if (!this.debugEnabled) {
|
|
721
|
+
this.promptController?.setStatusMessage('Debug already off');
|
|
722
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
this.applyDebugState(false, 'Debug off');
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
// Invalid argument
|
|
729
|
+
this.promptController?.setStatusMessage(`Invalid: /debug ${arg}. Use on|off|status`);
|
|
730
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2500);
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Synthesize a user-facing response from reasoning content when the model
|
|
735
|
+
* provides reasoning but no actual response (common with deepseek-v4-pro).
|
|
736
|
+
* Extracts key conclusions and formats them as a concise response.
|
|
737
|
+
*/
|
|
738
|
+
synthesizeFromReasoning(reasoning) {
|
|
739
|
+
if (!reasoning || reasoning.trim().length < 50) {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
// Filter out internal meta-reasoning patterns that shouldn't be shown to user
|
|
743
|
+
const metaPatterns = [
|
|
744
|
+
/according to the rules?:?/gi,
|
|
745
|
+
/let me (?:use|search|look|check|find|think|analyze)/gi,
|
|
746
|
+
/I (?:should|need to|will|can|must) (?:use|search|look|check|find)/gi,
|
|
747
|
+
/⚡\s*Executing\.*/gi,
|
|
748
|
+
/use web\s?search/gi,
|
|
749
|
+
/for (?:non-)?coding (?:questions|tasks)/gi,
|
|
750
|
+
/answer (?:directly )?from knowledge/gi,
|
|
751
|
+
/this is a (?:general knowledge|coding|security)/gi,
|
|
752
|
+
/the user (?:is asking|wants|might be)/gi,
|
|
753
|
+
/however,? (?:the user|I|we)/gi,
|
|
754
|
+
/(?:first|next),? (?:I should|let me|I need)/gi,
|
|
755
|
+
];
|
|
756
|
+
let filtered = reasoning;
|
|
757
|
+
for (const pattern of metaPatterns) {
|
|
758
|
+
filtered = filtered.replace(pattern, '');
|
|
759
|
+
}
|
|
760
|
+
// Split into sentences
|
|
761
|
+
const sentences = filtered
|
|
762
|
+
.split(/[.!?\n]+/)
|
|
763
|
+
.map(s => s.trim())
|
|
764
|
+
.filter(s => s.length > 20 && !/^[•\-–—*]/.test(s)); // Skip bullets and short fragments
|
|
765
|
+
if (sentences.length === 0) {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
// Look for actual content (not process descriptions)
|
|
769
|
+
const contentPatterns = [
|
|
770
|
+
/(?:refers? to|involves?|relates? to|is about|concerns?)/i,
|
|
771
|
+
/(?:scandal|deal|agreement|proposal|plan|policy)/i,
|
|
772
|
+
/(?:Trump|Biden|Ukraine|Russia|president|congress)/i,
|
|
773
|
+
/(?:the (?:main|key|primary)|importantly)/i,
|
|
774
|
+
];
|
|
775
|
+
const contentSentences = [];
|
|
776
|
+
for (const sentence of sentences) {
|
|
777
|
+
// Skip sentences that are clearly meta-reasoning
|
|
778
|
+
if (/^(?:so|therefore|thus|hence|accordingly)/i.test(sentence))
|
|
779
|
+
continue;
|
|
780
|
+
if (/(?:I should|let me|I will|I need|I can)/i.test(sentence))
|
|
781
|
+
continue;
|
|
782
|
+
for (const pattern of contentPatterns) {
|
|
783
|
+
if (pattern.test(sentence)) {
|
|
784
|
+
contentSentences.push(sentence);
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// Use content sentences if found, otherwise take last few sentences (often conclusions)
|
|
790
|
+
const useSentences = contentSentences.length > 0
|
|
791
|
+
? contentSentences.slice(0, 3)
|
|
792
|
+
: sentences.slice(-3);
|
|
793
|
+
if (useSentences.length === 0) {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
const response = useSentences.join('. ').replace(/\.{2,}/g, '.').trim();
|
|
797
|
+
// Don't prefix with "Based on my analysis" - just return clean content
|
|
798
|
+
return response.endsWith('.') ? response : response + '.';
|
|
799
|
+
}
|
|
800
|
+
async runLocalCommand(command) {
|
|
801
|
+
const renderer = this.promptController?.getRenderer();
|
|
802
|
+
if (!command) {
|
|
803
|
+
this.promptController?.setStatusMessage('Usage: /bash <command>');
|
|
804
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2500);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
this.promptController?.setStatusMessage(`bash: ${command}`);
|
|
808
|
+
try {
|
|
809
|
+
const { stdout: out, stderr } = await exec(command, {
|
|
810
|
+
cwd: this.workingDir,
|
|
811
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
812
|
+
});
|
|
813
|
+
const output = [out, stderr].filter(Boolean).join('').trim() || '(no output)';
|
|
814
|
+
renderer?.addEvent('tool', `$ ${command}\n${output}`);
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
const err = error;
|
|
818
|
+
const output = [err.stdout, err.stderr, err.message].filter(Boolean).join('\n').trim();
|
|
819
|
+
renderer?.addEvent('error', `$ ${command}\n${output || 'command failed'}`);
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
this.promptController?.setStatusMessage(null);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
handleSlashCommand(command) {
|
|
826
|
+
const trimmed = command.trim();
|
|
827
|
+
const lower = trimmed.toLowerCase();
|
|
828
|
+
// /model and /secrets were removed: Trenchwork is locked to deepseek-v4-pro
|
|
829
|
+
// on max thought (no model switching), and /key is the one key you set.
|
|
830
|
+
// Handle /key — set your own DeepSeek OR Tavily API key. Routed by prefix:
|
|
831
|
+
// `sk-…` → DeepSeek (the model), `tvly-…` → Tavily (web search). Explicit
|
|
832
|
+
// `/key tavily <k>` / `/key deepseek <k>` also work. Bring-your-own-key is
|
|
833
|
+
// the model; both are stored in the OS-permission secret store.
|
|
834
|
+
if (lower === '/key' || lower.startsWith('/key ')) {
|
|
835
|
+
const renderer = this.promptController?.getRenderer();
|
|
836
|
+
const arg = trimmed.slice('/key'.length).trim();
|
|
837
|
+
const entry = classifyKeyEntry(arg);
|
|
838
|
+
if (entry) {
|
|
839
|
+
try {
|
|
840
|
+
setSecretValue(entry.id, entry.value);
|
|
841
|
+
const label = getSecretDefinition(entry.id)?.label ?? entry.id;
|
|
842
|
+
renderer?.addEvent('system', chalk.green(`✓ ${label} saved`));
|
|
843
|
+
}
|
|
844
|
+
catch (error) {
|
|
845
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
846
|
+
renderer?.addEvent('system', chalk.red(`✗ Failed: ${msg}`));
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
renderer?.addEvent('system', chalk.yellow('Usage: /key sk-… (DeepSeek) or /key tvly-… (Tavily web search)'));
|
|
851
|
+
}
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
// /account — show the active key source (hosted vs your own) and switch
|
|
855
|
+
// between them. `/account own` forces your own keys even while signed in;
|
|
856
|
+
// `/account hosted` returns to hosted. Hosted keys come from sign-in
|
|
857
|
+
// (server-side, never baked into this client) — see core/hostedAuth.ts.
|
|
858
|
+
if (lower === '/account' || lower.startsWith('/account ')) {
|
|
859
|
+
const r = this.promptController?.getRenderer();
|
|
860
|
+
const arg = trimmed.slice('/account'.length).trim().toLowerCase();
|
|
861
|
+
if (arg === 'own')
|
|
862
|
+
setPreferOwnKeys(true);
|
|
863
|
+
else if (arg === 'hosted')
|
|
864
|
+
setPreferOwnKeys(false);
|
|
865
|
+
if (arg === 'own' || arg === 'hosted')
|
|
866
|
+
void this.showWelcome(); // banner reflects the switch
|
|
867
|
+
r?.addEvent('system', this.accountStatusText(resolveKeyMode()));
|
|
868
|
+
return true;
|
|
869
|
+
}
|
|
870
|
+
// /login — Google sign-in via ero.solar (loopback OAuth) to unlock hosted keys.
|
|
871
|
+
if (lower === '/login' || lower === '/signin') {
|
|
872
|
+
void this.handleLogin();
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
// /logout — drop the hosted session (back to your own keys, or none).
|
|
876
|
+
if (lower === '/logout' || lower === '/signout') {
|
|
877
|
+
clearHostedSession();
|
|
878
|
+
this.promptController?.getRenderer()?.addEvent('system', chalk.green('✓ Signed out — using your own keys.'));
|
|
879
|
+
void this.showWelcome();
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
// /update — check npm for a newer version and upgrade in-shell.
|
|
883
|
+
if (lower === '/update' || lower === '/upgrade') {
|
|
884
|
+
void this.handleUpdateCommand();
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
if (lower === '/help' || lower === '/h' || lower === '/?') {
|
|
888
|
+
this.showHelp();
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
if (lower === '/clear' || lower === '/c') {
|
|
892
|
+
this.pendingPrompts = [];
|
|
893
|
+
const r = this.promptController?.getRenderer();
|
|
894
|
+
r?.setFollowUpQueueMode(false);
|
|
895
|
+
r?.setQueuedPrompts([]);
|
|
896
|
+
this.promptController?.clearScreen();
|
|
897
|
+
void this.showWelcome();
|
|
898
|
+
return true;
|
|
899
|
+
}
|
|
900
|
+
if (lower.startsWith('/bash') || lower.startsWith('/sh ')) {
|
|
901
|
+
const cmd = trimmed.replace(/^\/(bash|sh)\s*/i, '').trim();
|
|
902
|
+
void this.runLocalCommand(cmd);
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
if (lower === '/exit' || lower === '/quit' || lower === '/q') {
|
|
906
|
+
this.handleExit();
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
// Keyboard shortcuts help
|
|
910
|
+
if (lower === '/keys' || lower === '/shortcuts' || lower === '/kb') {
|
|
911
|
+
this.showKeyboardShortcuts();
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
// /resume — pick a saved conversation and restore its full history into
|
|
915
|
+
// context (the agent continues where it left off).
|
|
916
|
+
if (lower === '/resume' || lower === '/sessions') {
|
|
917
|
+
this.handleResume();
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
// /context — show how much of the model's context window is in use.
|
|
921
|
+
if (lower === '/context' || lower === '/usage') {
|
|
922
|
+
this.showContext();
|
|
923
|
+
return true;
|
|
924
|
+
}
|
|
925
|
+
// /cost — DeepSeek tokens + Tavily searches consumed (this session + all
|
|
926
|
+
// time), and the hosted free-pool reference. Account-wide remaining is a
|
|
927
|
+
// backend number shown in the ero.solar portal.
|
|
928
|
+
if (lower === '/cost' || lower === '/spend') {
|
|
929
|
+
this.showUsage();
|
|
930
|
+
return true;
|
|
931
|
+
}
|
|
932
|
+
// /diff — review the files the agent changed this run, as colored diffs.
|
|
933
|
+
if (lower === '/diff' || lower === '/changes') {
|
|
934
|
+
this.showDiff();
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
// /rewind — restore the files changed this run to their prior state
|
|
938
|
+
// (two-step: preview, then `/rewind confirm`).
|
|
939
|
+
if (lower === '/rewind' || lower.startsWith('/rewind ') || lower === '/revert' || lower.startsWith('/revert ')) {
|
|
940
|
+
this.handleRewind(trimmed.split(/\s+/).slice(1).join(' '));
|
|
941
|
+
return true;
|
|
942
|
+
}
|
|
943
|
+
// Everything is on by default for max performance — there are no toggles.
|
|
944
|
+
// Ultracode + max thought, the adversarial verifier, and auto-continue all
|
|
945
|
+
// run under the hood. The /auto, /adversarial, /debug, /ultracode, /model,
|
|
946
|
+
// /secrets, /pin, and /email commands were removed; /key is the one knob.
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Switch model silently without writing to chat.
|
|
951
|
+
* Accepts formats: "provider", "provider model", "provider/model", or "model"
|
|
952
|
+
* Updates status bar to show new model.
|
|
953
|
+
*/
|
|
954
|
+
async switchModel(arg) {
|
|
955
|
+
// Ensure we have provider info
|
|
956
|
+
if (!this.cachedProviders) {
|
|
957
|
+
await this.fetchProviders();
|
|
958
|
+
}
|
|
959
|
+
const providers = this.cachedProviders || [];
|
|
960
|
+
const configuredProviders = getConfiguredProviders();
|
|
961
|
+
let targetProvider = null;
|
|
962
|
+
let targetModel = null;
|
|
963
|
+
// Parse argument: could be "provider model", "provider/model", "provider", or just "model"
|
|
964
|
+
// Check for space-separated format first: "openai o1-pro"
|
|
965
|
+
const parts = arg.split(/[\s/]+/);
|
|
966
|
+
if (parts.length >= 2) {
|
|
967
|
+
// Try first part as provider
|
|
968
|
+
const providerMatch = this.matchProvider(parts[0] || '');
|
|
969
|
+
if (providerMatch) {
|
|
970
|
+
targetProvider = providerMatch;
|
|
971
|
+
targetModel = parts.slice(1).join('/'); // Rest is model (handle models with slashes)
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
// First part isn't a provider, treat whole arg as model name
|
|
975
|
+
const inferredProvider = this.inferProviderFromModel(arg.replace(/\s+/g, '-'));
|
|
976
|
+
if (inferredProvider) {
|
|
977
|
+
targetProvider = inferredProvider;
|
|
978
|
+
targetModel = arg.replace(/\s+/g, '-');
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
// Single token - could be provider or model
|
|
984
|
+
const matched = this.matchProvider(arg);
|
|
985
|
+
if (matched) {
|
|
986
|
+
targetProvider = matched;
|
|
987
|
+
// Use provider's best model
|
|
988
|
+
const providerStatus = providers.find(p => p.provider === targetProvider);
|
|
989
|
+
targetModel = providerStatus?.latestModel || null;
|
|
990
|
+
}
|
|
991
|
+
else {
|
|
992
|
+
// Assume it's a model name - try to infer provider from model prefix
|
|
993
|
+
const inferredProvider = this.inferProviderFromModel(arg);
|
|
994
|
+
if (inferredProvider) {
|
|
995
|
+
targetProvider = inferredProvider;
|
|
996
|
+
targetModel = arg;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
// Validate we have a valid provider
|
|
1001
|
+
if (!targetProvider) {
|
|
1002
|
+
// Silent error - just flash status briefly
|
|
1003
|
+
this.promptController?.setStatusMessage(`Unknown: ${arg}`);
|
|
1004
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
// Check provider is configured
|
|
1008
|
+
const providerInfo = configuredProviders.find(p => p.id === targetProvider);
|
|
1009
|
+
if (!providerInfo) {
|
|
1010
|
+
// Provider not configured - offer to set up API key
|
|
1011
|
+
const secretMap = {
|
|
1012
|
+
'deepseek': 'DEEPSEEK_API_KEY',
|
|
1013
|
+
};
|
|
1014
|
+
const secretId = secretMap[targetProvider];
|
|
1015
|
+
if (secretId) {
|
|
1016
|
+
this.promptController?.setStatusMessage(`${targetProvider} needs API key - setting up...`);
|
|
1017
|
+
// Store the pending model switch to complete after secret is set
|
|
1018
|
+
this.pendingModelSwitch = { provider: targetProvider, model: targetModel };
|
|
1019
|
+
setTimeout(() => this.promptForSecret(secretId), 500);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
// Provider not supported
|
|
1023
|
+
this.promptController?.setStatusMessage(`${targetProvider} not available - only DeepSeek is supported`);
|
|
1024
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
// Get model if not specified
|
|
1028
|
+
if (!targetModel) {
|
|
1029
|
+
const providerStatus = providers.find(p => p.provider === targetProvider);
|
|
1030
|
+
targetModel = providerStatus?.latestModel || providerInfo.latestModel;
|
|
1031
|
+
}
|
|
1032
|
+
// Save preference and update config
|
|
1033
|
+
saveModelPreference(this.profile, {
|
|
1034
|
+
provider: targetProvider,
|
|
1035
|
+
model: targetModel,
|
|
1036
|
+
});
|
|
1037
|
+
// Update local config
|
|
1038
|
+
this.profileConfig = {
|
|
1039
|
+
...this.profileConfig,
|
|
1040
|
+
provider: targetProvider,
|
|
1041
|
+
model: targetModel,
|
|
1042
|
+
};
|
|
1043
|
+
// Update controller's model
|
|
1044
|
+
await this.controller.switchModel({
|
|
1045
|
+
provider: targetProvider,
|
|
1046
|
+
model: targetModel,
|
|
1047
|
+
});
|
|
1048
|
+
// Update status bar - this displays the model below the chat box
|
|
1049
|
+
this.promptController?.setModelContext({
|
|
1050
|
+
model: targetModel,
|
|
1051
|
+
provider: targetProvider,
|
|
1052
|
+
});
|
|
1053
|
+
// Silent success - no chat output, just status bar update
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Match user input to a provider ID (fuzzy matching)
|
|
1057
|
+
*/
|
|
1058
|
+
matchProvider(input) {
|
|
1059
|
+
const lower = input.toLowerCase();
|
|
1060
|
+
const providers = getConfiguredProviders();
|
|
1061
|
+
// Exact match
|
|
1062
|
+
const exact = providers.find(p => p.id === lower || p.name.toLowerCase() === lower);
|
|
1063
|
+
if (exact)
|
|
1064
|
+
return exact.id;
|
|
1065
|
+
// Prefix match
|
|
1066
|
+
const prefix = providers.find(p => p.id.startsWith(lower) || p.name.toLowerCase().startsWith(lower));
|
|
1067
|
+
if (prefix)
|
|
1068
|
+
return prefix.id;
|
|
1069
|
+
// Alias matching
|
|
1070
|
+
const aliases = {
|
|
1071
|
+
'ds': 'deepseek',
|
|
1072
|
+
'deep': 'deepseek',
|
|
1073
|
+
};
|
|
1074
|
+
if (aliases[lower]) {
|
|
1075
|
+
const aliased = providers.find(p => p.id === aliases[lower]);
|
|
1076
|
+
if (aliased)
|
|
1077
|
+
return aliased.id;
|
|
1078
|
+
}
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Infer provider from model name
|
|
1083
|
+
*/
|
|
1084
|
+
inferProviderFromModel(model) {
|
|
1085
|
+
const lower = model.toLowerCase();
|
|
1086
|
+
if (lower.startsWith('deepseek')) {
|
|
1087
|
+
return 'deepseek';
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Show interactive model picker menu (Claude Code style).
|
|
1093
|
+
* Auto-discovers latest models from each provider's API.
|
|
1094
|
+
* Uses arrow key navigation with inline panel display.
|
|
1095
|
+
*/
|
|
1096
|
+
showModelMenu() {
|
|
1097
|
+
if (!this.promptController?.supportsInlinePanel()) {
|
|
1098
|
+
this.promptController?.setStatusMessage('Use /model <provider> <model> to switch');
|
|
1099
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 3000);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
// Show loading indicator
|
|
1103
|
+
this.promptController?.setStatusMessage('Discovering models...');
|
|
1104
|
+
// Fetch latest models from APIs
|
|
1105
|
+
void this.fetchAndShowModelMenu();
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Fetch models from provider APIs and show the interactive menu.
|
|
1109
|
+
*/
|
|
1110
|
+
async fetchAndShowModelMenu() {
|
|
1111
|
+
try {
|
|
1112
|
+
// Get provider status and cached models
|
|
1113
|
+
const allProviders = getProvidersStatus();
|
|
1114
|
+
const cachedModels = getCachedDiscoveredModels();
|
|
1115
|
+
const currentModel = this.profileConfig.model;
|
|
1116
|
+
const currentProvider = this.profileConfig.provider;
|
|
1117
|
+
// Try to get fresh models from configured providers (with short timeout)
|
|
1118
|
+
let freshStatus = [];
|
|
1119
|
+
try {
|
|
1120
|
+
freshStatus = await Promise.race([
|
|
1121
|
+
quickCheckProviders(),
|
|
1122
|
+
new Promise((resolve) => setTimeout(() => resolve([]), 3000))
|
|
1123
|
+
]);
|
|
1124
|
+
}
|
|
1125
|
+
catch {
|
|
1126
|
+
// Use cached data on error
|
|
1127
|
+
}
|
|
1128
|
+
// Build menu items - group by provider, show models
|
|
1129
|
+
const menuItems = [];
|
|
1130
|
+
for (const provider of allProviders) {
|
|
1131
|
+
// Get models for this provider
|
|
1132
|
+
const providerCachedModels = cachedModels.filter(m => m.provider === provider.id);
|
|
1133
|
+
const freshProvider = freshStatus.find(s => s.provider === provider.id);
|
|
1134
|
+
// Collect model IDs
|
|
1135
|
+
let modelIds = [];
|
|
1136
|
+
// Add fresh latest model if available
|
|
1137
|
+
if (freshProvider?.available && freshProvider.latestModel) {
|
|
1138
|
+
modelIds.push(freshProvider.latestModel);
|
|
1139
|
+
}
|
|
1140
|
+
// Add cached models
|
|
1141
|
+
modelIds.push(...providerCachedModels.map(m => m.id));
|
|
1142
|
+
// Add provider's default model
|
|
1143
|
+
if (provider.latestModel && !modelIds.includes(provider.latestModel)) {
|
|
1144
|
+
modelIds.push(provider.latestModel);
|
|
1145
|
+
}
|
|
1146
|
+
// Remove duplicates and sort by priority (best first)
|
|
1147
|
+
modelIds = [...new Set(modelIds)];
|
|
1148
|
+
modelIds = sortModelsByPriority(provider.id, modelIds);
|
|
1149
|
+
// Limit to top 3 models per provider
|
|
1150
|
+
const topModels = modelIds.slice(0, 3);
|
|
1151
|
+
if (!provider.configured) {
|
|
1152
|
+
// Show unconfigured provider as single disabled item
|
|
1153
|
+
menuItems.push({
|
|
1154
|
+
id: `${provider.id}:setup`,
|
|
1155
|
+
label: `${provider.name}`,
|
|
1156
|
+
description: `(${provider.envVar} not set - select to configure)`,
|
|
1157
|
+
category: provider.id,
|
|
1158
|
+
isActive: false,
|
|
1159
|
+
disabled: false, // Allow selection to configure
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
else if (topModels.length === 0) {
|
|
1163
|
+
// No models found - show provider with default
|
|
1164
|
+
menuItems.push({
|
|
1165
|
+
id: `${provider.id}:${provider.latestModel}`,
|
|
1166
|
+
label: `${provider.name} › ${provider.latestModel}`,
|
|
1167
|
+
description: 'default',
|
|
1168
|
+
category: provider.id,
|
|
1169
|
+
isActive: provider.id === currentProvider && provider.latestModel === currentModel,
|
|
1170
|
+
disabled: false,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
// Show each model as selectable item
|
|
1175
|
+
for (const modelId of topModels) {
|
|
1176
|
+
const isCurrentModel = provider.id === currentProvider && modelId === currentModel;
|
|
1177
|
+
const modelLabel = this.formatModelLabel(modelId);
|
|
1178
|
+
menuItems.push({
|
|
1179
|
+
id: `${provider.id}:${modelId}`,
|
|
1180
|
+
label: `${provider.name} › ${modelLabel}`,
|
|
1181
|
+
description: isCurrentModel ? '(current)' : '',
|
|
1182
|
+
category: provider.id,
|
|
1183
|
+
isActive: isCurrentModel,
|
|
1184
|
+
disabled: false,
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
// Clear loading message
|
|
1190
|
+
this.promptController?.setStatusMessage(null);
|
|
1191
|
+
// Show the interactive menu
|
|
1192
|
+
this.promptController?.setMenu(menuItems, { title: 'Select Model' }, (selected) => {
|
|
1193
|
+
if (selected) {
|
|
1194
|
+
// Parse provider:model format
|
|
1195
|
+
const [providerId, ...modelParts] = selected.id.split(':');
|
|
1196
|
+
const modelId = modelParts.join(':');
|
|
1197
|
+
if (modelId === 'setup') {
|
|
1198
|
+
// Configure provider API key
|
|
1199
|
+
const secretMap = {
|
|
1200
|
+
'deepseek': 'DEEPSEEK_API_KEY',
|
|
1201
|
+
};
|
|
1202
|
+
const secretId = secretMap[providerId ?? ''];
|
|
1203
|
+
if (secretId) {
|
|
1204
|
+
this.promptForSecret(secretId);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
else {
|
|
1208
|
+
// Switch to selected model
|
|
1209
|
+
void this.switchModel(`${providerId} ${modelId}`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
catch (error) {
|
|
1215
|
+
this.promptController?.setStatusMessage('Failed to load models');
|
|
1216
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Format model ID for display (shorten long IDs).
|
|
1221
|
+
*/
|
|
1222
|
+
formatModelLabel(modelId) {
|
|
1223
|
+
let label = modelId
|
|
1224
|
+
.replace(/^deepseek-/, 'DeepSeek ');
|
|
1225
|
+
if (label.length > 30) {
|
|
1226
|
+
label = label.slice(0, 27) + '...';
|
|
1227
|
+
}
|
|
1228
|
+
return label;
|
|
1229
|
+
}
|
|
1230
|
+
showSecrets() {
|
|
1231
|
+
const secrets = listSecretDefinitions();
|
|
1232
|
+
if (!this.promptController?.supportsInlinePanel()) {
|
|
1233
|
+
// Fallback for non-TTY - use status message
|
|
1234
|
+
const setCount = secrets.filter(s => !!process.env[s.envVar]).length;
|
|
1235
|
+
this.promptController?.setStatusMessage(`API Keys: ${setCount}/${secrets.length} configured`);
|
|
1236
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 3000);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
// Build interactive menu items
|
|
1240
|
+
const menuItems = secrets.map(secret => {
|
|
1241
|
+
const isSet = !!process.env[secret.envVar];
|
|
1242
|
+
const statusIcon = isSet ? '✓' : '✗';
|
|
1243
|
+
const providers = secret.providers?.length ? ` (${secret.providers.join(', ')})` : '';
|
|
1244
|
+
return {
|
|
1245
|
+
id: secret.id,
|
|
1246
|
+
label: `${statusIcon} ${secret.envVar}`,
|
|
1247
|
+
description: isSet ? 'configured' + providers : 'not set' + providers,
|
|
1248
|
+
isActive: isSet,
|
|
1249
|
+
disabled: false,
|
|
1250
|
+
};
|
|
1251
|
+
});
|
|
1252
|
+
// Show the interactive menu
|
|
1253
|
+
this.promptController.setMenu(menuItems, { title: 'API Keys — Select to Configure' }, (selected) => {
|
|
1254
|
+
if (selected) {
|
|
1255
|
+
// Start secret input for selected key
|
|
1256
|
+
this.promptForSecret(selected.id);
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Start interactive secret input flow.
|
|
1262
|
+
* If secretArg is provided, set only that secret.
|
|
1263
|
+
* Otherwise, prompt for all unset secrets.
|
|
1264
|
+
*/
|
|
1265
|
+
async startSecretInput(secretArg) {
|
|
1266
|
+
const secrets = listSecretDefinitions();
|
|
1267
|
+
if (secretArg) {
|
|
1268
|
+
// Set a specific secret
|
|
1269
|
+
const upper = secretArg.toUpperCase();
|
|
1270
|
+
const secret = secrets.find(s => s.id === upper || s.envVar === upper);
|
|
1271
|
+
if (!secret) {
|
|
1272
|
+
this.promptController?.setStatusMessage(`Unknown secret: ${secretArg}`);
|
|
1273
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
this.promptForSecret(secret.id);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
// Queue all unset secrets for input
|
|
1280
|
+
const unsetSecrets = secrets.filter(s => !getSecretValue(s.id));
|
|
1281
|
+
if (unsetSecrets.length === 0) {
|
|
1282
|
+
this.promptController?.setStatusMessage('All secrets configured');
|
|
1283
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
// Queue all unset secrets and start with the first one
|
|
1287
|
+
this.secretInputMode.queue = unsetSecrets.map(s => s.id);
|
|
1288
|
+
const first = this.secretInputMode.queue.shift();
|
|
1289
|
+
if (first) {
|
|
1290
|
+
this.promptForSecret(first);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Show prompt for a specific secret and enable secret input mode.
|
|
1295
|
+
*/
|
|
1296
|
+
promptForSecret(secretId) {
|
|
1297
|
+
const secrets = listSecretDefinitions();
|
|
1298
|
+
const secret = secrets.find(s => s.id === secretId);
|
|
1299
|
+
if (!secret)
|
|
1300
|
+
return;
|
|
1301
|
+
// Show in inline panel (no chat output)
|
|
1302
|
+
if (this.promptController?.supportsInlinePanel()) {
|
|
1303
|
+
const lines = [
|
|
1304
|
+
chalk.bold.hex('#ece6da')(`Set ${secret.label}`),
|
|
1305
|
+
chalk.dim(secret.description),
|
|
1306
|
+
'',
|
|
1307
|
+
chalk.dim('Enter value (or press Enter to skip)'),
|
|
1308
|
+
];
|
|
1309
|
+
this.promptController.setInlinePanel(lines);
|
|
1310
|
+
}
|
|
1311
|
+
// Enable secret input mode
|
|
1312
|
+
this.secretInputMode.active = true;
|
|
1313
|
+
this.secretInputMode.secretId = secretId;
|
|
1314
|
+
this.promptController?.setSecretMode(true);
|
|
1315
|
+
this.promptController?.setStatusMessage(`Enter ${secret.label}...`);
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Handle secret value submission.
|
|
1319
|
+
*/
|
|
1320
|
+
handleSecretValue(value) {
|
|
1321
|
+
const secretId = this.secretInputMode.secretId;
|
|
1322
|
+
if (!secretId)
|
|
1323
|
+
return;
|
|
1324
|
+
// Disable secret mode and clear inline panel
|
|
1325
|
+
this.promptController?.setSecretMode(false);
|
|
1326
|
+
this.promptController?.clearInlinePanel();
|
|
1327
|
+
this.secretInputMode.active = false;
|
|
1328
|
+
this.secretInputMode.secretId = null;
|
|
1329
|
+
let savedSuccessfully = false;
|
|
1330
|
+
if (value.trim()) {
|
|
1331
|
+
try {
|
|
1332
|
+
setSecretValue(secretId, value.trim());
|
|
1333
|
+
this.promptController?.setStatusMessage(`${secretId} saved`);
|
|
1334
|
+
savedSuccessfully = true;
|
|
1335
|
+
}
|
|
1336
|
+
catch (error) {
|
|
1337
|
+
const msg = error instanceof Error ? error.message : 'Failed to save';
|
|
1338
|
+
this.promptController?.setStatusMessage(msg);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
else {
|
|
1342
|
+
this.promptController?.setStatusMessage(`Skipped ${secretId}`);
|
|
1343
|
+
}
|
|
1344
|
+
// Clear status after a moment
|
|
1345
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 1500);
|
|
1346
|
+
// Process next secret in queue if any
|
|
1347
|
+
if (this.secretInputMode.queue.length > 0) {
|
|
1348
|
+
const next = this.secretInputMode.queue.shift();
|
|
1349
|
+
if (next) {
|
|
1350
|
+
setTimeout(() => this.promptForSecret(next), 500);
|
|
1351
|
+
}
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
// Complete pending model switch if secret was saved successfully
|
|
1355
|
+
if (savedSuccessfully && this.pendingModelSwitch) {
|
|
1356
|
+
const { provider, model } = this.pendingModelSwitch;
|
|
1357
|
+
this.pendingModelSwitch = null;
|
|
1358
|
+
// Refresh provider cache and complete the switch
|
|
1359
|
+
setTimeout(async () => {
|
|
1360
|
+
await this.fetchProviders();
|
|
1361
|
+
await this.switchModel(model ? `${provider} ${model}` : provider);
|
|
1362
|
+
}, 500);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Snapshot the live conversation to the session store so /resume can
|
|
1367
|
+
* restore it later. Best-effort: a persistence failure must never break a
|
|
1368
|
+
* turn, so everything is wrapped. Skips empty/system-only histories so we
|
|
1369
|
+
* don't litter the picker with sessions that have no real exchange.
|
|
1370
|
+
*/
|
|
1371
|
+
persistSessionSnapshot() {
|
|
1372
|
+
try {
|
|
1373
|
+
const messages = this.controller.getHistory();
|
|
1374
|
+
if (!messages.some((m) => m.role === 'user')) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
const summary = saveSessionSnapshot({
|
|
1378
|
+
id: this.sessionId,
|
|
1379
|
+
profile: this.profile,
|
|
1380
|
+
provider: this.profileConfig.provider,
|
|
1381
|
+
model: this.profileConfig.model,
|
|
1382
|
+
workspaceRoot: this.workingDir,
|
|
1383
|
+
messages,
|
|
1384
|
+
});
|
|
1385
|
+
this.sessionId = summary.id;
|
|
1386
|
+
}
|
|
1387
|
+
catch {
|
|
1388
|
+
// best-effort persistence — never interrupt the user's turn
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* /resume — present saved conversations newest-first and restore the
|
|
1393
|
+
* chosen one's full message history into the agent's context.
|
|
1394
|
+
*/
|
|
1395
|
+
handleResume() {
|
|
1396
|
+
const renderer = this.promptController?.getRenderer();
|
|
1397
|
+
const sessions = listSessions().filter((s) => s.id !== this.sessionId && s.messageCount > 0);
|
|
1398
|
+
if (sessions.length === 0) {
|
|
1399
|
+
renderer?.addEvent('system', chalk.dim('No saved conversations to resume yet.'));
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
const items = sessions.slice(0, 25).map((s) => ({
|
|
1403
|
+
id: s.id,
|
|
1404
|
+
label: s.title,
|
|
1405
|
+
description: `${s.messageCount} msg · ${relativeTime(s.updatedAt)}`,
|
|
1406
|
+
}));
|
|
1407
|
+
this.promptController?.setMenu(items, { title: 'Resume a conversation' }, (selected) => {
|
|
1408
|
+
if (selected) {
|
|
1409
|
+
this.resumeSession(selected.id);
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Load a saved session by id, restore its history into the controller (and
|
|
1415
|
+
* thus the agent's context), and reprint the prior exchange so the user
|
|
1416
|
+
* sees where they left off.
|
|
1417
|
+
*/
|
|
1418
|
+
resumeSession(id) {
|
|
1419
|
+
const renderer = this.promptController?.getRenderer();
|
|
1420
|
+
const stored = loadSessionById(id);
|
|
1421
|
+
if (!stored) {
|
|
1422
|
+
renderer?.addEvent('error', 'That conversation could not be loaded.');
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
this.controller.loadHistory(stored.messages);
|
|
1426
|
+
this.sessionId = stored.id;
|
|
1427
|
+
const restored = stored.messages.filter((m) => m.role === 'user' || m.role === 'assistant');
|
|
1428
|
+
renderer?.addEvent('system', chalk.dim(`Resumed "${stored.title}" — ${restored.length} message${restored.length === 1 ? '' : 's'} restored`));
|
|
1429
|
+
for (const m of stored.messages) {
|
|
1430
|
+
if (m.role === 'user') {
|
|
1431
|
+
renderer?.addUserHistoryItem(m.content);
|
|
1432
|
+
}
|
|
1433
|
+
else if (m.role === 'assistant' && m.content.trim()) {
|
|
1434
|
+
renderer?.addEvent('response', m.content);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* /context — a compact context-window usage panel. Uses the real model
|
|
1440
|
+
* window and the provider's last input-token count (falls back to a char/4
|
|
1441
|
+
* estimate, marked "~", before the first turn).
|
|
1442
|
+
*/
|
|
1443
|
+
showContext() {
|
|
1444
|
+
if (!this.promptController?.supportsInlinePanel()) {
|
|
1445
|
+
this.promptController?.setStatusMessage('Use /context in interactive mode');
|
|
1446
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 3000);
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
const model = this.profileConfig.model;
|
|
1450
|
+
const windowTokens = getModelContextInfo(model).contextWindow;
|
|
1451
|
+
const usage = computeContextUsage(this.controller.getHistory(), windowTokens, this.lastInputTokens);
|
|
1452
|
+
const label = (s) => chalk.hex('#ffb142')(s.padEnd(8));
|
|
1453
|
+
const dim = (s) => chalk.dim(s);
|
|
1454
|
+
const approx = usage.estimated ? '~' : '';
|
|
1455
|
+
const barWidth = 24;
|
|
1456
|
+
const filled = Math.min(barWidth, Math.round((usage.percentUsed / 100) * barWidth));
|
|
1457
|
+
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
1458
|
+
const lines = [
|
|
1459
|
+
chalk.bold.hex('#ece6da')('Context') + dim(' (press any key to dismiss)'),
|
|
1460
|
+
'',
|
|
1461
|
+
dim(bar) + ' ' + chalk.hex('#ece6da')(`${usage.percentLeft}% context left`),
|
|
1462
|
+
'',
|
|
1463
|
+
label('Window') + dim(`${formatTokenCount(windowTokens)} tokens · ${model}`),
|
|
1464
|
+
label('Used') + dim(`${approx}${formatTokenCount(usage.usedTokens)} tokens (${usage.percentUsed}%)`),
|
|
1465
|
+
label('Free') + dim(`${formatTokenCount(usage.freeTokens)} tokens (${usage.percentLeft}%)`),
|
|
1466
|
+
'',
|
|
1467
|
+
dim(`System prompt ~${formatTokenCount(usage.systemTokens)} · conversation ~${formatTokenCount(usage.conversationTokens)} · ${usage.messageCount} messages`),
|
|
1468
|
+
];
|
|
1469
|
+
this.promptController.setInlinePanel(lines);
|
|
1470
|
+
this.scheduleInlinePanelDismiss();
|
|
1471
|
+
}
|
|
1472
|
+
/** /cost — DeepSeek tokens + Tavily searches consumed (this install). */
|
|
1473
|
+
showUsage() {
|
|
1474
|
+
if (!this.promptController?.supportsInlinePanel()) {
|
|
1475
|
+
this.promptController?.setStatusMessage('Use /cost in interactive mode');
|
|
1476
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 3000);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const { session, cumulative } = getUsage();
|
|
1480
|
+
const label = (s) => chalk.hex('#ffb142')(s.padEnd(9));
|
|
1481
|
+
const dim = (s) => chalk.dim(s);
|
|
1482
|
+
const ds = (u) => `${formatTokenCount(u.deepseekInputTokens)} in · ${formatTokenCount(u.deepseekOutputTokens)} out`;
|
|
1483
|
+
const lines = [
|
|
1484
|
+
chalk.bold.hex('#ece6da')('Usage') + dim(' (press any key to dismiss)'),
|
|
1485
|
+
'',
|
|
1486
|
+
label('DeepSeek') + dim(`${ds(cumulative)} · this session ${ds(session)}`),
|
|
1487
|
+
label('Tavily') + dim(`${cumulative.tavilySearches} searches · this session ${session.tavilySearches}`),
|
|
1488
|
+
'',
|
|
1489
|
+
dim(`Hosted free pool: Tavily ${TAVILY_MONTHLY_FREE.toLocaleString('en-US')}/mo + ${TAVILY_ONE_TIME_BONUS.toLocaleString('en-US')} one-time bonus.`),
|
|
1490
|
+
dim('Account-wide totals + remaining show in the ero.solar portal after sign-in.'),
|
|
1491
|
+
];
|
|
1492
|
+
this.promptController.setInlinePanel(lines);
|
|
1493
|
+
this.scheduleInlinePanelDismiss();
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* /diff — review every file the agent changed this run as a colored diff,
|
|
1497
|
+
* in a dismissable panel. Reads each file's original content from the change
|
|
1498
|
+
* tracker and its current content from disk; an empty tracker means nothing
|
|
1499
|
+
* changed since the last prompt.
|
|
1500
|
+
*/
|
|
1501
|
+
showDiff() {
|
|
1502
|
+
const renderer = this.promptController?.getRenderer();
|
|
1503
|
+
const changed = getChangedFiles();
|
|
1504
|
+
if (changed.size === 0) {
|
|
1505
|
+
renderer?.addEvent('system', chalk.dim('No file changes in the last run.'));
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (!this.promptController?.supportsInlinePanel()) {
|
|
1509
|
+
this.promptController?.setStatusMessage(`${changed.size} file(s) changed this run`);
|
|
1510
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 3000);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
const items = [];
|
|
1514
|
+
for (const [absPath, record] of changed) {
|
|
1515
|
+
let current = '';
|
|
1516
|
+
let deleted = false;
|
|
1517
|
+
try {
|
|
1518
|
+
current = readFileSync(absPath, 'utf-8');
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
deleted = true;
|
|
1522
|
+
}
|
|
1523
|
+
items.push({
|
|
1524
|
+
relPath: relative(this.workingDir, absPath) || absPath,
|
|
1525
|
+
previous: record.originalContent ?? '',
|
|
1526
|
+
current,
|
|
1527
|
+
existedBefore: record.existedBefore,
|
|
1528
|
+
deleted,
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
const panel = renderChangePanel(items);
|
|
1532
|
+
const dim = (s) => chalk.dim(s);
|
|
1533
|
+
const lines = [
|
|
1534
|
+
chalk.bold.hex('#ece6da')('Changes') + dim(' (press any key to dismiss)'),
|
|
1535
|
+
'',
|
|
1536
|
+
...panel.lines,
|
|
1537
|
+
];
|
|
1538
|
+
this.promptController.setInlinePanel(lines);
|
|
1539
|
+
this.scheduleInlinePanelDismiss();
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* /rewind — restore the files changed this run. Two-step: bare `/rewind`
|
|
1543
|
+
* previews what will be restored/deleted (kept in the transcript so it stays
|
|
1544
|
+
* visible while the user types the confirm); `/rewind confirm` performs the
|
|
1545
|
+
* revert via the change tracker. File-level only — the conversation is not
|
|
1546
|
+
* rewound, and the message says so by scope ("files … before this run").
|
|
1547
|
+
*/
|
|
1548
|
+
handleRewind(arg) {
|
|
1549
|
+
const renderer = this.promptController?.getRenderer();
|
|
1550
|
+
const changed = getChangedFiles();
|
|
1551
|
+
if (changed.size === 0 || !hasChangesToRevert()) {
|
|
1552
|
+
renderer?.addEvent('system', chalk.dim('Nothing to rewind — no file changes this run.'));
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
if (arg.trim().toLowerCase() !== 'confirm') {
|
|
1556
|
+
const items = [...changed].map(([abs, rec]) => ({
|
|
1557
|
+
relPath: relative(this.workingDir, abs) || abs,
|
|
1558
|
+
existedBefore: rec.existedBefore,
|
|
1559
|
+
}));
|
|
1560
|
+
const lines = rewindPreviewLines(items);
|
|
1561
|
+
lines.forEach((line, i) => {
|
|
1562
|
+
const last = i === lines.length - 1;
|
|
1563
|
+
renderer?.addEvent('system', last ? chalk.hex('#ffb142')(line) : chalk.dim(line));
|
|
1564
|
+
});
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
let restored = 0;
|
|
1568
|
+
let deleted = 0;
|
|
1569
|
+
for (const [, rec] of changed) {
|
|
1570
|
+
if (rec.existedBefore && rec.originalContent !== null)
|
|
1571
|
+
restored += 1;
|
|
1572
|
+
else if (!rec.existedBefore)
|
|
1573
|
+
deleted += 1;
|
|
1574
|
+
}
|
|
1575
|
+
revertAllChanges(this.workingDir); // restores/deletes on disk + clears tracking
|
|
1576
|
+
renderer?.addEvent('system', chalk.green('✓ ' + rewindResultLine(restored, deleted)));
|
|
1577
|
+
}
|
|
1578
|
+
/** One-line summary of the active key source for /account. */
|
|
1579
|
+
accountStatusText(s) {
|
|
1580
|
+
if (s.mode === 'hosted') {
|
|
1581
|
+
return chalk.green(`Hosted keys · signed in as ${s.email}.`) +
|
|
1582
|
+
chalk.dim(` /account own to use your own · /logout to sign out.`);
|
|
1583
|
+
}
|
|
1584
|
+
if (s.mode === 'own') {
|
|
1585
|
+
return chalk.green(`Your own keys · DeepSeek${s.ownTavily ? ' + Tavily' : ''}.`) +
|
|
1586
|
+
chalk.dim(s.signedIn ? ` /account hosted to use hosted keys.` : ` /login to use hosted keys.`);
|
|
1587
|
+
}
|
|
1588
|
+
return chalk.yellow('No keys configured.') +
|
|
1589
|
+
chalk.dim(' /login for hosted keys, or set your own: /key sk-… (and /key tvly-…).');
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* /login — Google sign-in via ero.solar. Opens the browser to the SSO URL and
|
|
1593
|
+
* runs a one-shot 127.0.0.1 loopback server that captures the redirect with
|
|
1594
|
+
* the short-lived token (see core/hostedAuth.ts). On success the CLI is on
|
|
1595
|
+
* hosted keys; no key ever touches this client.
|
|
1596
|
+
*/
|
|
1597
|
+
async handleLogin() {
|
|
1598
|
+
const r = this.promptController?.getRenderer();
|
|
1599
|
+
const status = resolveKeyMode();
|
|
1600
|
+
if (status.signedIn) {
|
|
1601
|
+
r?.addEvent('system', chalk.green(`Already signed in as ${status.email}.`) +
|
|
1602
|
+
chalk.dim(' /logout to sign out · /account to switch key source.'));
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
r?.addEvent('system', chalk.dim('Opening ero.solar sign-in in your browser — finish there, then return here…'));
|
|
1606
|
+
const result = await loginViaLoopback({ open: (url) => this.openInBrowser(url) });
|
|
1607
|
+
if (result.ok && result.session) {
|
|
1608
|
+
r?.addEvent('system', chalk.green(`✓ Signed in as ${result.session.email} — using hosted keys.`));
|
|
1609
|
+
void this.showWelcome();
|
|
1610
|
+
}
|
|
1611
|
+
else {
|
|
1612
|
+
r?.addEvent('system', chalk.yellow(`Sign-in didn't complete: ${result.error ?? 'unknown error'}.`) +
|
|
1613
|
+
chalk.dim(' Retry /login, or use /key sk-… for your own key.'));
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
/** Best-effort open a URL in the OS browser; also prints it as a fallback. */
|
|
1617
|
+
openInBrowser(url) {
|
|
1618
|
+
const opener = process.platform === 'darwin' ? 'open'
|
|
1619
|
+
: process.platform === 'win32' ? 'start ""'
|
|
1620
|
+
: 'xdg-open';
|
|
1621
|
+
// url is built by loginViaLoopback (no user input) and JSON-quoted, so the
|
|
1622
|
+
// `&` in the query string can't break out of the argument.
|
|
1623
|
+
childExec(`${opener} ${JSON.stringify(url)}`, () => { });
|
|
1624
|
+
this.promptController?.getRenderer()?.addEvent('system', chalk.dim(`If the browser didn't open: ${url}`));
|
|
1625
|
+
}
|
|
1626
|
+
showHelp() {
|
|
1627
|
+
if (!this.promptController?.supportsInlinePanel()) {
|
|
1628
|
+
this.promptController?.setStatusMessage('Help: /key sk-… (everything else is automatic)');
|
|
1629
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 3000);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
const cmd = (s) => chalk.hex('#ffb142')(s);
|
|
1633
|
+
const dim = (s) => chalk.dim(s);
|
|
1634
|
+
// One knob. Everything else (ultracode, max thought, the adversarial
|
|
1635
|
+
// verifier, auto-continue) is on by default for max performance — there
|
|
1636
|
+
// are no toggles to tune.
|
|
1637
|
+
const lines = [
|
|
1638
|
+
chalk.bold.hex('#ece6da')('Trenchwork Coder') + dim(' (press any key to dismiss)'),
|
|
1639
|
+
'',
|
|
1640
|
+
cmd('/login') + dim(' Sign in with Google (ero.solar) to use hosted keys'),
|
|
1641
|
+
cmd('/key sk-…') + dim(' Set your DeepSeek API key (required)'),
|
|
1642
|
+
cmd('/key tvly-…') + dim(' Set your Tavily key for web search (optional)'),
|
|
1643
|
+
cmd('/account') + dim(' Show / switch key source (hosted vs your own)'),
|
|
1644
|
+
cmd('/update') + dim(' Check npm and upgrade to the latest version'),
|
|
1645
|
+
cmd('/resume') + dim(' Restore a previous conversation'),
|
|
1646
|
+
cmd('/context') + dim(' Show context-window usage'),
|
|
1647
|
+
cmd('/cost') + dim(' DeepSeek tokens + Tavily searches consumed'),
|
|
1648
|
+
cmd('/diff') + dim(' Review changes made this run'),
|
|
1649
|
+
cmd('/rewind') + dim(' Undo this run\'s file changes'),
|
|
1650
|
+
'',
|
|
1651
|
+
dim('Prefixes: ') + cmd('@file') + dim(' attach · ') + cmd('!cmd') + dim(' run shell · ') + cmd('#note') + dim(' save to memory'),
|
|
1652
|
+
'',
|
|
1653
|
+
dim('Everything else runs automatically —'),
|
|
1654
|
+
dim('deepseek-v4-pro · max thought · ultracode · adversarial verifier, all on.'),
|
|
1655
|
+
dim('Shift+Tab cycles permission mode · Ctrl+D exits · ? for shortcuts'),
|
|
1656
|
+
];
|
|
1657
|
+
this.promptController.setInlinePanel(lines);
|
|
1658
|
+
this.scheduleInlinePanelDismiss();
|
|
1659
|
+
}
|
|
1660
|
+
showKeyboardShortcuts() {
|
|
1661
|
+
if (!this.promptController?.supportsInlinePanel()) {
|
|
1662
|
+
this.promptController?.setStatusMessage('Use /keys in interactive mode');
|
|
1663
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 3000);
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
const kb = (key) => chalk.hex('#ffb142')(key);
|
|
1667
|
+
const desc = (text) => chalk.dim(text);
|
|
1668
|
+
// Only shortcuts the Ink Prompt (src/ui/ink/Prompt.tsx) actually
|
|
1669
|
+
// implements are listed — advertising keys the input handler ignores
|
|
1670
|
+
// would be a deceptive panel (Glasswing transparency).
|
|
1671
|
+
const lines = [
|
|
1672
|
+
chalk.bold.hex('#ece6da')('Keyboard Shortcuts') + chalk.dim(' (press any key to dismiss)'),
|
|
1673
|
+
'',
|
|
1674
|
+
chalk.hex('#cbf24e')('Navigation'),
|
|
1675
|
+
` ${kb('Ctrl+A')} / ${kb('Home')} ${desc('Move to start of line')}`,
|
|
1676
|
+
` ${kb('Ctrl+E')} / ${kb('End')} ${desc('Move to end of line')}`,
|
|
1677
|
+
` ${kb('←')} / ${kb('→')} ${desc('Move cursor')}`,
|
|
1678
|
+
` ${kb('↑')} / ${kb('↓')} ${desc('Prompt history (older / newer)')}`,
|
|
1679
|
+
` ${kb('Ctrl+R')} ${desc('Reverse-search prompt history')}`,
|
|
1680
|
+
'',
|
|
1681
|
+
chalk.hex('#cbf24e')('Editing'),
|
|
1682
|
+
` ${kb('Ctrl+U')} ${desc('Delete to start of line')}`,
|
|
1683
|
+
` ${kb('Ctrl+W')} ${desc('Delete word backward')}`,
|
|
1684
|
+
` ${kb('Ctrl+K')} ${desc('Delete to end of line')}`,
|
|
1685
|
+
'',
|
|
1686
|
+
chalk.hex('#cbf24e')('Modes'),
|
|
1687
|
+
` ${kb('Shift+Tab')} ${desc('Cycle permission mode (default · accept edits · plan)')}`,
|
|
1688
|
+
` ${kb('Ctrl+O')} ${desc('Expand the last truncated tool result')}`,
|
|
1689
|
+
'',
|
|
1690
|
+
chalk.hex('#cbf24e')('Completion'),
|
|
1691
|
+
` ${kb('@')} ${desc('Autocomplete a file (↑/↓ · Tab/Enter); its content is inlined for the agent')}`,
|
|
1692
|
+
` ${kb('/')} ${desc('Autocomplete a command (↑/↓ · Tab to complete; Enter runs it)')}`,
|
|
1693
|
+
'',
|
|
1694
|
+
chalk.hex('#cbf24e')('Control'),
|
|
1695
|
+
` ${kb('Ctrl+C')} ${desc('Clear input / interrupt')}`,
|
|
1696
|
+
` ${kb('Ctrl+D')} ${desc('Exit (when empty)')}`,
|
|
1697
|
+
];
|
|
1698
|
+
this.promptController.setInlinePanel(lines);
|
|
1699
|
+
this.scheduleInlinePanelDismiss();
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Auto-dismiss inline panel after timeout or on next input.
|
|
1703
|
+
*/
|
|
1704
|
+
inlinePanelDismissTimer = null;
|
|
1705
|
+
scheduleInlinePanelDismiss() {
|
|
1706
|
+
// Clear any existing timer
|
|
1707
|
+
if (this.inlinePanelDismissTimer) {
|
|
1708
|
+
clearTimeout(this.inlinePanelDismissTimer);
|
|
1709
|
+
}
|
|
1710
|
+
// Auto-dismiss after 8 seconds
|
|
1711
|
+
this.inlinePanelDismissTimer = setTimeout(() => {
|
|
1712
|
+
this.promptController?.clearInlinePanel();
|
|
1713
|
+
this.inlinePanelDismissTimer = null;
|
|
1714
|
+
}, 8000);
|
|
1715
|
+
}
|
|
1716
|
+
dismissInlinePanel() {
|
|
1717
|
+
if (this.inlinePanelDismissTimer) {
|
|
1718
|
+
clearTimeout(this.inlinePanelDismissTimer);
|
|
1719
|
+
this.inlinePanelDismissTimer = null;
|
|
1720
|
+
}
|
|
1721
|
+
this.promptController?.clearInlinePanel();
|
|
1722
|
+
}
|
|
1723
|
+
handleSubmit(text) {
|
|
1724
|
+
const trimmed = text.trim();
|
|
1725
|
+
// Handle secret input mode - capture the API key value
|
|
1726
|
+
if (this.secretInputMode.active && this.secretInputMode.secretId) {
|
|
1727
|
+
this.handleSecretValue(trimmed);
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
if (!trimmed) {
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
// Handle slash commands first - these don't go to the AI
|
|
1734
|
+
if (trimmed.startsWith('/')) {
|
|
1735
|
+
if (this.handleSlashCommand(trimmed)) {
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
// Unknown slash command - silent status flash, dismiss inline panel
|
|
1739
|
+
this.dismissInlinePanel();
|
|
1740
|
+
this.promptController?.setStatusMessage(`Unknown: ${trimmed.slice(0, 30)}`);
|
|
1741
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
// `!cmd` — bash mode (Claude Code parity): run the rest as a shell command
|
|
1745
|
+
// directly, no model round-trip. Same executor as /bash, via the leading
|
|
1746
|
+
// bang. Runs immediately (like a slash command), not queued behind the agent.
|
|
1747
|
+
if (trimmed.startsWith('!')) {
|
|
1748
|
+
this.dismissInlinePanel();
|
|
1749
|
+
void this.runLocalCommand(trimmed.slice(1).trim());
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
// `#note` — quick-capture a note to persistent project memory (Claude Code
|
|
1753
|
+
// parity), no model round-trip. Lands in .trenchwork/memory/ where the agent
|
|
1754
|
+
// reads it on later sessions.
|
|
1755
|
+
if (trimmed.startsWith('#')) {
|
|
1756
|
+
this.dismissInlinePanel();
|
|
1757
|
+
const note = trimmed.slice(1).trim();
|
|
1758
|
+
const r = this.promptController?.getRenderer();
|
|
1759
|
+
if (appendMemoryNote(this.workingDir, note))
|
|
1760
|
+
r?.addEvent('system', chalk.green('✓ Saved to memory'));
|
|
1761
|
+
else
|
|
1762
|
+
r?.addEvent('system', chalk.yellow('Usage: #<note to remember>'));
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
// Dismiss inline panel for regular user prompts
|
|
1766
|
+
this.dismissInlinePanel();
|
|
1767
|
+
// Live follow-up queue (Claude Code parity): a prompt typed while the agent
|
|
1768
|
+
// is working is accepted immediately into a transient queue (visible above
|
|
1769
|
+
// the input, *not* in permanent history). It is processed at the next turn
|
|
1770
|
+
// boundary (ASAP, before any outer auto-continue decides the original task
|
|
1771
|
+
// is "complete"). No polluting system banners.
|
|
1772
|
+
if (this.isProcessing) {
|
|
1773
|
+
this.pendingPrompts.push(trimmed);
|
|
1774
|
+
const renderer = this.promptController?.getRenderer();
|
|
1775
|
+
renderer?.setFollowUpQueueMode(true);
|
|
1776
|
+
renderer?.setQueuedPrompts(this.pendingPrompts.slice());
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
void this.processPrompt(trimmed);
|
|
1780
|
+
}
|
|
1781
|
+
async processPrompt(prompt) {
|
|
1782
|
+
if (this.isProcessing) {
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
// Start new run for file change tracking (enables /revert)
|
|
1786
|
+
startNewRun();
|
|
1787
|
+
// @-file mentions: inline the content of any `@path` the user referenced
|
|
1788
|
+
// so the agent gets it directly (the chat history still shows the raw
|
|
1789
|
+
// `@path` the user typed — only this agent-bound copy is expanded).
|
|
1790
|
+
const mentions = expandFileMentions(prompt, this.workingDir);
|
|
1791
|
+
const sanitizedPrompt = mentions.prompt;
|
|
1792
|
+
if (mentions.included.length > 0) {
|
|
1793
|
+
this.promptController?.getRenderer()?.addEvent('system', chalk.dim(`Included ${mentions.included.length} referenced file${mentions.included.length === 1 ? '' : 's'}: ${mentions.included.join(', ')}`));
|
|
1794
|
+
}
|
|
1795
|
+
// Store original prompt for auto-continuation (if not a continuation or auto-generated prompt)
|
|
1796
|
+
if (prompt !== 'continue' && !prompt.startsWith('IMPORTANT:')) {
|
|
1797
|
+
this.originalPromptForAutoContinue = prompt;
|
|
1798
|
+
// A fresh user prompt clears any prior interrupt state — this is new
|
|
1799
|
+
// work the user actually wants done.
|
|
1800
|
+
this.userInterruptedRun = false;
|
|
1801
|
+
// Fresh user request → start a new auto-continue turn budget + failure log.
|
|
1802
|
+
this.autoGovernor.reset();
|
|
1803
|
+
this.failureRegistry.reset();
|
|
1804
|
+
this.adversarialCorrectionCount = 0;
|
|
1805
|
+
// Pinned-prompt persistence removed per request — no longer
|
|
1806
|
+
// displayed above the chat box.
|
|
1807
|
+
}
|
|
1808
|
+
enterCriticalSection();
|
|
1809
|
+
this.isProcessing = true;
|
|
1810
|
+
this.currentResponseBuffer = '';
|
|
1811
|
+
this.finalResponseText = '';
|
|
1812
|
+
this.promptController?.setStreaming(true);
|
|
1813
|
+
this.promptController?.setStatusMessage('Analyzing request…');
|
|
1814
|
+
const renderer = this.promptController?.getRenderer();
|
|
1815
|
+
let episodeSuccess = false;
|
|
1816
|
+
const toolsUsed = [];
|
|
1817
|
+
const filesModified = [];
|
|
1818
|
+
// Tail of this turn's tool outputs (where TS/test/build errors land), so the
|
|
1819
|
+
// failure registry + governor see real error text, not just the narration.
|
|
1820
|
+
let turnToolOutput = '';
|
|
1821
|
+
// Reviewer findings from THIS turn (set by the adversarial.findings event),
|
|
1822
|
+
// used in the finally to drive a bounded auto-correction.
|
|
1823
|
+
let turnAdversarialFindings = null;
|
|
1824
|
+
// Track reasoning content for fallback when response is empty
|
|
1825
|
+
let reasoningBuffer = '';
|
|
1826
|
+
// Track reasoning-only time to prevent models from reasoning forever without action
|
|
1827
|
+
let reasoningOnlyStartTime = null;
|
|
1828
|
+
let reasoningTimedOut = false;
|
|
1829
|
+
let stepTimedOut = false;
|
|
1830
|
+
let hitlDepth = 0;
|
|
1831
|
+
// Track total prompt processing time to prevent infinite loops
|
|
1832
|
+
const promptStartTime = Date.now();
|
|
1833
|
+
const TOTAL_PROMPT_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours max for entire prompt without meaningful content
|
|
1834
|
+
let hasReceivedMeaningfulContent = false;
|
|
1835
|
+
// Track response content separately - tool calls don't count for reasoning timeout
|
|
1836
|
+
let hasReceivedResponseContent = false;
|
|
1837
|
+
try {
|
|
1838
|
+
// Use timeout-wrapped iterator to prevent hanging on slow/stuck models
|
|
1839
|
+
for await (const eventOrTimeout of iterateWithTimeout(this.controller.send(sanitizedPrompt), PROMPT_STEP_TIMEOUT_MS)) {
|
|
1840
|
+
// Check for timeout marker
|
|
1841
|
+
if (eventOrTimeout && typeof eventOrTimeout === 'object' && '__timeout' in eventOrTimeout) {
|
|
1842
|
+
if (hitlDepth > 0) {
|
|
1843
|
+
this.promptController?.setStatusMessage('Waiting for human decision…');
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
stepTimedOut = true;
|
|
1847
|
+
this.promptController?.setStatusMessage(`Step timeout (${PROMPT_STEP_TIMEOUT_MS / 1000}s) — completing response`);
|
|
1848
|
+
// Cancel the controller so the underlying agent stops generating
|
|
1849
|
+
// events that would never be consumed. Without this the spinner
|
|
1850
|
+
// can keep ticking against a "ghost" run after the for-await
|
|
1851
|
+
// loop exits, and any in-flight tool keeps doing work the user
|
|
1852
|
+
// can't see or stop.
|
|
1853
|
+
try {
|
|
1854
|
+
this.controller.cancel('step timeout');
|
|
1855
|
+
}
|
|
1856
|
+
catch { /* best-effort */ }
|
|
1857
|
+
break;
|
|
1858
|
+
}
|
|
1859
|
+
// Check total elapsed time - bail out if too long without meaningful content
|
|
1860
|
+
const totalElapsed = Date.now() - promptStartTime;
|
|
1861
|
+
if (!hasReceivedMeaningfulContent && totalElapsed > TOTAL_PROMPT_TIMEOUT_MS) {
|
|
1862
|
+
if (renderer) {
|
|
1863
|
+
renderer.addEvent('response', chalk.yellow(`\nResponse timeout (${Math.round(totalElapsed / 1000)}s) — completing\n`));
|
|
1864
|
+
}
|
|
1865
|
+
reasoningTimedOut = true;
|
|
1866
|
+
try {
|
|
1867
|
+
this.controller.cancel('response timeout');
|
|
1868
|
+
}
|
|
1869
|
+
catch { /* best-effort */ }
|
|
1870
|
+
break;
|
|
1871
|
+
}
|
|
1872
|
+
const event = eventOrTimeout;
|
|
1873
|
+
if (this.shouldExit) {
|
|
1874
|
+
break;
|
|
1875
|
+
}
|
|
1876
|
+
switch (event.type) {
|
|
1877
|
+
case 'message.start':
|
|
1878
|
+
// AI has started processing - update status to show activity
|
|
1879
|
+
this.currentResponseBuffer = '';
|
|
1880
|
+
this.finalResponseText = '';
|
|
1881
|
+
reasoningBuffer = '';
|
|
1882
|
+
reasoningOnlyStartTime = null; // Reset on new message
|
|
1883
|
+
this.promptController?.setStatusMessage('Thinking...');
|
|
1884
|
+
break;
|
|
1885
|
+
case 'message.delta':
|
|
1886
|
+
// Stream content as it arrives
|
|
1887
|
+
this.currentResponseBuffer += event.content ?? '';
|
|
1888
|
+
this.finalResponseText += event.content ?? '';
|
|
1889
|
+
if (renderer) {
|
|
1890
|
+
renderer.addEvent('stream', event.content);
|
|
1891
|
+
}
|
|
1892
|
+
// Reset reasoning timer only when we get actual non-empty content
|
|
1893
|
+
if (event.content && event.content.trim()) {
|
|
1894
|
+
reasoningOnlyStartTime = null;
|
|
1895
|
+
hasReceivedMeaningfulContent = true;
|
|
1896
|
+
hasReceivedResponseContent = true; // Track actual response content
|
|
1897
|
+
}
|
|
1898
|
+
break;
|
|
1899
|
+
case 'reasoning':
|
|
1900
|
+
// Accumulate reasoning for potential fallback synthesis
|
|
1901
|
+
reasoningBuffer += event.content ?? '';
|
|
1902
|
+
// Update status to show reasoning is actively streaming
|
|
1903
|
+
this.promptController?.setActivityMessage('Thinking');
|
|
1904
|
+
// Start the reasoning timer on first reasoning event
|
|
1905
|
+
if (!reasoningOnlyStartTime) {
|
|
1906
|
+
reasoningOnlyStartTime = Date.now();
|
|
1907
|
+
}
|
|
1908
|
+
// Display useful reasoning as 'thought' events BEFORE the response
|
|
1909
|
+
// The renderer's curateReasoningContent and shouldRenderThought will filter
|
|
1910
|
+
// to show only actionable/structured thoughts
|
|
1911
|
+
if (renderer && event.content?.trim()) {
|
|
1912
|
+
renderer.addEvent('thought', event.content);
|
|
1913
|
+
}
|
|
1914
|
+
break;
|
|
1915
|
+
case 'message.complete':
|
|
1916
|
+
// Response complete - clear the thinking indicator
|
|
1917
|
+
this.promptController?.setStatusMessage(null);
|
|
1918
|
+
// Response complete - ensure final output includes required "Next steps"
|
|
1919
|
+
if (renderer) {
|
|
1920
|
+
// Use the appended field from ensureNextSteps to avoid re-rendering the entire response
|
|
1921
|
+
const base = (event.content ?? '').trimEnd();
|
|
1922
|
+
let sourceText = base || this.currentResponseBuffer;
|
|
1923
|
+
// If content came via message.complete but NOT via deltas, render it now as a proper response
|
|
1924
|
+
// This handles models that don't stream deltas (e.g., deepseek-v4-pro)
|
|
1925
|
+
// IMPORTANT: Do NOT re-emit content that was already streamed via 'message.delta' events
|
|
1926
|
+
// to prevent duplicate display of the same response
|
|
1927
|
+
if (base && !this.currentResponseBuffer.trim()) {
|
|
1928
|
+
renderer.addEvent('response', base);
|
|
1929
|
+
}
|
|
1930
|
+
// Note: We intentionally DO NOT re-emit currentResponseBuffer as a 'response' event
|
|
1931
|
+
// because it was already displayed via 'stream' events during message.delta handling
|
|
1932
|
+
// Fallback: If response is empty but we have reasoning, synthesize a response
|
|
1933
|
+
if (!sourceText.trim() && reasoningBuffer.trim()) {
|
|
1934
|
+
// Extract key conclusions from reasoning for display
|
|
1935
|
+
const synthesized = this.synthesizeFromReasoning(reasoningBuffer);
|
|
1936
|
+
if (synthesized) {
|
|
1937
|
+
renderer.addEvent('response', synthesized);
|
|
1938
|
+
sourceText = synthesized;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
episodeSuccess = true; // Mark episode as successful only after we have content
|
|
1942
|
+
// Only add "Next steps" if tools were actually used (real work done)
|
|
1943
|
+
// This prevents showing "Next steps" after reasoning-only responses
|
|
1944
|
+
if (toolsUsed.length > 0) {
|
|
1945
|
+
const { appended } = ensureNextSteps(sourceText);
|
|
1946
|
+
// Only stream the newly appended content (e.g., "Next steps:")
|
|
1947
|
+
// The main response was already added as a response event above
|
|
1948
|
+
if (appended && appended.trim()) {
|
|
1949
|
+
renderer.addEvent('response', appended);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
renderer.addEvent('response', '\n');
|
|
1953
|
+
// Capture the authoritative final text BEFORE the buffer is cleared
|
|
1954
|
+
// (the finally's auto-continue reads run after this clear).
|
|
1955
|
+
this.finalResponseText = sourceText || this.finalResponseText;
|
|
1956
|
+
}
|
|
1957
|
+
this.currentResponseBuffer = '';
|
|
1958
|
+
break;
|
|
1959
|
+
case 'tool.start': {
|
|
1960
|
+
const toolName = event.toolName;
|
|
1961
|
+
const args = event.parameters;
|
|
1962
|
+
if (isHitlToolName(toolName)) {
|
|
1963
|
+
hitlDepth += 1;
|
|
1964
|
+
}
|
|
1965
|
+
// Reset reasoning timer when tools are being called (model is taking action)
|
|
1966
|
+
reasoningOnlyStartTime = null;
|
|
1967
|
+
hasReceivedMeaningfulContent = true;
|
|
1968
|
+
if (!toolsUsed.includes(toolName)) {
|
|
1969
|
+
toolsUsed.push(toolName);
|
|
1970
|
+
}
|
|
1971
|
+
const filePath = (args?.['file_path'] ?? args?.['path']);
|
|
1972
|
+
if (filePath && /edit|write|create|update/i.test(toolName)) {
|
|
1973
|
+
if (!filesModified.includes(filePath)) {
|
|
1974
|
+
filesModified.push(filePath);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
// Claude-Code action line: `⏺ ToolName(primaryArg)`. The dim
|
|
1978
|
+
// present-tense label drives the working spinner above the prompt.
|
|
1979
|
+
// parallel_agents is suppressed here — its per-sub-agent Task notes
|
|
1980
|
+
// (subagent.start/complete) are the visible surface instead of a
|
|
1981
|
+
// raw `parallel_agents({"tasks":…})` JSON dump.
|
|
1982
|
+
if (renderer && toolName !== 'parallel_agents') {
|
|
1983
|
+
renderer.addEvent('tool', formatToolCall(toolName, args, this.workingDir));
|
|
1984
|
+
}
|
|
1985
|
+
this.promptController?.setStatusMessage(toolActivityLabel(toolName, args, this.workingDir));
|
|
1986
|
+
break;
|
|
1987
|
+
}
|
|
1988
|
+
case 'tool.complete': {
|
|
1989
|
+
if (isHitlToolName(event.toolName)) {
|
|
1990
|
+
hitlDepth = Math.max(0, hitlDepth - 1);
|
|
1991
|
+
}
|
|
1992
|
+
// Keep the tail of tool output for the failure registry / governor
|
|
1993
|
+
// (errors land here, not in the assistant narration).
|
|
1994
|
+
if (typeof event.result === 'string' && event.result) {
|
|
1995
|
+
turnToolOutput = (turnToolOutput + '\n' + event.result).slice(-16000);
|
|
1996
|
+
}
|
|
1997
|
+
// Clear the activity label; the agent is thinking again.
|
|
1998
|
+
this.promptController?.setStatusMessage('Thinking…');
|
|
1999
|
+
// Reset reasoning timer after tool completes
|
|
2000
|
+
reasoningOnlyStartTime = null;
|
|
2001
|
+
// Render the result as a dim ` ⎿ …` block (summarised, never a
|
|
2002
|
+
// raw multi-KB dump). Pre-formatted ⏺ blocks (editTools) pass
|
|
2003
|
+
// through with just their duplicate header stripped.
|
|
2004
|
+
if (event.result && typeof event.result === 'string' && event.result.trim() && renderer) {
|
|
2005
|
+
const params = event.parameters;
|
|
2006
|
+
const summary = formatToolResult(event.toolName, event.result, params);
|
|
2007
|
+
renderer.addEvent('tool-result', summary);
|
|
2008
|
+
// Remember the full result so Ctrl+O can expand it — but only
|
|
2009
|
+
// when the summary actually truncated (the `(ctrl+o to expand)`
|
|
2010
|
+
// marker promises the affordance; without truncation there's
|
|
2011
|
+
// nothing to expand). Keeps that promise honest.
|
|
2012
|
+
this.lastExpandableResult = summary.includes('(ctrl+o to expand)')
|
|
2013
|
+
? { name: event.toolName, result: event.result, params }
|
|
2014
|
+
: null;
|
|
2015
|
+
}
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
case 'tool.error':
|
|
2019
|
+
if (isHitlToolName(event.toolName)) {
|
|
2020
|
+
hitlDepth = Math.max(0, hitlDepth - 1);
|
|
2021
|
+
}
|
|
2022
|
+
this.promptController?.setStatusMessage('Thinking…');
|
|
2023
|
+
if (renderer) {
|
|
2024
|
+
// Red ` ⎿ Error: …` line, mirroring a failed tool result.
|
|
2025
|
+
renderer.addEvent('error', formatToolError(event.error));
|
|
2026
|
+
}
|
|
2027
|
+
break;
|
|
2028
|
+
case 'error':
|
|
2029
|
+
if (renderer) {
|
|
2030
|
+
renderer.addEvent('error', event.error);
|
|
2031
|
+
}
|
|
2032
|
+
break;
|
|
2033
|
+
case 'usage': {
|
|
2034
|
+
// Meter cumulative DeepSeek consumption for /usage + the portal.
|
|
2035
|
+
recordDeepSeekUsage(event.inputTokens, event.outputTokens);
|
|
2036
|
+
// inputTokens = exactly what occupies the context window this turn.
|
|
2037
|
+
// The real model window (not a hardcoded guess) is the denominator
|
|
2038
|
+
// so "% context left" reflects the actual model.
|
|
2039
|
+
const contextTokens = event.inputTokens ?? event.totalTokens ?? null;
|
|
2040
|
+
if (typeof contextTokens === 'number' && contextTokens > 0) {
|
|
2041
|
+
this.lastInputTokens = contextTokens;
|
|
2042
|
+
}
|
|
2043
|
+
const windowTokens = getModelContextInfo(this.profileConfig.model).contextWindow;
|
|
2044
|
+
this.promptController?.setMetaStatus({
|
|
2045
|
+
tokensUsed: contextTokens,
|
|
2046
|
+
tokenLimit: windowTokens,
|
|
2047
|
+
});
|
|
2048
|
+
break;
|
|
2049
|
+
}
|
|
2050
|
+
case 'subagent.start':
|
|
2051
|
+
// A parallel sub-agent spawned — show it like Claude Code's Task.
|
|
2052
|
+
renderer?.addEvent('tool', formatSubAgentStart(event.description));
|
|
2053
|
+
this.promptController?.setStatusMessage(`Running sub-agent: ${event.description}`);
|
|
2054
|
+
break;
|
|
2055
|
+
case 'subagent.complete':
|
|
2056
|
+
renderer?.addEvent('system', chalk.dim(formatSubAgentComplete({
|
|
2057
|
+
description: event.description,
|
|
2058
|
+
success: event.success,
|
|
2059
|
+
elapsedMs: event.elapsedMs,
|
|
2060
|
+
})));
|
|
2061
|
+
break;
|
|
2062
|
+
case 'adversarial.findings':
|
|
2063
|
+
// The reviewer refuted this turn's draft — remember it so the
|
|
2064
|
+
// auto-continue loop can run a bounded re-fix (handled in finally).
|
|
2065
|
+
turnAdversarialFindings = event.findings;
|
|
2066
|
+
break;
|
|
2067
|
+
case 'context.compacted': {
|
|
2068
|
+
// The conversation was auto-compacted to stay within the window —
|
|
2069
|
+
// surface it as a dim note (Claude Code parity) instead of silently.
|
|
2070
|
+
renderer?.addEvent('system', chalk.dim(formatCompactionNote({
|
|
2071
|
+
removed: event.removed,
|
|
2072
|
+
freedTokens: event.freedTokens,
|
|
2073
|
+
summarized: event.summarized,
|
|
2074
|
+
percentage: event.percentage,
|
|
2075
|
+
})));
|
|
2076
|
+
break;
|
|
2077
|
+
}
|
|
2078
|
+
case 'provider.fallback': {
|
|
2079
|
+
// Display fallback notification
|
|
2080
|
+
if (renderer) {
|
|
2081
|
+
const fallbackMsg = chalk.yellow('⚠ ') +
|
|
2082
|
+
chalk.dim(`${event.fromProvider}/${event.fromModel} failed: `) +
|
|
2083
|
+
chalk.hex('#EF4444')(event.reason) +
|
|
2084
|
+
chalk.dim(' → switching to ') +
|
|
2085
|
+
chalk.hex('#34D399')(`${event.toProvider}/${event.toModel}`);
|
|
2086
|
+
renderer.addEvent('banner', fallbackMsg);
|
|
2087
|
+
}
|
|
2088
|
+
// Update the model context to reflect the new provider/model
|
|
2089
|
+
this.profileConfig = {
|
|
2090
|
+
...this.profileConfig,
|
|
2091
|
+
provider: event.toProvider,
|
|
2092
|
+
model: event.toModel,
|
|
2093
|
+
};
|
|
2094
|
+
this.promptController?.setModelContext({
|
|
2095
|
+
model: event.toModel,
|
|
2096
|
+
provider: event.toProvider,
|
|
2097
|
+
});
|
|
2098
|
+
break;
|
|
2099
|
+
}
|
|
2100
|
+
case 'edit.explanation':
|
|
2101
|
+
// Show explanation for edits made
|
|
2102
|
+
if (event.content && renderer) {
|
|
2103
|
+
const filesInfo = event.files?.length ? ` (${event.files.join(', ')})` : '';
|
|
2104
|
+
renderer.addEvent('response', `${event.content}${filesInfo}`);
|
|
2105
|
+
}
|
|
2106
|
+
break;
|
|
2107
|
+
}
|
|
2108
|
+
// Check reasoning timeout on EVERY iteration (not just when reasoning events arrive)
|
|
2109
|
+
// This ensures we bail out even if events are sparse
|
|
2110
|
+
// Use hasReceivedResponseContent (not hasReceivedMeaningfulContent) so timeout
|
|
2111
|
+
// still triggers after tool calls if model just reasons without responding
|
|
2112
|
+
if (reasoningOnlyStartTime && !hasReceivedResponseContent) {
|
|
2113
|
+
const reasoningElapsed = Date.now() - reasoningOnlyStartTime;
|
|
2114
|
+
if (reasoningElapsed > PROMPT_REASONING_TIMEOUT_MS) {
|
|
2115
|
+
if (renderer) {
|
|
2116
|
+
renderer.addEvent('response', chalk.yellow(`\nReasoning timeout (${Math.round(reasoningElapsed / 1000)}s)\n`));
|
|
2117
|
+
}
|
|
2118
|
+
reasoningTimedOut = true;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
// Check if reasoning timeout was triggered - break out of event loop
|
|
2122
|
+
if (reasoningTimedOut) {
|
|
2123
|
+
// Cancel the controller too; otherwise the for-await drain
|
|
2124
|
+
// exits but the agent keeps producing events and side-effects
|
|
2125
|
+
// for the next 30+ seconds with no UI to consume them.
|
|
2126
|
+
try {
|
|
2127
|
+
this.controller.cancel('reasoning timeout');
|
|
2128
|
+
}
|
|
2129
|
+
catch { /* best-effort */ }
|
|
2130
|
+
break;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
// After loop: synthesize from reasoning if no response was generated or timed out
|
|
2134
|
+
// This handles models like deepseek-v4-pro that output thinking but empty response
|
|
2135
|
+
// Also handles step timeouts where the model was stuck
|
|
2136
|
+
// IMPORTANT: Don't add "Next steps" when only reasoning occurred - only after real work
|
|
2137
|
+
if ((!episodeSuccess || reasoningTimedOut || stepTimedOut) && reasoningBuffer.trim() && !this.currentResponseBuffer.trim()) {
|
|
2138
|
+
const synthesized = this.synthesizeFromReasoning(reasoningBuffer);
|
|
2139
|
+
if (synthesized && renderer) {
|
|
2140
|
+
renderer.addEvent('stream', '\n' + synthesized);
|
|
2141
|
+
// Only add "Next steps" if tools were actually used (real work done)
|
|
2142
|
+
if (toolsUsed.length > 0) {
|
|
2143
|
+
const { appended } = ensureNextSteps(synthesized);
|
|
2144
|
+
if (appended?.trim()) {
|
|
2145
|
+
renderer.addEvent('stream', appended);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
renderer.addEvent('response', '\n');
|
|
2149
|
+
episodeSuccess = true;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
catch (error) {
|
|
2154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2155
|
+
if (renderer) {
|
|
2156
|
+
renderer.addEvent('error', message);
|
|
2157
|
+
}
|
|
2158
|
+
// Fallback: If we have reasoning content but no response was generated, synthesize one
|
|
2159
|
+
if (!episodeSuccess && reasoningBuffer.trim() && !this.currentResponseBuffer.trim()) {
|
|
2160
|
+
const synthesized = this.synthesizeFromReasoning(reasoningBuffer);
|
|
2161
|
+
if (synthesized && renderer) {
|
|
2162
|
+
renderer.addEvent('stream', '\n' + synthesized);
|
|
2163
|
+
renderer.addEvent('response', '\n');
|
|
2164
|
+
episodeSuccess = true; // Mark as partial success
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
finally {
|
|
2169
|
+
// Exit critical section - allow termination again
|
|
2170
|
+
exitCriticalSection();
|
|
2171
|
+
// Final fallback: If stream ended without message.complete but we have reasoning
|
|
2172
|
+
if (!episodeSuccess && reasoningBuffer.trim() && !this.currentResponseBuffer.trim()) {
|
|
2173
|
+
const synthesized = this.synthesizeFromReasoning(reasoningBuffer);
|
|
2174
|
+
if (synthesized && renderer) {
|
|
2175
|
+
renderer.addEvent('stream', '\n' + synthesized);
|
|
2176
|
+
// Only add "Next steps" if tools were actually used (real work done)
|
|
2177
|
+
if (toolsUsed.length > 0) {
|
|
2178
|
+
const { appended } = ensureNextSteps(synthesized);
|
|
2179
|
+
if (appended?.trim()) {
|
|
2180
|
+
renderer.addEvent('stream', appended);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
renderer.addEvent('response', '\n');
|
|
2184
|
+
episodeSuccess = true;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
// Detect a model safety refusal in the just-finished turn. When the
|
|
2188
|
+
// model declines the request, the request is *done* — auto-continue
|
|
2189
|
+
// would just resubmit "continue" and start a new spinner cycle, which
|
|
2190
|
+
// is what produced the stuck "Thinking… (4m N s)" timer the user saw.
|
|
2191
|
+
const refusedTurn = isSafetyRefusal(this.finalResponseText);
|
|
2192
|
+
this.isProcessing = false;
|
|
2193
|
+
this.promptController?.setStreaming(false);
|
|
2194
|
+
this.promptController?.setStatusMessage(null);
|
|
2195
|
+
// Belt-and-suspenders: explicitly clear the activity message so the
|
|
2196
|
+
// "Thinking… (esc to interrupt · Ns)" line doesn't linger after the
|
|
2197
|
+
// final reply if setMode→stopSpinnerAnimation races with another
|
|
2198
|
+
// renderPrompt tick.
|
|
2199
|
+
this.promptController?.setActivityMessage(null);
|
|
2200
|
+
// Force an idle re-render so the spinner area is repainted without
|
|
2201
|
+
// the streaming activity line. setStreaming(false) → setMode('idle')
|
|
2202
|
+
// already calls renderPrompt(), but a coalesced spinner tick that
|
|
2203
|
+
// races with the transition can leave the last "Thinking… (Ns)"
|
|
2204
|
+
// frame on screen until the next event. forceRender squashes it.
|
|
2205
|
+
this.promptController?.forceRender();
|
|
2206
|
+
// Clear any transient follow-up queue UI when we return to idle.
|
|
2207
|
+
const r = this.promptController?.getRenderer();
|
|
2208
|
+
r?.setFollowUpQueueMode(false);
|
|
2209
|
+
r?.setQueuedPrompts([]);
|
|
2210
|
+
// Note: pendingPrompts may still have items if a drain just started
|
|
2211
|
+
// a new processPrompt; the new run will manage the list.
|
|
2212
|
+
// Snapshot this turn's full output (tool results + narration) BEFORE the
|
|
2213
|
+
// buffer is cleared — the auto-continue governor + failure registry need
|
|
2214
|
+
// the real error text, which the reset below would otherwise wipe.
|
|
2215
|
+
const combinedTurnOutput = (turnToolOutput + '\n' + this.finalResponseText).slice(-16000);
|
|
2216
|
+
this.currentResponseBuffer = '';
|
|
2217
|
+
// Autosave the conversation so /resume has something to restore. Each
|
|
2218
|
+
// turn updates the same snapshot in place (keyed by this.sessionId).
|
|
2219
|
+
this.persistSessionSnapshot();
|
|
2220
|
+
// Process any queued prompts (late safety net; primary drain is now per-turn
|
|
2221
|
+
// after each assistant response for "ASAP before the running prompt finishes").
|
|
2222
|
+
if (this.pendingPrompts.length > 0 && !this.shouldExit) {
|
|
2223
|
+
const next = this.pendingPrompts.shift();
|
|
2224
|
+
if (next) {
|
|
2225
|
+
const r = this.promptController?.getRenderer();
|
|
2226
|
+
r?.setFollowUpQueueMode(false);
|
|
2227
|
+
r?.addUserHistoryItem(next);
|
|
2228
|
+
r?.setQueuedPrompts(this.pendingPrompts.slice());
|
|
2229
|
+
await this.processPrompt(next);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
else if (refusedTurn) {
|
|
2233
|
+
// Refusal terminates the turn. Don't re-prompt the model — the
|
|
2234
|
+
// user's request is finished from the agent's side. Clear the
|
|
2235
|
+
// stored "original prompt" so a stray Alt+G later doesn't pick
|
|
2236
|
+
// up where this turn left off.
|
|
2237
|
+
this.originalPromptForAutoContinue = null;
|
|
2238
|
+
}
|
|
2239
|
+
else if (!this.shouldExit && !this.userInterruptedRun) {
|
|
2240
|
+
// Auto mode: keep running until user's prompt is fully completed.
|
|
2241
|
+
// Skipped after a Ctrl+C interrupt so we don't immediately resume
|
|
2242
|
+
// the work the user just cancelled.
|
|
2243
|
+
const autoMode = this.promptController?.getAutoMode() ?? 'off';
|
|
2244
|
+
if (autoMode !== 'off') {
|
|
2245
|
+
// Check if original user prompt is fully completed
|
|
2246
|
+
const detector = getTaskCompletionDetector();
|
|
2247
|
+
const analysis = detector.analyzeCompletion(this.finalResponseText, toolsUsed);
|
|
2248
|
+
// Record this turn with the governor (bounds the loop + detects a
|
|
2249
|
+
// stall: the same tools/files/failure repeating with no new progress)
|
|
2250
|
+
// and the failure registry (catches the same error recurring across
|
|
2251
|
+
// NON-consecutive turns — a thrash the stall check would miss).
|
|
2252
|
+
this.autoGovernor.recordTurn({
|
|
2253
|
+
toolsUsed,
|
|
2254
|
+
filesModified,
|
|
2255
|
+
failingSignal: detectFailingTestOrBuild(combinedTurnOutput),
|
|
2256
|
+
});
|
|
2257
|
+
this.failureRegistry.trackTurn(combinedTurnOutput);
|
|
2258
|
+
const gov = this.autoGovernor.check();
|
|
2259
|
+
const failureNudge = this.failureRegistry.nudge();
|
|
2260
|
+
const todos = getCurrentTodos();
|
|
2261
|
+
const pending = pendingTodos(todos);
|
|
2262
|
+
if (gov.stop) {
|
|
2263
|
+
// Yield to the user WITH state instead of thrashing forever.
|
|
2264
|
+
const note = gov.reason === 'limit'
|
|
2265
|
+
? `Paused after ${gov.turn} auto-continue turns (turn limit).${pending.length ? ` ${pending.length} task${pending.length === 1 ? '' : 's'} still pending` : ''} — say "continue" to keep going.`
|
|
2266
|
+
: `Paused: no new progress over the last few turns (same actions repeating).${pending.length ? ` ${pending.length} task${pending.length === 1 ? '' : 's'} pending` : ''} — tell me how to proceed.`;
|
|
2267
|
+
this.promptController?.getRenderer()?.addEvent('system', chalk.dim(note));
|
|
2268
|
+
this.promptController?.setStatusMessage(null);
|
|
2269
|
+
this.originalPromptForAutoContinue = null;
|
|
2270
|
+
}
|
|
2271
|
+
else if (turnAdversarialFindings && this.adversarialCorrectionCount < MAX_ADVERSARIAL_CORRECTIONS) {
|
|
2272
|
+
// The reviewer refuted this turn's draft — re-run the FULL tool loop
|
|
2273
|
+
// to actually fix the findings (not just show the caveat), bounded
|
|
2274
|
+
// by the governor + this per-request cap.
|
|
2275
|
+
this.adversarialCorrectionCount += 1;
|
|
2276
|
+
this.promptController?.setStatusMessage('Addressing reviewer findings…');
|
|
2277
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
2278
|
+
await this.processPrompt(buildAdversarialCorrectionPrompt(turnAdversarialFindings));
|
|
2279
|
+
}
|
|
2280
|
+
else if (!analysis.isComplete || pending.length > 0) {
|
|
2281
|
+
// Continue — but only stop when the LIVE PLAN is also clear: pending
|
|
2282
|
+
// todos force a continue even if the response sounded "done".
|
|
2283
|
+
this.promptController?.setStatusMessage('Continuing...');
|
|
2284
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
2285
|
+
// Prefer the plan's next task; fall back to the response heuristic.
|
|
2286
|
+
const base = nextTodoPrompt(todos)
|
|
2287
|
+
?? this.generateAutoContinuePrompt(this.originalPromptForAutoContinue || '', combinedTurnOutput, toolsUsed)
|
|
2288
|
+
?? 'continue';
|
|
2289
|
+
// When a failure keeps recurring, lead with the change-approach nudge.
|
|
2290
|
+
// Keep an IMPORTANT: prefix so this counts as an auto-continue (not a
|
|
2291
|
+
// fresh user prompt, which would reset the governor).
|
|
2292
|
+
const autoPrompt = failureNudge
|
|
2293
|
+
? `IMPORTANT: ${failureNudge}\n\n${base.replace(/^IMPORTANT:\s*/, '')}`
|
|
2294
|
+
: base;
|
|
2295
|
+
await this.processPrompt(autoPrompt);
|
|
2296
|
+
}
|
|
2297
|
+
else {
|
|
2298
|
+
this.promptController?.setStatusMessage('Task complete');
|
|
2299
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
generateAutoContinuePrompt(originalPrompt, response, toolsUsed) {
|
|
2306
|
+
// Highest-priority signal: a test or build is currently failing
|
|
2307
|
+
// in the visible output. Override every other heuristic and force
|
|
2308
|
+
// a sharp, focused next-action prompt — the agent must drill into
|
|
2309
|
+
// the FIRST failure rather than declaring victory.
|
|
2310
|
+
const failingSignal = detectFailingTestOrBuild(response);
|
|
2311
|
+
if (failingSignal) {
|
|
2312
|
+
const noDocsInstruction = `IMPORTANT: Do NOT create markdown files, documentation, summaries, or reports.`;
|
|
2313
|
+
return `${noDocsInstruction} The output above shows a failing test/build (${failingSignal}). Read the FIRST failure carefully, identify the root cause, edit exactly the file(s) needed, then re-run the same test/build command to confirm. Do not stop until that command exits cleanly.`;
|
|
2314
|
+
}
|
|
2315
|
+
// Only auto-continue for certain types of work
|
|
2316
|
+
const hasFileOperations = toolsUsed.some(t => ['Read', 'Write', 'Edit', 'Search', 'Grep'].includes(t));
|
|
2317
|
+
const hasBashOperations = toolsUsed.includes('Bash');
|
|
2318
|
+
if (!hasFileOperations && !hasBashOperations) {
|
|
2319
|
+
return null; // No meaningful work to continue
|
|
2320
|
+
}
|
|
2321
|
+
// Analyze response to determine what to do next
|
|
2322
|
+
const lowercaseResponse = response.toLowerCase();
|
|
2323
|
+
// Check for common patterns that indicate more work is needed
|
|
2324
|
+
if (lowercaseResponse.includes('next steps') ||
|
|
2325
|
+
lowercaseResponse.includes('further') ||
|
|
2326
|
+
lowercaseResponse.includes('additional') ||
|
|
2327
|
+
lowercaseResponse.includes('implement') ||
|
|
2328
|
+
lowercaseResponse.includes('complete') ||
|
|
2329
|
+
lowercaseResponse.includes('finish')) {
|
|
2330
|
+
// Core instruction to prevent documentation spam
|
|
2331
|
+
const noDocsInstruction = `IMPORTANT: Do NOT create markdown files, documentation, summaries, or reports. Focus only on the actual code/implementation work. Perform the next concrete action in the codebase.`;
|
|
2332
|
+
// Generate a follow-up prompt based on the original task
|
|
2333
|
+
if (originalPrompt.includes('fix') || originalPrompt.includes('bug')) {
|
|
2334
|
+
return `${noDocsInstruction} Continue fixing - edit the next file that needs changes.`;
|
|
2335
|
+
}
|
|
2336
|
+
else if (originalPrompt.includes('implement') || originalPrompt.includes('add')) {
|
|
2337
|
+
return `${noDocsInstruction} Continue implementing - write or edit the next piece of code.`;
|
|
2338
|
+
}
|
|
2339
|
+
else if (originalPrompt.includes('refactor') || originalPrompt.includes('clean')) {
|
|
2340
|
+
return `${noDocsInstruction} Continue refactoring - apply changes to the next file.`;
|
|
2341
|
+
}
|
|
2342
|
+
else if (originalPrompt.includes('test')) {
|
|
2343
|
+
return `${noDocsInstruction} Continue with tests - run or fix the next test.`;
|
|
2344
|
+
}
|
|
2345
|
+
else if (originalPrompt.includes('build') || originalPrompt.includes('deploy') || originalPrompt.includes('publish')) {
|
|
2346
|
+
return `${noDocsInstruction} Continue the build/deploy process - execute the next command.`;
|
|
2347
|
+
}
|
|
2348
|
+
else {
|
|
2349
|
+
return `${noDocsInstruction} Continue with the original task "${originalPrompt.slice(0, 100)}..." - perform the next action.`;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
return null;
|
|
2353
|
+
}
|
|
2354
|
+
handleInterrupt() {
|
|
2355
|
+
if (!this.isProcessing) {
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
const renderer = this.promptController?.getRenderer();
|
|
2359
|
+
if (renderer) {
|
|
2360
|
+
renderer.addEvent('banner', chalk.yellow('Interrupted'));
|
|
2361
|
+
}
|
|
2362
|
+
// Actually cancel the in-flight controller run. Without this the
|
|
2363
|
+
// for-await loop in processPrompt keeps consuming events, the spinner
|
|
2364
|
+
// stays up, and the agent grinds through the rest of its tool loop
|
|
2365
|
+
// while the user sees only a "Interrupted" banner. cancel() is a no-op
|
|
2366
|
+
// when there's no active sink, so this is safe to call unconditionally.
|
|
2367
|
+
try {
|
|
2368
|
+
this.controller.cancel('user interrupt via Ctrl+C');
|
|
2369
|
+
}
|
|
2370
|
+
catch {
|
|
2371
|
+
// Best-effort; if the controller is already torn down the next
|
|
2372
|
+
// Ctrl+C will fall through to authorizedShutdown.
|
|
2373
|
+
}
|
|
2374
|
+
// Suppress the auto-continue re-launch in processPrompt's finally
|
|
2375
|
+
// block. Otherwise the agent immediately starts a fresh "continue"
|
|
2376
|
+
// cycle 500ms later and the user has to keep mashing Ctrl+C to keep
|
|
2377
|
+
// up. Cleared when the user submits a new prompt.
|
|
2378
|
+
this.userInterruptedRun = true;
|
|
2379
|
+
}
|
|
2380
|
+
handleAutoContinueToggle() {
|
|
2381
|
+
const autoMode = this.promptController?.getAutoMode() ?? 'off';
|
|
2382
|
+
this.promptController?.setStatusMessage(`Auto: ${autoMode}`);
|
|
2383
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 1500);
|
|
2384
|
+
// Reset task completion detector when entering any auto mode
|
|
2385
|
+
if (autoMode !== 'off') {
|
|
2386
|
+
const detector = getTaskCompletionDetector();
|
|
2387
|
+
detector.reset();
|
|
2388
|
+
// Clear any stored original prompt
|
|
2389
|
+
this.originalPromptForAutoContinue = null;
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
handleHITLToggle() {
|
|
2393
|
+
const mode = this.promptController?.getModeToggleState().hitlMode ?? 'off';
|
|
2394
|
+
getHITL().updateConfig({ autoPause: mode === 'on' });
|
|
2395
|
+
this.promptController?.setStatusMessage(`HITL: ${mode}`);
|
|
2396
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 1500);
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Shift+Tab cycled the permission mode. The hint line under the input box
|
|
2400
|
+
* already shows the active mode; this surfaces a brief one-line note in
|
|
2401
|
+
* the chat so the change is unmistakable, matching how Claude Code echoes
|
|
2402
|
+
* a mode switch.
|
|
2403
|
+
*/
|
|
2404
|
+
handlePermissionModeChange(mode) {
|
|
2405
|
+
const note = mode === 'plan'
|
|
2406
|
+
? 'plan mode — read-only; I won’t edit files or run commands until you approve a plan'
|
|
2407
|
+
: mode === 'acceptEdits'
|
|
2408
|
+
? 'accept edits on — file edits apply without the adversarial pre-flight'
|
|
2409
|
+
: 'default mode';
|
|
2410
|
+
this.promptController?.setStatusMessage(note);
|
|
2411
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 2500);
|
|
2412
|
+
}
|
|
2413
|
+
/**
|
|
2414
|
+
* Ctrl+O — expand the last truncated tool result. The `(ctrl+o to expand)`
|
|
2415
|
+
* marker promises this; we honor it by re-emitting the SAME tool result with
|
|
2416
|
+
* no line cap (a huge maxLines), appended below as a dim block. If nothing is
|
|
2417
|
+
* pending (the last result fit on screen), a brief status note says so rather
|
|
2418
|
+
* than silently doing nothing.
|
|
2419
|
+
*/
|
|
2420
|
+
handleExpandToolResult() {
|
|
2421
|
+
const last = this.lastExpandableResult;
|
|
2422
|
+
const renderer = this.promptController?.getRenderer();
|
|
2423
|
+
if (!last || !renderer) {
|
|
2424
|
+
this.promptController?.setStatusMessage('Nothing to expand');
|
|
2425
|
+
setTimeout(() => this.promptController?.setStatusMessage(null), 1500);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
// Re-render the full result (no truncation). One expand per result.
|
|
2429
|
+
this.lastExpandableResult = null;
|
|
2430
|
+
renderer.addEvent('tool-result', formatToolResult(last.name, last.result, last.params, { maxLines: 100000 }));
|
|
2431
|
+
}
|
|
2432
|
+
handleCtrlC(info) {
|
|
2433
|
+
const now = Date.now();
|
|
2434
|
+
// Reset count if more than 2 seconds since last Ctrl+C
|
|
2435
|
+
if (now - this.lastCtrlCTime > 2000) {
|
|
2436
|
+
this.ctrlCCount = 0;
|
|
2437
|
+
}
|
|
2438
|
+
this.lastCtrlCTime = now;
|
|
2439
|
+
this.ctrlCCount++;
|
|
2440
|
+
if (info.hadBuffer) {
|
|
2441
|
+
// Clear buffer, reset count
|
|
2442
|
+
this.ctrlCCount = 0;
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
// Always allow double Ctrl+C to exit, even while processing
|
|
2446
|
+
if (this.ctrlCCount >= 2) {
|
|
2447
|
+
// Use authorized shutdown to bypass anti-termination guard
|
|
2448
|
+
void authorizedShutdown(0);
|
|
2449
|
+
this.shouldExit = true;
|
|
2450
|
+
this.ctrlCCount = 0;
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
if (this.isProcessing) {
|
|
2454
|
+
// Interrupt processing on first Ctrl+C, then allow next Ctrl+C to exit
|
|
2455
|
+
this.handleInterrupt();
|
|
2456
|
+
const renderer = this.promptController?.getRenderer();
|
|
2457
|
+
if (renderer) {
|
|
2458
|
+
renderer.addEvent('banner', chalk.dim('Press Ctrl+C again to exit'));
|
|
2459
|
+
}
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
// First Ctrl+C when idle: show hint
|
|
2463
|
+
const renderer = this.promptController?.getRenderer();
|
|
2464
|
+
if (renderer) {
|
|
2465
|
+
renderer.addEvent('banner', chalk.dim('Press Ctrl+C again to exit'));
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
handleExit() {
|
|
2469
|
+
this.shouldExit = true;
|
|
2470
|
+
this.cleanupSudoPasswordHandler();
|
|
2471
|
+
this.promptController?.stop();
|
|
2472
|
+
void authorizedShutdown(0);
|
|
2473
|
+
}
|
|
2474
|
+
waitForExit() {
|
|
2475
|
+
return new Promise((resolve) => {
|
|
2476
|
+
const check = () => {
|
|
2477
|
+
if (this.shouldExit) {
|
|
2478
|
+
resolve();
|
|
2479
|
+
}
|
|
2480
|
+
else {
|
|
2481
|
+
setTimeout(check, 100);
|
|
2482
|
+
}
|
|
2483
|
+
};
|
|
2484
|
+
check();
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
// The --profile / -p flag was removed; the only call site passes nothing.
|
|
2489
|
+
// We retain the function as a single source of truth for the hardcoded
|
|
2490
|
+
// profile name that downstream config (agent prompt, model, rulebook)
|
|
2491
|
+
// keys off of.
|
|
2492
|
+
function resolveProfile() {
|
|
2493
|
+
return 'trenchwork-code';
|
|
2494
|
+
}
|
|
2495
|
+
//# sourceMappingURL=interactiveShell.js.map
|