流出したclaude codeのソースコード1

@Kongyokongyo / 更新: 2026/04/19 12:41

2mbに収まるように複数分割されてるのは悪しからず

MD 1.95MB
1

Directory Structure

src/ assistant/ sessionHistory.ts bootstrap/ state.ts bridge/ bridgeApi.ts bridgeConfig.ts bridgeDebug.ts bridgeEnabled.ts bridgeMain.ts bridgeMessaging.ts bridgePermissionCallbacks.ts bridgePointer.ts bridgeStatusUtil.ts bridgeUI.ts capacityWake.ts codeSessionApi.ts createSession.ts debugUtils.ts envLessBridgeConfig.ts flushGate.ts inboundAttachments.ts inboundMessages.ts initReplBridge.ts jwtUtils.ts pollConfig.ts pollConfigDefaults.ts remoteBridgeCore.ts replBridge.ts replBridgeHandle.ts replBridgeTransport.ts sessionIdCompat.ts sessionRunner.ts trustedDevice.ts types.ts workSecret.ts buddy/ companion.ts CompanionSprite.tsx prompt.ts sprites.ts types.ts useBuddyNotification.tsx cli/ handlers/ agents.ts auth.ts autoMode.ts mcp.tsx plugins.ts util.tsx transports/ ccrClient.ts HybridTransport.ts SerialBatchEventUploader.ts SSETransport.ts transportUtils.ts WebSocketTransport.ts WorkerStateUploader.ts exit.ts ndjsonSafeStringify.ts print.ts remoteIO.ts structuredIO.ts update.ts commands/ add-dir/ add-dir.tsx index.ts validation.ts agents/ agents.tsx index.ts ant-trace/ index.js autofix-pr/ index.js backfill-sessions/ index.js branch/ branch.ts index.ts break-cache/ index.js bridge/ bridge.tsx index.ts btw/ btw.tsx index.ts bughunter/ index.js chrome/ chrome.tsx index.ts clear/ caches.ts clear.ts conversation.ts index.ts color/ color.ts index.ts compact/ compact.ts index.ts config/ config.tsx index.ts context/ context-noninteractive.ts context.tsx index.ts copy/ copy.tsx index.ts cost/ cost.ts index.ts ctx_viz/ index.js debug-tool-call/ index.js desktop/ desktop.tsx index.ts diff/ diff.tsx index.ts doctor/ doctor.tsx index.ts effort/ effort.tsx index.ts env/ index.js exit/ exit.tsx index.ts export/ export.tsx index.ts extra-usage/ extra-usage-core.ts extra-usage-noninteractive.ts extra-usage.tsx index.ts fast/ fast.tsx index.ts feedback/ feedback.tsx index.ts files/ files.ts index.ts good-claude/ index.js heapdump/ heapdump.ts index.ts help/ help.tsx index.ts hooks/ hooks.tsx index.ts ide/ ide.tsx index.ts install-github-app/ ApiKeyStep.tsx CheckExistingSecretStep.tsx CheckGitHubStep.tsx ChooseRepoStep.tsx CreatingStep.tsx ErrorStep.tsx ExistingWorkflowStep.tsx index.ts install-github-app.tsx InstallAppStep.tsx OAuthFlowStep.tsx setupGitHubActions.ts SuccessStep.tsx WarningsStep.tsx install-slack-app/ index.ts install-slack-app.ts issue/ index.js keybindings/ index.ts keybindings.ts login/ index.ts login.tsx logout/ index.ts logout.tsx mcp/ addCommand.ts index.ts mcp.tsx xaaIdpCommand.ts memory/ index.ts memory.tsx mobile/ index.ts mobile.tsx mock-limits/ index.js model/ index.ts model.tsx oauth-refresh/ index.js onboarding/ index.js output-style/ index.ts output-style.tsx passes/ index.ts passes.tsx perf-issue/ index.js permissions/ index.ts permissions.tsx plan/ index.ts plan.tsx plugin/ AddMarketplace.tsx BrowseMarketplace.tsx DiscoverPlugins.tsx index.tsx ManageMarketplaces.tsx ManagePlugins.tsx parseArgs.ts plugin.tsx pluginDetailsHelpers.tsx PluginErrors.tsx PluginOptionsDialog.tsx PluginOptionsFlow.tsx PluginSettings.tsx PluginTrustWarning.tsx UnifiedInstalledCell.tsx usePagination.ts ValidatePlugin.tsx pr_comments/ index.ts privacy-settings/ index.ts privacy-settings.tsx rate-limit-options/ index.ts rate-limit-options.tsx release-notes/ index.ts release-notes.ts reload-plugins/ index.ts reload-plugins.ts remote-env/ index.ts remote-env.tsx remote-setup/ api.ts index.ts remote-setup.tsx rename/ generateSessionName.ts index.ts rename.ts reset-limits/ index.js resume/ index.ts resume.tsx review/ reviewRemote.ts ultrareviewCommand.tsx ultrareviewEnabled.ts UltrareviewOverageDialog.tsx rewind/ index.ts rewind.ts sandbox-toggle/ index.ts sandbox-toggle.tsx session/ index.ts session.tsx share/ index.js skills/ index.ts skills.tsx stats/ index.ts stats.tsx status/ index.ts status.tsx stickers/ index.ts stickers.ts summary/ index.js tag/ index.ts tag.tsx tasks/ index.ts tasks.tsx teleport/ index.js terminalSetup/ index.ts terminalSetup.tsx theme/ index.ts theme.tsx thinkback/ index.ts thinkback.tsx thinkback-play/ index.ts thinkback-play.ts upgrade/ index.ts upgrade.tsx usage/ index.ts usage.tsx vim/ index.ts vim.ts voice/ index.ts voice.ts advisor.ts bridge-kick.ts brief.ts commit-push-pr.ts commit.ts createMovedToPluginCommand.ts init-verifiers.ts init.ts insights.ts install.tsx review.ts security-review.ts statusline.tsx ultraplan.tsx version.ts components/ agents/ new-agent-creation/ wizard-steps/ ColorStep.tsx ConfirmStep.tsx ConfirmStepWrapper.tsx DescriptionStep.tsx GenerateStep.tsx LocationStep.tsx MemoryStep.tsx MethodStep.tsx ModelStep.tsx PromptStep.tsx ToolsStep.tsx TypeStep.tsx CreateAgentWizard.tsx AgentDetail.tsx AgentEditor.tsx agentFileUtils.ts AgentNavigationFooter.tsx AgentsList.tsx AgentsMenu.tsx ColorPicker.tsx generateAgent.ts ModelSelector.tsx ToolSelector.tsx types.ts utils.ts validateAgent.ts ClaudeCodeHint/ PluginHintMenu.tsx CustomSelect/ index.ts option-map.ts select-input-option.tsx select-option.tsx select.tsx SelectMulti.tsx use-multi-select-state.ts use-select-input.ts use-select-navigation.ts use-select-state.ts design-system/ Byline.tsx color.ts Dialog.tsx Divider.tsx FuzzyPicker.tsx KeyboardShortcutHint.tsx ListItem.tsx LoadingState.tsx Pane.tsx ProgressBar.tsx Ratchet.tsx StatusIcon.tsx Tabs.tsx ThemedBox.tsx ThemedText.tsx ThemeProvider.tsx DesktopUpsell/ DesktopUpsellStartup.tsx diff/ DiffDetailView.tsx DiffDialog.tsx DiffFileList.tsx FeedbackSurvey/ FeedbackSurvey.tsx FeedbackSurveyView.tsx submitTranscriptShare.ts TranscriptSharePrompt.tsx useDebouncedDigitInput.ts useFeedbackSurvey.tsx useMemorySurvey.tsx usePostCompactSurvey.tsx useSurveyState.tsx grove/ Grove.tsx HelpV2/ Commands.tsx General.tsx HelpV2.tsx HighlightedCode/ Fallback.tsx hooks/ HooksConfigMenu.tsx PromptDialog.tsx SelectEventMode.tsx SelectHookMode.tsx SelectMatcherMode.tsx ViewHookMode.tsx LogoV2/ AnimatedAsterisk.tsx AnimatedClawd.tsx ChannelsNotice.tsx Clawd.tsx CondensedLogo.tsx EmergencyTip.tsx Feed.tsx FeedColumn.tsx feedConfigs.tsx GuestPassesUpsell.tsx LogoV2.tsx Opus1mMergeNotice.tsx OverageCreditUpsell.tsx VoiceModeNotice.tsx WelcomeV2.tsx LspRecommendation/ LspRecommendationMenu.tsx ManagedSettingsSecurityDialog/ ManagedSettingsSecurityDialog.tsx utils.ts mcp/ utils/ reconnectHelpers.tsx CapabilitiesSection.tsx ElicitationDialog.tsx index.ts MCPAgentServerMenu.tsx MCPListPanel.tsx McpParsingWarnings.tsx MCPReconnect.tsx MCPRemoteServerMenu.tsx MCPSettings.tsx MCPStdioServerMenu.tsx MCPToolDetailView.tsx MCPToolListView.tsx memory/ MemoryFileSelector.tsx MemoryUpdateNotification.tsx messages/ UserToolResultMessage/ RejectedPlanMessage.tsx RejectedToolUseMessage.tsx UserToolCanceledMessage.tsx UserToolErrorMessage.tsx UserToolRejectMessage.tsx UserToolResultMessage.tsx UserToolSuccessMessage.tsx utils.tsx AdvisorMessage.tsx AssistantRedactedThinkingMessage.tsx AssistantTextMessage.tsx AssistantThinkingMessage.tsx AssistantToolUseMessage.tsx AttachmentMessage.tsx CollapsedReadSearchContent.tsx CompactBoundaryMessage.tsx GroupedToolUseContent.tsx HighlightedThinkingText.tsx HookProgressMessage.tsx nullRenderingAttachments.ts PlanApprovalMessage.tsx RateLimitMessage.tsx ShutdownMessage.tsx SystemAPIErrorMessage.tsx SystemTextMessage.tsx TaskAssignmentMessage.tsx teamMemCollapsed.tsx teamMemSaved.ts UserAgentNotificationMessage.tsx UserBashInputMessage.tsx UserBashOutputMessage.tsx UserChannelMessage.tsx UserCommandMessage.tsx UserImageMessage.tsx UserLocalCommandOutputMessage.tsx UserMemoryInputMessage.tsx UserPlanMessage.tsx UserPromptMessage.tsx UserResourceUpdateMessage.tsx UserTeammateMessage.tsx UserTextMessage.tsx Passes/ Passes.tsx permissions/ AskUserQuestionPermissionRequest/ AskUserQuestionPermissionRequest.tsx PreviewBox.tsx PreviewQuestionView.tsx QuestionNavigationBar.tsx QuestionView.tsx SubmitQuestionsView.tsx use-multiple-choice-state.ts BashPermissionRequest/ BashPermissionRequest.tsx bashToolUseOptions.tsx ComputerUseApproval/ ComputerUseApproval.tsx EnterPlanModePermissionRequest/ EnterPlanModePermissionRequest.tsx ExitPlanModePermissionRequest/ ExitPlanModePermissionRequest.tsx FileEditPermissionRequest/ FileEditPermissionRequest.tsx FilePermissionDialog/ FilePermissionDialog.tsx ideDiffConfig.ts permissionOptions.tsx useFilePermissionDialog.ts usePermissionHandler.ts FilesystemPermissionRequest/ FilesystemPermissionRequest.tsx FileWritePermissionRequest/ FileWritePermissionRequest.tsx FileWriteToolDiff.tsx NotebookEditPermissionRequest/ NotebookEditPermissionRequest.tsx NotebookEditToolDiff.tsx PowerShellPermissionRequest/ PowerShellPermissionRequest.tsx powershellToolUseOptions.tsx rules/ AddPermissionRules.tsx AddWorkspaceDirectory.tsx PermissionRuleDescription.tsx PermissionRuleInput.tsx PermissionRuleList.tsx RecentDenialsTab.tsx RemoveWorkspaceDirectory.tsx WorkspaceTab.tsx SedEditPermissionRequest/ SedEditPermissionRequest.tsx SkillPermissionRequest/ SkillPermissionRequest.tsx WebFetchPermissionRequest/ WebFetchPermissionRequest.tsx FallbackPermissionRequest.tsx hooks.ts PermissionDecisionDebugInfo.tsx PermissionDialog.tsx PermissionExplanation.tsx PermissionPrompt.tsx PermissionRequest.tsx PermissionRequestTitle.tsx PermissionRuleExplanation.tsx SandboxPermissionRequest.tsx shellPermissionHelpers.tsx useShellPermissionFeedback.ts utils.ts WorkerBadge.tsx WorkerPendingPermission.tsx PromptInput/ HistorySearchInput.tsx inputModes.ts inputPaste.ts IssueFlagBanner.tsx Notifications.tsx PromptInput.tsx PromptInputFooter.tsx PromptInputFooterLeftSide.tsx PromptInputFooterSuggestions.tsx PromptInputHelpMenu.tsx PromptInputModeIndicator.tsx PromptInputQueuedCommands.tsx PromptInputStashNotice.tsx SandboxPromptFooterHint.tsx ShimmeredInput.tsx useMaybeTruncateInput.ts usePromptInputPlaceholder.ts useShowFastIconHint.ts useSwarmBanner.ts utils.ts VoiceIndicator.tsx sandbox/ SandboxConfigTab.tsx SandboxDependenciesTab.tsx SandboxDoctorSection.tsx SandboxOverridesTab.tsx SandboxSettings.tsx Settings/ Config.tsx Settings.tsx Status.tsx Usage.tsx shell/ ExpandShellOutputContext.tsx OutputLine.tsx ShellProgressMessage.tsx ShellTimeDisplay.tsx skills/ SkillsMenu.tsx Spinner/ FlashingChar.tsx GlimmerMessage.tsx index.ts ShimmerChar.tsx SpinnerAnimationRow.tsx SpinnerGlyph.tsx teammateSelectHint.ts TeammateSpinnerLine.tsx TeammateSpinnerTree.tsx useShimmerAnimation.ts useStalledAnimation.ts utils.ts StructuredDiff/ colorDiff.ts Fallback.tsx tasks/ AsyncAgentDetailDialog.tsx BackgroundTask.tsx BackgroundTasksDialog.tsx BackgroundTaskStatus.tsx DreamDetailDialog.tsx InProcessTeammateDetailDialog.tsx RemoteSessionDetailDialog.tsx RemoteSessionProgress.tsx renderToolActivity.tsx ShellDetailDialog.tsx ShellProgress.tsx taskStatusUtils.tsx teams/ TeamsDialog.tsx TeamStatus.tsx TrustDialog/ TrustDialog.tsx utils.ts ui/ OrderedList.tsx OrderedListItem.tsx TreeSelect.tsx wizard/ index.ts useWizard.ts WizardDialogLayout.tsx WizardNavigationFooter.tsx WizardProvider.tsx AgentProgressLine.tsx App.tsx ApproveApiKey.tsx AutoModeOptInDialog.tsx AutoUpdater.tsx AutoUpdaterWrapper.tsx AwsAuthStatusBox.tsx BaseTextInput.tsx BashModeProgress.tsx BridgeDialog.tsx BypassPermissionsModeDialog.tsx ChannelDowngradeDialog.tsx ClaudeInChromeOnboarding.tsx ClaudeMdExternalIncludesDialog.tsx ClickableImageRef.tsx CompactSummary.tsx ConfigurableShortcutHint.tsx ConsoleOAuthFlow.tsx ContextSuggestions.tsx ContextVisualization.tsx CoordinatorAgentStatus.tsx CostThresholdDialog.tsx CtrlOToExpand.tsx DesktopHandoff.tsx DevBar.tsx DevChannelsDialog.tsx DiagnosticsDisplay.tsx EffortCallout.tsx EffortIndicator.ts ExitFlow.tsx ExportDialog.tsx FallbackToolUseErrorMessage.tsx FallbackToolUseRejectedMessage.tsx FastIcon.tsx Feedback.tsx FileEditToolDiff.tsx FileEditToolUpdatedMessage.tsx FileEditToolUseRejectedMessage.tsx FilePathLink.tsx FullscreenLayout.tsx GlobalSearchDialog.tsx HighlightedCode.tsx HistorySearchDialog.tsx IdeAutoConnectDialog.tsx IdeOnboardingDialog.tsx IdeStatusIndicator.tsx IdleReturnDialog.tsx InterruptedByUser.tsx InvalidConfigDialog.tsx InvalidSettingsDialog.tsx KeybindingWarnings.tsx LanguagePicker.tsx LogSelector.tsx Markdown.tsx MarkdownTable.tsx MCPServerApprovalDialog.tsx MCPServerDesktopImportDialog.tsx MCPServerDialogCopy.tsx MCPServerMultiselectDialog.tsx MemoryUsageIndicator.tsx Message.tsx messageActions.tsx MessageModel.tsx MessageResponse.tsx MessageRow.tsx Messages.tsx MessageSelector.tsx MessageTimestamp.tsx ModelPicker.tsx NativeAutoUpdater.tsx NotebookEditToolUseRejectedMessage.tsx OffscreenFreeze.tsx Onboarding.tsx OutputStylePicker.tsx PackageManagerAutoUpdater.tsx PrBadge.tsx PressEnterToContinue.tsx QuickOpenDialog.tsx RemoteCallout.tsx RemoteEnvironmentDialog.tsx ResumeTask.tsx SandboxViolationExpandedView.tsx ScrollKeybindingHandler.tsx SearchBox.tsx SentryErrorBoundary.ts SessionBackgroundHint.tsx SessionPreview.tsx ShowInIDEPrompt.tsx SkillImprovementSurvey.tsx Spinner.tsx Stats.tsx StatusLine.tsx StatusNotices.tsx StructuredDiff.tsx StructuredDiffList.tsx TagTabs.tsx TaskListV2.tsx TeammateViewHeader.tsx TeleportError.tsx TeleportProgress.tsx TeleportRepoMismatchDialog.tsx TeleportResumeWrapper.tsx TeleportStash.tsx TextInput.tsx ThemePicker.tsx ThinkingToggle.tsx TokenWarning.tsx ToolUseLoader.tsx ValidationErrorsList.tsx VimTextInput.tsx VirtualMessageList.tsx WorkflowMultiselectDialog.tsx WorktreeExitDialog.tsx constants/ apiLimits.ts betas.ts common.ts cyberRiskInstruction.ts errorIds.ts figures.ts files.ts github-app.ts keys.ts messages.ts oauth.ts outputStyles.ts product.ts prompts.ts spinnerVerbs.ts system.ts systemPromptSections.ts toolLimits.ts tools.ts turnCompletionVerbs.ts xml.ts context/ fpsMetrics.tsx mailbox.tsx modalContext.tsx notifications.tsx overlayContext.tsx promptOverlayContext.tsx QueuedMessageContext.tsx stats.tsx voice.tsx coordinator/ coordinatorMode.ts entrypoints/ sdk/ controlSchemas.ts coreSchemas.ts coreTypes.ts agentSdkTypes.ts cli.tsx init.ts mcp.ts sandboxTypes.ts hooks/ notifs/ useAutoModeUnavailableNotification.ts useCanSwitchToExistingSubscription.tsx useDeprecationWarningNotification.tsx useFastModeNotification.tsx useIDEStatusIndicator.tsx useInstallMessages.tsx useLspInitializationNotification.tsx useMcpConnectivityStatus.tsx useModelMigrationNotifications.tsx useNpmDeprecationNotification.tsx usePluginAutoupdateNotification.tsx usePluginInstallationStatus.tsx useRateLimitWarningNotification.tsx useSettingsErrors.tsx useStartupNotification.ts useTeammateShutdownNotification.ts toolPermission/ handlers/ coordinatorHandler.ts interactiveHandler.ts swarmWorkerHandler.ts PermissionContext.ts permissionLogging.ts fileSuggestions.ts renderPlaceholder.ts unifiedSuggestions.ts useAfterFirstRender.ts useApiKeyVerification.ts useArrowKeyHistory.tsx useAssistantHistory.ts useAwaySummary.ts useBackgroundTaskNavigation.ts useBlink.ts useCancelRequest.ts useCanUseTool.tsx useChromeExtensionNotification.tsx useClaudeCodeHintRecommendation.tsx useClipboardImageHint.ts useCommandKeybindings.tsx useCommandQueue.ts useCopyOnSelect.ts useDeferredHookMessages.ts useDiffData.ts useDiffInIDE.ts useDirectConnect.ts useDoublePress.ts useDynamicConfig.ts useElapsedTime.ts useExitOnCtrlCD.ts useExitOnCtrlCDWithKeybindings.ts useFileHistorySnapshotInit.ts useGlobalKeybindings.tsx useHistorySearch.ts useIdeAtMentioned.ts useIdeConnectionStatus.ts useIDEIntegration.tsx useIdeLogging.ts useIdeSelection.ts useInboxPoller.ts useInputBuffer.ts useIssueFlagBanner.ts useLogMessages.ts useLspPluginRecommendation.tsx useMailboxBridge.ts useMainLoopModel.ts useManagePlugins.ts useMemoryUsage.ts useMergedClients.ts useMergedCommands.ts useMergedTools.ts useMinDisplayTime.ts useNotifyAfterTimeout.ts useOfficialMarketplaceNotification.tsx usePasteHandler.ts usePluginRecommendationBase.tsx usePromptsFromClaudeInChrome.tsx usePromptSuggestion.ts usePrStatus.ts useQueueProcessor.ts useRemoteSession.ts useReplBridge.tsx useScheduledTasks.ts useSearchInput.ts useSessionBackgrounding.ts useSettings.ts useSettingsChange.ts useSkillImprovementSurvey.ts useSkillsChange.ts useSSHSession.ts useSwarmInitialization.ts useSwarmPermissionPoller.ts useTaskListWatcher.ts useTasksV2.ts useTeammateViewAutoExit.ts useTeleportResume.tsx useTerminalSize.ts useTextInput.ts useTimeout.ts useTurnDiffs.ts useTypeahead.tsx useUpdateNotification.ts useVimInput.ts useVirtualScroll.ts useVoice.ts useVoiceEnabled.ts useVoiceIntegration.tsx ink/ components/ AlternateScreen.tsx App.tsx AppContext.ts Box.tsx Button.tsx ClockContext.tsx CursorDeclarationContext.ts ErrorOverview.tsx Link.tsx Newline.tsx NoSelect.tsx RawAnsi.tsx ScrollBox.tsx Spacer.tsx StdinContext.ts TerminalFocusContext.tsx TerminalSizeContext.tsx Text.tsx events/ click-event.ts dispatcher.ts emitter.ts event-handlers.ts event.ts focus-event.ts input-event.ts keyboard-event.ts terminal-event.ts terminal-focus-event.ts hooks/ use-animation-frame.ts use-app.ts use-declared-cursor.ts use-input.ts use-interval.ts use-search-highlight.ts use-selection.ts use-stdin.ts use-tab-status.ts use-terminal-focus.ts use-terminal-title.ts use-terminal-viewport.ts layout/ engine.ts geometry.ts node.ts yoga.ts termio/ ansi.ts csi.ts dec.ts esc.ts osc.ts parser.ts sgr.ts tokenize.ts types.ts Ansi.tsx bidi.ts clearTerminal.ts colorize.ts constants.ts dom.ts focus.ts frame.ts get-max-width.ts hit-test.ts ink.tsx instances.ts line-width-cache.ts log-update.ts measure-element.ts measure-text.ts node-cache.ts optimizer.ts output.ts parse-keypress.ts reconciler.ts render-border.ts render-node-to-output.ts render-to-screen.ts renderer.ts root.ts screen.ts searchHighlight.ts selection.ts squash-text-nodes.ts stringWidth.ts styles.ts supports-hyperlinks.ts tabstops.ts terminal-focus-state.ts terminal-querier.ts terminal.ts termio.ts useTerminalNotification.ts warn.ts widest-line.ts wrap-text.ts wrapAnsi.ts keybindings/ defaultBindings.ts KeybindingContext.tsx KeybindingProviderSetup.tsx loadUserBindings.ts match.ts parser.ts reservedShortcuts.ts resolver.ts schema.ts shortcutFormat.ts template.ts useKeybinding.ts useShortcutDisplay.ts validate.ts memdir/ findRelevantMemories.ts memdir.ts memoryAge.ts memoryScan.ts memoryTypes.ts paths.ts teamMemPaths.ts teamMemPrompts.ts migrations/ migrateAutoUpdatesToSettings.ts migrateBypassPermissionsAcceptedToSettings.ts migrateEnableAllProjectMcpServersToSettings.ts migrateFennecToOpus.ts migrateLegacyOpusToCurrent.ts migrateOpusToOpus1m.ts migrateReplBridgeEnabledToRemoteControlAtStartup.ts migrateSonnet1mToSonnet45.ts migrateSonnet45ToSonnet46.ts resetAutoModeOptInForDefaultOffer.ts resetProToOpusDefault.ts moreright/ useMoreRight.tsx native-ts/ color-diff/ index.ts file-index/ index.ts yoga-layout/ enums.ts index.ts outputStyles/ loadOutputStylesDir.ts plugins/ bundled/ index.ts builtinPlugins.ts query/ config.ts deps.ts stopHooks.ts tokenBudget.ts remote/ remotePermissionBridge.ts RemoteSessionManager.ts sdkMessageAdapter.ts SessionsWebSocket.ts schemas/ hooks.ts screens/ Doctor.tsx REPL.tsx ResumeConversation.tsx server/ createDirectConnectSession.ts directConnectManager.ts types.ts services/ AgentSummary/ agentSummary.ts analytics/ config.ts datadog.ts firstPartyEventLogger.ts firstPartyEventLoggingExporter.ts growthbook.ts index.ts metadata.ts sink.ts sinkKillswitch.ts api/ adminRequests.ts bootstrap.ts claude.ts client.ts dumpPrompts.ts emptyUsage.ts errors.ts errorUtils.ts filesApi.ts firstTokenDate.ts grove.ts logging.ts metricsOptOut.ts overageCreditGrant.ts promptCacheBreakDetection.ts referral.ts sessionIngress.ts ultrareviewQuota.ts usage.ts withRetry.ts autoDream/ autoDream.ts config.ts consolidationLock.ts consolidationPrompt.ts compact/ apiMicrocompact.ts autoCompact.ts compact.ts compactWarningHook.ts compactWarningState.ts grouping.ts microCompact.ts postCompactCleanup.ts prompt.ts sessionMemoryCompact.ts timeBasedMCConfig.ts extractMemories/ extractMemories.ts prompts.ts lsp/ config.ts LSPClient.ts LSPDiagnosticRegistry.ts LSPServerInstance.ts LSPServerManager.ts manager.ts passiveFeedback.ts MagicDocs/ magicDocs.ts prompts.ts mcp/ auth.ts channelAllowlist.ts channelNotification.ts channelPermissions.ts claudeai.ts client.ts config.ts elicitationHandler.ts envExpansion.ts headersHelper.ts InProcessTransport.ts MCPConnectionManager.tsx mcpStringUtils.ts normalization.ts oauthPort.ts officialRegistry.ts SdkControlTransport.ts types.ts useManageMCPConnections.ts utils.ts vscodeSdkMcp.ts xaa.ts xaaIdpLogin.ts oauth/ auth-code-listener.ts client.ts crypto.ts getOauthProfile.ts index.ts plugins/ pluginCliCommands.ts PluginInstallationManager.ts pluginOperations.ts policyLimits/ index.ts types.ts PromptSuggestion/ promptSuggestion.ts speculation.ts remoteManagedSettings/ index.ts securityCheck.tsx syncCache.ts syncCacheState.ts types.ts SessionMemory/ prompts.ts sessionMemory.ts sessionMemoryUtils.ts settingsSync/ index.ts types.ts teamMemorySync/ index.ts secretScanner.ts teamMemSecretGuard.ts types.ts watcher.ts tips/ tipHistory.ts tipRegistry.ts tipScheduler.ts tools/ StreamingToolExecutor.ts toolExecution.ts toolHooks.ts toolOrchestration.ts toolUseSummary/ toolUseSummaryGenerator.ts awaySummary.ts claudeAiLimits.ts claudeAiLimitsHook.ts diagnosticTracking.ts internalLogging.ts mcpServerApproval.tsx mockRateLimits.ts notifier.ts preventSleep.ts rateLimitMessages.ts rateLimitMocking.ts tokenEstimation.ts vcr.ts voice.ts voiceKeyterms.ts voiceStreamSTT.ts skills/ bundled/ batch.ts claudeApi.ts claudeApiContent.ts claudeInChrome.ts debug.ts index.ts keybindings.ts loop.ts loremIpsum.ts remember.ts scheduleRemoteAgents.ts simplify.ts skillify.ts stuck.ts updateConfig.ts verify.ts verifyContent.ts bundledSkills.ts loadSkillsDir.ts mcpSkillBuilders.ts state/ AppState.tsx AppStateStore.ts onChangeAppState.ts selectors.ts store.ts teammateViewHelpers.ts tasks/ DreamTask/ DreamTask.ts InProcessTeammateTask/ InProcessTeammateTask.tsx types.ts LocalAgentTask/ LocalAgentTask.tsx LocalShellTask/ guards.ts killShellTasks.ts LocalShellTask.tsx RemoteAgentTask/ RemoteAgentTask.tsx LocalMainSessionTask.ts pillLabel.ts stopTask.ts types.ts tools/ AgentTool/ built-in/ claudeCodeGuideAgent.ts exploreAgent.ts generalPurposeAgent.ts planAgent.ts statuslineSetup.ts verificationAgent.ts agentColorManager.ts agentDisplay.ts agentMemory.ts agentMemorySnapshot.ts AgentTool.tsx agentToolUtils.ts builtInAgents.ts constants.ts forkSubagent.ts loadAgentsDir.ts prompt.ts resumeAgent.ts runAgent.ts UI.tsx AskUserQuestionTool/ AskUserQuestionTool.tsx prompt.ts BashTool/ bashCommandHelpers.ts bashPermissions.ts bashSecurity.ts BashTool.tsx BashToolResultMessage.tsx commandSemantics.ts commentLabel.ts destructiveCommandWarning.ts modeValidation.ts pathValidation.ts prompt.ts readOnlyValidation.ts sedEditParser.ts sedValidation.ts shouldUseSandbox.ts toolName.ts UI.tsx utils.ts BriefTool/ attachments.ts BriefTool.ts prompt.ts UI.tsx upload.ts ConfigTool/ ConfigTool.ts constants.ts prompt.ts supportedSettings.ts UI.tsx EnterPlanModeTool/ constants.ts EnterPlanModeTool.ts prompt.ts UI.tsx EnterWorktreeTool/ constants.ts EnterWorktreeTool.ts prompt.ts UI.tsx ExitPlanModeTool/ constants.ts ExitPlanModeV2Tool.ts prompt.ts UI.tsx ExitWorktreeTool/ constants.ts ExitWorktreeTool.ts prompt.ts UI.tsx FileEditTool/ constants.ts FileEditTool.ts prompt.ts types.ts UI.tsx utils.ts FileReadTool/ FileReadTool.ts imageProcessor.ts limits.ts prompt.ts UI.tsx FileWriteTool/ FileWriteTool.ts prompt.ts UI.tsx GlobTool/ GlobTool.ts prompt.ts UI.tsx GrepTool/ GrepTool.ts prompt.ts UI.tsx ListMcpResourcesTool/ ListMcpResourcesTool.ts prompt.ts UI.tsx LSPTool/ formatters.ts LSPTool.ts prompt.ts schemas.ts symbolContext.ts UI.tsx McpAuthTool/ McpAuthTool.ts MCPTool/ classifyForCollapse.ts MCPTool.ts prompt.ts UI.tsx NotebookEditTool/ constants.ts NotebookEditTool.ts prompt.ts UI.tsx PowerShellTool/ clmTypes.ts commandSemantics.ts commonParameters.ts destructiveCommandWarning.ts gitSafety.ts modeValidation.ts pathValidation.ts powershellPermissions.ts powershellSecurity.ts PowerShellTool.tsx prompt.ts readOnlyValidation.ts toolName.ts UI.tsx ReadMcpResourceTool/ prompt.ts ReadMcpResourceTool.ts UI.tsx RemoteTriggerTool/ prompt.ts RemoteTriggerTool.ts UI.tsx REPLTool/ constants.ts primitiveTools.ts ScheduleCronTool/ CronCreateTool.ts CronDeleteTool.ts CronListTool.ts prompt.ts UI.tsx SendMessageTool/ constants.ts prompt.ts SendMessageTool.ts UI.tsx shared/ gitOperationTracking.ts spawnMultiAgent.ts SkillTool/ constants.ts prompt.ts SkillTool.ts UI.tsx SleepTool/ prompt.ts SyntheticOutputTool/ SyntheticOutputTool.ts TaskCreateTool/ constants.ts prompt.ts TaskCreateTool.ts TaskGetTool/ constants.ts prompt.ts TaskGetTool.ts TaskListTool/ constants.ts prompt.ts TaskListTool.ts TaskOutputTool/ constants.ts TaskOutputTool.tsx TaskStopTool/ prompt.ts TaskStopTool.ts UI.tsx TaskUpdateTool/ constants.ts prompt.ts TaskUpdateTool.ts TeamCreateTool/ constants.ts prompt.ts TeamCreateTool.ts UI.tsx TeamDeleteTool/ constants.ts prompt.ts TeamDeleteTool.ts UI.tsx testing/ TestingPermissionTool.tsx TodoWriteTool/ constants.ts prompt.ts TodoWriteTool.ts ToolSearchTool/ constants.ts prompt.ts ToolSearchTool.ts WebFetchTool/ preapproved.ts prompt.ts UI.tsx utils.ts WebFetchTool.ts WebSearchTool/ prompt.ts UI.tsx WebSearchTool.ts utils.ts types/ generated/ events_mono/ claude_code/ v1/ claude_code_internal_event.ts common/ v1/ auth.ts growthbook/ v1/ growthbook_experiment_event.ts google/ protobuf/ timestamp.ts command.ts hooks.ts ids.ts logs.ts permissions.ts plugin.ts textInputTypes.ts upstreamproxy/ relay.ts upstreamproxy.ts utils/ background/ remote/ preconditions.ts remoteSession.ts bash/ specs/ alias.ts index.ts nohup.ts pyright.ts sleep.ts srun.ts time.ts timeout.ts ast.ts bashParser.ts bashPipeCommand.ts commands.ts heredoc.ts ParsedCommand.ts parser.ts prefix.ts registry.ts shellCompletion.ts shellPrefix.ts shellQuote.ts shellQuoting.ts ShellSnapshot.ts treeSitterAnalysis.ts claudeInChrome/ chromeNativeHost.ts common.ts mcpServer.ts prompt.ts setup.ts setupPortable.ts toolRendering.tsx computerUse/ appNames.ts cleanup.ts common.ts computerUseLock.ts drainRunLoop.ts escHotkey.ts executor.ts gates.ts hostAdapter.ts inputLoader.ts mcpServer.ts setup.ts swiftLoader.ts toolRendering.tsx wrapper.tsx deepLink/ banner.ts parseDeepLink.ts protocolHandler.ts registerProtocol.ts terminalLauncher.ts terminalPreference.ts dxt/ helpers.ts zip.ts filePersistence/ filePersistence.ts outputsScanner.ts git/ gitConfigParser.ts gitFilesystem.ts gitignore.ts github/ ghAuthStatus.ts hooks/ apiQueryHookHelper.ts AsyncHookRegistry.ts execAgentHook.ts execHttpHook.ts execPromptHook.ts fileChangedWatcher.ts hookEvents.ts hookHelpers.ts hooksConfigManager.ts hooksConfigSnapshot.ts hooksSettings.ts postSamplingHooks.ts registerFrontmatterHooks.ts registerSkillHooks.ts sessionHooks.ts skillImprovement.ts ssrfGuard.ts mcp/ dateTimeParser.ts elicitationValidation.ts memory/ types.ts versions.ts messages/ mappers.ts systemInit.ts model/ agent.ts aliases.ts antModels.ts bedrock.ts check1mAccess.ts configs.ts contextWindowUpgradeCheck.ts deprecation.ts model.ts modelAllowlist.ts modelCapabilities.ts modelOptions.ts modelStrings.ts modelSupportOverrides.ts providers.ts validateModel.ts nativeInstaller/ download.ts index.ts installer.ts packageManagers.ts pidLock.ts permissions/ autoModeState.ts bashClassifier.ts bypassPermissionsKillswitch.ts classifierDecision.ts classifierShared.ts dangerousPatterns.ts denialTracking.ts filesystem.ts getNextPermissionMode.ts pathValidation.ts permissionExplainer.ts PermissionMode.ts PermissionPromptToolResultSchema.ts PermissionResult.ts PermissionRule.ts permissionRuleParser.ts permissions.ts permissionSetup.ts permissionsLoader.ts PermissionUpdate.ts PermissionUpdateSchema.ts shadowedRuleDetection.ts shellRuleMatching.ts yoloClassifier.ts plugins/ addDirPluginSettings.ts cacheUtils.ts dependencyResolver.ts fetchTelemetry.ts gitAvailability.ts headlessPluginInstall.ts hintRecommendation.ts installCounts.ts installedPluginsManager.ts loadPluginAgents.ts loadPluginCommands.ts loadPluginHooks.ts loadPluginOutputStyles.ts lspPluginIntegration.ts lspRecommendation.ts managedPlugins.ts marketplaceHelpers.ts marketplaceManager.ts mcpbHandler.ts mcpPluginIntegration.ts officialMarketplace.ts officialMarketplaceGcs.ts officialMarketplaceStartupCheck.ts orphanedPluginFilter.ts parseMarketplaceInput.ts performStartupChecks.tsx pluginAutoupdate.ts pluginBlocklist.ts pluginDirectories.ts pluginFlagging.ts pluginIdentifier.ts pluginInstallationHelpers.ts pluginLoader.ts pluginOptionsStorage.ts pluginPolicy.ts pluginStartupCheck.ts pluginVersioning.ts reconciler.ts refresh.ts schemas.ts validatePlugin.ts walkPluginMarkdown.ts zipCache.ts zipCacheAdapters.ts powershell/ dangerousCmdlets.ts parser.ts staticPrefix.ts processUserInput/ processBashCommand.tsx processSlashCommand.tsx processTextPrompt.ts processUserInput.ts sandbox/ sandbox-adapter.ts sandbox-ui-utils.ts secureStorage/ fallbackStorage.ts index.ts keychainPrefetch.ts macOsKeychainHelpers.ts macOsKeychainStorage.ts plainTextStorage.ts settings/ mdm/ constants.ts rawRead.ts settings.ts allErrors.ts applySettingsChange.ts changeDetector.ts constants.ts internalWrites.ts managedPath.ts permissionValidation.ts pluginOnlyPolicy.ts schemaOutput.ts settings.ts settingsCache.ts toolValidationConfig.ts types.ts validateEditTool.ts validation.ts validationTips.ts shell/ bashProvider.ts outputLimits.ts powershellDetection.ts powershellProvider.ts prefix.ts readOnlyCommandValidation.ts resolveDefaultShell.ts shellProvider.ts shellToolUtils.ts specPrefix.ts skills/ skillChangeDetector.ts suggestions/ commandSuggestions.ts directoryCompletion.ts shellHistoryCompletion.ts skillUsageTracking.ts slackChannelSuggestions.ts swarm/ backends/ detection.ts InProcessBackend.ts it2Setup.ts ITermBackend.ts PaneBackendExecutor.ts registry.ts teammateModeSnapshot.ts TmuxBackend.ts types.ts constants.ts inProcessRunner.ts It2SetupPrompt.tsx leaderPermissionBridge.ts permissionSync.ts reconnection.ts spawnInProcess.ts spawnUtils.ts teamHelpers.ts teammateInit.ts teammateLayoutManager.ts teammateModel.ts teammatePromptAddendum.ts task/ diskOutput.ts framework.ts outputFormatting.ts sdkProgress.ts TaskOutput.ts telemetry/ betaSessionTracing.ts bigqueryExporter.ts events.ts instrumentation.ts logger.ts perfettoTracing.ts pluginTelemetry.ts sessionTracing.ts skillLoadedEvent.ts teleport/ api.ts environments.ts environmentSelection.ts gitBundle.ts todo/ types.ts ultraplan/ ccrSession.ts keyword.ts abortController.ts activityManager.ts advisor.ts agentContext.ts agenticSessionSearch.ts agentId.ts agentSwarmsEnabled.ts analyzeContext.ts ansiToPng.ts ansiToSvg.ts api.ts apiPreconnect.ts appleTerminalBackup.ts argumentSubstitution.ts array.ts asciicast.ts attachments.ts attribution.ts auth.ts authFileDescriptor.ts authPortable.ts autoModeDenials.ts autoRunIssue.tsx autoUpdater.ts aws.ts awsAuthStatusManager.ts backgroundHousekeeping.ts betas.ts billing.ts binaryCheck.ts browser.ts bufferedWriter.ts bundledMode.ts caCerts.ts caCertsConfig.ts cachePaths.ts CircularBuffer.ts classifierApprovals.ts classifierApprovalsHook.ts claudeCodeHints.ts claudeDesktop.ts claudemd.ts cleanup.ts cleanupRegistry.ts cliArgs.ts cliHighlight.ts codeIndexing.ts collapseBackgroundBashNotifications.ts collapseHookSummaries.ts collapseReadSearch.ts collapseTeammateShutdowns.ts combinedAbortSignal.ts commandLifecycle.ts commitAttribution.ts completionCache.ts concurrentSessions.ts config.ts configConstants.ts contentArray.ts context.ts contextAnalysis.ts contextSuggestions.ts controlMessageCompat.ts conversationRecovery.ts cron.ts cronJitterConfig.ts cronScheduler.ts cronTasks.ts cronTasksLock.ts crossProjectResume.ts crypto.ts Cursor.ts cwd.ts debug.ts debugFilter.ts desktopDeepLink.ts detectRepository.ts diagLogs.ts diff.ts directMemberMessage.ts displayTags.ts doctorContextWarnings.ts doctorDiagnostic.ts earlyInput.ts editor.ts effort.ts embeddedTools.ts env.ts envDynamic.ts envUtils.ts envValidation.ts errorLogSink.ts errors.ts exampleCommands.ts execFileNoThrow.ts execFileNoThrowPortable.ts execSyncWrapper.ts exportRenderer.tsx extraUsage.ts fastMode.ts file.ts fileHistory.ts fileOperationAnalytics.ts fileRead.ts fileReadCache.ts fileStateCache.ts findExecutable.ts fingerprint.ts forkedAgent.ts format.ts formatBriefTimestamp.ts fpsTracker.ts frontmatterParser.ts fsOperations.ts fullscreen.ts generatedFiles.ts generators.ts genericProcessUtils.ts getWorktreePaths.ts getWorktreePathsPortable.ts ghPrStatus.ts git.ts gitDiff.ts githubRepoPathMapping.ts gitSettings.ts glob.ts gracefulShutdown.ts groupToolUses.ts handlePromptSubmit.ts hash.ts headlessProfiler.ts heapDumpService.ts heatmap.ts highlightMatch.tsx hooks.ts horizontalScroll.ts http.ts hyperlink.ts ide.ts idePathConversion.ts idleTimeout.ts imagePaste.ts imageResizer.ts imageStore.ts imageValidation.ts immediateCommand.ts ink.ts inProcessTeammateHelpers.ts intl.ts iTermBackup.ts jetbrains.ts json.ts jsonRead.ts keyboardShortcuts.ts lazySchema.ts listSessionsImpl.ts localInstaller.ts lockfile.ts log.ts logoV2Utils.ts mailbox.ts managedEnv.ts managedEnvConstants.ts markdown.ts markdownConfigLoader.ts mcpInstructionsDelta.ts mcpOutputStorage.ts mcpValidation.ts mcpWebSocketTransport.ts memoize.ts memoryFileDetection.ts messagePredicates.ts messageQueueManager.ts messages.ts modelCost.ts modifiers.ts mtls.ts notebook.ts objectGroupBy.ts pasteStore.ts path.ts pdf.ts pdfUtils.ts peerAddress.ts planModeV2.ts plans.ts platform.ts preflightChecks.tsx privacyLevel.ts process.ts profilerBase.ts promptCategory.ts promptEditor.ts promptShellExecution.ts proxy.ts queryContext.ts QueryGuard.ts queryHelpers.ts queryProfiler.ts queueProcessor.ts readEditContext.ts readFileInRange.ts releaseNotes.ts renderOptions.ts ripgrep.ts sanitization.ts screenshotClipboard.ts sdkEventQueue.ts semanticBoolean.ts semanticNumber.ts semver.ts sequential.ts sessionActivity.ts sessionEnvironment.ts sessionEnvVars.ts sessionFileAccessHooks.ts sessionIngressAuth.ts sessionRestore.ts sessionStart.ts sessionState.ts sessionStorage.ts sessionStoragePortable.ts sessionTitle.ts sessionUrl.ts set.ts Shell.ts ShellCommand.ts shellConfig.ts sideQuery.ts sideQuestion.ts signal.ts sinks.ts slashCommandParsing.ts sleep.ts sliceAnsi.ts slowOperations.ts standaloneAgent.ts startupProfiler.ts staticRender.tsx stats.ts statsCache.ts status.tsx statusNoticeDefinitions.tsx statusNoticeHelpers.ts stream.ts streamJsonStdoutGuard.ts streamlinedTransform.ts stringUtils.ts subprocessEnv.ts systemDirectories.ts systemPrompt.ts systemPromptType.ts systemTheme.ts taggedId.ts tasks.ts teamDiscovery.ts teammate.ts teammateContext.ts teammateMailbox.ts teamMemoryOps.ts telemetryAttributes.ts teleport.tsx tempfile.ts terminal.ts terminalPanel.ts textHighlighting.ts theme.ts thinking.ts timeouts.ts tmuxSocket.ts tokenBudget.ts tokens.ts toolErrors.ts toolPool.ts toolResultStorage.ts toolSchemaCache.ts toolSearch.ts transcriptSearch.ts treeify.ts truncate.ts unaryLogging.ts undercover.ts user.ts userAgent.ts userPromptKeywords.ts uuid.ts warningHandler.ts which.ts windowsPaths.ts withResolvers.ts words.ts workloadContext.ts worktree.ts worktreeModeEnabled.ts xdg.ts xml.ts yaml.ts zodToJsonSchema.ts vim/ motions.ts operators.ts textObjects.ts transitions.ts types.ts voice/ voiceModeEnabled.ts commands.ts context.ts cost-tracker.ts costHook.ts dialogLaunchers.tsx history.ts ink.ts interactiveHelpers.tsx main.tsx projectOnboardingState.ts query.ts QueryEngine.ts replLauncher.tsx setup.ts Task.ts tasks.ts Tool.ts tools.ts

Files

File: src/assistant/sessionHistory.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig } from '../constants/oauth.js' 3: import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 4: import { logForDebugging } from '../utils/debug.js' 5: import { getOAuthHeaders, prepareApiRequest } from '../utils/teleport/api.js' 6: export const HISTORY_PAGE_SIZE = 100 7: export type HistoryPage = { 8: events: SDKMessage[] 9: firstId: string | null 10: hasMore: boolean 11: } 12: type SessionEventsResponse = { 13: data: SDKMessage[] 14: has_more: boolean 15: first_id: string | null 16: last_id: string | null 17: } 18: export type HistoryAuthCtx = { 19: baseUrl: string 20: headers: Record<string, string> 21: } 22: export async function createHistoryAuthCtx( 23: sessionId: string, 24: ): Promise<HistoryAuthCtx> { 25: const { accessToken, orgUUID } = await prepareApiRequest() 26: return { 27: baseUrl: `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`, 28: headers: { 29: ...getOAuthHeaders(accessToken), 30: 'anthropic-beta': 'ccr-byoc-2025-07-29', 31: 'x-organization-uuid': orgUUID, 32: }, 33: } 34: } 35: async function fetchPage( 36: ctx: HistoryAuthCtx, 37: params: Record<string, string | number | boolean>, 38: label: string, 39: ): Promise<HistoryPage | null> { 40: const resp = await axios 41: .get<SessionEventsResponse>(ctx.baseUrl, { 42: headers: ctx.headers, 43: params, 44: timeout: 15000, 45: validateStatus: () => true, 46: }) 47: .catch(() => null) 48: if (!resp || resp.status !== 200) { 49: logForDebugging(`[${label}] HTTP ${resp?.status ?? 'error'}`) 50: return null 51: } 52: return { 53: events: Array.isArray(resp.data.data) ? resp.data.data : [], 54: firstId: resp.data.first_id, 55: hasMore: resp.data.has_more, 56: } 57: } 58: export async function fetchLatestEvents( 59: ctx: HistoryAuthCtx, 60: limit = HISTORY_PAGE_SIZE, 61: ): Promise<HistoryPage | null> { 62: return fetchPage(ctx, { limit, anchor_to_latest: true }, 'fetchLatestEvents') 63: } 64: export async function fetchOlderEvents( 65: ctx: HistoryAuthCtx, 66: beforeId: string, 67: limit = HISTORY_PAGE_SIZE, 68: ): Promise<HistoryPage | null> { 69: return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents') 70: }

File: src/bootstrap/state.ts

typescript 1: import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 2: import type { Attributes, Meter, MetricOptions } from '@opentelemetry/api' 3: import type { logs } from '@opentelemetry/api-logs' 4: import type { LoggerProvider } from '@opentelemetry/sdk-logs' 5: import type { MeterProvider } from '@opentelemetry/sdk-metrics' 6: import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' 7: import { realpathSync } from 'fs' 8: import sumBy from 'lodash-es/sumBy.js' 9: import { cwd } from 'process' 10: import type { HookEvent, ModelUsage } from 'src/entrypoints/agentSdkTypes.js' 11: import type { AgentColorName } from 'src/tools/AgentTool/agentColorManager.js' 12: import type { HookCallbackMatcher } from 'src/types/hooks.js' 13: import { randomUUID } from 'src/utils/crypto.js' 14: import type { ModelSetting } from 'src/utils/model/model.js' 15: import type { ModelStrings } from 'src/utils/model/modelStrings.js' 16: import type { SettingSource } from 'src/utils/settings/constants.js' 17: import { resetSettingsCache } from 'src/utils/settings/settingsCache.js' 18: import type { PluginHookMatcher } from 'src/utils/settings/types.js' 19: import { createSignal } from 'src/utils/signal.js' 20: type RegisteredHookMatcher = HookCallbackMatcher | PluginHookMatcher 21: import type { SessionId } from 'src/types/ids.js' 22: export type ChannelEntry = 23: | { kind: 'plugin'; name: string; marketplace: string; dev?: boolean } 24: | { kind: 'server'; name: string; dev?: boolean } 25: export type AttributedCounter = { 26: add(value: number, additionalAttributes?: Attributes): void 27: } 28: type State = { 29: originalCwd: string 30: projectRoot: string 31: totalCostUSD: number 32: totalAPIDuration: number 33: totalAPIDurationWithoutRetries: number 34: totalToolDuration: number 35: turnHookDurationMs: number 36: turnToolDurationMs: number 37: turnClassifierDurationMs: number 38: turnToolCount: number 39: turnHookCount: number 40: turnClassifierCount: number 41: startTime: number 42: lastInteractionTime: number 43: totalLinesAdded: number 44: totalLinesRemoved: number 45: hasUnknownModelCost: boolean 46: cwd: string 47: modelUsage: { [modelName: string]: ModelUsage } 48: mainLoopModelOverride: ModelSetting | undefined 49: initialMainLoopModel: ModelSetting 50: modelStrings: ModelStrings | null 51: isInteractive: boolean 52: kairosActive: boolean 53: strictToolResultPairing: boolean 54: sdkAgentProgressSummariesEnabled: boolean 55: userMsgOptIn: boolean 56: clientType: string 57: sessionSource: string | undefined 58: questionPreviewFormat: 'markdown' | 'html' | undefined 59: flagSettingsPath: string | undefined 60: flagSettingsInline: Record<string, unknown> | null 61: allowedSettingSources: SettingSource[] 62: sessionIngressToken: string | null | undefined 63: oauthTokenFromFd: string | null | undefined 64: apiKeyFromFd: string | null | undefined 65: meter: Meter | null 66: sessionCounter: AttributedCounter | null 67: locCounter: AttributedCounter | null 68: prCounter: AttributedCounter | null 69: commitCounter: AttributedCounter | null 70: costCounter: AttributedCounter | null 71: tokenCounter: AttributedCounter | null 72: codeEditToolDecisionCounter: AttributedCounter | null 73: activeTimeCounter: AttributedCounter | null 74: statsStore: { observe(name: string, value: number): void } | null 75: sessionId: SessionId 76: parentSessionId: SessionId | undefined 77: loggerProvider: LoggerProvider | null 78: eventLogger: ReturnType<typeof logs.getLogger> | null 79: meterProvider: MeterProvider | null 80: tracerProvider: BasicTracerProvider | null 81: agentColorMap: Map<string, AgentColorName> 82: agentColorIndex: number 83: lastAPIRequest: Omit<BetaMessageStreamParams, 'messages'> | null 84: lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null 85: lastClassifierRequests: unknown[] | null 86: cachedClaudeMdContent: string | null 87: inMemoryErrorLog: Array<{ error: string; timestamp: string }> 88: inlinePlugins: Array<string> 89: chromeFlagOverride: boolean | undefined 90: useCoworkPlugins: boolean 91: sessionBypassPermissionsMode: boolean 92: scheduledTasksEnabled: boolean 93: sessionCronTasks: SessionCronTask[] 94: sessionCreatedTeams: Set<string> 95: sessionTrustAccepted: boolean 96: sessionPersistenceDisabled: boolean 97: hasExitedPlanMode: boolean 98: needsPlanModeExitAttachment: boolean 99: needsAutoModeExitAttachment: boolean 100: lspRecommendationShownThisSession: boolean 101: initJsonSchema: Record<string, unknown> | null 102: registeredHooks: Partial<Record<HookEvent, RegisteredHookMatcher[]>> | null 103: planSlugCache: Map<string, string> 104: teleportedSessionInfo: { 105: isTeleported: boolean 106: hasLoggedFirstMessage: boolean 107: sessionId: string | null 108: } | null 109: invokedSkills: Map< 110: string, 111: { 112: skillName: string 113: skillPath: string 114: content: string 115: invokedAt: number 116: agentId: string | null 117: } 118: > 119: slowOperations: Array<{ 120: operation: string 121: durationMs: number 122: timestamp: number 123: }> 124: sdkBetas: string[] | undefined 125: mainThreadAgentType: string | undefined 126: isRemoteMode: boolean 127: directConnectServerUrl: string | undefined 128: systemPromptSectionCache: Map<string, string | null> 129: lastEmittedDate: string | null 130: additionalDirectoriesForClaudeMd: string[] 131: allowedChannels: ChannelEntry[] 132: hasDevChannels: boolean 133: sessionProjectDir: string | null 134: promptCache1hAllowlist: string[] | null 135: promptCache1hEligible: boolean | null 136: afkModeHeaderLatched: boolean | null 137: fastModeHeaderLatched: boolean | null 138: cacheEditingHeaderLatched: boolean | null 139: thinkingClearLatched: boolean | null 140: promptId: string | null 141: lastMainRequestId: string | undefined 142: lastApiCompletionTimestamp: number | null 143: pendingPostCompaction: boolean 144: } 145: function getInitialState(): State { 146: let resolvedCwd = '' 147: if ( 148: typeof process !== 'undefined' && 149: typeof process.cwd === 'function' && 150: typeof realpathSync === 'function' 151: ) { 152: const rawCwd = cwd() 153: try { 154: resolvedCwd = realpathSync(rawCwd).normalize('NFC') 155: } catch { 156: resolvedCwd = rawCwd.normalize('NFC') 157: } 158: } 159: const state: State = { 160: originalCwd: resolvedCwd, 161: projectRoot: resolvedCwd, 162: totalCostUSD: 0, 163: totalAPIDuration: 0, 164: totalAPIDurationWithoutRetries: 0, 165: totalToolDuration: 0, 166: turnHookDurationMs: 0, 167: turnToolDurationMs: 0, 168: turnClassifierDurationMs: 0, 169: turnToolCount: 0, 170: turnHookCount: 0, 171: turnClassifierCount: 0, 172: startTime: Date.now(), 173: lastInteractionTime: Date.now(), 174: totalLinesAdded: 0, 175: totalLinesRemoved: 0, 176: hasUnknownModelCost: false, 177: cwd: resolvedCwd, 178: modelUsage: {}, 179: mainLoopModelOverride: undefined, 180: initialMainLoopModel: null, 181: modelStrings: null, 182: isInteractive: false, 183: kairosActive: false, 184: strictToolResultPairing: false, 185: sdkAgentProgressSummariesEnabled: false, 186: userMsgOptIn: false, 187: clientType: 'cli', 188: sessionSource: undefined, 189: questionPreviewFormat: undefined, 190: sessionIngressToken: undefined, 191: oauthTokenFromFd: undefined, 192: apiKeyFromFd: undefined, 193: flagSettingsPath: undefined, 194: flagSettingsInline: null, 195: allowedSettingSources: [ 196: 'userSettings', 197: 'projectSettings', 198: 'localSettings', 199: 'flagSettings', 200: 'policySettings', 201: ], 202: meter: null, 203: sessionCounter: null, 204: locCounter: null, 205: prCounter: null, 206: commitCounter: null, 207: costCounter: null, 208: tokenCounter: null, 209: codeEditToolDecisionCounter: null, 210: activeTimeCounter: null, 211: statsStore: null, 212: sessionId: randomUUID() as SessionId, 213: parentSessionId: undefined, 214: loggerProvider: null, 215: eventLogger: null, 216: meterProvider: null, 217: tracerProvider: null, 218: agentColorMap: new Map(), 219: agentColorIndex: 0, 220: lastAPIRequest: null, 221: lastAPIRequestMessages: null, 222: lastClassifierRequests: null, 223: cachedClaudeMdContent: null, 224: inMemoryErrorLog: [], 225: inlinePlugins: [], 226: chromeFlagOverride: undefined, 227: useCoworkPlugins: false, 228: sessionBypassPermissionsMode: false, 229: scheduledTasksEnabled: false, 230: sessionCronTasks: [], 231: sessionCreatedTeams: new Set(), 232: sessionTrustAccepted: false, 233: sessionPersistenceDisabled: false, 234: hasExitedPlanMode: false, 235: needsPlanModeExitAttachment: false, 236: needsAutoModeExitAttachment: false, 237: lspRecommendationShownThisSession: false, 238: initJsonSchema: null, 239: registeredHooks: null, 240: planSlugCache: new Map(), 241: teleportedSessionInfo: null, 242: invokedSkills: new Map(), 243: slowOperations: [], 244: sdkBetas: undefined, 245: mainThreadAgentType: undefined, 246: isRemoteMode: false, 247: ...(process.env.USER_TYPE === 'ant' 248: ? { 249: replBridgeActive: false, 250: } 251: : {}), 252: directConnectServerUrl: undefined, 253: systemPromptSectionCache: new Map(), 254: lastEmittedDate: null, 255: additionalDirectoriesForClaudeMd: [], 256: allowedChannels: [], 257: hasDevChannels: false, 258: sessionProjectDir: null, 259: promptCache1hAllowlist: null, 260: promptCache1hEligible: null, 261: afkModeHeaderLatched: null, 262: fastModeHeaderLatched: null, 263: cacheEditingHeaderLatched: null, 264: thinkingClearLatched: null, 265: promptId: null, 266: lastMainRequestId: undefined, 267: lastApiCompletionTimestamp: null, 268: pendingPostCompaction: false, 269: } 270: return state 271: } 272: const STATE: State = getInitialState() 273: export function getSessionId(): SessionId { 274: return STATE.sessionId 275: } 276: export function regenerateSessionId( 277: options: { setCurrentAsParent?: boolean } = {}, 278: ): SessionId { 279: if (options.setCurrentAsParent) { 280: STATE.parentSessionId = STATE.sessionId 281: } 282: STATE.planSlugCache.delete(STATE.sessionId) 283: STATE.sessionId = randomUUID() as SessionId 284: STATE.sessionProjectDir = null 285: return STATE.sessionId 286: } 287: export function getParentSessionId(): SessionId | undefined { 288: return STATE.parentSessionId 289: } 290: export function switchSession( 291: sessionId: SessionId, 292: projectDir: string | null = null, 293: ): void { 294: STATE.planSlugCache.delete(STATE.sessionId) 295: STATE.sessionId = sessionId 296: STATE.sessionProjectDir = projectDir 297: sessionSwitched.emit(sessionId) 298: } 299: const sessionSwitched = createSignal<[id: SessionId]>() 300: export const onSessionSwitch = sessionSwitched.subscribe 301: export function getSessionProjectDir(): string | null { 302: return STATE.sessionProjectDir 303: } 304: export function getOriginalCwd(): string { 305: return STATE.originalCwd 306: } 307: export function getProjectRoot(): string { 308: return STATE.projectRoot 309: } 310: export function setOriginalCwd(cwd: string): void { 311: STATE.originalCwd = cwd.normalize('NFC') 312: } 313: export function setProjectRoot(cwd: string): void { 314: STATE.projectRoot = cwd.normalize('NFC') 315: } 316: export function getCwdState(): string { 317: return STATE.cwd 318: } 319: export function setCwdState(cwd: string): void { 320: STATE.cwd = cwd.normalize('NFC') 321: } 322: export function getDirectConnectServerUrl(): string | undefined { 323: return STATE.directConnectServerUrl 324: } 325: export function setDirectConnectServerUrl(url: string): void { 326: STATE.directConnectServerUrl = url 327: } 328: export function addToTotalDurationState( 329: duration: number, 330: durationWithoutRetries: number, 331: ): void { 332: STATE.totalAPIDuration += duration 333: STATE.totalAPIDurationWithoutRetries += durationWithoutRetries 334: } 335: export function resetTotalDurationStateAndCost_FOR_TESTS_ONLY(): void { 336: STATE.totalAPIDuration = 0 337: STATE.totalAPIDurationWithoutRetries = 0 338: STATE.totalCostUSD = 0 339: } 340: export function addToTotalCostState( 341: cost: number, 342: modelUsage: ModelUsage, 343: model: string, 344: ): void { 345: STATE.modelUsage[model] = modelUsage 346: STATE.totalCostUSD += cost 347: } 348: export function getTotalCostUSD(): number { 349: return STATE.totalCostUSD 350: } 351: export function getTotalAPIDuration(): number { 352: return STATE.totalAPIDuration 353: } 354: export function getTotalDuration(): number { 355: return Date.now() - STATE.startTime 356: } 357: export function getTotalAPIDurationWithoutRetries(): number { 358: return STATE.totalAPIDurationWithoutRetries 359: } 360: export function getTotalToolDuration(): number { 361: return STATE.totalToolDuration 362: } 363: export function addToToolDuration(duration: number): void { 364: STATE.totalToolDuration += duration 365: STATE.turnToolDurationMs += duration 366: STATE.turnToolCount++ 367: } 368: export function getTurnHookDurationMs(): number { 369: return STATE.turnHookDurationMs 370: } 371: export function addToTurnHookDuration(duration: number): void { 372: STATE.turnHookDurationMs += duration 373: STATE.turnHookCount++ 374: } 375: export function resetTurnHookDuration(): void { 376: STATE.turnHookDurationMs = 0 377: STATE.turnHookCount = 0 378: } 379: export function getTurnHookCount(): number { 380: return STATE.turnHookCount 381: } 382: export function getTurnToolDurationMs(): number { 383: return STATE.turnToolDurationMs 384: } 385: export function resetTurnToolDuration(): void { 386: STATE.turnToolDurationMs = 0 387: STATE.turnToolCount = 0 388: } 389: export function getTurnToolCount(): number { 390: return STATE.turnToolCount 391: } 392: export function getTurnClassifierDurationMs(): number { 393: return STATE.turnClassifierDurationMs 394: } 395: export function addToTurnClassifierDuration(duration: number): void { 396: STATE.turnClassifierDurationMs += duration 397: STATE.turnClassifierCount++ 398: } 399: export function resetTurnClassifierDuration(): void { 400: STATE.turnClassifierDurationMs = 0 401: STATE.turnClassifierCount = 0 402: } 403: export function getTurnClassifierCount(): number { 404: return STATE.turnClassifierCount 405: } 406: export function getStatsStore(): { 407: observe(name: string, value: number): void 408: } | null { 409: return STATE.statsStore 410: } 411: export function setStatsStore( 412: store: { observe(name: string, value: number): void } | null, 413: ): void { 414: STATE.statsStore = store 415: } 416: let interactionTimeDirty = false 417: export function updateLastInteractionTime(immediate?: boolean): void { 418: if (immediate) { 419: flushInteractionTime_inner() 420: } else { 421: interactionTimeDirty = true 422: } 423: } 424: export function flushInteractionTime(): void { 425: if (interactionTimeDirty) { 426: flushInteractionTime_inner() 427: } 428: } 429: function flushInteractionTime_inner(): void { 430: STATE.lastInteractionTime = Date.now() 431: interactionTimeDirty = false 432: } 433: export function addToTotalLinesChanged(added: number, removed: number): void { 434: STATE.totalLinesAdded += added 435: STATE.totalLinesRemoved += removed 436: } 437: export function getTotalLinesAdded(): number { 438: return STATE.totalLinesAdded 439: } 440: export function getTotalLinesRemoved(): number { 441: return STATE.totalLinesRemoved 442: } 443: export function getTotalInputTokens(): number { 444: return sumBy(Object.values(STATE.modelUsage), 'inputTokens') 445: } 446: export function getTotalOutputTokens(): number { 447: return sumBy(Object.values(STATE.modelUsage), 'outputTokens') 448: } 449: export function getTotalCacheReadInputTokens(): number { 450: return sumBy(Object.values(STATE.modelUsage), 'cacheReadInputTokens') 451: } 452: export function getTotalCacheCreationInputTokens(): number { 453: return sumBy(Object.values(STATE.modelUsage), 'cacheCreationInputTokens') 454: } 455: export function getTotalWebSearchRequests(): number { 456: return sumBy(Object.values(STATE.modelUsage), 'webSearchRequests') 457: } 458: let outputTokensAtTurnStart = 0 459: let currentTurnTokenBudget: number | null = null 460: export function getTurnOutputTokens(): number { 461: return getTotalOutputTokens() - outputTokensAtTurnStart 462: } 463: export function getCurrentTurnTokenBudget(): number | null { 464: return currentTurnTokenBudget 465: } 466: let budgetContinuationCount = 0 467: export function snapshotOutputTokensForTurn(budget: number | null): void { 468: outputTokensAtTurnStart = getTotalOutputTokens() 469: currentTurnTokenBudget = budget 470: budgetContinuationCount = 0 471: } 472: export function getBudgetContinuationCount(): number { 473: return budgetContinuationCount 474: } 475: export function incrementBudgetContinuationCount(): void { 476: budgetContinuationCount++ 477: } 478: export function setHasUnknownModelCost(): void { 479: STATE.hasUnknownModelCost = true 480: } 481: export function hasUnknownModelCost(): boolean { 482: return STATE.hasUnknownModelCost 483: } 484: export function getLastMainRequestId(): string | undefined { 485: return STATE.lastMainRequestId 486: } 487: export function setLastMainRequestId(requestId: string): void { 488: STATE.lastMainRequestId = requestId 489: } 490: export function getLastApiCompletionTimestamp(): number | null { 491: return STATE.lastApiCompletionTimestamp 492: } 493: export function setLastApiCompletionTimestamp(timestamp: number): void { 494: STATE.lastApiCompletionTimestamp = timestamp 495: } 496: export function markPostCompaction(): void { 497: STATE.pendingPostCompaction = true 498: } 499: export function consumePostCompaction(): boolean { 500: const was = STATE.pendingPostCompaction 501: STATE.pendingPostCompaction = false 502: return was 503: } 504: export function getLastInteractionTime(): number { 505: return STATE.lastInteractionTime 506: } 507: let scrollDraining = false 508: let scrollDrainTimer: ReturnType<typeof setTimeout> | undefined 509: const SCROLL_DRAIN_IDLE_MS = 150 510: export function markScrollActivity(): void { 511: scrollDraining = true 512: if (scrollDrainTimer) clearTimeout(scrollDrainTimer) 513: scrollDrainTimer = setTimeout(() => { 514: scrollDraining = false 515: scrollDrainTimer = undefined 516: }, SCROLL_DRAIN_IDLE_MS) 517: scrollDrainTimer.unref?.() 518: } 519: export function getIsScrollDraining(): boolean { 520: return scrollDraining 521: } 522: export async function waitForScrollIdle(): Promise<void> { 523: while (scrollDraining) { 524: await new Promise(r => setTimeout(r, SCROLL_DRAIN_IDLE_MS).unref?.()) 525: } 526: } 527: export function getModelUsage(): { [modelName: string]: ModelUsage } { 528: return STATE.modelUsage 529: } 530: export function getUsageForModel(model: string): ModelUsage | undefined { 531: return STATE.modelUsage[model] 532: } 533: export function getMainLoopModelOverride(): ModelSetting | undefined { 534: return STATE.mainLoopModelOverride 535: } 536: export function getInitialMainLoopModel(): ModelSetting { 537: return STATE.initialMainLoopModel 538: } 539: export function setMainLoopModelOverride( 540: model: ModelSetting | undefined, 541: ): void { 542: STATE.mainLoopModelOverride = model 543: } 544: export function setInitialMainLoopModel(model: ModelSetting): void { 545: STATE.initialMainLoopModel = model 546: } 547: export function getSdkBetas(): string[] | undefined { 548: return STATE.sdkBetas 549: } 550: export function setSdkBetas(betas: string[] | undefined): void { 551: STATE.sdkBetas = betas 552: } 553: export function resetCostState(): void { 554: STATE.totalCostUSD = 0 555: STATE.totalAPIDuration = 0 556: STATE.totalAPIDurationWithoutRetries = 0 557: STATE.totalToolDuration = 0 558: STATE.startTime = Date.now() 559: STATE.totalLinesAdded = 0 560: STATE.totalLinesRemoved = 0 561: STATE.hasUnknownModelCost = false 562: STATE.modelUsage = {} 563: STATE.promptId = null 564: } 565: export function setCostStateForRestore({ 566: totalCostUSD, 567: totalAPIDuration, 568: totalAPIDurationWithoutRetries, 569: totalToolDuration, 570: totalLinesAdded, 571: totalLinesRemoved, 572: lastDuration, 573: modelUsage, 574: }: { 575: totalCostUSD: number 576: totalAPIDuration: number 577: totalAPIDurationWithoutRetries: number 578: totalToolDuration: number 579: totalLinesAdded: number 580: totalLinesRemoved: number 581: lastDuration: number | undefined 582: modelUsage: { [modelName: string]: ModelUsage } | undefined 583: }): void { 584: STATE.totalCostUSD = totalCostUSD 585: STATE.totalAPIDuration = totalAPIDuration 586: STATE.totalAPIDurationWithoutRetries = totalAPIDurationWithoutRetries 587: STATE.totalToolDuration = totalToolDuration 588: STATE.totalLinesAdded = totalLinesAdded 589: STATE.totalLinesRemoved = totalLinesRemoved 590: if (modelUsage) { 591: STATE.modelUsage = modelUsage 592: } 593: if (lastDuration) { 594: STATE.startTime = Date.now() - lastDuration 595: } 596: } 597: export function resetStateForTests(): void { 598: if (process.env.NODE_ENV !== 'test') { 599: throw new Error('resetStateForTests can only be called in tests') 600: } 601: Object.entries(getInitialState()).forEach(([key, value]) => { 602: STATE[key as keyof State] = value as never 603: }) 604: outputTokensAtTurnStart = 0 605: currentTurnTokenBudget = null 606: budgetContinuationCount = 0 607: sessionSwitched.clear() 608: } 609: export function getModelStrings(): ModelStrings | null { 610: return STATE.modelStrings 611: } 612: export function setModelStrings(modelStrings: ModelStrings): void { 613: STATE.modelStrings = modelStrings 614: } 615: export function resetModelStringsForTestingOnly() { 616: STATE.modelStrings = null 617: } 618: export function setMeter( 619: meter: Meter, 620: createCounter: (name: string, options: MetricOptions) => AttributedCounter, 621: ): void { 622: STATE.meter = meter 623: STATE.sessionCounter = createCounter('claude_code.session.count', { 624: description: 'Count of CLI sessions started', 625: }) 626: STATE.locCounter = createCounter('claude_code.lines_of_code.count', { 627: description: 628: "Count of lines of code modified, with the 'type' attribute indicating whether lines were added or removed", 629: }) 630: STATE.prCounter = createCounter('claude_code.pull_request.count', { 631: description: 'Number of pull requests created', 632: }) 633: STATE.commitCounter = createCounter('claude_code.commit.count', { 634: description: 'Number of git commits created', 635: }) 636: STATE.costCounter = createCounter('claude_code.cost.usage', { 637: description: 'Cost of the Claude Code session', 638: unit: 'USD', 639: }) 640: STATE.tokenCounter = createCounter('claude_code.token.usage', { 641: description: 'Number of tokens used', 642: unit: 'tokens', 643: }) 644: STATE.codeEditToolDecisionCounter = createCounter( 645: 'claude_code.code_edit_tool.decision', 646: { 647: description: 648: 'Count of code editing tool permission decisions (accept/reject) for Edit, Write, and NotebookEdit tools', 649: }, 650: ) 651: STATE.activeTimeCounter = createCounter('claude_code.active_time.total', { 652: description: 'Total active time in seconds', 653: unit: 's', 654: }) 655: } 656: export function getMeter(): Meter | null { 657: return STATE.meter 658: } 659: export function getSessionCounter(): AttributedCounter | null { 660: return STATE.sessionCounter 661: } 662: export function getLocCounter(): AttributedCounter | null { 663: return STATE.locCounter 664: } 665: export function getPrCounter(): AttributedCounter | null { 666: return STATE.prCounter 667: } 668: export function getCommitCounter(): AttributedCounter | null { 669: return STATE.commitCounter 670: } 671: export function getCostCounter(): AttributedCounter | null { 672: return STATE.costCounter 673: } 674: export function getTokenCounter(): AttributedCounter | null { 675: return STATE.tokenCounter 676: } 677: export function getCodeEditToolDecisionCounter(): AttributedCounter | null { 678: return STATE.codeEditToolDecisionCounter 679: } 680: export function getActiveTimeCounter(): AttributedCounter | null { 681: return STATE.activeTimeCounter 682: } 683: export function getLoggerProvider(): LoggerProvider | null { 684: return STATE.loggerProvider 685: } 686: export function setLoggerProvider(provider: LoggerProvider | null): void { 687: STATE.loggerProvider = provider 688: } 689: export function getEventLogger(): ReturnType<typeof logs.getLogger> | null { 690: return STATE.eventLogger 691: } 692: export function setEventLogger( 693: logger: ReturnType<typeof logs.getLogger> | null, 694: ): void { 695: STATE.eventLogger = logger 696: } 697: export function getMeterProvider(): MeterProvider | null { 698: return STATE.meterProvider 699: } 700: export function setMeterProvider(provider: MeterProvider | null): void { 701: STATE.meterProvider = provider 702: } 703: export function getTracerProvider(): BasicTracerProvider | null { 704: return STATE.tracerProvider 705: } 706: export function setTracerProvider(provider: BasicTracerProvider | null): void { 707: STATE.tracerProvider = provider 708: } 709: export function getIsNonInteractiveSession(): boolean { 710: return !STATE.isInteractive 711: } 712: export function getIsInteractive(): boolean { 713: return STATE.isInteractive 714: } 715: export function setIsInteractive(value: boolean): void { 716: STATE.isInteractive = value 717: } 718: export function getClientType(): string { 719: return STATE.clientType 720: } 721: export function setClientType(type: string): void { 722: STATE.clientType = type 723: } 724: export function getSdkAgentProgressSummariesEnabled(): boolean { 725: return STATE.sdkAgentProgressSummariesEnabled 726: } 727: export function setSdkAgentProgressSummariesEnabled(value: boolean): void { 728: STATE.sdkAgentProgressSummariesEnabled = value 729: } 730: export function getKairosActive(): boolean { 731: return STATE.kairosActive 732: } 733: export function setKairosActive(value: boolean): void { 734: STATE.kairosActive = value 735: } 736: export function getStrictToolResultPairing(): boolean { 737: return STATE.strictToolResultPairing 738: } 739: export function setStrictToolResultPairing(value: boolean): void { 740: STATE.strictToolResultPairing = value 741: } 742: export function getUserMsgOptIn(): boolean { 743: return STATE.userMsgOptIn 744: } 745: export function setUserMsgOptIn(value: boolean): void { 746: STATE.userMsgOptIn = value 747: } 748: export function getSessionSource(): string | undefined { 749: return STATE.sessionSource 750: } 751: export function setSessionSource(source: string): void { 752: STATE.sessionSource = source 753: } 754: export function getQuestionPreviewFormat(): 'markdown' | 'html' | undefined { 755: return STATE.questionPreviewFormat 756: } 757: export function setQuestionPreviewFormat(format: 'markdown' | 'html'): void { 758: STATE.questionPreviewFormat = format 759: } 760: export function getAgentColorMap(): Map<string, AgentColorName> { 761: return STATE.agentColorMap 762: } 763: export function getFlagSettingsPath(): string | undefined { 764: return STATE.flagSettingsPath 765: } 766: export function setFlagSettingsPath(path: string | undefined): void { 767: STATE.flagSettingsPath = path 768: } 769: export function getFlagSettingsInline(): Record<string, unknown> | null { 770: return STATE.flagSettingsInline 771: } 772: export function setFlagSettingsInline( 773: settings: Record<string, unknown> | null, 774: ): void { 775: STATE.flagSettingsInline = settings 776: } 777: export function getSessionIngressToken(): string | null | undefined { 778: return STATE.sessionIngressToken 779: } 780: export function setSessionIngressToken(token: string | null): void { 781: STATE.sessionIngressToken = token 782: } 783: export function getOauthTokenFromFd(): string | null | undefined { 784: return STATE.oauthTokenFromFd 785: } 786: export function setOauthTokenFromFd(token: string | null): void { 787: STATE.oauthTokenFromFd = token 788: } 789: export function getApiKeyFromFd(): string | null | undefined { 790: return STATE.apiKeyFromFd 791: } 792: export function setApiKeyFromFd(key: string | null): void { 793: STATE.apiKeyFromFd = key 794: } 795: export function setLastAPIRequest( 796: params: Omit<BetaMessageStreamParams, 'messages'> | null, 797: ): void { 798: STATE.lastAPIRequest = params 799: } 800: export function getLastAPIRequest(): Omit< 801: BetaMessageStreamParams, 802: 'messages' 803: > | null { 804: return STATE.lastAPIRequest 805: } 806: export function setLastAPIRequestMessages( 807: messages: BetaMessageStreamParams['messages'] | null, 808: ): void { 809: STATE.lastAPIRequestMessages = messages 810: } 811: export function getLastAPIRequestMessages(): 812: | BetaMessageStreamParams['messages'] 813: | null { 814: return STATE.lastAPIRequestMessages 815: } 816: export function setLastClassifierRequests(requests: unknown[] | null): void { 817: STATE.lastClassifierRequests = requests 818: } 819: export function getLastClassifierRequests(): unknown[] | null { 820: return STATE.lastClassifierRequests 821: } 822: export function setCachedClaudeMdContent(content: string | null): void { 823: STATE.cachedClaudeMdContent = content 824: } 825: export function getCachedClaudeMdContent(): string | null { 826: return STATE.cachedClaudeMdContent 827: } 828: export function addToInMemoryErrorLog(errorInfo: { 829: error: string 830: timestamp: string 831: }): void { 832: const MAX_IN_MEMORY_ERRORS = 100 833: if (STATE.inMemoryErrorLog.length >= MAX_IN_MEMORY_ERRORS) { 834: STATE.inMemoryErrorLog.shift() 835: } 836: STATE.inMemoryErrorLog.push(errorInfo) 837: } 838: export function getAllowedSettingSources(): SettingSource[] { 839: return STATE.allowedSettingSources 840: } 841: export function setAllowedSettingSources(sources: SettingSource[]): void { 842: STATE.allowedSettingSources = sources 843: } 844: export function preferThirdPartyAuthentication(): boolean { 845: return getIsNonInteractiveSession() && STATE.clientType !== 'claude-vscode' 846: } 847: export function setInlinePlugins(plugins: Array<string>): void { 848: STATE.inlinePlugins = plugins 849: } 850: export function getInlinePlugins(): Array<string> { 851: return STATE.inlinePlugins 852: } 853: export function setChromeFlagOverride(value: boolean | undefined): void { 854: STATE.chromeFlagOverride = value 855: } 856: export function getChromeFlagOverride(): boolean | undefined { 857: return STATE.chromeFlagOverride 858: } 859: export function setUseCoworkPlugins(value: boolean): void { 860: STATE.useCoworkPlugins = value 861: resetSettingsCache() 862: } 863: export function getUseCoworkPlugins(): boolean { 864: return STATE.useCoworkPlugins 865: } 866: export function setSessionBypassPermissionsMode(enabled: boolean): void { 867: STATE.sessionBypassPermissionsMode = enabled 868: } 869: export function getSessionBypassPermissionsMode(): boolean { 870: return STATE.sessionBypassPermissionsMode 871: } 872: export function setScheduledTasksEnabled(enabled: boolean): void { 873: STATE.scheduledTasksEnabled = enabled 874: } 875: export function getScheduledTasksEnabled(): boolean { 876: return STATE.scheduledTasksEnabled 877: } 878: export type SessionCronTask = { 879: id: string 880: cron: string 881: prompt: string 882: createdAt: number 883: recurring?: boolean 884: agentId?: string 885: } 886: export function getSessionCronTasks(): SessionCronTask[] { 887: return STATE.sessionCronTasks 888: } 889: export function addSessionCronTask(task: SessionCronTask): void { 890: STATE.sessionCronTasks.push(task) 891: } 892: export function removeSessionCronTasks(ids: readonly string[]): number { 893: if (ids.length === 0) return 0 894: const idSet = new Set(ids) 895: const remaining = STATE.sessionCronTasks.filter(t => !idSet.has(t.id)) 896: const removed = STATE.sessionCronTasks.length - remaining.length 897: if (removed === 0) return 0 898: STATE.sessionCronTasks = remaining 899: return removed 900: } 901: export function setSessionTrustAccepted(accepted: boolean): void { 902: STATE.sessionTrustAccepted = accepted 903: } 904: export function getSessionTrustAccepted(): boolean { 905: return STATE.sessionTrustAccepted 906: } 907: export function setSessionPersistenceDisabled(disabled: boolean): void { 908: STATE.sessionPersistenceDisabled = disabled 909: } 910: export function isSessionPersistenceDisabled(): boolean { 911: return STATE.sessionPersistenceDisabled 912: } 913: export function hasExitedPlanModeInSession(): boolean { 914: return STATE.hasExitedPlanMode 915: } 916: export function setHasExitedPlanMode(value: boolean): void { 917: STATE.hasExitedPlanMode = value 918: } 919: export function needsPlanModeExitAttachment(): boolean { 920: return STATE.needsPlanModeExitAttachment 921: } 922: export function setNeedsPlanModeExitAttachment(value: boolean): void { 923: STATE.needsPlanModeExitAttachment = value 924: } 925: export function handlePlanModeTransition( 926: fromMode: string, 927: toMode: string, 928: ): void { 929: if (toMode === 'plan' && fromMode !== 'plan') { 930: STATE.needsPlanModeExitAttachment = false 931: } 932: if (fromMode === 'plan' && toMode !== 'plan') { 933: STATE.needsPlanModeExitAttachment = true 934: } 935: } 936: export function needsAutoModeExitAttachment(): boolean { 937: return STATE.needsAutoModeExitAttachment 938: } 939: export function setNeedsAutoModeExitAttachment(value: boolean): void { 940: STATE.needsAutoModeExitAttachment = value 941: } 942: export function handleAutoModeTransition( 943: fromMode: string, 944: toMode: string, 945: ): void { 946: if ( 947: (fromMode === 'auto' && toMode === 'plan') || 948: (fromMode === 'plan' && toMode === 'auto') 949: ) { 950: return 951: } 952: const fromIsAuto = fromMode === 'auto' 953: const toIsAuto = toMode === 'auto' 954: if (toIsAuto && !fromIsAuto) { 955: STATE.needsAutoModeExitAttachment = false 956: } 957: if (fromIsAuto && !toIsAuto) { 958: STATE.needsAutoModeExitAttachment = true 959: } 960: } 961: export function hasShownLspRecommendationThisSession(): boolean { 962: return STATE.lspRecommendationShownThisSession 963: } 964: export function setLspRecommendationShownThisSession(value: boolean): void { 965: STATE.lspRecommendationShownThisSession = value 966: } 967: export function setInitJsonSchema(schema: Record<string, unknown>): void { 968: STATE.initJsonSchema = schema 969: } 970: export function getInitJsonSchema(): Record<string, unknown> | null { 971: return STATE.initJsonSchema 972: } 973: export function registerHookCallbacks( 974: hooks: Partial<Record<HookEvent, RegisteredHookMatcher[]>>, 975: ): void { 976: if (!STATE.registeredHooks) { 977: STATE.registeredHooks = {} 978: } 979: for (const [event, matchers] of Object.entries(hooks)) { 980: const eventKey = event as HookEvent 981: if (!STATE.registeredHooks[eventKey]) { 982: STATE.registeredHooks[eventKey] = [] 983: } 984: STATE.registeredHooks[eventKey]!.push(...matchers) 985: } 986: } 987: export function getRegisteredHooks(): Partial< 988: Record<HookEvent, RegisteredHookMatcher[]> 989: > | null { 990: return STATE.registeredHooks 991: } 992: export function clearRegisteredHooks(): void { 993: STATE.registeredHooks = null 994: } 995: export function clearRegisteredPluginHooks(): void { 996: if (!STATE.registeredHooks) { 997: return 998: } 999: const filtered: Partial<Record<HookEvent, RegisteredHookMatcher[]>> = {} 1000: for (const [event, matchers] of Object.entries(STATE.registeredHooks)) { 1001: const callbackHooks = matchers.filter(m => !('pluginRoot' in m)) 1002: if (callbackHooks.length > 0) { 1003: filtered[event as HookEvent] = callbackHooks 1004: } 1005: } 1006: STATE.registeredHooks = Object.keys(filtered).length > 0 ? filtered : null 1007: } 1008: export function resetSdkInitState(): void { 1009: STATE.initJsonSchema = null 1010: STATE.registeredHooks = null 1011: } 1012: export function getPlanSlugCache(): Map<string, string> { 1013: return STATE.planSlugCache 1014: } 1015: export function getSessionCreatedTeams(): Set<string> { 1016: return STATE.sessionCreatedTeams 1017: } 1018: export function setTeleportedSessionInfo(info: { 1019: sessionId: string | null 1020: }): void { 1021: STATE.teleportedSessionInfo = { 1022: isTeleported: true, 1023: hasLoggedFirstMessage: false, 1024: sessionId: info.sessionId, 1025: } 1026: } 1027: export function getTeleportedSessionInfo(): { 1028: isTeleported: boolean 1029: hasLoggedFirstMessage: boolean 1030: sessionId: string | null 1031: } | null { 1032: return STATE.teleportedSessionInfo 1033: } 1034: export function markFirstTeleportMessageLogged(): void { 1035: if (STATE.teleportedSessionInfo) { 1036: STATE.teleportedSessionInfo.hasLoggedFirstMessage = true 1037: } 1038: } 1039: export type InvokedSkillInfo = { 1040: skillName: string 1041: skillPath: string 1042: content: string 1043: invokedAt: number 1044: agentId: string | null 1045: } 1046: export function addInvokedSkill( 1047: skillName: string, 1048: skillPath: string, 1049: content: string, 1050: agentId: string | null = null, 1051: ): void { 1052: const key = `${agentId ?? ''}:${skillName}` 1053: STATE.invokedSkills.set(key, { 1054: skillName, 1055: skillPath, 1056: content, 1057: invokedAt: Date.now(), 1058: agentId, 1059: }) 1060: } 1061: export function getInvokedSkills(): Map<string, InvokedSkillInfo> { 1062: return STATE.invokedSkills 1063: } 1064: export function getInvokedSkillsForAgent( 1065: agentId: string | undefined | null, 1066: ): Map<string, InvokedSkillInfo> { 1067: const normalizedId = agentId ?? null 1068: const filtered = new Map<string, InvokedSkillInfo>() 1069: for (const [key, skill] of STATE.invokedSkills) { 1070: if (skill.agentId === normalizedId) { 1071: filtered.set(key, skill) 1072: } 1073: } 1074: return filtered 1075: } 1076: export function clearInvokedSkills( 1077: preservedAgentIds?: ReadonlySet<string>, 1078: ): void { 1079: if (!preservedAgentIds || preservedAgentIds.size === 0) { 1080: STATE.invokedSkills.clear() 1081: return 1082: } 1083: for (const [key, skill] of STATE.invokedSkills) { 1084: if (skill.agentId === null || !preservedAgentIds.has(skill.agentId)) { 1085: STATE.invokedSkills.delete(key) 1086: } 1087: } 1088: } 1089: export function clearInvokedSkillsForAgent(agentId: string): void { 1090: for (const [key, skill] of STATE.invokedSkills) { 1091: if (skill.agentId === agentId) { 1092: STATE.invokedSkills.delete(key) 1093: } 1094: } 1095: } 1096: const MAX_SLOW_OPERATIONS = 10 1097: const SLOW_OPERATION_TTL_MS = 10000 1098: export function addSlowOperation(operation: string, durationMs: number): void { 1099: if (process.env.USER_TYPE !== 'ant') return 1100: if (operation.includes('exec') && operation.includes('claude-prompt-')) { 1101: return 1102: } 1103: const now = Date.now() 1104: STATE.slowOperations = STATE.slowOperations.filter( 1105: op => now - op.timestamp < SLOW_OPERATION_TTL_MS, 1106: ) 1107: STATE.slowOperations.push({ operation, durationMs, timestamp: now }) 1108: if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) { 1109: STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS) 1110: } 1111: } 1112: const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{ 1113: operation: string 1114: durationMs: number 1115: timestamp: number 1116: }> = [] 1117: export function getSlowOperations(): ReadonlyArray<{ 1118: operation: string 1119: durationMs: number 1120: timestamp: number 1121: }> { 1122: if (STATE.slowOperations.length === 0) { 1123: return EMPTY_SLOW_OPERATIONS 1124: } 1125: const now = Date.now() 1126: if ( 1127: STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS) 1128: ) { 1129: STATE.slowOperations = STATE.slowOperations.filter( 1130: op => now - op.timestamp < SLOW_OPERATION_TTL_MS, 1131: ) 1132: if (STATE.slowOperations.length === 0) { 1133: return EMPTY_SLOW_OPERATIONS 1134: } 1135: } 1136: return STATE.slowOperations 1137: } 1138: export function getMainThreadAgentType(): string | undefined { 1139: return STATE.mainThreadAgentType 1140: } 1141: export function setMainThreadAgentType(agentType: string | undefined): void { 1142: STATE.mainThreadAgentType = agentType 1143: } 1144: export function getIsRemoteMode(): boolean { 1145: return STATE.isRemoteMode 1146: } 1147: export function setIsRemoteMode(value: boolean): void { 1148: STATE.isRemoteMode = value 1149: } 1150: export function getSystemPromptSectionCache(): Map<string, string | null> { 1151: return STATE.systemPromptSectionCache 1152: } 1153: export function setSystemPromptSectionCacheEntry( 1154: name: string, 1155: value: string | null, 1156: ): void { 1157: STATE.systemPromptSectionCache.set(name, value) 1158: } 1159: export function clearSystemPromptSectionState(): void { 1160: STATE.systemPromptSectionCache.clear() 1161: } 1162: export function getLastEmittedDate(): string | null { 1163: return STATE.lastEmittedDate 1164: } 1165: export function setLastEmittedDate(date: string | null): void { 1166: STATE.lastEmittedDate = date 1167: } 1168: export function getAdditionalDirectoriesForClaudeMd(): string[] { 1169: return STATE.additionalDirectoriesForClaudeMd 1170: } 1171: export function setAdditionalDirectoriesForClaudeMd( 1172: directories: string[], 1173: ): void { 1174: STATE.additionalDirectoriesForClaudeMd = directories 1175: } 1176: export function getAllowedChannels(): ChannelEntry[] { 1177: return STATE.allowedChannels 1178: } 1179: export function setAllowedChannels(entries: ChannelEntry[]): void { 1180: STATE.allowedChannels = entries 1181: } 1182: export function getHasDevChannels(): boolean { 1183: return STATE.hasDevChannels 1184: } 1185: export function setHasDevChannels(value: boolean): void { 1186: STATE.hasDevChannels = value 1187: } 1188: export function getPromptCache1hAllowlist(): string[] | null { 1189: return STATE.promptCache1hAllowlist 1190: } 1191: export function setPromptCache1hAllowlist(allowlist: string[] | null): void { 1192: STATE.promptCache1hAllowlist = allowlist 1193: } 1194: export function getPromptCache1hEligible(): boolean | null { 1195: return STATE.promptCache1hEligible 1196: } 1197: export function setPromptCache1hEligible(eligible: boolean | null): void { 1198: STATE.promptCache1hEligible = eligible 1199: } 1200: export function getAfkModeHeaderLatched(): boolean | null { 1201: return STATE.afkModeHeaderLatched 1202: } 1203: export function setAfkModeHeaderLatched(v: boolean): void { 1204: STATE.afkModeHeaderLatched = v 1205: } 1206: export function getFastModeHeaderLatched(): boolean | null { 1207: return STATE.fastModeHeaderLatched 1208: } 1209: export function setFastModeHeaderLatched(v: boolean): void { 1210: STATE.fastModeHeaderLatched = v 1211: } 1212: export function getCacheEditingHeaderLatched(): boolean | null { 1213: return STATE.cacheEditingHeaderLatched 1214: } 1215: export function setCacheEditingHeaderLatched(v: boolean): void { 1216: STATE.cacheEditingHeaderLatched = v 1217: } 1218: export function getThinkingClearLatched(): boolean | null { 1219: return STATE.thinkingClearLatched 1220: } 1221: export function setThinkingClearLatched(v: boolean): void { 1222: STATE.thinkingClearLatched = v 1223: } 1224: export function clearBetaHeaderLatches(): void { 1225: STATE.afkModeHeaderLatched = null 1226: STATE.fastModeHeaderLatched = null 1227: STATE.cacheEditingHeaderLatched = null 1228: STATE.thinkingClearLatched = null 1229: } 1230: export function getPromptId(): string | null { 1231: return STATE.promptId 1232: } 1233: export function setPromptId(id: string | null): void { 1234: STATE.promptId = id 1235: }

File: src/bridge/bridgeApi.ts

typescript 1: import axios from 'axios' 2: import { debugBody, extractErrorDetail } from './debugUtils.js' 3: import { 4: BRIDGE_LOGIN_INSTRUCTION, 5: type BridgeApiClient, 6: type BridgeConfig, 7: type PermissionResponseEvent, 8: type WorkResponse, 9: } from './types.js' 10: type BridgeApiDeps = { 11: baseUrl: string 12: getAccessToken: () => string | undefined 13: runnerVersion: string 14: onDebug?: (msg: string) => void 15: onAuth401?: (staleAccessToken: string) => Promise<boolean> 16: getTrustedDeviceToken?: () => string | undefined 17: } 18: const BETA_HEADER = 'environments-2025-11-01' 19: const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/ 20: export function validateBridgeId(id: string, label: string): string { 21: if (!id || !SAFE_ID_PATTERN.test(id)) { 22: throw new Error(`Invalid ${label}: contains unsafe characters`) 23: } 24: return id 25: } 26: export class BridgeFatalError extends Error { 27: readonly status: number 28: readonly errorType: string | undefined 29: constructor(message: string, status: number, errorType?: string) { 30: super(message) 31: this.name = 'BridgeFatalError' 32: this.status = status 33: this.errorType = errorType 34: } 35: } 36: export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient { 37: function debug(msg: string): void { 38: deps.onDebug?.(msg) 39: } 40: let consecutiveEmptyPolls = 0 41: const EMPTY_POLL_LOG_INTERVAL = 100 42: function getHeaders(accessToken: string): Record<string, string> { 43: const headers: Record<string, string> = { 44: Authorization: `Bearer ${accessToken}`, 45: 'Content-Type': 'application/json', 46: 'anthropic-version': '2023-06-01', 47: 'anthropic-beta': BETA_HEADER, 48: 'x-environment-runner-version': deps.runnerVersion, 49: } 50: const deviceToken = deps.getTrustedDeviceToken?.() 51: if (deviceToken) { 52: headers['X-Trusted-Device-Token'] = deviceToken 53: } 54: return headers 55: } 56: function resolveAuth(): string { 57: const accessToken = deps.getAccessToken() 58: if (!accessToken) { 59: throw new Error(BRIDGE_LOGIN_INSTRUCTION) 60: } 61: return accessToken 62: } 63: async function withOAuthRetry<T>( 64: fn: (accessToken: string) => Promise<{ status: number; data: T }>, 65: context: string, 66: ): Promise<{ status: number; data: T }> { 67: const accessToken = resolveAuth() 68: const response = await fn(accessToken) 69: if (response.status !== 401) { 70: return response 71: } 72: if (!deps.onAuth401) { 73: debug(`[bridge:api] ${context}: 401 received, no refresh handler`) 74: return response 75: } 76: debug(`[bridge:api] ${context}: 401 received, attempting token refresh`) 77: const refreshed = await deps.onAuth401(accessToken) 78: if (refreshed) { 79: debug(`[bridge:api] ${context}: Token refreshed, retrying request`) 80: const newToken = resolveAuth() 81: const retryResponse = await fn(newToken) 82: if (retryResponse.status !== 401) { 83: return retryResponse 84: } 85: debug(`[bridge:api] ${context}: Retry after refresh also got 401`) 86: } else { 87: debug(`[bridge:api] ${context}: Token refresh failed`) 88: } 89: return response 90: } 91: return { 92: async registerBridgeEnvironment( 93: config: BridgeConfig, 94: ): Promise<{ environment_id: string; environment_secret: string }> { 95: debug( 96: `[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`, 97: ) 98: const response = await withOAuthRetry( 99: (token: string) => 100: axios.post<{ 101: environment_id: string 102: environment_secret: string 103: }>( 104: `${deps.baseUrl}/v1/environments/bridge`, 105: { 106: machine_name: config.machineName, 107: directory: config.dir, 108: branch: config.branch, 109: git_repo_url: config.gitRepoUrl, 110: max_sessions: config.maxSessions, 111: metadata: { worker_type: config.workerType }, 112: ...(config.reuseEnvironmentId && { 113: environment_id: config.reuseEnvironmentId, 114: }), 115: }, 116: { 117: headers: getHeaders(token), 118: timeout: 15_000, 119: validateStatus: status => status < 500, 120: }, 121: ), 122: 'Registration', 123: ) 124: handleErrorStatus(response.status, response.data, 'Registration') 125: debug( 126: `[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`, 127: ) 128: debug( 129: `[bridge:api] >>> ${debugBody({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`, 130: ) 131: debug(`[bridge:api] <<< ${debugBody(response.data)}`) 132: return response.data 133: }, 134: async pollForWork( 135: environmentId: string, 136: environmentSecret: string, 137: signal?: AbortSignal, 138: reclaimOlderThanMs?: number, 139: ): Promise<WorkResponse | null> { 140: validateBridgeId(environmentId, 'environmentId') 141: const prevEmptyPolls = consecutiveEmptyPolls 142: consecutiveEmptyPolls = 0 143: const response = await axios.get<WorkResponse | null>( 144: `${deps.baseUrl}/v1/environments/${environmentId}/work/poll`, 145: { 146: headers: getHeaders(environmentSecret), 147: params: 148: reclaimOlderThanMs !== undefined 149: ? { reclaim_older_than_ms: reclaimOlderThanMs } 150: : undefined, 151: timeout: 10_000, 152: signal, 153: validateStatus: status => status < 500, 154: }, 155: ) 156: handleErrorStatus(response.status, response.data, 'Poll') 157: if (!response.data) { 158: consecutiveEmptyPolls = prevEmptyPolls + 1 159: if ( 160: consecutiveEmptyPolls === 1 || 161: consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0 162: ) { 163: debug( 164: `[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`, 165: ) 166: } 167: return null 168: } 169: debug( 170: `[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`, 171: ) 172: debug(`[bridge:api] <<< ${debugBody(response.data)}`) 173: return response.data 174: }, 175: async acknowledgeWork( 176: environmentId: string, 177: workId: string, 178: sessionToken: string, 179: ): Promise<void> { 180: validateBridgeId(environmentId, 'environmentId') 181: validateBridgeId(workId, 'workId') 182: debug(`[bridge:api] POST .../work/${workId}/ack`) 183: const response = await axios.post( 184: `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`, 185: {}, 186: { 187: headers: getHeaders(sessionToken), 188: timeout: 10_000, 189: validateStatus: s => s < 500, 190: }, 191: ) 192: handleErrorStatus(response.status, response.data, 'Acknowledge') 193: debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`) 194: }, 195: async stopWork( 196: environmentId: string, 197: workId: string, 198: force: boolean, 199: ): Promise<void> { 200: validateBridgeId(environmentId, 'environmentId') 201: validateBridgeId(workId, 'workId') 202: debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`) 203: const response = await withOAuthRetry( 204: (token: string) => 205: axios.post( 206: `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`, 207: { force }, 208: { 209: headers: getHeaders(token), 210: timeout: 10_000, 211: validateStatus: s => s < 500, 212: }, 213: ), 214: 'StopWork', 215: ) 216: handleErrorStatus(response.status, response.data, 'StopWork') 217: debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`) 218: }, 219: async deregisterEnvironment(environmentId: string): Promise<void> { 220: validateBridgeId(environmentId, 'environmentId') 221: debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`) 222: const response = await withOAuthRetry( 223: (token: string) => 224: axios.delete( 225: `${deps.baseUrl}/v1/environments/bridge/${environmentId}`, 226: { 227: headers: getHeaders(token), 228: timeout: 10_000, 229: validateStatus: s => s < 500, 230: }, 231: ), 232: 'Deregister', 233: ) 234: handleErrorStatus(response.status, response.data, 'Deregister') 235: debug( 236: `[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`, 237: ) 238: }, 239: async archiveSession(sessionId: string): Promise<void> { 240: validateBridgeId(sessionId, 'sessionId') 241: debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`) 242: const response = await withOAuthRetry( 243: (token: string) => 244: axios.post( 245: `${deps.baseUrl}/v1/sessions/${sessionId}/archive`, 246: {}, 247: { 248: headers: getHeaders(token), 249: timeout: 10_000, 250: validateStatus: s => s < 500, 251: }, 252: ), 253: 'ArchiveSession', 254: ) 255: if (response.status === 409) { 256: debug( 257: `[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`, 258: ) 259: return 260: } 261: handleErrorStatus(response.status, response.data, 'ArchiveSession') 262: debug( 263: `[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`, 264: ) 265: }, 266: async reconnectSession( 267: environmentId: string, 268: sessionId: string, 269: ): Promise<void> { 270: validateBridgeId(environmentId, 'environmentId') 271: validateBridgeId(sessionId, 'sessionId') 272: debug( 273: `[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`, 274: ) 275: const response = await withOAuthRetry( 276: (token: string) => 277: axios.post( 278: `${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`, 279: { session_id: sessionId }, 280: { 281: headers: getHeaders(token), 282: timeout: 10_000, 283: validateStatus: s => s < 500, 284: }, 285: ), 286: 'ReconnectSession', 287: ) 288: handleErrorStatus(response.status, response.data, 'ReconnectSession') 289: debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`) 290: }, 291: async heartbeatWork( 292: environmentId: string, 293: workId: string, 294: sessionToken: string, 295: ): Promise<{ lease_extended: boolean; state: string }> { 296: validateBridgeId(environmentId, 'environmentId') 297: validateBridgeId(workId, 'workId') 298: debug(`[bridge:api] POST .../work/${workId}/heartbeat`) 299: const response = await axios.post<{ 300: lease_extended: boolean 301: state: string 302: last_heartbeat: string 303: ttl_seconds: number 304: }>( 305: `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`, 306: {}, 307: { 308: headers: getHeaders(sessionToken), 309: timeout: 10_000, 310: validateStatus: s => s < 500, 311: }, 312: ) 313: handleErrorStatus(response.status, response.data, 'Heartbeat') 314: debug( 315: `[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`, 316: ) 317: return response.data 318: }, 319: async sendPermissionResponseEvent( 320: sessionId: string, 321: event: PermissionResponseEvent, 322: sessionToken: string, 323: ): Promise<void> { 324: validateBridgeId(sessionId, 'sessionId') 325: debug( 326: `[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`, 327: ) 328: const response = await axios.post( 329: `${deps.baseUrl}/v1/sessions/${sessionId}/events`, 330: { events: [event] }, 331: { 332: headers: getHeaders(sessionToken), 333: timeout: 10_000, 334: validateStatus: s => s < 500, 335: }, 336: ) 337: handleErrorStatus( 338: response.status, 339: response.data, 340: 'SendPermissionResponseEvent', 341: ) 342: debug( 343: `[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`, 344: ) 345: debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`) 346: debug(`[bridge:api] <<< ${debugBody(response.data)}`) 347: }, 348: } 349: } 350: function handleErrorStatus( 351: status: number, 352: data: unknown, 353: context: string, 354: ): void { 355: if (status === 200 || status === 204) { 356: return 357: } 358: const detail = extractErrorDetail(data) 359: const errorType = extractErrorTypeFromData(data) 360: switch (status) { 361: case 401: 362: throw new BridgeFatalError( 363: `${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`, 364: 401, 365: errorType, 366: ) 367: case 403: 368: throw new BridgeFatalError( 369: isExpiredErrorType(errorType) 370: ? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.' 371: : `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`, 372: 403, 373: errorType, 374: ) 375: case 404: 376: throw new BridgeFatalError( 377: detail ?? 378: `${context}: Not found (404). Remote Control may not be available for this organization.`, 379: 404, 380: errorType, 381: ) 382: case 410: 383: throw new BridgeFatalError( 384: detail ?? 385: 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.', 386: 410, 387: errorType ?? 'environment_expired', 388: ) 389: case 429: 390: throw new Error(`${context}: Rate limited (429). Polling too frequently.`) 391: default: 392: throw new Error( 393: `${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`, 394: ) 395: } 396: } 397: export function isExpiredErrorType(errorType: string | undefined): boolean { 398: if (!errorType) { 399: return false 400: } 401: return errorType.includes('expired') || errorType.includes('lifetime') 402: } 403: export function isSuppressible403(err: BridgeFatalError): boolean { 404: if (err.status !== 403) { 405: return false 406: } 407: return ( 408: err.message.includes('external_poll_sessions') || 409: err.message.includes('environments:manage') 410: ) 411: } 412: function extractErrorTypeFromData(data: unknown): string | undefined { 413: if (data && typeof data === 'object') { 414: if ( 415: 'error' in data && 416: data.error && 417: typeof data.error === 'object' && 418: 'type' in data.error && 419: typeof data.error.type === 'string' 420: ) { 421: return data.error.type 422: } 423: } 424: return undefined 425: }

File: src/bridge/bridgeConfig.ts

typescript 1: import { getOauthConfig } from '../constants/oauth.js' 2: import { getClaudeAIOAuthTokens } from '../utils/auth.js' 3: export function getBridgeTokenOverride(): string | undefined { 4: return ( 5: (process.env.USER_TYPE === 'ant' && 6: process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) || 7: undefined 8: ) 9: } 10: export function getBridgeBaseUrlOverride(): string | undefined { 11: return ( 12: (process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) || 13: undefined 14: ) 15: } 16: export function getBridgeAccessToken(): string | undefined { 17: return getBridgeTokenOverride() ?? getClaudeAIOAuthTokens()?.accessToken 18: } 19: export function getBridgeBaseUrl(): string { 20: return getBridgeBaseUrlOverride() ?? getOauthConfig().BASE_API_URL 21: }

File: src/bridge/bridgeDebug.ts

typescript 1: import { logForDebugging } from '../utils/debug.js' 2: import { BridgeFatalError } from './bridgeApi.js' 3: import type { BridgeApiClient } from './types.js' 4: type BridgeFault = { 5: method: 6: | 'pollForWork' 7: | 'registerBridgeEnvironment' 8: | 'reconnectSession' 9: | 'heartbeatWork' 10: kind: 'fatal' | 'transient' 11: status: number 12: errorType?: string 13: count: number 14: } 15: export type BridgeDebugHandle = { 16: fireClose: (code: number) => void 17: forceReconnect: () => void 18: injectFault: (fault: BridgeFault) => void 19: wakePollLoop: () => void 20: describe: () => string 21: } 22: let debugHandle: BridgeDebugHandle | null = null 23: const faultQueue: BridgeFault[] = [] 24: export function registerBridgeDebugHandle(h: BridgeDebugHandle): void { 25: debugHandle = h 26: } 27: export function clearBridgeDebugHandle(): void { 28: debugHandle = null 29: faultQueue.length = 0 30: } 31: export function getBridgeDebugHandle(): BridgeDebugHandle | null { 32: return debugHandle 33: } 34: export function injectBridgeFault(fault: BridgeFault): void { 35: faultQueue.push(fault) 36: logForDebugging( 37: `[bridge:debug] Queued fault: ${fault.method} ${fault.kind}/${fault.status}${fault.errorType ? `/${fault.errorType}` : ''} ×${fault.count}`, 38: ) 39: } 40: export function wrapApiForFaultInjection( 41: api: BridgeApiClient, 42: ): BridgeApiClient { 43: function consume(method: BridgeFault['method']): BridgeFault | null { 44: const idx = faultQueue.findIndex(f => f.method === method) 45: if (idx === -1) return null 46: const fault = faultQueue[idx]! 47: fault.count-- 48: if (fault.count <= 0) faultQueue.splice(idx, 1) 49: return fault 50: } 51: function throwFault(fault: BridgeFault, context: string): never { 52: logForDebugging( 53: `[bridge:debug] Injecting ${fault.kind} fault into ${context}: status=${fault.status} errorType=${fault.errorType ?? 'none'}`, 54: ) 55: if (fault.kind === 'fatal') { 56: throw new BridgeFatalError( 57: `[injected] ${context} ${fault.status}`, 58: fault.status, 59: fault.errorType, 60: ) 61: } 62: throw new Error(`[injected transient] ${context} ${fault.status}`) 63: } 64: return { 65: ...api, 66: async pollForWork(envId, secret, signal, reclaimMs) { 67: const f = consume('pollForWork') 68: if (f) throwFault(f, 'Poll') 69: return api.pollForWork(envId, secret, signal, reclaimMs) 70: }, 71: async registerBridgeEnvironment(config) { 72: const f = consume('registerBridgeEnvironment') 73: if (f) throwFault(f, 'Registration') 74: return api.registerBridgeEnvironment(config) 75: }, 76: async reconnectSession(envId, sessionId) { 77: const f = consume('reconnectSession') 78: if (f) throwFault(f, 'ReconnectSession') 79: return api.reconnectSession(envId, sessionId) 80: }, 81: async heartbeatWork(envId, workId, token) { 82: const f = consume('heartbeatWork') 83: if (f) throwFault(f, 'Heartbeat') 84: return api.heartbeatWork(envId, workId, token) 85: }, 86: } 87: }

File: src/bridge/bridgeEnabled.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { 3: checkGate_CACHED_OR_BLOCKING, 4: getDynamicConfig_CACHED_MAY_BE_STALE, 5: getFeatureValue_CACHED_MAY_BE_STALE, 6: } from '../services/analytics/growthbook.js' 7: import * as authModule from '../utils/auth.js' 8: import { isEnvTruthy } from '../utils/envUtils.js' 9: import { lt } from '../utils/semver.js' 10: export function isBridgeEnabled(): boolean { 11: return feature('BRIDGE_MODE') 12: ? isClaudeAISubscriber() && 13: getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_bridge', false) 14: : false 15: } 16: export async function isBridgeEnabledBlocking(): Promise<boolean> { 17: return feature('BRIDGE_MODE') 18: ? isClaudeAISubscriber() && 19: (await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge')) 20: : false 21: } 22: export async function getBridgeDisabledReason(): Promise<string | null> { 23: if (feature('BRIDGE_MODE')) { 24: if (!isClaudeAISubscriber()) { 25: return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.' 26: } 27: if (!hasProfileScope()) { 28: return 'Remote Control requires a full-scope login token. Long-lived tokens (from `claude setup-token` or CLAUDE_CODE_OAUTH_TOKEN) are limited to inference-only for security reasons. Run `claude auth login` to use Remote Control.' 29: } 30: if (!getOauthAccountInfo()?.organizationUuid) { 31: return 'Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.' 32: } 33: if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) { 34: return 'Remote Control is not yet enabled for your account.' 35: } 36: return null 37: } 38: return 'Remote Control is not available in this build.' 39: } 40: function isClaudeAISubscriber(): boolean { 41: try { 42: return authModule.isClaudeAISubscriber() 43: } catch { 44: return false 45: } 46: } 47: function hasProfileScope(): boolean { 48: try { 49: return authModule.hasProfileScope() 50: } catch { 51: return false 52: } 53: } 54: function getOauthAccountInfo(): ReturnType< 55: typeof authModule.getOauthAccountInfo 56: > { 57: try { 58: return authModule.getOauthAccountInfo() 59: } catch { 60: return undefined 61: } 62: } 63: export function isEnvLessBridgeEnabled(): boolean { 64: return feature('BRIDGE_MODE') 65: ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_repl_v2', false) 66: : false 67: } 68: export function isCseShimEnabled(): boolean { 69: return feature('BRIDGE_MODE') 70: ? getFeatureValue_CACHED_MAY_BE_STALE( 71: 'tengu_bridge_repl_v2_cse_shim_enabled', 72: true, 73: ) 74: : true 75: } 76: export function checkBridgeMinVersion(): string | null { 77: if (feature('BRIDGE_MODE')) { 78: const config = getDynamicConfig_CACHED_MAY_BE_STALE<{ 79: minVersion: string 80: }>('tengu_bridge_min_version', { minVersion: '0.0.0' }) 81: if (config.minVersion && lt(MACRO.VERSION, config.minVersion)) { 82: return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${config.minVersion} or higher is required. Run \`claude update\` to update.` 83: } 84: } 85: return null 86: } 87: export function getCcrAutoConnectDefault(): boolean { 88: return feature('CCR_AUTO_CONNECT') 89: ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_harbor', false) 90: : false 91: } 92: export function isCcrMirrorEnabled(): boolean { 93: return feature('CCR_MIRROR') 94: ? isEnvTruthy(process.env.CLAUDE_CODE_CCR_MIRROR) || 95: getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_mirror', false) 96: : false 97: }

File: src/bridge/bridgeMain.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { randomUUID } from 'crypto' 3: import { hostname, tmpdir } from 'os' 4: import { basename, join, resolve } from 'path' 5: import { getRemoteSessionUrl } from '../constants/product.js' 6: import { shutdownDatadog } from '../services/analytics/datadog.js' 7: import { shutdown1PEventLogging } from '../services/analytics/firstPartyEventLogger.js' 8: import { checkGate_CACHED_OR_BLOCKING } from '../services/analytics/growthbook.js' 9: import { 10: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 11: logEvent, 12: logEventAsync, 13: } from '../services/analytics/index.js' 14: import { isInBundledMode } from '../utils/bundledMode.js' 15: import { logForDebugging } from '../utils/debug.js' 16: import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' 17: import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js' 18: import { errorMessage } from '../utils/errors.js' 19: import { truncateToWidth } from '../utils/format.js' 20: import { logError } from '../utils/log.js' 21: import { sleep } from '../utils/sleep.js' 22: import { createAgentWorktree, removeAgentWorktree } from '../utils/worktree.js' 23: import { 24: BridgeFatalError, 25: createBridgeApiClient, 26: isExpiredErrorType, 27: isSuppressible403, 28: validateBridgeId, 29: } from './bridgeApi.js' 30: import { formatDuration } from './bridgeStatusUtil.js' 31: import { createBridgeLogger } from './bridgeUI.js' 32: import { createCapacityWake } from './capacityWake.js' 33: import { describeAxiosError } from './debugUtils.js' 34: import { createTokenRefreshScheduler } from './jwtUtils.js' 35: import { getPollIntervalConfig } from './pollConfig.js' 36: import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js' 37: import { createSessionSpawner, safeFilenameId } from './sessionRunner.js' 38: import { getTrustedDeviceToken } from './trustedDevice.js' 39: import { 40: BRIDGE_LOGIN_ERROR, 41: type BridgeApiClient, 42: type BridgeConfig, 43: type BridgeLogger, 44: DEFAULT_SESSION_TIMEOUT_MS, 45: type SessionDoneStatus, 46: type SessionHandle, 47: type SessionSpawner, 48: type SessionSpawnOpts, 49: type SpawnMode, 50: } from './types.js' 51: import { 52: buildCCRv2SdkUrl, 53: buildSdkUrl, 54: decodeWorkSecret, 55: registerWorker, 56: sameSessionId, 57: } from './workSecret.js' 58: export type BackoffConfig = { 59: connInitialMs: number 60: connCapMs: number 61: connGiveUpMs: number 62: generalInitialMs: number 63: generalCapMs: number 64: generalGiveUpMs: number 65: shutdownGraceMs?: number 66: stopWorkBaseDelayMs?: number 67: } 68: const DEFAULT_BACKOFF: BackoffConfig = { 69: connInitialMs: 2_000, 70: connCapMs: 120_000, 71: connGiveUpMs: 600_000, 72: generalInitialMs: 500, 73: generalCapMs: 30_000, 74: generalGiveUpMs: 600_000, 75: } 76: const STATUS_UPDATE_INTERVAL_MS = 1_000 77: const SPAWN_SESSIONS_DEFAULT = 32 78: async function isMultiSessionSpawnEnabled(): Promise<boolean> { 79: return checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge_multi_session') 80: } 81: function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number { 82: return backoff.connCapMs * 2 83: } 84: function spawnScriptArgs(): string[] { 85: if (isInBundledMode() || !process.argv[1]) { 86: return [] 87: } 88: return [process.argv[1]] 89: } 90: function safeSpawn( 91: spawner: SessionSpawner, 92: opts: SessionSpawnOpts, 93: dir: string, 94: ): SessionHandle | string { 95: try { 96: return spawner.spawn(opts, dir) 97: } catch (err) { 98: const errMsg = errorMessage(err) 99: logError(new Error(`Session spawn failed: ${errMsg}`)) 100: return errMsg 101: } 102: } 103: export async function runBridgeLoop( 104: config: BridgeConfig, 105: environmentId: string, 106: environmentSecret: string, 107: api: BridgeApiClient, 108: spawner: SessionSpawner, 109: logger: BridgeLogger, 110: signal: AbortSignal, 111: backoffConfig: BackoffConfig = DEFAULT_BACKOFF, 112: initialSessionId?: string, 113: getAccessToken?: () => string | undefined | Promise<string | undefined>, 114: ): Promise<void> { 115: const controller = new AbortController() 116: if (signal.aborted) { 117: controller.abort() 118: } else { 119: signal.addEventListener('abort', () => controller.abort(), { once: true }) 120: } 121: const loopSignal = controller.signal 122: const activeSessions = new Map<string, SessionHandle>() 123: const sessionStartTimes = new Map<string, number>() 124: const sessionWorkIds = new Map<string, string>() 125: const sessionCompatIds = new Map<string, string>() 126: const sessionIngressTokens = new Map<string, string>() 127: const sessionTimers = new Map<string, ReturnType<typeof setTimeout>>() 128: const completedWorkIds = new Set<string>() 129: const sessionWorktrees = new Map< 130: string, 131: { 132: worktreePath: string 133: worktreeBranch?: string 134: gitRoot?: string 135: hookBased?: boolean 136: } 137: >() 138: const timedOutSessions = new Set<string>() 139: const titledSessions = new Set<string>() 140: const capacityWake = createCapacityWake(loopSignal) 141: async function heartbeatActiveWorkItems(): Promise< 142: 'ok' | 'auth_failed' | 'fatal' | 'failed' 143: > { 144: let anySuccess = false 145: let anyFatal = false 146: const authFailedSessions: string[] = [] 147: for (const [sessionId] of activeSessions) { 148: const workId = sessionWorkIds.get(sessionId) 149: const ingressToken = sessionIngressTokens.get(sessionId) 150: if (!workId || !ingressToken) { 151: continue 152: } 153: try { 154: await api.heartbeatWork(environmentId, workId, ingressToken) 155: anySuccess = true 156: } catch (err) { 157: logForDebugging( 158: `[bridge:heartbeat] Failed for sessionId=${sessionId} workId=${workId}: ${errorMessage(err)}`, 159: ) 160: if (err instanceof BridgeFatalError) { 161: logEvent('tengu_bridge_heartbeat_error', { 162: status: 163: err.status as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 164: error_type: (err.status === 401 || err.status === 403 165: ? 'auth_failed' 166: : 'fatal') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 167: }) 168: if (err.status === 401 || err.status === 403) { 169: authFailedSessions.push(sessionId) 170: } else { 171: anyFatal = true 172: } 173: } 174: } 175: } 176: for (const sessionId of authFailedSessions) { 177: logger.logVerbose( 178: `Session ${sessionId} token expired — re-queuing via bridge/reconnect`, 179: ) 180: try { 181: await api.reconnectSession(environmentId, sessionId) 182: logForDebugging( 183: `[bridge:heartbeat] Re-queued sessionId=${sessionId} via bridge/reconnect`, 184: ) 185: } catch (err) { 186: logger.logError( 187: `Failed to refresh session ${sessionId} token: ${errorMessage(err)}`, 188: ) 189: logForDebugging( 190: `[bridge:heartbeat] reconnectSession(${sessionId}) failed: ${errorMessage(err)}`, 191: { level: 'error' }, 192: ) 193: } 194: } 195: if (anyFatal) { 196: return 'fatal' 197: } 198: if (authFailedSessions.length > 0) { 199: return 'auth_failed' 200: } 201: return anySuccess ? 'ok' : 'failed' 202: } 203: const v2Sessions = new Set<string>() 204: const tokenRefresh = getAccessToken 205: ? createTokenRefreshScheduler({ 206: getAccessToken, 207: onRefresh: (sessionId, oauthToken) => { 208: const handle = activeSessions.get(sessionId) 209: if (!handle) { 210: return 211: } 212: if (v2Sessions.has(sessionId)) { 213: logger.logVerbose( 214: `Refreshing session ${sessionId} token via bridge/reconnect`, 215: ) 216: void api 217: .reconnectSession(environmentId, sessionId) 218: .catch((err: unknown) => { 219: logger.logError( 220: `Failed to refresh session ${sessionId} token: ${errorMessage(err)}`, 221: ) 222: logForDebugging( 223: `[bridge:token] reconnectSession(${sessionId}) failed: ${errorMessage(err)}`, 224: { level: 'error' }, 225: ) 226: }) 227: } else { 228: handle.updateAccessToken(oauthToken) 229: } 230: }, 231: label: 'bridge', 232: }) 233: : null 234: const loopStartTime = Date.now() 235: const pendingCleanups = new Set<Promise<unknown>>() 236: function trackCleanup(p: Promise<unknown>): void { 237: pendingCleanups.add(p) 238: void p.finally(() => pendingCleanups.delete(p)) 239: } 240: let connBackoff = 0 241: let generalBackoff = 0 242: let connErrorStart: number | null = null 243: let generalErrorStart: number | null = null 244: let lastPollErrorTime: number | null = null 245: let statusUpdateTimer: ReturnType<typeof setInterval> | null = null 246: let fatalExit = false 247: logForDebugging( 248: `[bridge:work] Starting poll loop spawnMode=${config.spawnMode} maxSessions=${config.maxSessions} environmentId=${environmentId}`, 249: ) 250: logForDiagnosticsNoPII('info', 'bridge_loop_started', { 251: max_sessions: config.maxSessions, 252: spawn_mode: config.spawnMode, 253: }) 254: if (process.env.USER_TYPE === 'ant') { 255: let debugGlob: string 256: if (config.debugFile) { 257: const ext = config.debugFile.lastIndexOf('.') 258: debugGlob = 259: ext > 0 260: ? `${config.debugFile.slice(0, ext)}-*${config.debugFile.slice(ext)}` 261: : `${config.debugFile}-*` 262: } else { 263: debugGlob = join(tmpdir(), 'claude', 'bridge-session-*.log') 264: } 265: logger.setDebugLogPath(debugGlob) 266: } 267: logger.printBanner(config, environmentId) 268: logger.updateSessionCount(0, config.maxSessions, config.spawnMode) 269: if (initialSessionId) { 270: logger.setAttached(initialSessionId) 271: } 272: function updateStatusDisplay(): void { 273: logger.updateSessionCount( 274: activeSessions.size, 275: config.maxSessions, 276: config.spawnMode, 277: ) 278: for (const [sid, handle] of activeSessions) { 279: const act = handle.currentActivity 280: if (act) { 281: logger.updateSessionActivity(sessionCompatIds.get(sid) ?? sid, act) 282: } 283: } 284: if (activeSessions.size === 0) { 285: logger.updateIdleStatus() 286: return 287: } 288: const [sessionId, handle] = [...activeSessions.entries()].pop()! 289: const startTime = sessionStartTimes.get(sessionId) 290: if (!startTime) return 291: const activity = handle.currentActivity 292: if (!activity || activity.type === 'result' || activity.type === 'error') { 293: if (config.maxSessions > 1) logger.refreshDisplay() 294: return 295: } 296: const elapsed = formatDuration(Date.now() - startTime) 297: const trail = handle.activities 298: .filter(a => a.type === 'tool_start') 299: .slice(-5) 300: .map(a => a.summary) 301: logger.updateSessionStatus(sessionId, elapsed, activity, trail) 302: } 303: function startStatusUpdates(): void { 304: stopStatusUpdates() 305: updateStatusDisplay() 306: statusUpdateTimer = setInterval( 307: updateStatusDisplay, 308: STATUS_UPDATE_INTERVAL_MS, 309: ) 310: } 311: function stopStatusUpdates(): void { 312: if (statusUpdateTimer) { 313: clearInterval(statusUpdateTimer) 314: statusUpdateTimer = null 315: } 316: } 317: function onSessionDone( 318: sessionId: string, 319: startTime: number, 320: handle: SessionHandle, 321: ): (status: SessionDoneStatus) => void { 322: return (rawStatus: SessionDoneStatus): void => { 323: const workId = sessionWorkIds.get(sessionId) 324: activeSessions.delete(sessionId) 325: sessionStartTimes.delete(sessionId) 326: sessionWorkIds.delete(sessionId) 327: sessionIngressTokens.delete(sessionId) 328: const compatId = sessionCompatIds.get(sessionId) ?? sessionId 329: sessionCompatIds.delete(sessionId) 330: logger.removeSession(compatId) 331: titledSessions.delete(compatId) 332: v2Sessions.delete(sessionId) 333: const timer = sessionTimers.get(sessionId) 334: if (timer) { 335: clearTimeout(timer) 336: sessionTimers.delete(sessionId) 337: } 338: tokenRefresh?.cancel(sessionId) 339: capacityWake.wake() 340: const wasTimedOut = timedOutSessions.delete(sessionId) 341: const status: SessionDoneStatus = 342: wasTimedOut && rawStatus === 'interrupted' ? 'failed' : rawStatus 343: const durationMs = Date.now() - startTime 344: logForDebugging( 345: `[bridge:session] sessionId=${sessionId} workId=${workId ?? 'unknown'} exited status=${status} duration=${formatDuration(durationMs)}`, 346: ) 347: logEvent('tengu_bridge_session_done', { 348: status: 349: status as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 350: duration_ms: durationMs, 351: }) 352: logForDiagnosticsNoPII('info', 'bridge_session_done', { 353: status, 354: duration_ms: durationMs, 355: }) 356: logger.clearStatus() 357: stopStatusUpdates() 358: const stderrSummary = 359: handle.lastStderr.length > 0 ? handle.lastStderr.join('\n') : undefined 360: let failureMessage: string | undefined 361: switch (status) { 362: case 'completed': 363: logger.logSessionComplete(sessionId, durationMs) 364: break 365: case 'failed': 366: if (!wasTimedOut && !loopSignal.aborted) { 367: failureMessage = stderrSummary ?? 'Process exited with error' 368: logger.logSessionFailed(sessionId, failureMessage) 369: logError(new Error(`Bridge session failed: ${failureMessage}`)) 370: } 371: break 372: case 'interrupted': 373: logger.logVerbose(`Session ${sessionId} interrupted`) 374: break 375: } 376: if (status !== 'interrupted' && workId) { 377: trackCleanup( 378: stopWorkWithRetry( 379: api, 380: environmentId, 381: workId, 382: logger, 383: backoffConfig.stopWorkBaseDelayMs, 384: ), 385: ) 386: completedWorkIds.add(workId) 387: } 388: const wt = sessionWorktrees.get(sessionId) 389: if (wt) { 390: sessionWorktrees.delete(sessionId) 391: trackCleanup( 392: removeAgentWorktree( 393: wt.worktreePath, 394: wt.worktreeBranch, 395: wt.gitRoot, 396: wt.hookBased, 397: ).catch((err: unknown) => 398: logger.logVerbose( 399: `Failed to remove worktree ${wt.worktreePath}: ${errorMessage(err)}`, 400: ), 401: ), 402: ) 403: } 404: if (status !== 'interrupted' && !loopSignal.aborted) { 405: if (config.spawnMode !== 'single-session') { 406: trackCleanup( 407: api 408: .archiveSession(compatId) 409: .catch((err: unknown) => 410: logger.logVerbose( 411: `Failed to archive session ${sessionId}: ${errorMessage(err)}`, 412: ), 413: ), 414: ) 415: logForDebugging( 416: `[bridge:session] Session ${status}, returning to idle (multi-session mode)`, 417: ) 418: } else { 419: logForDebugging( 420: `[bridge:session] Session ${status}, aborting poll loop to tear down environment`, 421: ) 422: controller.abort() 423: return 424: } 425: } 426: if (!loopSignal.aborted) { 427: startStatusUpdates() 428: } 429: } 430: } 431: if (!initialSessionId) { 432: startStatusUpdates() 433: } 434: while (!loopSignal.aborted) { 435: const pollConfig = getPollIntervalConfig() 436: try { 437: const work = await api.pollForWork( 438: environmentId, 439: environmentSecret, 440: loopSignal, 441: pollConfig.reclaim_older_than_ms, 442: ) 443: const wasDisconnected = 444: connErrorStart !== null || generalErrorStart !== null 445: if (wasDisconnected) { 446: const disconnectedMs = 447: Date.now() - (connErrorStart ?? generalErrorStart ?? Date.now()) 448: logger.logReconnected(disconnectedMs) 449: logForDebugging( 450: `[bridge:poll] Reconnected after ${formatDuration(disconnectedMs)}`, 451: ) 452: logEvent('tengu_bridge_reconnected', { 453: disconnected_ms: disconnectedMs, 454: }) 455: } 456: connBackoff = 0 457: generalBackoff = 0 458: connErrorStart = null 459: generalErrorStart = null 460: lastPollErrorTime = null 461: if (!work) { 462: const atCap = activeSessions.size >= config.maxSessions 463: if (atCap) { 464: const atCapMs = pollConfig.multisession_poll_interval_ms_at_capacity 465: if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) { 466: logEvent('tengu_bridge_heartbeat_mode_entered', { 467: active_sessions: activeSessions.size, 468: heartbeat_interval_ms: 469: pollConfig.non_exclusive_heartbeat_interval_ms, 470: }) 471: const pollDeadline = atCapMs > 0 ? Date.now() + atCapMs : null 472: let hbResult: 'ok' | 'auth_failed' | 'fatal' | 'failed' = 'ok' 473: let hbCycles = 0 474: while ( 475: !loopSignal.aborted && 476: activeSessions.size >= config.maxSessions && 477: (pollDeadline === null || Date.now() < pollDeadline) 478: ) { 479: const hbConfig = getPollIntervalConfig() 480: if (hbConfig.non_exclusive_heartbeat_interval_ms <= 0) break 481: const cap = capacityWake.signal() 482: hbResult = await heartbeatActiveWorkItems() 483: if (hbResult === 'auth_failed' || hbResult === 'fatal') { 484: cap.cleanup() 485: break 486: } 487: hbCycles++ 488: await sleep( 489: hbConfig.non_exclusive_heartbeat_interval_ms, 490: cap.signal, 491: ) 492: cap.cleanup() 493: } 494: const exitReason = 495: hbResult === 'auth_failed' || hbResult === 'fatal' 496: ? hbResult 497: : loopSignal.aborted 498: ? 'shutdown' 499: : activeSessions.size < config.maxSessions 500: ? 'capacity_changed' 501: : pollDeadline !== null && Date.now() >= pollDeadline 502: ? 'poll_due' 503: : 'config_disabled' 504: logEvent('tengu_bridge_heartbeat_mode_exited', { 505: reason: 506: exitReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 507: heartbeat_cycles: hbCycles, 508: active_sessions: activeSessions.size, 509: }) 510: if (exitReason === 'poll_due') { 511: logForDebugging( 512: `[bridge:poll] Heartbeat poll_due after ${hbCycles} cycles — falling through to pollForWork`, 513: ) 514: } 515: if (hbResult === 'auth_failed' || hbResult === 'fatal') { 516: const cap = capacityWake.signal() 517: await sleep( 518: atCapMs > 0 519: ? atCapMs 520: : pollConfig.non_exclusive_heartbeat_interval_ms, 521: cap.signal, 522: ) 523: cap.cleanup() 524: } 525: } else if (atCapMs > 0) { 526: const cap = capacityWake.signal() 527: await sleep(atCapMs, cap.signal) 528: cap.cleanup() 529: } 530: } else { 531: const interval = 532: activeSessions.size > 0 533: ? pollConfig.multisession_poll_interval_ms_partial_capacity 534: : pollConfig.multisession_poll_interval_ms_not_at_capacity 535: await sleep(interval, loopSignal) 536: } 537: continue 538: } 539: const atCapacityBeforeSwitch = activeSessions.size >= config.maxSessions 540: if (completedWorkIds.has(work.id)) { 541: logForDebugging( 542: `[bridge:work] Skipping already-completed workId=${work.id}`, 543: ) 544: if (atCapacityBeforeSwitch) { 545: const cap = capacityWake.signal() 546: if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) { 547: await heartbeatActiveWorkItems() 548: await sleep( 549: pollConfig.non_exclusive_heartbeat_interval_ms, 550: cap.signal, 551: ) 552: } else if (pollConfig.multisession_poll_interval_ms_at_capacity > 0) { 553: await sleep( 554: pollConfig.multisession_poll_interval_ms_at_capacity, 555: cap.signal, 556: ) 557: } 558: cap.cleanup() 559: } else { 560: await sleep(1000, loopSignal) 561: } 562: continue 563: } 564: let secret 565: try { 566: secret = decodeWorkSecret(work.secret) 567: } catch (err) { 568: const errMsg = errorMessage(err) 569: logger.logError( 570: `Failed to decode work secret for workId=${work.id}: ${errMsg}`, 571: ) 572: logEvent('tengu_bridge_work_secret_failed', {}) 573: completedWorkIds.add(work.id) 574: trackCleanup( 575: stopWorkWithRetry( 576: api, 577: environmentId, 578: work.id, 579: logger, 580: backoffConfig.stopWorkBaseDelayMs, 581: ), 582: ) 583: if (atCapacityBeforeSwitch) { 584: const cap = capacityWake.signal() 585: if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) { 586: await heartbeatActiveWorkItems() 587: await sleep( 588: pollConfig.non_exclusive_heartbeat_interval_ms, 589: cap.signal, 590: ) 591: } else if (pollConfig.multisession_poll_interval_ms_at_capacity > 0) { 592: await sleep( 593: pollConfig.multisession_poll_interval_ms_at_capacity, 594: cap.signal, 595: ) 596: } 597: cap.cleanup() 598: } 599: continue 600: } 601: const ackWork = async (): Promise<void> => { 602: logForDebugging(`[bridge:work] Acknowledging workId=${work.id}`) 603: try { 604: await api.acknowledgeWork( 605: environmentId, 606: work.id, 607: secret.session_ingress_token, 608: ) 609: } catch (err) { 610: logForDebugging( 611: `[bridge:work] Acknowledge failed workId=${work.id}: ${errorMessage(err)}`, 612: ) 613: } 614: } 615: const workType: string = work.data.type 616: switch (work.data.type) { 617: case 'healthcheck': 618: await ackWork() 619: logForDebugging('[bridge:work] Healthcheck received') 620: logger.logVerbose('Healthcheck received') 621: break 622: case 'session': { 623: const sessionId = work.data.id 624: try { 625: validateBridgeId(sessionId, 'session_id') 626: } catch { 627: await ackWork() 628: logger.logError(`Invalid session_id received: ${sessionId}`) 629: break 630: } 631: const existingHandle = activeSessions.get(sessionId) 632: if (existingHandle) { 633: existingHandle.updateAccessToken(secret.session_ingress_token) 634: sessionIngressTokens.set(sessionId, secret.session_ingress_token) 635: sessionWorkIds.set(sessionId, work.id) 636: tokenRefresh?.schedule(sessionId, secret.session_ingress_token) 637: logForDebugging( 638: `[bridge:work] Updated access token for existing sessionId=${sessionId} workId=${work.id}`, 639: ) 640: await ackWork() 641: break 642: } 643: if (activeSessions.size >= config.maxSessions) { 644: logForDebugging( 645: `[bridge:work] At capacity (${activeSessions.size}/${config.maxSessions}), cannot spawn new session for workId=${work.id}`, 646: ) 647: break 648: } 649: await ackWork() 650: const spawnStartTime = Date.now() 651: let sdkUrl: string 652: let useCcrV2 = false 653: let workerEpoch: number | undefined 654: if ( 655: secret.use_code_sessions === true || 656: isEnvTruthy(process.env.CLAUDE_BRIDGE_USE_CCR_V2) 657: ) { 658: sdkUrl = buildCCRv2SdkUrl(config.apiBaseUrl, sessionId) 659: for (let attempt = 1; attempt <= 2; attempt++) { 660: try { 661: workerEpoch = await registerWorker( 662: sdkUrl, 663: secret.session_ingress_token, 664: ) 665: useCcrV2 = true 666: logForDebugging( 667: `[bridge:session] CCR v2: registered worker sessionId=${sessionId} epoch=${workerEpoch} attempt=${attempt}`, 668: ) 669: break 670: } catch (err) { 671: const errMsg = errorMessage(err) 672: if (attempt < 2) { 673: logForDebugging( 674: `[bridge:session] CCR v2: registerWorker attempt ${attempt} failed, retrying: ${errMsg}`, 675: ) 676: await sleep(2_000, loopSignal) 677: if (loopSignal.aborted) break 678: continue 679: } 680: logger.logError( 681: `CCR v2 worker registration failed for session ${sessionId}: ${errMsg}`, 682: ) 683: logError(new Error(`registerWorker failed: ${errMsg}`)) 684: completedWorkIds.add(work.id) 685: trackCleanup( 686: stopWorkWithRetry( 687: api, 688: environmentId, 689: work.id, 690: logger, 691: backoffConfig.stopWorkBaseDelayMs, 692: ), 693: ) 694: } 695: } 696: if (!useCcrV2) break 697: } else { 698: sdkUrl = buildSdkUrl(config.sessionIngressUrl, sessionId) 699: } 700: const spawnModeAtDecision = config.spawnMode 701: let sessionDir = config.dir 702: let worktreeCreateMs = 0 703: if ( 704: spawnModeAtDecision === 'worktree' && 705: (initialSessionId === undefined || 706: !sameSessionId(sessionId, initialSessionId)) 707: ) { 708: const wtStart = Date.now() 709: try { 710: const wt = await createAgentWorktree( 711: `bridge-${safeFilenameId(sessionId)}`, 712: ) 713: worktreeCreateMs = Date.now() - wtStart 714: sessionWorktrees.set(sessionId, { 715: worktreePath: wt.worktreePath, 716: worktreeBranch: wt.worktreeBranch, 717: gitRoot: wt.gitRoot, 718: hookBased: wt.hookBased, 719: }) 720: sessionDir = wt.worktreePath 721: logForDebugging( 722: `[bridge:session] Created worktree for sessionId=${sessionId} at ${wt.worktreePath}`, 723: ) 724: } catch (err) { 725: const errMsg = errorMessage(err) 726: logger.logError( 727: `Failed to create worktree for session ${sessionId}: ${errMsg}`, 728: ) 729: logError(new Error(`Worktree creation failed: ${errMsg}`)) 730: completedWorkIds.add(work.id) 731: trackCleanup( 732: stopWorkWithRetry( 733: api, 734: environmentId, 735: work.id, 736: logger, 737: backoffConfig.stopWorkBaseDelayMs, 738: ), 739: ) 740: break 741: } 742: } 743: logForDebugging( 744: `[bridge:session] Spawning sessionId=${sessionId} sdkUrl=${sdkUrl}`, 745: ) 746: const compatSessionId = toCompatSessionId(sessionId) 747: const spawnResult = safeSpawn( 748: spawner, 749: { 750: sessionId, 751: sdkUrl, 752: accessToken: secret.session_ingress_token, 753: useCcrV2, 754: workerEpoch, 755: onFirstUserMessage: text => { 756: if (titledSessions.has(compatSessionId)) return 757: titledSessions.add(compatSessionId) 758: const title = deriveSessionTitle(text) 759: logger.setSessionTitle(compatSessionId, title) 760: logForDebugging( 761: `[bridge:title] derived title for ${compatSessionId}: ${title}`, 762: ) 763: void import('./createSession.js') 764: .then(({ updateBridgeSessionTitle }) => 765: updateBridgeSessionTitle(compatSessionId, title, { 766: baseUrl: config.apiBaseUrl, 767: }), 768: ) 769: .catch(err => 770: logForDebugging( 771: `[bridge:title] failed to update title for ${compatSessionId}: ${err}`, 772: { level: 'error' }, 773: ), 774: ) 775: }, 776: }, 777: sessionDir, 778: ) 779: if (typeof spawnResult === 'string') { 780: logger.logError( 781: `Failed to spawn session ${sessionId}: ${spawnResult}`, 782: ) 783: const wt = sessionWorktrees.get(sessionId) 784: if (wt) { 785: sessionWorktrees.delete(sessionId) 786: trackCleanup( 787: removeAgentWorktree( 788: wt.worktreePath, 789: wt.worktreeBranch, 790: wt.gitRoot, 791: wt.hookBased, 792: ).catch((err: unknown) => 793: logger.logVerbose( 794: `Failed to remove worktree ${wt.worktreePath}: ${errorMessage(err)}`, 795: ), 796: ), 797: ) 798: } 799: completedWorkIds.add(work.id) 800: trackCleanup( 801: stopWorkWithRetry( 802: api, 803: environmentId, 804: work.id, 805: logger, 806: backoffConfig.stopWorkBaseDelayMs, 807: ), 808: ) 809: break 810: } 811: const handle = spawnResult 812: const spawnDurationMs = Date.now() - spawnStartTime 813: logEvent('tengu_bridge_session_started', { 814: active_sessions: activeSessions.size, 815: spawn_mode: 816: spawnModeAtDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 817: in_worktree: sessionWorktrees.has(sessionId), 818: spawn_duration_ms: spawnDurationMs, 819: worktree_create_ms: worktreeCreateMs, 820: inProtectedNamespace: isInProtectedNamespace(), 821: }) 822: logForDiagnosticsNoPII('info', 'bridge_session_started', { 823: spawn_mode: spawnModeAtDecision, 824: in_worktree: sessionWorktrees.has(sessionId), 825: spawn_duration_ms: spawnDurationMs, 826: worktree_create_ms: worktreeCreateMs, 827: }) 828: activeSessions.set(sessionId, handle) 829: sessionWorkIds.set(sessionId, work.id) 830: sessionIngressTokens.set(sessionId, secret.session_ingress_token) 831: sessionCompatIds.set(sessionId, compatSessionId) 832: const startTime = Date.now() 833: sessionStartTimes.set(sessionId, startTime) 834: logger.logSessionStart(sessionId, `Session ${sessionId}`) 835: const safeId = safeFilenameId(sessionId) 836: let sessionDebugFile: string | undefined 837: if (config.debugFile) { 838: const ext = config.debugFile.lastIndexOf('.') 839: if (ext > 0) { 840: sessionDebugFile = `${config.debugFile.slice(0, ext)}-${safeId}${config.debugFile.slice(ext)}` 841: } else { 842: sessionDebugFile = `${config.debugFile}-${safeId}` 843: } 844: } else if (config.verbose || process.env.USER_TYPE === 'ant') { 845: sessionDebugFile = join( 846: tmpdir(), 847: 'claude', 848: `bridge-session-${safeId}.log`, 849: ) 850: } 851: if (sessionDebugFile) { 852: logger.logVerbose(`Debug log: ${sessionDebugFile}`) 853: } 854: logger.addSession( 855: compatSessionId, 856: getRemoteSessionUrl(compatSessionId, config.sessionIngressUrl), 857: ) 858: startStatusUpdates() 859: logger.setAttached(compatSessionId) 860: void fetchSessionTitle(compatSessionId, config.apiBaseUrl) 861: .then(title => { 862: if (title && activeSessions.has(sessionId)) { 863: titledSessions.add(compatSessionId) 864: logger.setSessionTitle(compatSessionId, title) 865: logForDebugging( 866: `[bridge:title] server title for ${compatSessionId}: ${title}`, 867: ) 868: } 869: }) 870: .catch(err => 871: logForDebugging( 872: `[bridge:title] failed to fetch title for ${compatSessionId}: ${err}`, 873: { level: 'error' }, 874: ), 875: ) 876: const timeoutMs = 877: config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS 878: if (timeoutMs > 0) { 879: const timer = setTimeout( 880: onSessionTimeout, 881: timeoutMs, 882: sessionId, 883: timeoutMs, 884: logger, 885: timedOutSessions, 886: handle, 887: ) 888: sessionTimers.set(sessionId, timer) 889: } 890: if (useCcrV2) { 891: v2Sessions.add(sessionId) 892: } 893: tokenRefresh?.schedule(sessionId, secret.session_ingress_token) 894: void handle.done.then(onSessionDone(sessionId, startTime, handle)) 895: break 896: } 897: default: 898: await ackWork() 899: logForDebugging( 900: `[bridge:work] Unknown work type: ${workType}, skipping`, 901: ) 902: break 903: } 904: if (atCapacityBeforeSwitch) { 905: const cap = capacityWake.signal() 906: if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) { 907: await heartbeatActiveWorkItems() 908: await sleep( 909: pollConfig.non_exclusive_heartbeat_interval_ms, 910: cap.signal, 911: ) 912: } else if (pollConfig.multisession_poll_interval_ms_at_capacity > 0) { 913: await sleep( 914: pollConfig.multisession_poll_interval_ms_at_capacity, 915: cap.signal, 916: ) 917: } 918: cap.cleanup() 919: } 920: } catch (err) { 921: if (loopSignal.aborted) { 922: break 923: } 924: if (err instanceof BridgeFatalError) { 925: fatalExit = true 926: if (isExpiredErrorType(err.errorType)) { 927: logger.logStatus(err.message) 928: } else if (isSuppressible403(err)) { 929: logForDebugging(`[bridge:work] Suppressed 403 error: ${err.message}`) 930: } else { 931: logger.logError(err.message) 932: logError(err) 933: } 934: logEvent('tengu_bridge_fatal_error', { 935: status: err.status, 936: error_type: 937: err.errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 938: }) 939: logForDiagnosticsNoPII( 940: isExpiredErrorType(err.errorType) ? 'info' : 'error', 941: 'bridge_fatal_error', 942: { status: err.status, error_type: err.errorType }, 943: ) 944: break 945: } 946: const errMsg = describeAxiosError(err) 947: if (isConnectionError(err) || isServerError(err)) { 948: const now = Date.now() 949: if ( 950: lastPollErrorTime !== null && 951: now - lastPollErrorTime > pollSleepDetectionThresholdMs(backoffConfig) 952: ) { 953: logForDebugging( 954: `[bridge:work] Detected system sleep (${Math.round((now - lastPollErrorTime) / 1000)}s gap), resetting error budget`, 955: ) 956: logForDiagnosticsNoPII('info', 'bridge_poll_sleep_detected', { 957: gapMs: now - lastPollErrorTime, 958: }) 959: connErrorStart = null 960: connBackoff = 0 961: generalErrorStart = null 962: generalBackoff = 0 963: } 964: lastPollErrorTime = now 965: if (!connErrorStart) { 966: connErrorStart = now 967: } 968: const elapsed = now - connErrorStart 969: if (elapsed >= backoffConfig.connGiveUpMs) { 970: logger.logError( 971: `Server unreachable for ${Math.round(elapsed / 60_000)} minutes, giving up.`, 972: ) 973: logEvent('tengu_bridge_poll_give_up', { 974: error_type: 975: 'connection' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 976: elapsed_ms: elapsed, 977: }) 978: logForDiagnosticsNoPII('error', 'bridge_poll_give_up', { 979: error_type: 'connection', 980: elapsed_ms: elapsed, 981: }) 982: fatalExit = true 983: break 984: } 985: generalErrorStart = null 986: generalBackoff = 0 987: connBackoff = connBackoff 988: ? Math.min(connBackoff * 2, backoffConfig.connCapMs) 989: : backoffConfig.connInitialMs 990: const delay = addJitter(connBackoff) 991: logger.logVerbose( 992: `Connection error, retrying in ${formatDelay(delay)} (${Math.round(elapsed / 1000)}s elapsed): ${errMsg}`, 993: ) 994: logger.updateReconnectingStatus( 995: formatDelay(delay), 996: formatDuration(elapsed), 997: ) 998: if (getPollIntervalConfig().non_exclusive_heartbeat_interval_ms > 0) { 999: await heartbeatActiveWorkItems() 1000: } 1001: await sleep(delay, loopSignal) 1002: } else { 1003: const now = Date.now() 1004: if ( 1005: lastPollErrorTime !== null && 1006: now - lastPollErrorTime > pollSleepDetectionThresholdMs(backoffConfig) 1007: ) { 1008: logForDebugging( 1009: `[bridge:work] Detected system sleep (${Math.round((now - lastPollErrorTime) / 1000)}s gap), resetting error budget`, 1010: ) 1011: logForDiagnosticsNoPII('info', 'bridge_poll_sleep_detected', { 1012: gapMs: now - lastPollErrorTime, 1013: }) 1014: connErrorStart = null 1015: connBackoff = 0 1016: generalErrorStart = null 1017: generalBackoff = 0 1018: } 1019: lastPollErrorTime = now 1020: if (!generalErrorStart) { 1021: generalErrorStart = now 1022: } 1023: const elapsed = now - generalErrorStart 1024: if (elapsed >= backoffConfig.generalGiveUpMs) { 1025: logger.logError( 1026: `Persistent errors for ${Math.round(elapsed / 60_000)} minutes, giving up.`, 1027: ) 1028: logEvent('tengu_bridge_poll_give_up', { 1029: error_type: 1030: 'general' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1031: elapsed_ms: elapsed, 1032: }) 1033: logForDiagnosticsNoPII('error', 'bridge_poll_give_up', { 1034: error_type: 'general', 1035: elapsed_ms: elapsed, 1036: }) 1037: fatalExit = true 1038: break 1039: } 1040: connErrorStart = null 1041: connBackoff = 0 1042: generalBackoff = generalBackoff 1043: ? Math.min(generalBackoff * 2, backoffConfig.generalCapMs) 1044: : backoffConfig.generalInitialMs 1045: const delay = addJitter(generalBackoff) 1046: logger.logVerbose( 1047: `Poll failed, retrying in ${formatDelay(delay)} (${Math.round(elapsed / 1000)}s elapsed): ${errMsg}`, 1048: ) 1049: logger.updateReconnectingStatus( 1050: formatDelay(delay), 1051: formatDuration(elapsed), 1052: ) 1053: if (getPollIntervalConfig().non_exclusive_heartbeat_interval_ms > 0) { 1054: await heartbeatActiveWorkItems() 1055: } 1056: await sleep(delay, loopSignal) 1057: } 1058: } 1059: } 1060: stopStatusUpdates() 1061: logger.clearStatus() 1062: const loopDurationMs = Date.now() - loopStartTime 1063: logEvent('tengu_bridge_shutdown', { 1064: active_sessions: activeSessions.size, 1065: loop_duration_ms: loopDurationMs, 1066: }) 1067: logForDiagnosticsNoPII('info', 'bridge_shutdown', { 1068: active_sessions: activeSessions.size, 1069: loop_duration_ms: loopDurationMs, 1070: }) 1071: const sessionsToArchive = new Set(activeSessions.keys()) 1072: if (initialSessionId) { 1073: sessionsToArchive.add(initialSessionId) 1074: } 1075: const compatIdSnapshot = new Map(sessionCompatIds) 1076: if (activeSessions.size > 0) { 1077: logForDebugging( 1078: `[bridge:shutdown] Shutting down ${activeSessions.size} active session(s)`, 1079: ) 1080: logger.logStatus( 1081: `Shutting down ${activeSessions.size} active session(s)\u2026`, 1082: ) 1083: const shutdownWorkIds = new Map(sessionWorkIds) 1084: for (const [sessionId, handle] of activeSessions.entries()) { 1085: logForDebugging( 1086: `[bridge:shutdown] Sending SIGTERM to sessionId=${sessionId}`, 1087: ) 1088: handle.kill() 1089: } 1090: const timeout = new AbortController() 1091: await Promise.race([ 1092: Promise.allSettled([...activeSessions.values()].map(h => h.done)), 1093: sleep(backoffConfig.shutdownGraceMs ?? 30_000, timeout.signal), 1094: ]) 1095: timeout.abort() 1096: for (const [sid, handle] of activeSessions.entries()) { 1097: logForDebugging(`[bridge:shutdown] Force-killing stuck sessionId=${sid}`) 1098: handle.forceKill() 1099: } 1100: for (const timer of sessionTimers.values()) { 1101: clearTimeout(timer) 1102: } 1103: sessionTimers.clear() 1104: tokenRefresh?.cancelAll() 1105: if (sessionWorktrees.size > 0) { 1106: const remainingWorktrees = [...sessionWorktrees.values()] 1107: sessionWorktrees.clear() 1108: logForDebugging( 1109: `[bridge:shutdown] Cleaning up ${remainingWorktrees.length} worktree(s)`, 1110: ) 1111: await Promise.allSettled( 1112: remainingWorktrees.map(wt => 1113: removeAgentWorktree( 1114: wt.worktreePath, 1115: wt.worktreeBranch, 1116: wt.gitRoot, 1117: wt.hookBased, 1118: ), 1119: ), 1120: ) 1121: } 1122: await Promise.allSettled( 1123: [...shutdownWorkIds.entries()].map(([sessionId, workId]) => { 1124: return api 1125: .stopWork(environmentId, workId, true) 1126: .catch(err => 1127: logger.logVerbose( 1128: `Failed to stop work ${workId} for session ${sessionId}: ${errorMessage(err)}`, 1129: ), 1130: ) 1131: }), 1132: ) 1133: } 1134: if (pendingCleanups.size > 0) { 1135: await Promise.allSettled([...pendingCleanups]) 1136: } 1137: if ( 1138: feature('KAIROS') && 1139: config.spawnMode === 'single-session' && 1140: initialSessionId && 1141: !fatalExit 1142: ) { 1143: logger.logStatus( 1144: `Resume this session by running \`claude remote-control --continue\``, 1145: ) 1146: logForDebugging( 1147: `[bridge:shutdown] Skipping archive+deregister to allow resume of session ${initialSessionId}`, 1148: ) 1149: return 1150: } 1151: if (sessionsToArchive.size > 0) { 1152: logForDebugging( 1153: `[bridge:shutdown] Archiving ${sessionsToArchive.size} session(s)`, 1154: ) 1155: await Promise.allSettled( 1156: [...sessionsToArchive].map(sessionId => 1157: api 1158: .archiveSession( 1159: compatIdSnapshot.get(sessionId) ?? toCompatSessionId(sessionId), 1160: ) 1161: .catch(err => 1162: logger.logVerbose( 1163: `Failed to archive session ${sessionId}: ${errorMessage(err)}`, 1164: ), 1165: ), 1166: ), 1167: ) 1168: } 1169: try { 1170: await api.deregisterEnvironment(environmentId) 1171: logForDebugging( 1172: `[bridge:shutdown] Environment deregistered, bridge offline`, 1173: ) 1174: logger.logVerbose('Environment deregistered.') 1175: } catch (err) { 1176: logger.logVerbose(`Failed to deregister environment: ${errorMessage(err)}`) 1177: } 1178: const { clearBridgePointer } = await import('./bridgePointer.js') 1179: await clearBridgePointer(config.dir) 1180: logger.logVerbose('Environment offline.') 1181: } 1182: const CONNECTION_ERROR_CODES = new Set([ 1183: 'ECONNREFUSED', 1184: 'ECONNRESET', 1185: 'ETIMEDOUT', 1186: 'ENETUNREACH', 1187: 'EHOSTUNREACH', 1188: ]) 1189: export function isConnectionError(err: unknown): boolean { 1190: if ( 1191: err && 1192: typeof err === 'object' && 1193: 'code' in err && 1194: typeof err.code === 'string' && 1195: CONNECTION_ERROR_CODES.has(err.code) 1196: ) { 1197: return true 1198: } 1199: return false 1200: } 1201: export function isServerError(err: unknown): boolean { 1202: return ( 1203: !!err && 1204: typeof err === 'object' && 1205: 'code' in err && 1206: typeof err.code === 'string' && 1207: err.code === 'ERR_BAD_RESPONSE' 1208: ) 1209: } 1210: function addJitter(ms: number): number { 1211: return Math.max(0, ms + ms * 0.25 * (2 * Math.random() - 1)) 1212: } 1213: function formatDelay(ms: number): string { 1214: return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms` 1215: } 1216: async function stopWorkWithRetry( 1217: api: BridgeApiClient, 1218: environmentId: string, 1219: workId: string, 1220: logger: BridgeLogger, 1221: baseDelayMs = 1000, 1222: ): Promise<void> { 1223: const MAX_ATTEMPTS = 3 1224: for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { 1225: try { 1226: await api.stopWork(environmentId, workId, false) 1227: logForDebugging( 1228: `[bridge:work] stopWork succeeded for workId=${workId} on attempt ${attempt}/${MAX_ATTEMPTS}`, 1229: ) 1230: return 1231: } catch (err) { 1232: if (err instanceof BridgeFatalError) { 1233: if (isSuppressible403(err)) { 1234: logForDebugging( 1235: `[bridge:work] Suppressed stopWork 403 for ${workId}: ${err.message}`, 1236: ) 1237: } else { 1238: logger.logError(`Failed to stop work ${workId}: ${err.message}`) 1239: } 1240: logForDiagnosticsNoPII('error', 'bridge_stop_work_failed', { 1241: attempts: attempt, 1242: fatal: true, 1243: }) 1244: return 1245: } 1246: const errMsg = errorMessage(err) 1247: if (attempt < MAX_ATTEMPTS) { 1248: const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1)) 1249: logger.logVerbose( 1250: `Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`, 1251: ) 1252: await sleep(delay) 1253: } else { 1254: logger.logError( 1255: `Failed to stop work ${workId} after ${MAX_ATTEMPTS} attempts: ${errMsg}`, 1256: ) 1257: logForDiagnosticsNoPII('error', 'bridge_stop_work_failed', { 1258: attempts: MAX_ATTEMPTS, 1259: }) 1260: } 1261: } 1262: } 1263: } 1264: function onSessionTimeout( 1265: sessionId: string, 1266: timeoutMs: number, 1267: logger: BridgeLogger, 1268: timedOutSessions: Set<string>, 1269: handle: SessionHandle, 1270: ): void { 1271: logForDebugging( 1272: `[bridge:session] sessionId=${sessionId} timed out after ${formatDuration(timeoutMs)}`, 1273: ) 1274: logEvent('tengu_bridge_session_timeout', { 1275: timeout_ms: timeoutMs, 1276: }) 1277: logger.logSessionFailed( 1278: sessionId, 1279: `Session timed out after ${formatDuration(timeoutMs)}`, 1280: ) 1281: timedOutSessions.add(sessionId) 1282: handle.kill() 1283: } 1284: export type ParsedArgs = { 1285: verbose: boolean 1286: sandbox: boolean 1287: debugFile?: string 1288: sessionTimeoutMs?: number 1289: permissionMode?: string 1290: name?: string 1291: spawnMode: SpawnMode | undefined 1292: capacity: number | undefined 1293: createSessionInDir: boolean | undefined 1294: sessionId?: string 1295: continueSession: boolean 1296: help: boolean 1297: error?: string 1298: } 1299: const SPAWN_FLAG_VALUES = ['session', 'same-dir', 'worktree'] as const 1300: function parseSpawnValue(raw: string | undefined): SpawnMode | string { 1301: if (raw === 'session') return 'single-session' 1302: if (raw === 'same-dir') return 'same-dir' 1303: if (raw === 'worktree') return 'worktree' 1304: return `--spawn requires one of: ${SPAWN_FLAG_VALUES.join(', ')} (got: ${raw ?? '<missing>'})` 1305: } 1306: function parseCapacityValue(raw: string | undefined): number | string { 1307: const n = raw === undefined ? NaN : parseInt(raw, 10) 1308: if (isNaN(n) || n < 1) { 1309: return `--capacity requires a positive integer (got: ${raw ?? '<missing>'})` 1310: } 1311: return n 1312: } 1313: export function parseArgs(args: string[]): ParsedArgs { 1314: let verbose = false 1315: let sandbox = false 1316: let debugFile: string | undefined 1317: let sessionTimeoutMs: number | undefined 1318: let permissionMode: string | undefined 1319: let name: string | undefined 1320: let help = false 1321: let spawnMode: SpawnMode | undefined 1322: let capacity: number | undefined 1323: let createSessionInDir: boolean | undefined 1324: let sessionId: string | undefined 1325: let continueSession = false 1326: for (let i = 0; i < args.length; i++) { 1327: const arg = args[i]! 1328: if (arg === '--help' || arg === '-h') { 1329: help = true 1330: } else if (arg === '--verbose' || arg === '-v') { 1331: verbose = true 1332: } else if (arg === '--sandbox') { 1333: sandbox = true 1334: } else if (arg === '--no-sandbox') { 1335: sandbox = false 1336: } else if (arg === '--debug-file' && i + 1 < args.length) { 1337: debugFile = resolve(args[++i]!) 1338: } else if (arg.startsWith('--debug-file=')) { 1339: debugFile = resolve(arg.slice('--debug-file='.length)) 1340: } else if (arg === '--session-timeout' && i + 1 < args.length) { 1341: sessionTimeoutMs = parseInt(args[++i]!, 10) * 1000 1342: } else if (arg.startsWith('--session-timeout=')) { 1343: sessionTimeoutMs = 1344: parseInt(arg.slice('--session-timeout='.length), 10) * 1000 1345: } else if (arg === '--permission-mode' && i + 1 < args.length) { 1346: permissionMode = args[++i]! 1347: } else if (arg.startsWith('--permission-mode=')) { 1348: permissionMode = arg.slice('--permission-mode='.length) 1349: } else if (arg === '--name' && i + 1 < args.length) { 1350: name = args[++i]! 1351: } else if (arg.startsWith('--name=')) { 1352: name = arg.slice('--name='.length) 1353: } else if ( 1354: feature('KAIROS') && 1355: arg === '--session-id' && 1356: i + 1 < args.length 1357: ) { 1358: sessionId = args[++i]! 1359: if (!sessionId) { 1360: return makeError('--session-id requires a value') 1361: } 1362: } else if (feature('KAIROS') && arg.startsWith('--session-id=')) { 1363: sessionId = arg.slice('--session-id='.length) 1364: if (!sessionId) { 1365: return makeError('--session-id requires a value') 1366: } 1367: } else if (feature('KAIROS') && (arg === '--continue' || arg === '-c')) { 1368: continueSession = true 1369: } else if (arg === '--spawn' || arg.startsWith('--spawn=')) { 1370: if (spawnMode !== undefined) { 1371: return makeError('--spawn may only be specified once') 1372: } 1373: const raw = arg.startsWith('--spawn=') 1374: ? arg.slice('--spawn='.length) 1375: : args[++i] 1376: const v = parseSpawnValue(raw) 1377: if (v === 'single-session' || v === 'same-dir' || v === 'worktree') { 1378: spawnMode = v 1379: } else { 1380: return makeError(v) 1381: } 1382: } else if (arg === '--capacity' || arg.startsWith('--capacity=')) { 1383: if (capacity !== undefined) { 1384: return makeError('--capacity may only be specified once') 1385: } 1386: const raw = arg.startsWith('--capacity=') 1387: ? arg.slice('--capacity='.length) 1388: : args[++i] 1389: const v = parseCapacityValue(raw) 1390: if (typeof v === 'number') capacity = v 1391: else return makeError(v) 1392: } else if (arg === '--create-session-in-dir') { 1393: createSessionInDir = true 1394: } else if (arg === '--no-create-session-in-dir') { 1395: createSessionInDir = false 1396: } else { 1397: return makeError( 1398: `Unknown argument: ${arg}\nRun 'claude remote-control --help' for usage.`, 1399: ) 1400: } 1401: } 1402: if (spawnMode === 'single-session' && capacity !== undefined) { 1403: return makeError( 1404: `--capacity cannot be used with --spawn=session (single-session mode has fixed capacity 1).`, 1405: ) 1406: } 1407: if ( 1408: (sessionId || continueSession) && 1409: (spawnMode !== undefined || 1410: capacity !== undefined || 1411: createSessionInDir !== undefined) 1412: ) { 1413: return makeError( 1414: `--session-id and --continue cannot be used with --spawn, --capacity, or --create-session-in-dir.`, 1415: ) 1416: } 1417: if (sessionId && continueSession) { 1418: return makeError(`--session-id and --continue cannot be used together.`) 1419: } 1420: return { 1421: verbose, 1422: sandbox, 1423: debugFile, 1424: sessionTimeoutMs, 1425: permissionMode, 1426: name, 1427: spawnMode, 1428: capacity, 1429: createSessionInDir, 1430: sessionId, 1431: continueSession, 1432: help, 1433: } 1434: function makeError(error: string): ParsedArgs { 1435: return { 1436: verbose, 1437: sandbox, 1438: debugFile, 1439: sessionTimeoutMs, 1440: permissionMode, 1441: name, 1442: spawnMode, 1443: capacity, 1444: createSessionInDir, 1445: sessionId, 1446: continueSession, 1447: help, 1448: error, 1449: } 1450: } 1451: } 1452: async function printHelp(): Promise<void> { 1453: const { EXTERNAL_PERMISSION_MODES } = await import('../types/permissions.js') 1454: const modes = EXTERNAL_PERMISSION_MODES.join(', ') 1455: const showServer = await isMultiSessionSpawnEnabled() 1456: const serverOptions = showServer 1457: ? ` --spawn <mode> Spawn mode: same-dir, worktree, session 1458: (default: same-dir) 1459: --capacity <N> Max concurrent sessions in worktree or 1460: same-dir mode (default: ${SPAWN_SESSIONS_DEFAULT}) 1461: --[no-]create-session-in-dir Pre-create a session in the current 1462: directory; in worktree mode this session 1463: stays in cwd while on-demand sessions get 1464: isolated worktrees (default: on) 1465: ` 1466: : '' 1467: const serverDescription = showServer 1468: ? ` 1469: Remote Control runs as a persistent server that accepts multiple concurrent 1470: sessions in the current directory. One session is pre-created on start so 1471: you have somewhere to type immediately. Use --spawn=worktree to isolate 1472: each on-demand session in its own git worktree, or --spawn=session for 1473: the classic single-session mode (exits when that session ends). Press 'w' 1474: during runtime to toggle between same-dir and worktree. 1475: ` 1476: : '' 1477: const serverNote = showServer 1478: ? ` - Worktree mode requires a git repository or WorktreeCreate/WorktreeRemove hooks 1479: ` 1480: : '' 1481: const help = ` 1482: Remote Control - Connect your local environment to claude.ai/code 1483: USAGE 1484: claude remote-control [options] 1485: OPTIONS 1486: --name <name> Name for the session (shown in claude.ai/code) 1487: ${ 1488: feature('KAIROS') 1489: ? ` -c, --continue Resume the last session in this directory 1490: --session-id <id> Resume a specific session by ID (cannot be 1491: used with spawn flags or --continue) 1492: ` 1493: : '' 1494: } --permission-mode <mode> Permission mode for spawned sessions 1495: (${modes}) 1496: --debug-file <path> Write debug logs to file 1497: -v, --verbose Enable verbose output 1498: -h, --help Show this help 1499: ${serverOptions} 1500: DESCRIPTION 1501: Remote Control allows you to control sessions on your local device from 1502: claude.ai/code (https://claude.ai/code). Run this command in the 1503: directory you want to work in, then connect from the Claude app or web. 1504: ${serverDescription} 1505: NOTES 1506: - You must be logged in with a Claude account that has a subscription 1507: - Run \`claude\` first in the directory to accept the workspace trust dialog 1508: ${serverNote}` 1509: // biome-ignore lint/suspicious/noConsole: intentional help output 1510: console.log(help) 1511: } 1512: const TITLE_MAX_LEN = 80 1513: /** Derive a session title from a user message: first line, truncated. */ 1514: function deriveSessionTitle(text: string): string { 1515: // Collapse whitespace — newlines/tabs would break the single-line status display. 1516: const flat = text.replace(/\s+/g, ' ').trim() 1517: return truncateToWidth(flat, TITLE_MAX_LEN) 1518: } 1519: /** 1520: * One-shot fetch of a session's title via GET /v1/sessions/{id}. 1521: * 1522: * Uses `getBridgeSession` from createSession.ts (ccr-byoc headers + org UUID) 1523: * rather than the environments-level bridgeApi client, whose headers make the 1524: * Sessions API return 404. Returns undefined if the session has no title yet 1525: * or the fetch fails — the caller falls back to deriving a title from the 1526: * first user message. 1527: */ 1528: async function fetchSessionTitle( 1529: compatSessionId: string, 1530: baseUrl: string, 1531: ): Promise<string | undefined> { 1532: const { getBridgeSession } = await import('./createSession.js') 1533: const session = await getBridgeSession(compatSessionId, { baseUrl }) 1534: return session?.title || undefined 1535: } 1536: export async function bridgeMain(args: string[]): Promise<void> { 1537: const parsed = parseArgs(args) 1538: if (parsed.help) { 1539: await printHelp() 1540: return 1541: } 1542: if (parsed.error) { 1543: console.error(`Error: ${parsed.error}`) 1544: process.exit(1) 1545: } 1546: const { 1547: verbose, 1548: sandbox, 1549: debugFile, 1550: sessionTimeoutMs, 1551: permissionMode, 1552: name, 1553: spawnMode: parsedSpawnMode, 1554: capacity: parsedCapacity, 1555: createSessionInDir: parsedCreateSessionInDir, 1556: sessionId: parsedSessionId, 1557: continueSession, 1558: } = parsed 1559: let resumeSessionId = parsedSessionId 1560: let resumePointerDir: string | undefined 1561: const usedMultiSessionFeature = 1562: parsedSpawnMode !== undefined || 1563: parsedCapacity !== undefined || 1564: parsedCreateSessionInDir !== undefined 1565: if (permissionMode !== undefined) { 1566: const { PERMISSION_MODES } = await import('../types/permissions.js') 1567: const valid: readonly string[] = PERMISSION_MODES 1568: if (!valid.includes(permissionMode)) { 1569: console.error( 1570: `Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`, 1571: ) 1572: process.exit(1) 1573: } 1574: } 1575: const dir = resolve('.') 1576: const { enableConfigs, checkHasTrustDialogAccepted } = await import( 1577: '../utils/config.js' 1578: ) 1579: enableConfigs() 1580: const { initSinks } = await import('../utils/sinks.js') 1581: initSinks() 1582: const multiSessionEnabled = await isMultiSessionSpawnEnabled() 1583: if (usedMultiSessionFeature && !multiSessionEnabled) { 1584: await logEventAsync('tengu_bridge_multi_session_denied', { 1585: used_spawn: parsedSpawnMode !== undefined, 1586: used_capacity: parsedCapacity !== undefined, 1587: used_create_session_in_dir: parsedCreateSessionInDir !== undefined, 1588: }) 1589: await Promise.race([ 1590: Promise.all([shutdown1PEventLogging(), shutdownDatadog()]), 1591: sleep(500, undefined, { unref: true }), 1592: ]).catch(() => {}) 1593: console.error( 1594: 'Error: Multi-session Remote Control is not enabled for your account yet.', 1595: ) 1596: process.exit(1) 1597: } 1598: const { setOriginalCwd, setCwdState } = await import('../bootstrap/state.js') 1599: setOriginalCwd(dir) 1600: setCwdState(dir) 1601: if (!checkHasTrustDialogAccepted()) { 1602: console.error( 1603: `Error: Workspace not trusted. Please run \`claude\` in ${dir} first to review and accept the workspace trust dialog.`, 1604: ) 1605: process.exit(1) 1606: } 1607: const { clearOAuthTokenCache, checkAndRefreshOAuthTokenIfNeeded } = 1608: await import('../utils/auth.js') 1609: const { getBridgeAccessToken, getBridgeBaseUrl } = await import( 1610: './bridgeConfig.js' 1611: ) 1612: const bridgeToken = getBridgeAccessToken() 1613: if (!bridgeToken) { 1614: console.error(BRIDGE_LOGIN_ERROR) 1615: process.exit(1) 1616: } 1617: const { 1618: getGlobalConfig, 1619: saveGlobalConfig, 1620: getCurrentProjectConfig, 1621: saveCurrentProjectConfig, 1622: } = await import('../utils/config.js') 1623: if (!getGlobalConfig().remoteDialogSeen) { 1624: const readline = await import('readline') 1625: const rl = readline.createInterface({ 1626: input: process.stdin, 1627: output: process.stdout, 1628: }) 1629: console.log( 1630: '\nRemote Control lets you access this CLI session from the web (claude.ai/code)\nor the Claude app, so you can pick up where you left off on any device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n', 1631: ) 1632: const answer = await new Promise<string>(resolve => { 1633: rl.question('Enable Remote Control? (y/n) ', resolve) 1634: }) 1635: rl.close() 1636: saveGlobalConfig(current => { 1637: if (current.remoteDialogSeen) return current 1638: return { ...current, remoteDialogSeen: true } 1639: }) 1640: if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') { 1641: process.exit(0) 1642: } 1643: } 1644: if (feature('KAIROS') && continueSession) { 1645: const { readBridgePointerAcrossWorktrees } = await import( 1646: './bridgePointer.js' 1647: ) 1648: const found = await readBridgePointerAcrossWorktrees(dir) 1649: if (!found) { 1650: console.error( 1651: `Error: No recent session found in this directory or its worktrees. Run \`claude remote-control\` to start a new one.`, 1652: ) 1653: process.exit(1) 1654: } 1655: const { pointer, dir: pointerDir } = found 1656: const ageMin = Math.round(pointer.ageMs / 60_000) 1657: const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h` 1658: const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : '' 1659: // biome-ignore lint/suspicious/noConsole: intentional info output 1660: console.error( 1661: `Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`, 1662: ) 1663: resumeSessionId = pointer.sessionId 1664: // Track where the pointer came from so the #20460 exit(1) paths below 1665: // clear the RIGHT file on deterministic failure — otherwise --continue 1666: // would keep hitting the same dead session. May be a worktree sibling. 1667: resumePointerDir = pointerDir 1668: } 1669: // In production, baseUrl is the Anthropic API (from OAuth config). 1670: // CLAUDE_BRIDGE_BASE_URL overrides this for ant local dev only. 1671: const baseUrl = getBridgeBaseUrl() 1672: // For non-localhost targets, require HTTPS to protect credentials. 1673: if ( 1674: baseUrl.startsWith('http: 1675: !baseUrl.includes('localhost') && 1676: !baseUrl.includes('127.0.0.1') 1677: ) { 1678: console.error( 1679: 'Error: Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.', 1680: ) 1681: process.exit(1) 1682: } 1683: const sessionIngressUrl = 1684: process.env.USER_TYPE === 'ant' && 1685: process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL 1686: ? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL 1687: : baseUrl 1688: const { getBranch, getRemoteUrl, findGitRoot } = await import( 1689: '../utils/git.js' 1690: ) 1691: const { hasWorktreeCreateHook } = await import('../utils/hooks.js') 1692: const worktreeAvailable = hasWorktreeCreateHook() || findGitRoot(dir) !== null 1693: let savedSpawnMode = multiSessionEnabled 1694: ? getCurrentProjectConfig().remoteControlSpawnMode 1695: : undefined 1696: if (savedSpawnMode === 'worktree' && !worktreeAvailable) { 1697: console.error( 1698: 'Warning: Saved spawn mode is worktree but this directory is not a git repository. Falling back to same-dir.', 1699: ) 1700: savedSpawnMode = undefined 1701: saveCurrentProjectConfig(current => { 1702: if (current.remoteControlSpawnMode === undefined) return current 1703: return { ...current, remoteControlSpawnMode: undefined } 1704: }) 1705: } 1706: if ( 1707: multiSessionEnabled && 1708: !savedSpawnMode && 1709: worktreeAvailable && 1710: parsedSpawnMode === undefined && 1711: !resumeSessionId && 1712: process.stdin.isTTY 1713: ) { 1714: const readline = await import('readline') 1715: const rl = readline.createInterface({ 1716: input: process.stdin, 1717: output: process.stdout, 1718: }) 1719: console.log( 1720: `\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Claude Code on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` + 1721: `Spawn mode for this project:\n` + 1722: ` [1] same-dir \u2014 sessions share the current directory (default)\n` + 1723: ` [2] worktree \u2014 each session gets an isolated git worktree\n\n` + 1724: `This can be changed later or explicitly set with --spawn=same-dir or --spawn=worktree.\n`, 1725: ) 1726: const answer = await new Promise<string>(resolve => { 1727: rl.question('Choose [1/2] (default: 1): ', resolve) 1728: }) 1729: rl.close() 1730: const chosen: 'same-dir' | 'worktree' = 1731: answer.trim() === '2' ? 'worktree' : 'same-dir' 1732: savedSpawnMode = chosen 1733: logEvent('tengu_bridge_spawn_mode_chosen', { 1734: spawn_mode: 1735: chosen as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1736: }) 1737: saveCurrentProjectConfig(current => { 1738: if (current.remoteControlSpawnMode === chosen) return current 1739: return { ...current, remoteControlSpawnMode: chosen } 1740: }) 1741: } 1742: type SpawnModeSource = 'resume' | 'flag' | 'saved' | 'gate_default' 1743: let spawnModeSource: SpawnModeSource 1744: let spawnMode: SpawnMode 1745: if (resumeSessionId) { 1746: spawnMode = 'single-session' 1747: spawnModeSource = 'resume' 1748: } else if (parsedSpawnMode !== undefined) { 1749: spawnMode = parsedSpawnMode 1750: spawnModeSource = 'flag' 1751: } else if (savedSpawnMode !== undefined) { 1752: spawnMode = savedSpawnMode 1753: spawnModeSource = 'saved' 1754: } else { 1755: spawnMode = multiSessionEnabled ? 'same-dir' : 'single-session' 1756: spawnModeSource = 'gate_default' 1757: } 1758: const maxSessions = 1759: spawnMode === 'single-session' 1760: ? 1 1761: : (parsedCapacity ?? SPAWN_SESSIONS_DEFAULT) 1762: const preCreateSession = parsedCreateSessionInDir ?? true 1763: if (!resumeSessionId) { 1764: const { clearBridgePointer } = await import('./bridgePointer.js') 1765: await clearBridgePointer(dir) 1766: } 1767: if (spawnMode === 'worktree' && !worktreeAvailable) { 1768: console.error( 1769: `Error: Worktree mode requires a git repository or WorktreeCreate hooks configured. Use --spawn=session for single-session mode.`, 1770: ) 1771: process.exit(1) 1772: } 1773: const branch = await getBranch() 1774: const gitRepoUrl = await getRemoteUrl() 1775: const machineName = hostname() 1776: const bridgeId = randomUUID() 1777: const { handleOAuth401Error } = await import('../utils/auth.js') 1778: const api = createBridgeApiClient({ 1779: baseUrl, 1780: getAccessToken: getBridgeAccessToken, 1781: runnerVersion: MACRO.VERSION, 1782: onDebug: logForDebugging, 1783: onAuth401: handleOAuth401Error, 1784: getTrustedDeviceToken, 1785: }) 1786: let reuseEnvironmentId: string | undefined 1787: if (feature('KAIROS') && resumeSessionId) { 1788: try { 1789: validateBridgeId(resumeSessionId, 'sessionId') 1790: } catch { 1791: console.error( 1792: `Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`, 1793: ) 1794: process.exit(1) 1795: } 1796: await checkAndRefreshOAuthTokenIfNeeded() 1797: clearOAuthTokenCache() 1798: const { getBridgeSession } = await import('./createSession.js') 1799: const session = await getBridgeSession(resumeSessionId, { 1800: baseUrl, 1801: getAccessToken: getBridgeAccessToken, 1802: }) 1803: if (!session) { 1804: if (resumePointerDir) { 1805: const { clearBridgePointer } = await import('./bridgePointer.js') 1806: await clearBridgePointer(resumePointerDir) 1807: } 1808: console.error( 1809: `Error: Session ${resumeSessionId} not found. It may have been archived or expired, or your login may have lapsed (run \`claude /login\`).`, 1810: ) 1811: process.exit(1) 1812: } 1813: if (!session.environment_id) { 1814: if (resumePointerDir) { 1815: const { clearBridgePointer } = await import('./bridgePointer.js') 1816: await clearBridgePointer(resumePointerDir) 1817: } 1818: console.error( 1819: `Error: Session ${resumeSessionId} has no environment_id. It may never have been attached to a bridge.`, 1820: ) 1821: process.exit(1) 1822: } 1823: reuseEnvironmentId = session.environment_id 1824: logForDebugging( 1825: `[bridge:init] Resuming session ${resumeSessionId} on environment ${reuseEnvironmentId}`, 1826: ) 1827: } 1828: const config: BridgeConfig = { 1829: dir, 1830: machineName, 1831: branch, 1832: gitRepoUrl, 1833: maxSessions, 1834: spawnMode, 1835: verbose, 1836: sandbox, 1837: bridgeId, 1838: workerType: 'claude_code', 1839: environmentId: randomUUID(), 1840: reuseEnvironmentId, 1841: apiBaseUrl: baseUrl, 1842: sessionIngressUrl, 1843: debugFile, 1844: sessionTimeoutMs, 1845: } 1846: logForDebugging( 1847: `[bridge:init] bridgeId=${bridgeId}${reuseEnvironmentId ? ` reuseEnvironmentId=${reuseEnvironmentId}` : ''} dir=${dir} branch=${branch} gitRepoUrl=${gitRepoUrl} machine=${machineName}`, 1848: ) 1849: logForDebugging( 1850: `[bridge:init] apiBaseUrl=${baseUrl} sessionIngressUrl=${sessionIngressUrl}`, 1851: ) 1852: logForDebugging( 1853: `[bridge:init] sandbox=${sandbox}${debugFile ? ` debugFile=${debugFile}` : ''}`, 1854: ) 1855: let environmentId: string 1856: let environmentSecret: string 1857: try { 1858: const reg = await api.registerBridgeEnvironment(config) 1859: environmentId = reg.environment_id 1860: environmentSecret = reg.environment_secret 1861: } catch (err) { 1862: logEvent('tengu_bridge_registration_failed', { 1863: status: err instanceof BridgeFatalError ? err.status : undefined, 1864: }) 1865: console.error( 1866: err instanceof BridgeFatalError && err.status === 404 1867: ? 'Remote Control environments are not available for your account.' 1868: : `Error: ${errorMessage(err)}`, 1869: ) 1870: process.exit(1) 1871: } 1872: let effectiveResumeSessionId: string | undefined 1873: if (feature('KAIROS') && resumeSessionId) { 1874: if (reuseEnvironmentId && environmentId !== reuseEnvironmentId) { 1875: logError( 1876: new Error( 1877: `Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`, 1878: ), 1879: ) 1880: console.warn( 1881: `Warning: Could not resume session ${resumeSessionId} — its environment has expired. Creating a fresh session instead.`, 1882: ) 1883: } else { 1884: const infraResumeId = toInfraSessionId(resumeSessionId) 1885: const reconnectCandidates = 1886: infraResumeId === resumeSessionId 1887: ? [resumeSessionId] 1888: : [resumeSessionId, infraResumeId] 1889: let reconnected = false 1890: let lastReconnectErr: unknown 1891: for (const candidateId of reconnectCandidates) { 1892: try { 1893: await api.reconnectSession(environmentId, candidateId) 1894: logForDebugging( 1895: `[bridge:init] Session ${candidateId} re-queued via bridge/reconnect`, 1896: ) 1897: effectiveResumeSessionId = resumeSessionId 1898: reconnected = true 1899: break 1900: } catch (err) { 1901: lastReconnectErr = err 1902: logForDebugging( 1903: `[bridge:init] reconnectSession(${candidateId}) failed: ${errorMessage(err)}`, 1904: ) 1905: } 1906: } 1907: if (!reconnected) { 1908: const err = lastReconnectErr 1909: const isFatal = err instanceof BridgeFatalError 1910: if (resumePointerDir && isFatal) { 1911: const { clearBridgePointer } = await import('./bridgePointer.js') 1912: await clearBridgePointer(resumePointerDir) 1913: } 1914: console.error( 1915: isFatal 1916: ? `Error: ${errorMessage(err)}` 1917: : `Error: Failed to reconnect session ${resumeSessionId}: ${errorMessage(err)}\nThe session may still be resumable — try running the same command again.`, 1918: ) 1919: process.exit(1) 1920: } 1921: } 1922: } 1923: logForDebugging( 1924: `[bridge:init] Registered, server environmentId=${environmentId}`, 1925: ) 1926: const startupPollConfig = getPollIntervalConfig() 1927: logEvent('tengu_bridge_started', { 1928: max_sessions: config.maxSessions, 1929: has_debug_file: !!config.debugFile, 1930: sandbox: config.sandbox, 1931: verbose: config.verbose, 1932: heartbeat_interval_ms: 1933: startupPollConfig.non_exclusive_heartbeat_interval_ms, 1934: spawn_mode: 1935: config.spawnMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1936: spawn_mode_source: 1937: spawnModeSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1938: multi_session_gate: multiSessionEnabled, 1939: pre_create_session: preCreateSession, 1940: worktree_available: worktreeAvailable, 1941: }) 1942: logForDiagnosticsNoPII('info', 'bridge_started', { 1943: max_sessions: config.maxSessions, 1944: sandbox: config.sandbox, 1945: spawn_mode: config.spawnMode, 1946: }) 1947: const spawner = createSessionSpawner({ 1948: execPath: process.execPath, 1949: scriptArgs: spawnScriptArgs(), 1950: env: process.env, 1951: verbose, 1952: sandbox, 1953: debugFile, 1954: permissionMode, 1955: onDebug: logForDebugging, 1956: onActivity: (sessionId, activity) => { 1957: logForDebugging( 1958: `[bridge:activity] sessionId=${sessionId} ${activity.type} ${activity.summary}`, 1959: ) 1960: }, 1961: onPermissionRequest: (sessionId, request, _accessToken) => { 1962: logForDebugging( 1963: `[bridge:perm] sessionId=${sessionId} tool=${request.request.tool_name} request_id=${request.request_id} (not auto-approving)`, 1964: ) 1965: }, 1966: }) 1967: const logger = createBridgeLogger({ verbose }) 1968: const { parseGitHubRepository } = await import('../utils/detectRepository.js') 1969: const ownerRepo = gitRepoUrl ? parseGitHubRepository(gitRepoUrl) : null 1970: const repoName = ownerRepo ? ownerRepo.split('/').pop()! : basename(dir) 1971: logger.setRepoInfo(repoName, branch) 1972: const toggleAvailable = spawnMode !== 'single-session' && worktreeAvailable 1973: if (toggleAvailable) { 1974: logger.setSpawnModeDisplay(spawnMode as 'same-dir' | 'worktree') 1975: } 1976: const onStdinData = (data: Buffer): void => { 1977: if (data[0] === 0x03 || data[0] === 0x04) { 1978: process.emit('SIGINT') 1979: return 1980: } 1981: if (data[0] === 0x20 ) { 1982: logger.toggleQr() 1983: return 1984: } 1985: if (data[0] === 0x77 ) { 1986: if (!toggleAvailable) return 1987: const newMode: 'same-dir' | 'worktree' = 1988: config.spawnMode === 'same-dir' ? 'worktree' : 'same-dir' 1989: config.spawnMode = newMode 1990: logEvent('tengu_bridge_spawn_mode_toggled', { 1991: spawn_mode: 1992: newMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1993: }) 1994: logger.logStatus( 1995: newMode === 'worktree' 1996: ? 'Spawn mode: worktree (new sessions get isolated git worktrees)' 1997: : 'Spawn mode: same-dir (new sessions share the current directory)', 1998: ) 1999: logger.setSpawnModeDisplay(newMode) 2000: logger.refreshDisplay() 2001: saveCurrentProjectConfig(current => { 2002: if (current.remoteControlSpawnMode === newMode) return current 2003: return { ...current, remoteControlSpawnMode: newMode } 2004: }) 2005: return 2006: } 2007: } 2008: if (process.stdin.isTTY) { 2009: process.stdin.setRawMode(true) 2010: process.stdin.resume() 2011: process.stdin.on('data', onStdinData) 2012: } 2013: const controller = new AbortController() 2014: const onSigint = (): void => { 2015: logForDebugging('[bridge:shutdown] SIGINT received, shutting down') 2016: controller.abort() 2017: } 2018: const onSigterm = (): void => { 2019: logForDebugging('[bridge:shutdown] SIGTERM received, shutting down') 2020: controller.abort() 2021: } 2022: process.on('SIGINT', onSigint) 2023: process.on('SIGTERM', onSigterm) 2024: let initialSessionId: string | null = 2025: feature('KAIROS') && effectiveResumeSessionId 2026: ? effectiveResumeSessionId 2027: : null 2028: if (preCreateSession && !(feature('KAIROS') && effectiveResumeSessionId)) { 2029: const { createBridgeSession } = await import('./createSession.js') 2030: try { 2031: initialSessionId = await createBridgeSession({ 2032: environmentId, 2033: title: name, 2034: events: [], 2035: gitRepoUrl, 2036: branch, 2037: signal: controller.signal, 2038: baseUrl, 2039: getAccessToken: getBridgeAccessToken, 2040: permissionMode, 2041: }) 2042: if (initialSessionId) { 2043: logForDebugging( 2044: `[bridge:init] Created initial session ${initialSessionId}`, 2045: ) 2046: } 2047: } catch (err) { 2048: logForDebugging( 2049: `[bridge:init] Session creation failed (non-fatal): ${errorMessage(err)}`, 2050: ) 2051: } 2052: } 2053: let pointerRefreshTimer: ReturnType<typeof setInterval> | null = null 2054: if (initialSessionId && spawnMode === 'single-session') { 2055: const { writeBridgePointer } = await import('./bridgePointer.js') 2056: const pointerPayload = { 2057: sessionId: initialSessionId, 2058: environmentId, 2059: source: 'standalone' as const, 2060: } 2061: await writeBridgePointer(config.dir, pointerPayload) 2062: pointerRefreshTimer = setInterval( 2063: writeBridgePointer, 2064: 60 * 60 * 1000, 2065: config.dir, 2066: pointerPayload, 2067: ) 2068: pointerRefreshTimer.unref?.() 2069: } 2070: try { 2071: await runBridgeLoop( 2072: config, 2073: environmentId, 2074: environmentSecret, 2075: api, 2076: spawner, 2077: logger, 2078: controller.signal, 2079: undefined, 2080: initialSessionId ?? undefined, 2081: async () => { 2082: clearOAuthTokenCache() 2083: await checkAndRefreshOAuthTokenIfNeeded() 2084: return getBridgeAccessToken() 2085: }, 2086: ) 2087: } finally { 2088: if (pointerRefreshTimer !== null) { 2089: clearInterval(pointerRefreshTimer) 2090: } 2091: process.off('SIGINT', onSigint) 2092: process.off('SIGTERM', onSigterm) 2093: process.stdin.off('data', onStdinData) 2094: if (process.stdin.isTTY) { 2095: process.stdin.setRawMode(false) 2096: } 2097: process.stdin.pause() 2098: } 2099: process.exit(0) 2100: } 2101: export class BridgeHeadlessPermanentError extends Error { 2102: constructor(message: string) { 2103: super(message) 2104: this.name = 'BridgeHeadlessPermanentError' 2105: } 2106: } 2107: export type HeadlessBridgeOpts = { 2108: dir: string 2109: name?: string 2110: spawnMode: 'same-dir' | 'worktree' 2111: capacity: number 2112: permissionMode?: string 2113: sandbox: boolean 2114: sessionTimeoutMs?: number 2115: createSessionOnStart: boolean 2116: getAccessToken: () => string | undefined 2117: onAuth401: (failedToken: string) => Promise<boolean> 2118: log: (s: string) => void 2119: } 2120: export async function runBridgeHeadless( 2121: opts: HeadlessBridgeOpts, 2122: signal: AbortSignal, 2123: ): Promise<void> { 2124: const { dir, log } = opts 2125: process.chdir(dir) 2126: const { setOriginalCwd, setCwdState } = await import('../bootstrap/state.js') 2127: setOriginalCwd(dir) 2128: setCwdState(dir) 2129: const { enableConfigs, checkHasTrustDialogAccepted } = await import( 2130: '../utils/config.js' 2131: ) 2132: enableConfigs() 2133: const { initSinks } = await import('../utils/sinks.js') 2134: initSinks() 2135: if (!checkHasTrustDialogAccepted()) { 2136: throw new BridgeHeadlessPermanentError( 2137: `Workspace not trusted: ${dir}. Run \`claude\` in that directory first to accept the trust dialog.`, 2138: ) 2139: } 2140: if (!opts.getAccessToken()) { 2141: throw new Error(BRIDGE_LOGIN_ERROR) 2142: } 2143: const { getBridgeBaseUrl } = await import('./bridgeConfig.js') 2144: const baseUrl = getBridgeBaseUrl() 2145: if ( 2146: baseUrl.startsWith('http://') && 2147: !baseUrl.includes('localhost') && 2148: !baseUrl.includes('127.0.0.1') 2149: ) { 2150: throw new BridgeHeadlessPermanentError( 2151: 'Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.', 2152: ) 2153: } 2154: const sessionIngressUrl = 2155: process.env.USER_TYPE === 'ant' && 2156: process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL 2157: ? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL 2158: : baseUrl 2159: const { getBranch, getRemoteUrl, findGitRoot } = await import( 2160: '../utils/git.js' 2161: ) 2162: const { hasWorktreeCreateHook } = await import('../utils/hooks.js') 2163: if (opts.spawnMode === 'worktree') { 2164: const worktreeAvailable = 2165: hasWorktreeCreateHook() || findGitRoot(dir) !== null 2166: if (!worktreeAvailable) { 2167: throw new BridgeHeadlessPermanentError( 2168: `Worktree mode requires a git repository or WorktreeCreate hooks. Directory ${dir} has neither.`, 2169: ) 2170: } 2171: } 2172: const branch = await getBranch() 2173: const gitRepoUrl = await getRemoteUrl() 2174: const machineName = hostname() 2175: const bridgeId = randomUUID() 2176: const config: BridgeConfig = { 2177: dir, 2178: machineName, 2179: branch, 2180: gitRepoUrl, 2181: maxSessions: opts.capacity, 2182: spawnMode: opts.spawnMode, 2183: verbose: false, 2184: sandbox: opts.sandbox, 2185: bridgeId, 2186: workerType: 'claude_code', 2187: environmentId: randomUUID(), 2188: apiBaseUrl: baseUrl, 2189: sessionIngressUrl, 2190: sessionTimeoutMs: opts.sessionTimeoutMs, 2191: } 2192: const api = createBridgeApiClient({ 2193: baseUrl, 2194: getAccessToken: opts.getAccessToken, 2195: runnerVersion: MACRO.VERSION, 2196: onDebug: log, 2197: onAuth401: opts.onAuth401, 2198: getTrustedDeviceToken, 2199: }) 2200: let environmentId: string 2201: let environmentSecret: string 2202: try { 2203: const reg = await api.registerBridgeEnvironment(config) 2204: environmentId = reg.environment_id 2205: environmentSecret = reg.environment_secret 2206: } catch (err) { 2207: throw new Error(`Bridge registration failed: ${errorMessage(err)}`) 2208: } 2209: const spawner = createSessionSpawner({ 2210: execPath: process.execPath, 2211: scriptArgs: spawnScriptArgs(), 2212: env: process.env, 2213: verbose: false, 2214: sandbox: opts.sandbox, 2215: permissionMode: opts.permissionMode, 2216: onDebug: log, 2217: }) 2218: const logger = createHeadlessBridgeLogger(log) 2219: logger.printBanner(config, environmentId) 2220: let initialSessionId: string | undefined 2221: if (opts.createSessionOnStart) { 2222: const { createBridgeSession } = await import('./createSession.js') 2223: try { 2224: const sid = await createBridgeSession({ 2225: environmentId, 2226: title: opts.name, 2227: events: [], 2228: gitRepoUrl, 2229: branch, 2230: signal, 2231: baseUrl, 2232: getAccessToken: opts.getAccessToken, 2233: permissionMode: opts.permissionMode, 2234: }) 2235: if (sid) { 2236: initialSessionId = sid 2237: log(`created initial session ${sid}`) 2238: } 2239: } catch (err) { 2240: log(`session pre-creation failed (non-fatal): ${errorMessage(err)}`) 2241: } 2242: } 2243: await runBridgeLoop( 2244: config, 2245: environmentId, 2246: environmentSecret, 2247: api, 2248: spawner, 2249: logger, 2250: signal, 2251: undefined, 2252: initialSessionId, 2253: async () => opts.getAccessToken(), 2254: ) 2255: } 2256: function createHeadlessBridgeLogger(log: (s: string) => void): BridgeLogger { 2257: const noop = (): void => {} 2258: return { 2259: printBanner: (cfg, envId) => 2260: log( 2261: `registered environmentId=${envId} dir=${cfg.dir} spawnMode=${cfg.spawnMode} capacity=${cfg.maxSessions}`, 2262: ), 2263: logSessionStart: (id, _prompt) => log(`session start ${id}`), 2264: logSessionComplete: (id, ms) => log(`session complete ${id} (${ms}ms)`), 2265: logSessionFailed: (id, err) => log(`session failed ${id}: ${err}`), 2266: logStatus: log, 2267: logVerbose: log, 2268: logError: s => log(`error: ${s}`), 2269: logReconnected: ms => log(`reconnected after ${ms}ms`), 2270: addSession: (id, _url) => log(`session attached ${id}`), 2271: removeSession: id => log(`session detached ${id}`), 2272: updateIdleStatus: noop, 2273: updateReconnectingStatus: noop, 2274: updateSessionStatus: noop, 2275: updateSessionActivity: noop, 2276: updateSessionCount: noop, 2277: updateFailedStatus: noop, 2278: setSpawnModeDisplay: noop, 2279: setRepoInfo: noop, 2280: setDebugLogPath: noop, 2281: setAttached: noop, 2282: setSessionTitle: noop, 2283: clearStatus: noop, 2284: toggleQr: noop, 2285: refreshDisplay: noop, 2286: } 2287: }

File: src/bridge/bridgeMessaging.ts

typescript 1: import { randomUUID } from 'crypto' 2: import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 3: import type { 4: SDKControlRequest, 5: SDKControlResponse, 6: } from '../entrypoints/sdk/controlTypes.js' 7: import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js' 8: import { logEvent } from '../services/analytics/index.js' 9: import { EMPTY_USAGE } from '../services/api/emptyUsage.js' 10: import type { Message } from '../types/message.js' 11: import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js' 12: import { logForDebugging } from '../utils/debug.js' 13: import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js' 14: import { errorMessage } from '../utils/errors.js' 15: import type { PermissionMode } from '../utils/permissions/PermissionMode.js' 16: import { jsonParse } from '../utils/slowOperations.js' 17: import type { ReplBridgeTransport } from './replBridgeTransport.js' 18: export function isSDKMessage(value: unknown): value is SDKMessage { 19: return ( 20: value !== null && 21: typeof value === 'object' && 22: 'type' in value && 23: typeof value.type === 'string' 24: ) 25: } 26: export function isSDKControlResponse( 27: value: unknown, 28: ): value is SDKControlResponse { 29: return ( 30: value !== null && 31: typeof value === 'object' && 32: 'type' in value && 33: value.type === 'control_response' && 34: 'response' in value 35: ) 36: } 37: export function isSDKControlRequest( 38: value: unknown, 39: ): value is SDKControlRequest { 40: return ( 41: value !== null && 42: typeof value === 'object' && 43: 'type' in value && 44: value.type === 'control_request' && 45: 'request_id' in value && 46: 'request' in value 47: ) 48: } 49: export function isEligibleBridgeMessage(m: Message): boolean { 50: if ((m.type === 'user' || m.type === 'assistant') && m.isVirtual) { 51: return false 52: } 53: return ( 54: m.type === 'user' || 55: m.type === 'assistant' || 56: (m.type === 'system' && m.subtype === 'local_command') 57: ) 58: } 59: export function extractTitleText(m: Message): string | undefined { 60: if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary) 61: return undefined 62: if (m.origin && m.origin.kind !== 'human') return undefined 63: const content = m.message.content 64: let raw: string | undefined 65: if (typeof content === 'string') { 66: raw = content 67: } else { 68: for (const block of content) { 69: if (block.type === 'text') { 70: raw = block.text 71: break 72: } 73: } 74: } 75: if (!raw) return undefined 76: const clean = stripDisplayTagsAllowEmpty(raw) 77: return clean || undefined 78: } 79: export function handleIngressMessage( 80: data: string, 81: recentPostedUUIDs: BoundedUUIDSet, 82: recentInboundUUIDs: BoundedUUIDSet, 83: onInboundMessage: ((msg: SDKMessage) => void | Promise<void>) | undefined, 84: onPermissionResponse?: ((response: SDKControlResponse) => void) | undefined, 85: onControlRequest?: ((request: SDKControlRequest) => void) | undefined, 86: ): void { 87: try { 88: const parsed: unknown = normalizeControlMessageKeys(jsonParse(data)) 89: if (isSDKControlResponse(parsed)) { 90: logForDebugging('[bridge:repl] Ingress message type=control_response') 91: onPermissionResponse?.(parsed) 92: return 93: } 94: if (isSDKControlRequest(parsed)) { 95: logForDebugging( 96: `[bridge:repl] Inbound control_request subtype=${parsed.request.subtype}`, 97: ) 98: onControlRequest?.(parsed) 99: return 100: } 101: if (!isSDKMessage(parsed)) return 102: const uuid = 103: 'uuid' in parsed && typeof parsed.uuid === 'string' 104: ? parsed.uuid 105: : undefined 106: if (uuid && recentPostedUUIDs.has(uuid)) { 107: logForDebugging( 108: `[bridge:repl] Ignoring echo: type=${parsed.type} uuid=${uuid}`, 109: ) 110: return 111: } 112: if (uuid && recentInboundUUIDs.has(uuid)) { 113: logForDebugging( 114: `[bridge:repl] Ignoring re-delivered inbound: type=${parsed.type} uuid=${uuid}`, 115: ) 116: return 117: } 118: logForDebugging( 119: `[bridge:repl] Ingress message type=${parsed.type}${uuid ? ` uuid=${uuid}` : ''}`, 120: ) 121: if (parsed.type === 'user') { 122: if (uuid) recentInboundUUIDs.add(uuid) 123: logEvent('tengu_bridge_message_received', { 124: is_repl: true, 125: }) 126: void onInboundMessage?.(parsed) 127: } else { 128: logForDebugging( 129: `[bridge:repl] Ignoring non-user inbound message: type=${parsed.type}`, 130: ) 131: } 132: } catch (err) { 133: logForDebugging( 134: `[bridge:repl] Failed to parse ingress message: ${errorMessage(err)}`, 135: ) 136: } 137: } 138: export type ServerControlRequestHandlers = { 139: transport: ReplBridgeTransport | null 140: sessionId: string 141: outboundOnly?: boolean 142: onInterrupt?: () => void 143: onSetModel?: (model: string | undefined) => void 144: onSetMaxThinkingTokens?: (maxTokens: number | null) => void 145: onSetPermissionMode?: ( 146: mode: PermissionMode, 147: ) => { ok: true } | { ok: false; error: string } 148: } 149: const OUTBOUND_ONLY_ERROR = 150: 'This session is outbound-only. Enable Remote Control locally to allow inbound control.' 151: export function handleServerControlRequest( 152: request: SDKControlRequest, 153: handlers: ServerControlRequestHandlers, 154: ): void { 155: const { 156: transport, 157: sessionId, 158: outboundOnly, 159: onInterrupt, 160: onSetModel, 161: onSetMaxThinkingTokens, 162: onSetPermissionMode, 163: } = handlers 164: if (!transport) { 165: logForDebugging( 166: '[bridge:repl] Cannot respond to control_request: transport not configured', 167: ) 168: return 169: } 170: let response: SDKControlResponse 171: if (outboundOnly && request.request.subtype !== 'initialize') { 172: response = { 173: type: 'control_response', 174: response: { 175: subtype: 'error', 176: request_id: request.request_id, 177: error: OUTBOUND_ONLY_ERROR, 178: }, 179: } 180: const event = { ...response, session_id: sessionId } 181: void transport.write(event) 182: logForDebugging( 183: `[bridge:repl] Rejected ${request.request.subtype} (outbound-only) request_id=${request.request_id}`, 184: ) 185: return 186: } 187: switch (request.request.subtype) { 188: case 'initialize': 189: response = { 190: type: 'control_response', 191: response: { 192: subtype: 'success', 193: request_id: request.request_id, 194: response: { 195: commands: [], 196: output_style: 'normal', 197: available_output_styles: ['normal'], 198: models: [], 199: account: {}, 200: pid: process.pid, 201: }, 202: }, 203: } 204: break 205: case 'set_model': 206: onSetModel?.(request.request.model) 207: response = { 208: type: 'control_response', 209: response: { 210: subtype: 'success', 211: request_id: request.request_id, 212: }, 213: } 214: break 215: case 'set_max_thinking_tokens': 216: onSetMaxThinkingTokens?.(request.request.max_thinking_tokens) 217: response = { 218: type: 'control_response', 219: response: { 220: subtype: 'success', 221: request_id: request.request_id, 222: }, 223: } 224: break 225: case 'set_permission_mode': { 226: const verdict = onSetPermissionMode?.(request.request.mode) ?? { 227: ok: false, 228: error: 229: 'set_permission_mode is not supported in this context (onSetPermissionMode callback not registered)', 230: } 231: if (verdict.ok) { 232: response = { 233: type: 'control_response', 234: response: { 235: subtype: 'success', 236: request_id: request.request_id, 237: }, 238: } 239: } else { 240: response = { 241: type: 'control_response', 242: response: { 243: subtype: 'error', 244: request_id: request.request_id, 245: error: verdict.error, 246: }, 247: } 248: } 249: break 250: } 251: case 'interrupt': 252: onInterrupt?.() 253: response = { 254: type: 'control_response', 255: response: { 256: subtype: 'success', 257: request_id: request.request_id, 258: }, 259: } 260: break 261: default: 262: response = { 263: type: 'control_response', 264: response: { 265: subtype: 'error', 266: request_id: request.request_id, 267: error: `REPL bridge does not handle control_request subtype: ${request.request.subtype}`, 268: }, 269: } 270: } 271: const event = { ...response, session_id: sessionId } 272: void transport.write(event) 273: logForDebugging( 274: `[bridge:repl] Sent control_response for ${request.request.subtype} request_id=${request.request_id} result=${response.response.subtype}`, 275: ) 276: } 277: export function makeResultMessage(sessionId: string): SDKResultSuccess { 278: return { 279: type: 'result', 280: subtype: 'success', 281: duration_ms: 0, 282: duration_api_ms: 0, 283: is_error: false, 284: num_turns: 0, 285: result: '', 286: stop_reason: null, 287: total_cost_usd: 0, 288: usage: { ...EMPTY_USAGE }, 289: modelUsage: {}, 290: permission_denials: [], 291: session_id: sessionId, 292: uuid: randomUUID(), 293: } 294: } 295: // ─── BoundedUUIDSet (echo-dedup ring buffer) ───────────────────────────────── 296: /** 297: * FIFO-bounded set backed by a circular buffer. Evicts the oldest entry 298: * when capacity is reached, keeping memory usage constant at O(capacity). 299: * 300: * Messages are added in chronological order, so evicted entries are always 301: * the oldest. The caller relies on external ordering (the hook's 302: * lastWrittenIndexRef) as the primary dedup — this set is a secondary 303: * safety net for echo filtering and race-condition dedup. 304: */ 305: export class BoundedUUIDSet { 306: private readonly capacity: number 307: private readonly ring: (string | undefined)[] 308: private readonly set = new Set<string>() 309: private writeIdx = 0 310: constructor(capacity: number) { 311: this.capacity = capacity 312: this.ring = new Array<string | undefined>(capacity) 313: } 314: add(uuid: string): void { 315: if (this.set.has(uuid)) return 316: const evicted = this.ring[this.writeIdx] 317: if (evicted !== undefined) { 318: this.set.delete(evicted) 319: } 320: this.ring[this.writeIdx] = uuid 321: this.set.add(uuid) 322: this.writeIdx = (this.writeIdx + 1) % this.capacity 323: } 324: has(uuid: string): boolean { 325: return this.set.has(uuid) 326: } 327: clear(): void { 328: this.set.clear() 329: this.ring.fill(undefined) 330: this.writeIdx = 0 331: } 332: }

File: src/bridge/bridgePermissionCallbacks.ts

typescript 1: import type { PermissionUpdate } from '../utils/permissions/PermissionUpdateSchema.js' 2: type BridgePermissionResponse = { 3: behavior: 'allow' | 'deny' 4: updatedInput?: Record<string, unknown> 5: updatedPermissions?: PermissionUpdate[] 6: message?: string 7: } 8: type BridgePermissionCallbacks = { 9: sendRequest( 10: requestId: string, 11: toolName: string, 12: input: Record<string, unknown>, 13: toolUseId: string, 14: description: string, 15: permissionSuggestions?: PermissionUpdate[], 16: blockedPath?: string, 17: ): void 18: sendResponse(requestId: string, response: BridgePermissionResponse): void 19: cancelRequest(requestId: string): void 20: onResponse( 21: requestId: string, 22: handler: (response: BridgePermissionResponse) => void, 23: ): () => void 24: } 25: function isBridgePermissionResponse( 26: value: unknown, 27: ): value is BridgePermissionResponse { 28: if (!value || typeof value !== 'object') return false 29: return ( 30: 'behavior' in value && 31: (value.behavior === 'allow' || value.behavior === 'deny') 32: ) 33: } 34: export { isBridgePermissionResponse } 35: export type { BridgePermissionCallbacks, BridgePermissionResponse }

File: src/bridge/bridgePointer.ts

typescript 1: import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises' 2: import { dirname, join } from 'path' 3: import { z } from 'zod/v4' 4: import { logForDebugging } from '../utils/debug.js' 5: import { isENOENT } from '../utils/errors.js' 6: import { getWorktreePathsPortable } from '../utils/getWorktreePathsPortable.js' 7: import { lazySchema } from '../utils/lazySchema.js' 8: import { 9: getProjectsDir, 10: sanitizePath, 11: } from '../utils/sessionStoragePortable.js' 12: import { jsonParse, jsonStringify } from '../utils/slowOperations.js' 13: const MAX_WORKTREE_FANOUT = 50 14: export const BRIDGE_POINTER_TTL_MS = 4 * 60 * 60 * 1000 15: const BridgePointerSchema = lazySchema(() => 16: z.object({ 17: sessionId: z.string(), 18: environmentId: z.string(), 19: source: z.enum(['standalone', 'repl']), 20: }), 21: ) 22: export type BridgePointer = z.infer<ReturnType<typeof BridgePointerSchema>> 23: export function getBridgePointerPath(dir: string): string { 24: return join(getProjectsDir(), sanitizePath(dir), 'bridge-pointer.json') 25: } 26: export async function writeBridgePointer( 27: dir: string, 28: pointer: BridgePointer, 29: ): Promise<void> { 30: const path = getBridgePointerPath(dir) 31: try { 32: await mkdir(dirname(path), { recursive: true }) 33: await writeFile(path, jsonStringify(pointer), 'utf8') 34: logForDebugging(`[bridge:pointer] wrote ${path}`) 35: } catch (err: unknown) { 36: logForDebugging(`[bridge:pointer] write failed: ${err}`, { level: 'warn' }) 37: } 38: } 39: export async function readBridgePointer( 40: dir: string, 41: ): Promise<(BridgePointer & { ageMs: number }) | null> { 42: const path = getBridgePointerPath(dir) 43: let raw: string 44: let mtimeMs: number 45: try { 46: mtimeMs = (await stat(path)).mtimeMs 47: raw = await readFile(path, 'utf8') 48: } catch { 49: return null 50: } 51: const parsed = BridgePointerSchema().safeParse(safeJsonParse(raw)) 52: if (!parsed.success) { 53: logForDebugging(`[bridge:pointer] invalid schema, clearing: ${path}`) 54: await clearBridgePointer(dir) 55: return null 56: } 57: const ageMs = Math.max(0, Date.now() - mtimeMs) 58: if (ageMs > BRIDGE_POINTER_TTL_MS) { 59: logForDebugging(`[bridge:pointer] stale (>4h mtime), clearing: ${path}`) 60: await clearBridgePointer(dir) 61: return null 62: } 63: return { ...parsed.data, ageMs } 64: } 65: export async function readBridgePointerAcrossWorktrees( 66: dir: string, 67: ): Promise<{ pointer: BridgePointer & { ageMs: number }; dir: string } | null> { 68: const here = await readBridgePointer(dir) 69: if (here) { 70: return { pointer: here, dir } 71: } 72: const worktrees = await getWorktreePathsPortable(dir) 73: if (worktrees.length <= 1) return null 74: if (worktrees.length > MAX_WORKTREE_FANOUT) { 75: logForDebugging( 76: `[bridge:pointer] ${worktrees.length} worktrees exceeds fanout cap ${MAX_WORKTREE_FANOUT}, skipping`, 77: ) 78: return null 79: } 80: const dirKey = sanitizePath(dir) 81: const candidates = worktrees.filter(wt => sanitizePath(wt) !== dirKey) 82: const results = await Promise.all( 83: candidates.map(async wt => { 84: const p = await readBridgePointer(wt) 85: return p ? { pointer: p, dir: wt } : null 86: }), 87: ) 88: let freshest: { 89: pointer: BridgePointer & { ageMs: number } 90: dir: string 91: } | null = null 92: for (const r of results) { 93: if (r && (!freshest || r.pointer.ageMs < freshest.pointer.ageMs)) { 94: freshest = r 95: } 96: } 97: if (freshest) { 98: logForDebugging( 99: `[bridge:pointer] fanout found pointer in worktree ${freshest.dir} (ageMs=${freshest.pointer.ageMs})`, 100: ) 101: } 102: return freshest 103: } 104: export async function clearBridgePointer(dir: string): Promise<void> { 105: const path = getBridgePointerPath(dir) 106: try { 107: await unlink(path) 108: logForDebugging(`[bridge:pointer] cleared ${path}`) 109: } catch (err: unknown) { 110: if (!isENOENT(err)) { 111: logForDebugging(`[bridge:pointer] clear failed: ${err}`, { 112: level: 'warn', 113: }) 114: } 115: } 116: } 117: function safeJsonParse(raw: string): unknown { 118: try { 119: return jsonParse(raw) 120: } catch { 121: return null 122: } 123: }

File: src/bridge/bridgeStatusUtil.ts

typescript 1: import { 2: getClaudeAiBaseUrl, 3: getRemoteSessionUrl, 4: } from '../constants/product.js' 5: import { stringWidth } from '../ink/stringWidth.js' 6: import { formatDuration, truncateToWidth } from '../utils/format.js' 7: import { getGraphemeSegmenter } from '../utils/intl.js' 8: export type StatusState = 9: | 'idle' 10: | 'attached' 11: | 'titled' 12: | 'reconnecting' 13: | 'failed' 14: export const TOOL_DISPLAY_EXPIRY_MS = 30_000 15: export const SHIMMER_INTERVAL_MS = 150 16: export function timestamp(): string { 17: const now = new Date() 18: const h = String(now.getHours()).padStart(2, '0') 19: const m = String(now.getMinutes()).padStart(2, '0') 20: const s = String(now.getSeconds()).padStart(2, '0') 21: return `${h}:${m}:${s}` 22: } 23: export { formatDuration, truncateToWidth as truncatePrompt } 24: export function abbreviateActivity(summary: string): string { 25: return truncateToWidth(summary, 30) 26: } 27: export function buildBridgeConnectUrl( 28: environmentId: string, 29: ingressUrl?: string, 30: ): string { 31: const baseUrl = getClaudeAiBaseUrl(undefined, ingressUrl) 32: return `${baseUrl}/code?bridge=${environmentId}` 33: } 34: export function buildBridgeSessionUrl( 35: sessionId: string, 36: environmentId: string, 37: ingressUrl?: string, 38: ): string { 39: return `${getRemoteSessionUrl(sessionId, ingressUrl)}?bridge=${environmentId}` 40: } 41: export function computeGlimmerIndex( 42: tick: number, 43: messageWidth: number, 44: ): number { 45: const cycleLength = messageWidth + 20 46: return messageWidth + 10 - (tick % cycleLength) 47: } 48: export function computeShimmerSegments( 49: text: string, 50: glimmerIndex: number, 51: ): { before: string; shimmer: string; after: string } { 52: const messageWidth = stringWidth(text) 53: const shimmerStart = glimmerIndex - 1 54: const shimmerEnd = glimmerIndex + 1 55: if (shimmerStart >= messageWidth || shimmerEnd < 0) { 56: return { before: text, shimmer: '', after: '' } 57: } 58: // Split into at most 3 segments by visual column position 59: const clampedStart = Math.max(0, shimmerStart) 60: let colPos = 0 61: let before = '' 62: let shimmer = '' 63: let after = '' 64: for (const { segment } of getGraphemeSegmenter().segment(text)) { 65: const segWidth = stringWidth(segment) 66: if (colPos + segWidth <= clampedStart) { 67: before += segment 68: } else if (colPos > shimmerEnd) { 69: after += segment 70: } else { 71: shimmer += segment 72: } 73: colPos += segWidth 74: } 75: return { before, shimmer, after } 76: } 77: /** Computed bridge status label and color from connection state. */ 78: export type BridgeStatusInfo = { 79: label: 80: | 'Remote Control failed' 81: | 'Remote Control reconnecting' 82: | 'Remote Control active' 83: | 'Remote Control connecting\u2026' 84: color: 'error' | 'warning' | 'success' 85: } 86: export function getBridgeStatus({ 87: error, 88: connected, 89: sessionActive, 90: reconnecting, 91: }: { 92: error: string | undefined 93: connected: boolean 94: sessionActive: boolean 95: reconnecting: boolean 96: }): BridgeStatusInfo { 97: if (error) return { label: 'Remote Control failed', color: 'error' } 98: if (reconnecting) 99: return { label: 'Remote Control reconnecting', color: 'warning' } 100: if (sessionActive || connected) 101: return { label: 'Remote Control active', color: 'success' } 102: return { label: 'Remote Control connecting\u2026', color: 'warning' } 103: } 104: export function buildIdleFooterText(url: string): string { 105: return `Code everywhere with the Claude app or ${url}` 106: } 107: export function buildActiveFooterText(url: string): string { 108: return `Continue coding in the Claude app or ${url}` 109: } 110: export const FAILED_FOOTER_TEXT = 'Something went wrong, please try again' 111: export function wrapWithOsc8Link(text: string, url: string): string { 112: return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07` 113: }

File: src/bridge/bridgeUI.ts

typescript 1: import chalk from 'chalk' 2: import { toString as qrToString } from 'qrcode' 3: import { 4: BRIDGE_FAILED_INDICATOR, 5: BRIDGE_READY_INDICATOR, 6: BRIDGE_SPINNER_FRAMES, 7: } from '../constants/figures.js' 8: import { stringWidth } from '../ink/stringWidth.js' 9: import { logForDebugging } from '../utils/debug.js' 10: import { 11: buildActiveFooterText, 12: buildBridgeConnectUrl, 13: buildBridgeSessionUrl, 14: buildIdleFooterText, 15: FAILED_FOOTER_TEXT, 16: formatDuration, 17: type StatusState, 18: TOOL_DISPLAY_EXPIRY_MS, 19: timestamp, 20: truncatePrompt, 21: wrapWithOsc8Link, 22: } from './bridgeStatusUtil.js' 23: import type { 24: BridgeConfig, 25: BridgeLogger, 26: SessionActivity, 27: SpawnMode, 28: } from './types.js' 29: const QR_OPTIONS = { 30: type: 'utf8' as const, 31: errorCorrectionLevel: 'L' as const, 32: small: true, 33: } 34: async function generateQr(url: string): Promise<string[]> { 35: const qr = await qrToString(url, QR_OPTIONS) 36: return qr.split('\n').filter((line: string) => line.length > 0) 37: } 38: export function createBridgeLogger(options: { 39: verbose: boolean 40: write?: (s: string) => void 41: }): BridgeLogger { 42: const write = options.write ?? ((s: string) => process.stdout.write(s)) 43: const verbose = options.verbose 44: let statusLineCount = 0 45: let currentState: StatusState = 'idle' 46: let currentStateText = 'Ready' 47: let repoName = '' 48: let branch = '' 49: let debugLogPath = '' 50: // Connect URL (built in printBanner with correct base for staging/prod) 51: let connectUrl = '' 52: let cachedIngressUrl = '' 53: let cachedEnvironmentId = '' 54: let activeSessionUrl: string | null = null 55: // QR code lines for the current URL 56: let qrLines: string[] = [] 57: let qrVisible = false 58: // Tool activity for the second status line 59: let lastToolSummary: string | null = null 60: let lastToolTime = 0 61: // Session count indicator (shown when multi-session mode is enabled) 62: let sessionActive = 0 63: let sessionMax = 1 64: // Spawn mode shown in the session-count line + gates the `w` hint 65: let spawnModeDisplay: 'same-dir' | 'worktree' | null = null 66: let spawnMode: SpawnMode = 'single-session' 67: const sessionDisplayInfo = new Map< 68: string, 69: { title?: string; url: string; activity?: SessionActivity } 70: >() 71: let connectingTimer: ReturnType<typeof setInterval> | null = null 72: let connectingTick = 0 73: function countVisualLines(text: string): number { 74: const cols = process.stdout.columns || 80 75: let count = 0 76: for (const logical of text.split('\n')) { 77: if (logical.length === 0) { 78: count++ 79: continue 80: } 81: const width = stringWidth(logical) 82: count += Math.max(1, Math.ceil(width / cols)) 83: } 84: if (text.endsWith('\n')) { 85: count-- 86: } 87: return count 88: } 89: function writeStatus(text: string): void { 90: write(text) 91: statusLineCount += countVisualLines(text) 92: } 93: function clearStatusLines(): void { 94: if (statusLineCount <= 0) return 95: logForDebugging(`[bridge:ui] clearStatusLines count=${statusLineCount}`) 96: write(`\x1b[${statusLineCount}A`) 97: write('\x1b[J') 98: statusLineCount = 0 99: } 100: function printLog(line: string): void { 101: clearStatusLines() 102: write(line) 103: } 104: function regenerateQr(url: string): void { 105: generateQr(url) 106: .then(lines => { 107: qrLines = lines 108: renderStatusLine() 109: }) 110: .catch(e => { 111: logForDebugging(`QR code generation failed: ${e}`, { level: 'error' }) 112: }) 113: } 114: function renderConnectingLine(): void { 115: clearStatusLines() 116: const frame = 117: BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]! 118: let suffix = '' 119: if (repoName) { 120: suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName) 121: } 122: if (branch) { 123: suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch) 124: } 125: writeStatus( 126: `${chalk.yellow(frame)} ${chalk.yellow('Connecting')}${suffix}\n`, 127: ) 128: } 129: function startConnecting(): void { 130: stopConnecting() 131: renderConnectingLine() 132: connectingTimer = setInterval(() => { 133: connectingTick++ 134: renderConnectingLine() 135: }, 150) 136: } 137: function stopConnecting(): void { 138: if (connectingTimer) { 139: clearInterval(connectingTimer) 140: connectingTimer = null 141: } 142: } 143: function renderStatusLine(): void { 144: if (currentState === 'reconnecting' || currentState === 'failed') { 145: return 146: } 147: clearStatusLines() 148: const isIdle = currentState === 'idle' 149: if (qrVisible) { 150: for (const line of qrLines) { 151: writeStatus(`${chalk.dim(line)}\n`) 152: } 153: } 154: const indicator = BRIDGE_READY_INDICATOR 155: const indicatorColor = isIdle ? chalk.green : chalk.cyan 156: const baseColor = isIdle ? chalk.green : chalk.cyan 157: const stateText = baseColor(currentStateText) 158: let suffix = '' 159: if (repoName) { 160: suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName) 161: } 162: // In worktree mode each session gets its own branch, so showing the 163: // bridge's branch would be misleading. 164: if (branch && spawnMode !== 'worktree') { 165: suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch) 166: } 167: if (process.env.USER_TYPE === 'ant' && debugLogPath) { 168: writeStatus( 169: `${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`, 170: ) 171: } 172: writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`) 173: if (sessionMax > 1) { 174: const modeHint = 175: spawnMode === 'worktree' 176: ? 'New sessions will be created in an isolated worktree' 177: : 'New sessions will be created in the current directory' 178: writeStatus( 179: ` ${chalk.dim(`Capacity: ${sessionActive}/${sessionMax} \u00b7 ${modeHint}`)}\n`, 180: ) 181: for (const [, info] of sessionDisplayInfo) { 182: const titleText = info.title 183: ? truncatePrompt(info.title, 35) 184: : chalk.dim('Attached') 185: const titleLinked = wrapWithOsc8Link(titleText, info.url) 186: const act = info.activity 187: const showAct = act && act.type !== 'result' && act.type !== 'error' 188: const actText = showAct 189: ? chalk.dim(` ${truncatePrompt(act.summary, 40)}`) 190: : '' 191: writeStatus(` ${titleLinked}${actText} 192: `) 193: } 194: } 195: // Mode line for spawn modes with a single slot (or true single-session mode) 196: if (sessionMax === 1) { 197: const modeText = 198: spawnMode === 'single-session' 199: ? 'Single session \u00b7 exits when complete' 200: : spawnMode === 'worktree' 201: ? `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in an isolated worktree` 202: : `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in the current directory` 203: writeStatus(` ${chalk.dim(modeText)}\n`) 204: } 205: if ( 206: sessionMax === 1 && 207: !isIdle && 208: lastToolSummary && 209: Date.now() - lastToolTime < TOOL_DISPLAY_EXPIRY_MS 210: ) { 211: writeStatus(` ${chalk.dim(truncatePrompt(lastToolSummary, 60))}\n`) 212: } 213: const url = activeSessionUrl ?? connectUrl 214: if (url) { 215: writeStatus('\n') 216: const footerText = isIdle 217: ? buildIdleFooterText(url) 218: : buildActiveFooterText(url) 219: const qrHint = qrVisible 220: ? chalk.dim.italic('space to hide QR code') 221: : chalk.dim.italic('space to show QR code') 222: const toggleHint = spawnModeDisplay 223: ? chalk.dim.italic(' \u00b7 w to toggle spawn mode') 224: : '' 225: writeStatus(`${chalk.dim(footerText)}\n`) 226: writeStatus(`${qrHint}${toggleHint}\n`) 227: } 228: } 229: return { 230: printBanner(config: BridgeConfig, environmentId: string): void { 231: cachedIngressUrl = config.sessionIngressUrl 232: cachedEnvironmentId = environmentId 233: connectUrl = buildBridgeConnectUrl(environmentId, cachedIngressUrl) 234: regenerateQr(connectUrl) 235: if (verbose) { 236: write(chalk.dim(`Remote Control`) + ` v${MACRO.VERSION}\n`) 237: } 238: if (verbose) { 239: if (config.spawnMode !== 'single-session') { 240: write(chalk.dim(`Spawn mode: `) + `${config.spawnMode}\n`) 241: write( 242: chalk.dim(`Max concurrent sessions: `) + `${config.maxSessions}\n`, 243: ) 244: } 245: write(chalk.dim(`Environment ID: `) + `${environmentId}\n`) 246: } 247: if (config.sandbox) { 248: write(chalk.dim(`Sandbox: `) + `${chalk.green('Enabled')}\n`) 249: } 250: write('\n') 251: startConnecting() 252: }, 253: logSessionStart(sessionId: string, prompt: string): void { 254: if (verbose) { 255: const short = truncatePrompt(prompt, 80) 256: printLog( 257: chalk.dim(`[${timestamp()}]`) + 258: ` Session started: ${chalk.white(`"${short}"`)} (${chalk.dim(sessionId)})\n`, 259: ) 260: } 261: }, 262: logSessionComplete(sessionId: string, durationMs: number): void { 263: printLog( 264: chalk.dim(`[${timestamp()}]`) + 265: ` Session ${chalk.green('completed')} (${formatDuration(durationMs)}) ${chalk.dim(sessionId)}\n`, 266: ) 267: }, 268: logSessionFailed(sessionId: string, error: string): void { 269: printLog( 270: chalk.dim(`[${timestamp()}]`) + 271: ` Session ${chalk.red('failed')}: ${error} ${chalk.dim(sessionId)}\n`, 272: ) 273: }, 274: logStatus(message: string): void { 275: printLog(chalk.dim(`[${timestamp()}]`) + ` ${message}\n`) 276: }, 277: logVerbose(message: string): void { 278: if (verbose) { 279: printLog(chalk.dim(`[${timestamp()}] ${message}`) + '\n') 280: } 281: }, 282: logError(message: string): void { 283: printLog(chalk.red(`[${timestamp()}] Error: ${message}`) + '\n') 284: }, 285: logReconnected(disconnectedMs: number): void { 286: printLog( 287: chalk.dim(`[${timestamp()}]`) + 288: ` ${chalk.green('Reconnected')} after ${formatDuration(disconnectedMs)}\n`, 289: ) 290: }, 291: setRepoInfo(repo: string, branchName: string): void { 292: repoName = repo 293: branch = branchName 294: }, 295: setDebugLogPath(path: string): void { 296: debugLogPath = path 297: }, 298: updateIdleStatus(): void { 299: stopConnecting() 300: currentState = 'idle' 301: currentStateText = 'Ready' 302: lastToolSummary = null 303: lastToolTime = 0 304: activeSessionUrl = null 305: regenerateQr(connectUrl) 306: renderStatusLine() 307: }, 308: setAttached(sessionId: string): void { 309: stopConnecting() 310: currentState = 'attached' 311: currentStateText = 'Connected' 312: lastToolSummary = null 313: lastToolTime = 0 314: if (sessionMax <= 1) { 315: activeSessionUrl = buildBridgeSessionUrl( 316: sessionId, 317: cachedEnvironmentId, 318: cachedIngressUrl, 319: ) 320: regenerateQr(activeSessionUrl) 321: } 322: renderStatusLine() 323: }, 324: updateReconnectingStatus(delayStr: string, elapsedStr: string): void { 325: stopConnecting() 326: clearStatusLines() 327: currentState = 'reconnecting' 328: if (qrVisible) { 329: for (const line of qrLines) { 330: writeStatus(`${chalk.dim(line)}\n`) 331: } 332: } 333: const frame = 334: BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]! 335: connectingTick++ 336: writeStatus( 337: `${chalk.yellow(frame)} ${chalk.yellow('Reconnecting')} ${chalk.dim('\u00b7')} ${chalk.dim(`retrying in ${delayStr}`)} ${chalk.dim('\u00b7')} ${chalk.dim(`disconnected ${elapsedStr}`)}\n`, 338: ) 339: }, 340: updateFailedStatus(error: string): void { 341: stopConnecting() 342: clearStatusLines() 343: currentState = 'failed' 344: let suffix = '' 345: if (repoName) { 346: suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName) 347: } 348: if (branch) { 349: suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch) 350: } 351: writeStatus( 352: `${chalk.red(BRIDGE_FAILED_INDICATOR)} ${chalk.red('Remote Control Failed')}${suffix}\n`, 353: ) 354: writeStatus(`${chalk.dim(FAILED_FOOTER_TEXT)}\n`) 355: if (error) { 356: writeStatus(`${chalk.red(error)}\n`) 357: } 358: }, 359: updateSessionStatus( 360: _sessionId: string, 361: _elapsed: string, 362: activity: SessionActivity, 363: _trail: string[], 364: ): void { 365: if (activity.type === 'tool_start') { 366: lastToolSummary = activity.summary 367: lastToolTime = Date.now() 368: } 369: renderStatusLine() 370: }, 371: clearStatus(): void { 372: stopConnecting() 373: clearStatusLines() 374: }, 375: toggleQr(): void { 376: qrVisible = !qrVisible 377: renderStatusLine() 378: }, 379: updateSessionCount(active: number, max: number, mode: SpawnMode): void { 380: if (sessionActive === active && sessionMax === max && spawnMode === mode) 381: return 382: sessionActive = active 383: sessionMax = max 384: spawnMode = mode 385: }, 386: setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void { 387: if (spawnModeDisplay === mode) return 388: spawnModeDisplay = mode 389: if (mode) spawnMode = mode 390: }, 391: addSession(sessionId: string, url: string): void { 392: sessionDisplayInfo.set(sessionId, { url }) 393: }, 394: updateSessionActivity(sessionId: string, activity: SessionActivity): void { 395: const info = sessionDisplayInfo.get(sessionId) 396: if (!info) return 397: info.activity = activity 398: }, 399: setSessionTitle(sessionId: string, title: string): void { 400: const info = sessionDisplayInfo.get(sessionId) 401: if (!info) return 402: info.title = title 403: if (currentState === 'reconnecting' || currentState === 'failed') return 404: if (sessionMax === 1) { 405: currentState = 'titled' 406: currentStateText = truncatePrompt(title, 40) 407: } 408: renderStatusLine() 409: }, 410: removeSession(sessionId: string): void { 411: sessionDisplayInfo.delete(sessionId) 412: }, 413: refreshDisplay(): void { 414: if (currentState === 'reconnecting' || currentState === 'failed') return 415: renderStatusLine() 416: }, 417: } 418: }

File: src/bridge/capacityWake.ts

typescript 1: export type CapacitySignal = { signal: AbortSignal; cleanup: () => void } 2: export type CapacityWake = { 3: signal(): CapacitySignal 4: wake(): void 5: } 6: export function createCapacityWake(outerSignal: AbortSignal): CapacityWake { 7: let wakeController = new AbortController() 8: function wake(): void { 9: wakeController.abort() 10: wakeController = new AbortController() 11: } 12: function signal(): CapacitySignal { 13: const merged = new AbortController() 14: const abort = (): void => merged.abort() 15: if (outerSignal.aborted || wakeController.signal.aborted) { 16: merged.abort() 17: return { signal: merged.signal, cleanup: () => {} } 18: } 19: outerSignal.addEventListener('abort', abort, { once: true }) 20: const capSig = wakeController.signal 21: capSig.addEventListener('abort', abort, { once: true }) 22: return { 23: signal: merged.signal, 24: cleanup: () => { 25: outerSignal.removeEventListener('abort', abort) 26: capSig.removeEventListener('abort', abort) 27: }, 28: } 29: } 30: return { signal, wake } 31: }

File: src/bridge/codeSessionApi.ts

typescript 1: import axios from 'axios' 2: import { logForDebugging } from '../utils/debug.js' 3: import { errorMessage } from '../utils/errors.js' 4: import { jsonStringify } from '../utils/slowOperations.js' 5: import { extractErrorDetail } from './debugUtils.js' 6: const ANTHROPIC_VERSION = '2023-06-01' 7: function oauthHeaders(accessToken: string): Record<string, string> { 8: return { 9: Authorization: `Bearer ${accessToken}`, 10: 'Content-Type': 'application/json', 11: 'anthropic-version': ANTHROPIC_VERSION, 12: } 13: } 14: export async function createCodeSession( 15: baseUrl: string, 16: accessToken: string, 17: title: string, 18: timeoutMs: number, 19: tags?: string[], 20: ): Promise<string | null> { 21: const url = `${baseUrl}/v1/code/sessions` 22: let response 23: try { 24: response = await axios.post( 25: url, 26: { title, bridge: {}, ...(tags?.length ? { tags } : {}) }, 27: { 28: headers: oauthHeaders(accessToken), 29: timeout: timeoutMs, 30: validateStatus: s => s < 500, 31: }, 32: ) 33: } catch (err: unknown) { 34: logForDebugging( 35: `[code-session] Session create request failed: ${errorMessage(err)}`, 36: ) 37: return null 38: } 39: if (response.status !== 200 && response.status !== 201) { 40: const detail = extractErrorDetail(response.data) 41: logForDebugging( 42: `[code-session] Session create failed ${response.status}${detail ? `: ${detail}` : ''}`, 43: ) 44: return null 45: } 46: const data: unknown = response.data 47: if ( 48: !data || 49: typeof data !== 'object' || 50: !('session' in data) || 51: !data.session || 52: typeof data.session !== 'object' || 53: !('id' in data.session) || 54: typeof data.session.id !== 'string' || 55: !data.session.id.startsWith('cse_') 56: ) { 57: logForDebugging( 58: `[code-session] No session.id (cse_*) in response: ${jsonStringify(data).slice(0, 200)}`, 59: ) 60: return null 61: } 62: return data.session.id 63: } 64: export type RemoteCredentials = { 65: worker_jwt: string 66: api_base_url: string 67: expires_in: number 68: worker_epoch: number 69: } 70: export async function fetchRemoteCredentials( 71: sessionId: string, 72: baseUrl: string, 73: accessToken: string, 74: timeoutMs: number, 75: trustedDeviceToken?: string, 76: ): Promise<RemoteCredentials | null> { 77: const url = `${baseUrl}/v1/code/sessions/${sessionId}/bridge` 78: const headers = oauthHeaders(accessToken) 79: if (trustedDeviceToken) { 80: headers['X-Trusted-Device-Token'] = trustedDeviceToken 81: } 82: let response 83: try { 84: response = await axios.post( 85: url, 86: {}, 87: { 88: headers, 89: timeout: timeoutMs, 90: validateStatus: s => s < 500, 91: }, 92: ) 93: } catch (err: unknown) { 94: logForDebugging( 95: `[code-session] /bridge request failed: ${errorMessage(err)}`, 96: ) 97: return null 98: } 99: if (response.status !== 200) { 100: const detail = extractErrorDetail(response.data) 101: logForDebugging( 102: `[code-session] /bridge failed ${response.status}${detail ? `: ${detail}` : ''}`, 103: ) 104: return null 105: } 106: const data: unknown = response.data 107: if ( 108: data === null || 109: typeof data !== 'object' || 110: !('worker_jwt' in data) || 111: typeof data.worker_jwt !== 'string' || 112: !('expires_in' in data) || 113: typeof data.expires_in !== 'number' || 114: !('api_base_url' in data) || 115: typeof data.api_base_url !== 'string' || 116: !('worker_epoch' in data) 117: ) { 118: logForDebugging( 119: `[code-session] /bridge response malformed (need worker_jwt, expires_in, api_base_url, worker_epoch): ${jsonStringify(data).slice(0, 200)}`, 120: ) 121: return null 122: } 123: const rawEpoch = data.worker_epoch 124: const epoch = typeof rawEpoch === 'string' ? Number(rawEpoch) : rawEpoch 125: if ( 126: typeof epoch !== 'number' || 127: !Number.isFinite(epoch) || 128: !Number.isSafeInteger(epoch) 129: ) { 130: logForDebugging( 131: `[code-session] /bridge worker_epoch invalid: ${jsonStringify(rawEpoch)}`, 132: ) 133: return null 134: } 135: return { 136: worker_jwt: data.worker_jwt, 137: api_base_url: data.api_base_url, 138: expires_in: data.expires_in, 139: worker_epoch: epoch, 140: } 141: }

File: src/bridge/createSession.ts

typescript 1: import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 2: import { logForDebugging } from '../utils/debug.js' 3: import { errorMessage } from '../utils/errors.js' 4: import { extractErrorDetail } from './debugUtils.js' 5: import { toCompatSessionId } from './sessionIdCompat.js' 6: type GitSource = { 7: type: 'git_repository' 8: url: string 9: revision?: string 10: } 11: type GitOutcome = { 12: type: 'git_repository' 13: git_info: { type: 'github'; repo: string; branches: string[] } 14: } 15: type SessionEvent = { 16: type: 'event' 17: data: SDKMessage 18: } 19: export async function createBridgeSession({ 20: environmentId, 21: title, 22: events, 23: gitRepoUrl, 24: branch, 25: signal, 26: baseUrl: baseUrlOverride, 27: getAccessToken, 28: permissionMode, 29: }: { 30: environmentId: string 31: title?: string 32: events: SessionEvent[] 33: gitRepoUrl: string | null 34: branch: string 35: signal: AbortSignal 36: baseUrl?: string 37: getAccessToken?: () => string | undefined 38: permissionMode?: string 39: }): Promise<string | null> { 40: const { getClaudeAIOAuthTokens } = await import('../utils/auth.js') 41: const { getOrganizationUUID } = await import('../services/oauth/client.js') 42: const { getOauthConfig } = await import('../constants/oauth.js') 43: const { getOAuthHeaders } = await import('../utils/teleport/api.js') 44: const { parseGitHubRepository } = await import('../utils/detectRepository.js') 45: const { getDefaultBranch } = await import('../utils/git.js') 46: const { getMainLoopModel } = await import('../utils/model/model.js') 47: const { default: axios } = await import('axios') 48: const accessToken = 49: getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken 50: if (!accessToken) { 51: logForDebugging('[bridge] No access token for session creation') 52: return null 53: } 54: const orgUUID = await getOrganizationUUID() 55: if (!orgUUID) { 56: logForDebugging('[bridge] No org UUID for session creation') 57: return null 58: } 59: let gitSource: GitSource | null = null 60: let gitOutcome: GitOutcome | null = null 61: if (gitRepoUrl) { 62: const { parseGitRemote } = await import('../utils/detectRepository.js') 63: const parsed = parseGitRemote(gitRepoUrl) 64: if (parsed) { 65: const { host, owner, name } = parsed 66: const revision = branch || (await getDefaultBranch()) || undefined 67: gitSource = { 68: type: 'git_repository', 69: url: `https://${host}/${owner}/${name}`, 70: revision, 71: } 72: gitOutcome = { 73: type: 'git_repository', 74: git_info: { 75: type: 'github', 76: repo: `${owner}/${name}`, 77: branches: [`claude/${branch || 'task'}`], 78: }, 79: } 80: } else { 81: const ownerRepo = parseGitHubRepository(gitRepoUrl) 82: if (ownerRepo) { 83: const [owner, name] = ownerRepo.split('/') 84: if (owner && name) { 85: const revision = branch || (await getDefaultBranch()) || undefined 86: gitSource = { 87: type: 'git_repository', 88: url: `https://github.com/${owner}/${name}`, 89: revision, 90: } 91: gitOutcome = { 92: type: 'git_repository', 93: git_info: { 94: type: 'github', 95: repo: `${owner}/${name}`, 96: branches: [`claude/${branch || 'task'}`], 97: }, 98: } 99: } 100: } 101: } 102: } 103: const requestBody = { 104: ...(title !== undefined && { title }), 105: events, 106: session_context: { 107: sources: gitSource ? [gitSource] : [], 108: outcomes: gitOutcome ? [gitOutcome] : [], 109: model: getMainLoopModel(), 110: }, 111: environment_id: environmentId, 112: source: 'remote-control', 113: ...(permissionMode && { permission_mode: permissionMode }), 114: } 115: const headers = { 116: ...getOAuthHeaders(accessToken), 117: 'anthropic-beta': 'ccr-byoc-2025-07-29', 118: 'x-organization-uuid': orgUUID, 119: } 120: const url = `${baseUrlOverride ?? getOauthConfig().BASE_API_URL}/v1/sessions` 121: let response 122: try { 123: response = await axios.post(url, requestBody, { 124: headers, 125: signal, 126: validateStatus: s => s < 500, 127: }) 128: } catch (err: unknown) { 129: logForDebugging( 130: `[bridge] Session creation request failed: ${errorMessage(err)}`, 131: ) 132: return null 133: } 134: const isSuccess = response.status === 200 || response.status === 201 135: if (!isSuccess) { 136: const detail = extractErrorDetail(response.data) 137: logForDebugging( 138: `[bridge] Session creation failed with status ${response.status}${detail ? `: ${detail}` : ''}`, 139: ) 140: return null 141: } 142: const sessionData: unknown = response.data 143: if ( 144: !sessionData || 145: typeof sessionData !== 'object' || 146: !('id' in sessionData) || 147: typeof sessionData.id !== 'string' 148: ) { 149: logForDebugging('[bridge] No session ID in response') 150: return null 151: } 152: return sessionData.id 153: } 154: export async function getBridgeSession( 155: sessionId: string, 156: opts?: { baseUrl?: string; getAccessToken?: () => string | undefined }, 157: ): Promise<{ environment_id?: string; title?: string } | null> { 158: const { getClaudeAIOAuthTokens } = await import('../utils/auth.js') 159: const { getOrganizationUUID } = await import('../services/oauth/client.js') 160: const { getOauthConfig } = await import('../constants/oauth.js') 161: const { getOAuthHeaders } = await import('../utils/teleport/api.js') 162: const { default: axios } = await import('axios') 163: const accessToken = 164: opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken 165: if (!accessToken) { 166: logForDebugging('[bridge] No access token for session fetch') 167: return null 168: } 169: const orgUUID = await getOrganizationUUID() 170: if (!orgUUID) { 171: logForDebugging('[bridge] No org UUID for session fetch') 172: return null 173: } 174: const headers = { 175: ...getOAuthHeaders(accessToken), 176: 'anthropic-beta': 'ccr-byoc-2025-07-29', 177: 'x-organization-uuid': orgUUID, 178: } 179: const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}` 180: logForDebugging(`[bridge] Fetching session ${sessionId}`) 181: let response 182: try { 183: response = await axios.get<{ environment_id?: string; title?: string }>( 184: url, 185: { headers, timeout: 10_000, validateStatus: s => s < 500 }, 186: ) 187: } catch (err: unknown) { 188: logForDebugging( 189: `[bridge] Session fetch request failed: ${errorMessage(err)}`, 190: ) 191: return null 192: } 193: if (response.status !== 200) { 194: const detail = extractErrorDetail(response.data) 195: logForDebugging( 196: `[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`, 197: ) 198: return null 199: } 200: return response.data 201: } 202: export async function archiveBridgeSession( 203: sessionId: string, 204: opts?: { 205: baseUrl?: string 206: getAccessToken?: () => string | undefined 207: timeoutMs?: number 208: }, 209: ): Promise<void> { 210: const { getClaudeAIOAuthTokens } = await import('../utils/auth.js') 211: const { getOrganizationUUID } = await import('../services/oauth/client.js') 212: const { getOauthConfig } = await import('../constants/oauth.js') 213: const { getOAuthHeaders } = await import('../utils/teleport/api.js') 214: const { default: axios } = await import('axios') 215: const accessToken = 216: opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken 217: if (!accessToken) { 218: logForDebugging('[bridge] No access token for session archive') 219: return 220: } 221: const orgUUID = await getOrganizationUUID() 222: if (!orgUUID) { 223: logForDebugging('[bridge] No org UUID for session archive') 224: return 225: } 226: const headers = { 227: ...getOAuthHeaders(accessToken), 228: 'anthropic-beta': 'ccr-byoc-2025-07-29', 229: 'x-organization-uuid': orgUUID, 230: } 231: const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive` 232: logForDebugging(`[bridge] Archiving session ${sessionId}`) 233: const response = await axios.post( 234: url, 235: {}, 236: { 237: headers, 238: timeout: opts?.timeoutMs ?? 10_000, 239: validateStatus: s => s < 500, 240: }, 241: ) 242: if (response.status === 200) { 243: logForDebugging(`[bridge] Session ${sessionId} archived successfully`) 244: } else { 245: const detail = extractErrorDetail(response.data) 246: logForDebugging( 247: `[bridge] Session archive failed with status ${response.status}${detail ? `: ${detail}` : ''}`, 248: ) 249: } 250: } 251: export async function updateBridgeSessionTitle( 252: sessionId: string, 253: title: string, 254: opts?: { baseUrl?: string; getAccessToken?: () => string | undefined }, 255: ): Promise<void> { 256: const { getClaudeAIOAuthTokens } = await import('../utils/auth.js') 257: const { getOrganizationUUID } = await import('../services/oauth/client.js') 258: const { getOauthConfig } = await import('../constants/oauth.js') 259: const { getOAuthHeaders } = await import('../utils/teleport/api.js') 260: const { default: axios } = await import('axios') 261: const accessToken = 262: opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken 263: if (!accessToken) { 264: logForDebugging('[bridge] No access token for session title update') 265: return 266: } 267: const orgUUID = await getOrganizationUUID() 268: if (!orgUUID) { 269: logForDebugging('[bridge] No org UUID for session title update') 270: return 271: } 272: const headers = { 273: ...getOAuthHeaders(accessToken), 274: 'anthropic-beta': 'ccr-byoc-2025-07-29', 275: 'x-organization-uuid': orgUUID, 276: } 277: const compatId = toCompatSessionId(sessionId) 278: const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${compatId}` 279: logForDebugging(`[bridge] Updating session title: ${compatId} → ${title}`) 280: try { 281: const response = await axios.patch( 282: url, 283: { title }, 284: { headers, timeout: 10_000, validateStatus: s => s < 500 }, 285: ) 286: if (response.status === 200) { 287: logForDebugging(`[bridge] Session title updated successfully`) 288: } else { 289: const detail = extractErrorDetail(response.data) 290: logForDebugging( 291: `[bridge] Session title update failed with status ${response.status}${detail ? `: ${detail}` : ''}`, 292: ) 293: } 294: } catch (err: unknown) { 295: logForDebugging( 296: `[bridge] Session title update request failed: ${errorMessage(err)}`, 297: ) 298: } 299: }

File: src/bridge/debugUtils.ts

typescript 1: import { 2: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3: logEvent, 4: } from '../services/analytics/index.js' 5: import { logForDebugging } from '../utils/debug.js' 6: import { errorMessage } from '../utils/errors.js' 7: import { jsonStringify } from '../utils/slowOperations.js' 8: const DEBUG_MSG_LIMIT = 2000 9: const SECRET_FIELD_NAMES = [ 10: 'session_ingress_token', 11: 'environment_secret', 12: 'access_token', 13: 'secret', 14: 'token', 15: ] 16: const SECRET_PATTERN = new RegExp( 17: `"(${SECRET_FIELD_NAMES.join('|')})"\\s*:\\s*"([^"]*)"`, 18: 'g', 19: ) 20: const REDACT_MIN_LENGTH = 16 21: export function redactSecrets(s: string): string { 22: return s.replace(SECRET_PATTERN, (_match, field: string, value: string) => { 23: if (value.length < REDACT_MIN_LENGTH) { 24: return `"${field}":"[REDACTED]"` 25: } 26: const redacted = `${value.slice(0, 8)}...${value.slice(-4)}` 27: return `"${field}":"${redacted}"` 28: }) 29: } 30: export function debugTruncate(s: string): string { 31: const flat = s.replace(/\n/g, '\\n') 32: if (flat.length <= DEBUG_MSG_LIMIT) { 33: return flat 34: } 35: return flat.slice(0, DEBUG_MSG_LIMIT) + `... (${flat.length} chars)` 36: } 37: export function debugBody(data: unknown): string { 38: const raw = typeof data === 'string' ? data : jsonStringify(data) 39: const s = redactSecrets(raw) 40: if (s.length <= DEBUG_MSG_LIMIT) { 41: return s 42: } 43: return s.slice(0, DEBUG_MSG_LIMIT) + `... (${s.length} chars)` 44: } 45: export function describeAxiosError(err: unknown): string { 46: const msg = errorMessage(err) 47: if (err && typeof err === 'object' && 'response' in err) { 48: const response = (err as { response?: { data?: unknown } }).response 49: if (response?.data && typeof response.data === 'object') { 50: const data = response.data as Record<string, unknown> 51: const detail = 52: typeof data.message === 'string' 53: ? data.message 54: : typeof data.error === 'object' && 55: data.error && 56: 'message' in data.error && 57: typeof (data.error as Record<string, unknown>).message === 58: 'string' 59: ? (data.error as Record<string, unknown>).message 60: : undefined 61: if (detail) { 62: return `${msg}: ${detail}` 63: } 64: } 65: } 66: return msg 67: } 68: export function extractHttpStatus(err: unknown): number | undefined { 69: if ( 70: err && 71: typeof err === 'object' && 72: 'response' in err && 73: (err as { response?: { status?: unknown } }).response && 74: typeof (err as { response: { status?: unknown } }).response.status === 75: 'number' 76: ) { 77: return (err as { response: { status: number } }).response.status 78: } 79: return undefined 80: } 81: export function extractErrorDetail(data: unknown): string | undefined { 82: if (!data || typeof data !== 'object') return undefined 83: if ('message' in data && typeof data.message === 'string') { 84: return data.message 85: } 86: if ( 87: 'error' in data && 88: data.error !== null && 89: typeof data.error === 'object' && 90: 'message' in data.error && 91: typeof data.error.message === 'string' 92: ) { 93: return data.error.message 94: } 95: return undefined 96: } 97: export function logBridgeSkip( 98: reason: string, 99: debugMsg?: string, 100: v2?: boolean, 101: ): void { 102: if (debugMsg) { 103: logForDebugging(debugMsg) 104: } 105: logEvent('tengu_bridge_repl_skipped', { 106: reason: 107: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 108: ...(v2 !== undefined && { v2 }), 109: }) 110: }

File: src/bridge/envLessBridgeConfig.ts

typescript 1: import { z } from 'zod/v4' 2: import { getFeatureValue_DEPRECATED } from '../services/analytics/growthbook.js' 3: import { lazySchema } from '../utils/lazySchema.js' 4: import { lt } from '../utils/semver.js' 5: import { isEnvLessBridgeEnabled } from './bridgeEnabled.js' 6: export type EnvLessBridgeConfig = { 7: init_retry_max_attempts: number 8: init_retry_base_delay_ms: number 9: init_retry_jitter_fraction: number 10: init_retry_max_delay_ms: number 11: http_timeout_ms: number 12: uuid_dedup_buffer_size: number 13: heartbeat_interval_ms: number 14: heartbeat_jitter_fraction: number 15: token_refresh_buffer_ms: number 16: teardown_archive_timeout_ms: number 17: connect_timeout_ms: number 18: min_version: string 19: should_show_app_upgrade_message: boolean 20: } 21: export const DEFAULT_ENV_LESS_BRIDGE_CONFIG: EnvLessBridgeConfig = { 22: init_retry_max_attempts: 3, 23: init_retry_base_delay_ms: 500, 24: init_retry_jitter_fraction: 0.25, 25: init_retry_max_delay_ms: 4000, 26: http_timeout_ms: 10_000, 27: uuid_dedup_buffer_size: 2000, 28: heartbeat_interval_ms: 20_000, 29: heartbeat_jitter_fraction: 0.1, 30: token_refresh_buffer_ms: 300_000, 31: teardown_archive_timeout_ms: 1500, 32: connect_timeout_ms: 15_000, 33: min_version: '0.0.0', 34: should_show_app_upgrade_message: false, 35: } 36: const envLessBridgeConfigSchema = lazySchema(() => 37: z.object({ 38: init_retry_max_attempts: z.number().int().min(1).max(10).default(3), 39: init_retry_base_delay_ms: z.number().int().min(100).default(500), 40: init_retry_jitter_fraction: z.number().min(0).max(1).default(0.25), 41: init_retry_max_delay_ms: z.number().int().min(500).default(4000), 42: http_timeout_ms: z.number().int().min(2000).default(10_000), 43: uuid_dedup_buffer_size: z.number().int().min(100).max(50_000).default(2000), 44: heartbeat_interval_ms: z 45: .number() 46: .int() 47: .min(5000) 48: .max(30_000) 49: .default(20_000), 50: heartbeat_jitter_fraction: z.number().min(0).max(0.5).default(0.1), 51: token_refresh_buffer_ms: z 52: .number() 53: .int() 54: .min(30_000) 55: .max(1_800_000) 56: .default(300_000), 57: teardown_archive_timeout_ms: z 58: .number() 59: .int() 60: .min(500) 61: .max(2000) 62: .default(1500), 63: connect_timeout_ms: z.number().int().min(5_000).max(60_000).default(15_000), 64: min_version: z 65: .string() 66: .refine(v => { 67: try { 68: lt(v, '0.0.0') 69: return true 70: } catch { 71: return false 72: } 73: }) 74: .default('0.0.0'), 75: should_show_app_upgrade_message: z.boolean().default(false), 76: }), 77: ) 78: export async function getEnvLessBridgeConfig(): Promise<EnvLessBridgeConfig> { 79: const raw = await getFeatureValue_DEPRECATED<unknown>( 80: 'tengu_bridge_repl_v2_config', 81: DEFAULT_ENV_LESS_BRIDGE_CONFIG, 82: ) 83: const parsed = envLessBridgeConfigSchema().safeParse(raw) 84: return parsed.success ? parsed.data : DEFAULT_ENV_LESS_BRIDGE_CONFIG 85: } 86: export async function checkEnvLessBridgeMinVersion(): Promise<string | null> { 87: const cfg = await getEnvLessBridgeConfig() 88: if (cfg.min_version && lt(MACRO.VERSION, cfg.min_version)) { 89: return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${cfg.min_version} or higher is required. Run \`claude update\` to update.` 90: } 91: return null 92: } 93: export async function shouldShowAppUpgradeMessage(): Promise<boolean> { 94: if (!isEnvLessBridgeEnabled()) return false 95: const cfg = await getEnvLessBridgeConfig() 96: return cfg.should_show_app_upgrade_message 97: }

File: src/bridge/flushGate.ts

typescript 1: export class FlushGate<T> { 2: private _active = false 3: private _pending: T[] = [] 4: get active(): boolean { 5: return this._active 6: } 7: get pendingCount(): number { 8: return this._pending.length 9: } 10: start(): void { 11: this._active = true 12: } 13: end(): T[] { 14: this._active = false 15: return this._pending.splice(0) 16: } 17: enqueue(...items: T[]): boolean { 18: if (!this._active) return false 19: this._pending.push(...items) 20: return true 21: } 22: drop(): number { 23: this._active = false 24: const count = this._pending.length 25: this._pending.length = 0 26: return count 27: } 28: deactivate(): void { 29: this._active = false 30: } 31: }

File: src/bridge/inboundAttachments.ts

typescript 1: import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' 2: import axios from 'axios' 3: import { randomUUID } from 'crypto' 4: import { mkdir, writeFile } from 'fs/promises' 5: import { basename, join } from 'path' 6: import { z } from 'zod/v4' 7: import { getSessionId } from '../bootstrap/state.js' 8: import { logForDebugging } from '../utils/debug.js' 9: import { getClaudeConfigHomeDir } from '../utils/envUtils.js' 10: import { lazySchema } from '../utils/lazySchema.js' 11: import { getBridgeAccessToken, getBridgeBaseUrl } from './bridgeConfig.js' 12: const DOWNLOAD_TIMEOUT_MS = 30_000 13: function debug(msg: string): void { 14: logForDebugging(`[bridge:inbound-attach] ${msg}`) 15: } 16: const attachmentSchema = lazySchema(() => 17: z.object({ 18: file_uuid: z.string(), 19: file_name: z.string(), 20: }), 21: ) 22: const attachmentsArraySchema = lazySchema(() => z.array(attachmentSchema())) 23: export type InboundAttachment = z.infer<ReturnType<typeof attachmentSchema>> 24: export function extractInboundAttachments(msg: unknown): InboundAttachment[] { 25: if (typeof msg !== 'object' || msg === null || !('file_attachments' in msg)) { 26: return [] 27: } 28: const parsed = attachmentsArraySchema().safeParse(msg.file_attachments) 29: return parsed.success ? parsed.data : [] 30: } 31: function sanitizeFileName(name: string): string { 32: const base = basename(name).replace(/[^a-zA-Z0-9._-]/g, '_') 33: return base || 'attachment' 34: } 35: function uploadsDir(): string { 36: return join(getClaudeConfigHomeDir(), 'uploads', getSessionId()) 37: } 38: async function resolveOne(att: InboundAttachment): Promise<string | undefined> { 39: const token = getBridgeAccessToken() 40: if (!token) { 41: debug('skip: no oauth token') 42: return undefined 43: } 44: let data: Buffer 45: try { 46: const url = `${getBridgeBaseUrl()}/api/oauth/files/${encodeURIComponent(att.file_uuid)}/content` 47: const response = await axios.get(url, { 48: headers: { Authorization: `Bearer ${token}` }, 49: responseType: 'arraybuffer', 50: timeout: DOWNLOAD_TIMEOUT_MS, 51: validateStatus: () => true, 52: }) 53: if (response.status !== 200) { 54: debug(`fetch ${att.file_uuid} failed: status=${response.status}`) 55: return undefined 56: } 57: data = Buffer.from(response.data) 58: } catch (e) { 59: debug(`fetch ${att.file_uuid} threw: ${e}`) 60: return undefined 61: } 62: const safeName = sanitizeFileName(att.file_name) 63: const prefix = ( 64: att.file_uuid.slice(0, 8) || randomUUID().slice(0, 8) 65: ).replace(/[^a-zA-Z0-9_-]/g, '_') 66: const dir = uploadsDir() 67: const outPath = join(dir, `${prefix}-${safeName}`) 68: try { 69: await mkdir(dir, { recursive: true }) 70: await writeFile(outPath, data) 71: } catch (e) { 72: debug(`write ${outPath} failed: ${e}`) 73: return undefined 74: } 75: debug(`resolved ${att.file_uuid} → ${outPath} (${data.length} bytes)`) 76: return outPath 77: } 78: export async function resolveInboundAttachments( 79: attachments: InboundAttachment[], 80: ): Promise<string> { 81: if (attachments.length === 0) return '' 82: debug(`resolving ${attachments.length} attachment(s)`) 83: const paths = await Promise.all(attachments.map(resolveOne)) 84: const ok = paths.filter((p): p is string => p !== undefined) 85: if (ok.length === 0) return '' 86: // Quoted form — extractAtMentionedFiles truncates unquoted @refs at the 87: // first space, which breaks any home dir with spaces (/Users/John Smith/). 88: return ok.map(p => `@"${p}"`).join(' ') + ' ' 89: } 90: /** 91: * Prepend @path refs to content, whichever form it's in. 92: * Targets the LAST text block — processUserInputBase reads inputString 93: * from processedBlocks[processedBlocks.length - 1], so putting refs in 94: * block[0] means they're silently ignored for [text, image] content. 95: */ 96: export function prependPathRefs( 97: content: string | Array<ContentBlockParam>, 98: prefix: string, 99: ): string | Array<ContentBlockParam> { 100: if (!prefix) return content 101: if (typeof content === 'string') return prefix + content 102: const i = content.findLastIndex(b => b.type === 'text') 103: if (i !== -1) { 104: const b = content[i]! 105: if (b.type === 'text') { 106: return [ 107: ...content.slice(0, i), 108: { ...b, text: prefix + b.text }, 109: ...content.slice(i + 1), 110: ] 111: } 112: } 113: return [...content, { type: 'text', text: prefix.trimEnd() }] 114: } 115: export async function resolveAndPrepend( 116: msg: unknown, 117: content: string | Array<ContentBlockParam>, 118: ): Promise<string | Array<ContentBlockParam>> { 119: const attachments = extractInboundAttachments(msg) 120: if (attachments.length === 0) return content 121: const prefix = await resolveInboundAttachments(attachments) 122: return prependPathRefs(content, prefix) 123: }

File: src/bridge/inboundMessages.ts

typescript 1: import type { 2: Base64ImageSource, 3: ContentBlockParam, 4: ImageBlockParam, 5: } from '@anthropic-ai/sdk/resources/messages.mjs' 6: import type { UUID } from 'crypto' 7: import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 8: import { detectImageFormatFromBase64 } from '../utils/imageResizer.js' 9: export function extractInboundMessageFields( 10: msg: SDKMessage, 11: ): 12: | { content: string | Array<ContentBlockParam>; uuid: UUID | undefined } 13: | undefined { 14: if (msg.type !== 'user') return undefined 15: const content = msg.message?.content 16: if (!content) return undefined 17: if (Array.isArray(content) && content.length === 0) return undefined 18: const uuid = 19: 'uuid' in msg && typeof msg.uuid === 'string' 20: ? (msg.uuid as UUID) 21: : undefined 22: return { 23: content: Array.isArray(content) ? normalizeImageBlocks(content) : content, 24: uuid, 25: } 26: } 27: export function normalizeImageBlocks( 28: blocks: Array<ContentBlockParam>, 29: ): Array<ContentBlockParam> { 30: if (!blocks.some(isMalformedBase64Image)) return blocks 31: return blocks.map(block => { 32: if (!isMalformedBase64Image(block)) return block 33: const src = block.source as unknown as Record<string, unknown> 34: const mediaType = 35: typeof src.mediaType === 'string' && src.mediaType 36: ? src.mediaType 37: : detectImageFormatFromBase64(block.source.data) 38: return { 39: ...block, 40: source: { 41: type: 'base64' as const, 42: media_type: mediaType as Base64ImageSource['media_type'], 43: data: block.source.data, 44: }, 45: } 46: }) 47: } 48: function isMalformedBase64Image( 49: block: ContentBlockParam, 50: ): block is ImageBlockParam & { source: Base64ImageSource } { 51: if (block.type !== 'image' || block.source?.type !== 'base64') return false 52: return !(block.source as unknown as Record<string, unknown>).media_type 53: }

File: src/bridge/initReplBridge.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { hostname } from 'os' 3: import { getOriginalCwd, getSessionId } from '../bootstrap/state.js' 4: import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 5: import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js' 6: import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js' 7: import { getOrganizationUUID } from '../services/oauth/client.js' 8: import { 9: isPolicyAllowed, 10: waitForPolicyLimitsToLoad, 11: } from '../services/policyLimits/index.js' 12: import type { Message } from '../types/message.js' 13: import { 14: checkAndRefreshOAuthTokenIfNeeded, 15: getClaudeAIOAuthTokens, 16: handleOAuth401Error, 17: } from '../utils/auth.js' 18: import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' 19: import { logForDebugging } from '../utils/debug.js' 20: import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js' 21: import { errorMessage } from '../utils/errors.js' 22: import { getBranch, getRemoteUrl } from '../utils/git.js' 23: import { toSDKMessages } from '../utils/messages/mappers.js' 24: import { 25: getContentText, 26: getMessagesAfterCompactBoundary, 27: isSyntheticMessage, 28: } from '../utils/messages.js' 29: import type { PermissionMode } from '../utils/permissions/PermissionMode.js' 30: import { getCurrentSessionTitle } from '../utils/sessionStorage.js' 31: import { 32: extractConversationText, 33: generateSessionTitle, 34: } from '../utils/sessionTitle.js' 35: import { generateShortWordSlug } from '../utils/words.js' 36: import { 37: getBridgeAccessToken, 38: getBridgeBaseUrl, 39: getBridgeTokenOverride, 40: } from './bridgeConfig.js' 41: import { 42: checkBridgeMinVersion, 43: isBridgeEnabledBlocking, 44: isCseShimEnabled, 45: isEnvLessBridgeEnabled, 46: } from './bridgeEnabled.js' 47: import { 48: archiveBridgeSession, 49: createBridgeSession, 50: updateBridgeSessionTitle, 51: } from './createSession.js' 52: import { logBridgeSkip } from './debugUtils.js' 53: import { checkEnvLessBridgeMinVersion } from './envLessBridgeConfig.js' 54: import { getPollIntervalConfig } from './pollConfig.js' 55: import type { BridgeState, ReplBridgeHandle } from './replBridge.js' 56: import { initBridgeCore } from './replBridge.js' 57: import { setCseShimGate } from './sessionIdCompat.js' 58: import type { BridgeWorkerType } from './types.js' 59: export type InitBridgeOptions = { 60: onInboundMessage?: (msg: SDKMessage) => void | Promise<void> 61: onPermissionResponse?: (response: SDKControlResponse) => void 62: onInterrupt?: () => void 63: onSetModel?: (model: string | undefined) => void 64: onSetMaxThinkingTokens?: (maxTokens: number | null) => void 65: onSetPermissionMode?: ( 66: mode: PermissionMode, 67: ) => { ok: true } | { ok: false; error: string } 68: onStateChange?: (state: BridgeState, detail?: string) => void 69: initialMessages?: Message[] 70: initialName?: string 71: getMessages?: () => Message[] 72: previouslyFlushedUUIDs?: Set<string> 73: perpetual?: boolean 74: outboundOnly?: boolean 75: tags?: string[] 76: } 77: export async function initReplBridge( 78: options?: InitBridgeOptions, 79: ): Promise<ReplBridgeHandle | null> { 80: const { 81: onInboundMessage, 82: onPermissionResponse, 83: onInterrupt, 84: onSetModel, 85: onSetMaxThinkingTokens, 86: onSetPermissionMode, 87: onStateChange, 88: initialMessages, 89: getMessages, 90: previouslyFlushedUUIDs, 91: initialName, 92: perpetual, 93: outboundOnly, 94: tags, 95: } = options ?? {} 96: setCseShimGate(isCseShimEnabled) 97: if (!(await isBridgeEnabledBlocking())) { 98: logBridgeSkip('not_enabled', '[bridge:repl] Skipping: bridge not enabled') 99: return null 100: } 101: if (!getBridgeAccessToken()) { 102: logBridgeSkip('no_oauth', '[bridge:repl] Skipping: no OAuth tokens') 103: onStateChange?.('failed', '/login') 104: return null 105: } 106: await waitForPolicyLimitsToLoad() 107: if (!isPolicyAllowed('allow_remote_control')) { 108: logBridgeSkip( 109: 'policy_denied', 110: '[bridge:repl] Skipping: allow_remote_control policy not allowed', 111: ) 112: onStateChange?.('failed', "disabled by your organization's policy") 113: return null 114: } 115: if (!getBridgeTokenOverride()) { 116: const cfg = getGlobalConfig() 117: if ( 118: cfg.bridgeOauthDeadExpiresAt != null && 119: (cfg.bridgeOauthDeadFailCount ?? 0) >= 3 && 120: getClaudeAIOAuthTokens()?.expiresAt === cfg.bridgeOauthDeadExpiresAt 121: ) { 122: logForDebugging( 123: `[bridge:repl] Skipping: cross-process backoff (dead token seen ${cfg.bridgeOauthDeadFailCount} times)`, 124: ) 125: return null 126: } 127: await checkAndRefreshOAuthTokenIfNeeded() 128: const tokens = getClaudeAIOAuthTokens() 129: if (tokens && tokens.expiresAt !== null && tokens.expiresAt <= Date.now()) { 130: logBridgeSkip( 131: 'oauth_expired_unrefreshable', 132: '[bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required)', 133: ) 134: onStateChange?.('failed', '/login') 135: const deadExpiresAt = tokens.expiresAt 136: saveGlobalConfig(c => ({ 137: ...c, 138: bridgeOauthDeadExpiresAt: deadExpiresAt, 139: bridgeOauthDeadFailCount: 140: c.bridgeOauthDeadExpiresAt === deadExpiresAt 141: ? (c.bridgeOauthDeadFailCount ?? 0) + 1 142: : 1, 143: })) 144: return null 145: } 146: } 147: const baseUrl = getBridgeBaseUrl() 148: let title = `remote-control-${generateShortWordSlug()}` 149: let hasTitle = false 150: let hasExplicitTitle = false 151: if (initialName) { 152: title = initialName 153: hasTitle = true 154: hasExplicitTitle = true 155: } else { 156: const sessionId = getSessionId() 157: const customTitle = sessionId 158: ? getCurrentSessionTitle(sessionId) 159: : undefined 160: if (customTitle) { 161: title = customTitle 162: hasTitle = true 163: hasExplicitTitle = true 164: } else if (initialMessages && initialMessages.length > 0) { 165: for (let i = initialMessages.length - 1; i >= 0; i--) { 166: const msg = initialMessages[i]! 167: if ( 168: msg.type !== 'user' || 169: msg.isMeta || 170: msg.toolUseResult || 171: msg.isCompactSummary || 172: (msg.origin && msg.origin.kind !== 'human') || 173: isSyntheticMessage(msg) 174: ) 175: continue 176: const rawContent = getContentText(msg.message.content) 177: if (!rawContent) continue 178: const derived = deriveTitle(rawContent) 179: if (!derived) continue 180: title = derived 181: hasTitle = true 182: break 183: } 184: } 185: } 186: let userMessageCount = 0 187: let lastBridgeSessionId: string | undefined 188: let genSeq = 0 189: const patch = ( 190: derived: string, 191: bridgeSessionId: string, 192: atCount: number, 193: ): void => { 194: hasTitle = true 195: title = derived 196: logForDebugging( 197: `[bridge:repl] derived title from message ${atCount}: ${derived}`, 198: ) 199: void updateBridgeSessionTitle(bridgeSessionId, derived, { 200: baseUrl, 201: getAccessToken: getBridgeAccessToken, 202: }).catch(() => {}) 203: } 204: const generateAndPatch = (input: string, bridgeSessionId: string): void => { 205: const gen = ++genSeq 206: const atCount = userMessageCount 207: void generateSessionTitle(input, AbortSignal.timeout(15_000)).then( 208: generated => { 209: if ( 210: generated && 211: gen === genSeq && 212: lastBridgeSessionId === bridgeSessionId && 213: !getCurrentSessionTitle(getSessionId()) 214: ) { 215: patch(generated, bridgeSessionId, atCount) 216: } 217: }, 218: ) 219: } 220: const onUserMessage = (text: string, bridgeSessionId: string): boolean => { 221: if (hasExplicitTitle || getCurrentSessionTitle(getSessionId())) { 222: return true 223: } 224: if ( 225: lastBridgeSessionId !== undefined && 226: lastBridgeSessionId !== bridgeSessionId 227: ) { 228: userMessageCount = 0 229: } 230: lastBridgeSessionId = bridgeSessionId 231: userMessageCount++ 232: if (userMessageCount === 1 && !hasTitle) { 233: const placeholder = deriveTitle(text) 234: if (placeholder) patch(placeholder, bridgeSessionId, userMessageCount) 235: generateAndPatch(text, bridgeSessionId) 236: } else if (userMessageCount === 3) { 237: const msgs = getMessages?.() 238: const input = msgs 239: ? extractConversationText(getMessagesAfterCompactBoundary(msgs)) 240: : text 241: generateAndPatch(input, bridgeSessionId) 242: } 243: return userMessageCount >= 3 244: } 245: const initialHistoryCap = getFeatureValue_CACHED_WITH_REFRESH( 246: 'tengu_bridge_initial_history_cap', 247: 200, 248: 5 * 60 * 1000, 249: ) 250: const orgUUID = await getOrganizationUUID() 251: if (!orgUUID) { 252: logBridgeSkip('no_org_uuid', '[bridge:repl] Skipping: no org UUID') 253: onStateChange?.('failed', '/login') 254: return null 255: } 256: if (isEnvLessBridgeEnabled() && !perpetual) { 257: const versionError = await checkEnvLessBridgeMinVersion() 258: if (versionError) { 259: logBridgeSkip( 260: 'version_too_old', 261: `[bridge:repl] Skipping: ${versionError}`, 262: true, 263: ) 264: onStateChange?.('failed', 'run `claude update` to upgrade') 265: return null 266: } 267: logForDebugging( 268: '[bridge:repl] Using env-less bridge path (tengu_bridge_repl_v2)', 269: ) 270: const { initEnvLessBridgeCore } = await import('./remoteBridgeCore.js') 271: return initEnvLessBridgeCore({ 272: baseUrl, 273: orgUUID, 274: title, 275: getAccessToken: getBridgeAccessToken, 276: onAuth401: handleOAuth401Error, 277: toSDKMessages, 278: initialHistoryCap, 279: initialMessages, 280: onInboundMessage, 281: onUserMessage, 282: onPermissionResponse, 283: onInterrupt, 284: onSetModel, 285: onSetMaxThinkingTokens, 286: onSetPermissionMode, 287: onStateChange, 288: outboundOnly, 289: tags, 290: }) 291: } 292: const versionError = checkBridgeMinVersion() 293: if (versionError) { 294: logBridgeSkip('version_too_old', `[bridge:repl] Skipping: ${versionError}`) 295: onStateChange?.('failed', 'run `claude update` to upgrade') 296: return null 297: } 298: const branch = await getBranch() 299: const gitRepoUrl = await getRemoteUrl() 300: const sessionIngressUrl = 301: process.env.USER_TYPE === 'ant' && 302: process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL 303: ? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL 304: : baseUrl 305: let workerType: BridgeWorkerType = 'claude_code' 306: if (feature('KAIROS')) { 307: const { isAssistantMode } = 308: require('../assistant/index.js') as typeof import('../assistant/index.js') 309: if (isAssistantMode()) { 310: workerType = 'claude_code_assistant' 311: } 312: } 313: return initBridgeCore({ 314: dir: getOriginalCwd(), 315: machineName: hostname(), 316: branch, 317: gitRepoUrl, 318: title, 319: baseUrl, 320: sessionIngressUrl, 321: workerType, 322: getAccessToken: getBridgeAccessToken, 323: createSession: opts => 324: createBridgeSession({ 325: ...opts, 326: events: [], 327: baseUrl, 328: getAccessToken: getBridgeAccessToken, 329: }), 330: archiveSession: sessionId => 331: archiveBridgeSession(sessionId, { 332: baseUrl, 333: getAccessToken: getBridgeAccessToken, 334: timeoutMs: 1500, 335: }).catch((err: unknown) => { 336: logForDebugging( 337: `[bridge:repl] archiveBridgeSession threw: ${errorMessage(err)}`, 338: { level: 'error' }, 339: ) 340: }), 341: getCurrentTitle: () => getCurrentSessionTitle(getSessionId()) ?? title, 342: onUserMessage, 343: toSDKMessages, 344: onAuth401: handleOAuth401Error, 345: getPollIntervalConfig, 346: initialHistoryCap, 347: initialMessages, 348: previouslyFlushedUUIDs, 349: onInboundMessage, 350: onPermissionResponse, 351: onInterrupt, 352: onSetModel, 353: onSetMaxThinkingTokens, 354: onSetPermissionMode, 355: onStateChange, 356: perpetual, 357: }) 358: } 359: const TITLE_MAX_LEN = 50 360: function deriveTitle(raw: string): string | undefined { 361: const clean = stripDisplayTagsAllowEmpty(raw) 362: const firstSentence = /^(.*?[.!?])\s/.exec(clean)?.[1] ?? clean 363: const flat = firstSentence.replace(/\s+/g, ' ').trim() 364: if (!flat) return undefined 365: return flat.length > TITLE_MAX_LEN 366: ? flat.slice(0, TITLE_MAX_LEN - 1) + '\u2026' 367: : flat 368: }

File: src/bridge/jwtUtils.ts

typescript 1: import { logEvent } from '../services/analytics/index.js' 2: import { logForDebugging } from '../utils/debug.js' 3: import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' 4: import { errorMessage } from '../utils/errors.js' 5: import { jsonParse } from '../utils/slowOperations.js' 6: function formatDuration(ms: number): string { 7: if (ms < 60_000) return `${Math.round(ms / 1000)}s` 8: const m = Math.floor(ms / 60_000) 9: const s = Math.round((ms % 60_000) / 1000) 10: return s > 0 ? `${m}m ${s}s` : `${m}m` 11: } 12: export function decodeJwtPayload(token: string): unknown | null { 13: const jwt = token.startsWith('sk-ant-si-') 14: ? token.slice('sk-ant-si-'.length) 15: : token 16: const parts = jwt.split('.') 17: if (parts.length !== 3 || !parts[1]) return null 18: try { 19: return jsonParse(Buffer.from(parts[1], 'base64url').toString('utf8')) 20: } catch { 21: return null 22: } 23: } 24: export function decodeJwtExpiry(token: string): number | null { 25: const payload = decodeJwtPayload(token) 26: if ( 27: payload !== null && 28: typeof payload === 'object' && 29: 'exp' in payload && 30: typeof payload.exp === 'number' 31: ) { 32: return payload.exp 33: } 34: return null 35: } 36: const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000 37: const FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000 38: const MAX_REFRESH_FAILURES = 3 39: const REFRESH_RETRY_DELAY_MS = 60_000 40: export function createTokenRefreshScheduler({ 41: getAccessToken, 42: onRefresh, 43: label, 44: refreshBufferMs = TOKEN_REFRESH_BUFFER_MS, 45: }: { 46: getAccessToken: () => string | undefined | Promise<string | undefined> 47: onRefresh: (sessionId: string, oauthToken: string) => void 48: label: string 49: refreshBufferMs?: number 50: }): { 51: schedule: (sessionId: string, token: string) => void 52: scheduleFromExpiresIn: (sessionId: string, expiresInSeconds: number) => void 53: cancel: (sessionId: string) => void 54: cancelAll: () => void 55: } { 56: const timers = new Map<string, ReturnType<typeof setTimeout>>() 57: const failureCounts = new Map<string, number>() 58: const generations = new Map<string, number>() 59: function nextGeneration(sessionId: string): number { 60: const gen = (generations.get(sessionId) ?? 0) + 1 61: generations.set(sessionId, gen) 62: return gen 63: } 64: function schedule(sessionId: string, token: string): void { 65: const expiry = decodeJwtExpiry(token) 66: if (!expiry) { 67: logForDebugging( 68: `[${label}:token] Could not decode JWT expiry for sessionId=${sessionId}, token prefix=${token.slice(0, 15)}…, keeping existing timer`, 69: ) 70: return 71: } 72: const existing = timers.get(sessionId) 73: if (existing) { 74: clearTimeout(existing) 75: } 76: const gen = nextGeneration(sessionId) 77: const expiryDate = new Date(expiry * 1000).toISOString() 78: const delayMs = expiry * 1000 - Date.now() - refreshBufferMs 79: if (delayMs <= 0) { 80: logForDebugging( 81: `[${label}:token] Token for sessionId=${sessionId} expires=${expiryDate} (past or within buffer), refreshing immediately`, 82: ) 83: void doRefresh(sessionId, gen) 84: return 85: } 86: logForDebugging( 87: `[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires=${expiryDate}, buffer=${refreshBufferMs / 1000}s)`, 88: ) 89: const timer = setTimeout(doRefresh, delayMs, sessionId, gen) 90: timers.set(sessionId, timer) 91: } 92: function scheduleFromExpiresIn( 93: sessionId: string, 94: expiresInSeconds: number, 95: ): void { 96: const existing = timers.get(sessionId) 97: if (existing) clearTimeout(existing) 98: const gen = nextGeneration(sessionId) 99: const delayMs = Math.max(expiresInSeconds * 1000 - refreshBufferMs, 30_000) 100: logForDebugging( 101: `[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires_in=${expiresInSeconds}s, buffer=${refreshBufferMs / 1000}s)`, 102: ) 103: const timer = setTimeout(doRefresh, delayMs, sessionId, gen) 104: timers.set(sessionId, timer) 105: } 106: async function doRefresh(sessionId: string, gen: number): Promise<void> { 107: let oauthToken: string | undefined 108: try { 109: oauthToken = await getAccessToken() 110: } catch (err) { 111: logForDebugging( 112: `[${label}:token] getAccessToken threw for sessionId=${sessionId}: ${errorMessage(err)}`, 113: { level: 'error' }, 114: ) 115: } 116: if (generations.get(sessionId) !== gen) { 117: logForDebugging( 118: `[${label}:token] doRefresh for sessionId=${sessionId} stale (gen ${gen} vs ${generations.get(sessionId)}), skipping`, 119: ) 120: return 121: } 122: if (!oauthToken) { 123: const failures = (failureCounts.get(sessionId) ?? 0) + 1 124: failureCounts.set(sessionId, failures) 125: logForDebugging( 126: `[${label}:token] No OAuth token available for refresh, sessionId=${sessionId} (failure ${failures}/${MAX_REFRESH_FAILURES})`, 127: { level: 'error' }, 128: ) 129: logForDiagnosticsNoPII('error', 'bridge_token_refresh_no_oauth') 130: if (failures < MAX_REFRESH_FAILURES) { 131: const retryTimer = setTimeout( 132: doRefresh, 133: REFRESH_RETRY_DELAY_MS, 134: sessionId, 135: gen, 136: ) 137: timers.set(sessionId, retryTimer) 138: } 139: return 140: } 141: failureCounts.delete(sessionId) 142: logForDebugging( 143: `[${label}:token] Refreshing token for sessionId=${sessionId}: new token prefix=${oauthToken.slice(0, 15)}…`, 144: ) 145: logEvent('tengu_bridge_token_refreshed', {}) 146: onRefresh(sessionId, oauthToken) 147: const timer = setTimeout( 148: doRefresh, 149: FALLBACK_REFRESH_INTERVAL_MS, 150: sessionId, 151: gen, 152: ) 153: timers.set(sessionId, timer) 154: logForDebugging( 155: `[${label}:token] Scheduled follow-up refresh for sessionId=${sessionId} in ${formatDuration(FALLBACK_REFRESH_INTERVAL_MS)}`, 156: ) 157: } 158: function cancel(sessionId: string): void { 159: nextGeneration(sessionId) 160: const timer = timers.get(sessionId) 161: if (timer) { 162: clearTimeout(timer) 163: timers.delete(sessionId) 164: } 165: failureCounts.delete(sessionId) 166: } 167: function cancelAll(): void { 168: for (const sessionId of generations.keys()) { 169: nextGeneration(sessionId) 170: } 171: for (const timer of timers.values()) { 172: clearTimeout(timer) 173: } 174: timers.clear() 175: failureCounts.clear() 176: } 177: return { schedule, scheduleFromExpiresIn, cancel, cancelAll } 178: }

File: src/bridge/pollConfig.ts

typescript 1: import { z } from 'zod/v4' 2: import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js' 3: import { lazySchema } from '../utils/lazySchema.js' 4: import { 5: DEFAULT_POLL_CONFIG, 6: type PollIntervalConfig, 7: } from './pollConfigDefaults.js' 8: const zeroOrAtLeast100 = { 9: message: 'must be 0 (disabled) or ≥100ms', 10: } 11: const pollIntervalConfigSchema = lazySchema(() => 12: z 13: .object({ 14: poll_interval_ms_not_at_capacity: z.number().int().min(100), 15: poll_interval_ms_at_capacity: z 16: .number() 17: .int() 18: .refine(v => v === 0 || v >= 100, zeroOrAtLeast100), 19: non_exclusive_heartbeat_interval_ms: z.number().int().min(0).default(0), 20: multisession_poll_interval_ms_not_at_capacity: z 21: .number() 22: .int() 23: .min(100) 24: .default( 25: DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_not_at_capacity, 26: ), 27: multisession_poll_interval_ms_partial_capacity: z 28: .number() 29: .int() 30: .min(100) 31: .default( 32: DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_partial_capacity, 33: ), 34: multisession_poll_interval_ms_at_capacity: z 35: .number() 36: .int() 37: .refine(v => v === 0 || v >= 100, zeroOrAtLeast100) 38: .default(DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_at_capacity), 39: reclaim_older_than_ms: z.number().int().min(1).default(5000), 40: session_keepalive_interval_v2_ms: z 41: .number() 42: .int() 43: .min(0) 44: .default(120_000), 45: }) 46: .refine( 47: cfg => 48: cfg.non_exclusive_heartbeat_interval_ms > 0 || 49: cfg.poll_interval_ms_at_capacity > 0, 50: { 51: message: 52: 'at-capacity liveness requires non_exclusive_heartbeat_interval_ms > 0 or poll_interval_ms_at_capacity > 0', 53: }, 54: ) 55: .refine( 56: cfg => 57: cfg.non_exclusive_heartbeat_interval_ms > 0 || 58: cfg.multisession_poll_interval_ms_at_capacity > 0, 59: { 60: message: 61: 'at-capacity liveness requires non_exclusive_heartbeat_interval_ms > 0 or multisession_poll_interval_ms_at_capacity > 0', 62: }, 63: ), 64: ) 65: export function getPollIntervalConfig(): PollIntervalConfig { 66: const raw = getFeatureValue_CACHED_WITH_REFRESH<unknown>( 67: 'tengu_bridge_poll_interval_config', 68: DEFAULT_POLL_CONFIG, 69: 5 * 60 * 1000, 70: ) 71: const parsed = pollIntervalConfigSchema().safeParse(raw) 72: return parsed.success ? parsed.data : DEFAULT_POLL_CONFIG 73: }

File: src/bridge/pollConfigDefaults.ts

typescript 1: const POLL_INTERVAL_MS_NOT_AT_CAPACITY = 2000 2: const POLL_INTERVAL_MS_AT_CAPACITY = 600_000 3: const MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY = 4: POLL_INTERVAL_MS_NOT_AT_CAPACITY 5: const MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY = 6: POLL_INTERVAL_MS_NOT_AT_CAPACITY 7: const MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY = POLL_INTERVAL_MS_AT_CAPACITY 8: export type PollIntervalConfig = { 9: poll_interval_ms_not_at_capacity: number 10: poll_interval_ms_at_capacity: number 11: non_exclusive_heartbeat_interval_ms: number 12: multisession_poll_interval_ms_not_at_capacity: number 13: multisession_poll_interval_ms_partial_capacity: number 14: multisession_poll_interval_ms_at_capacity: number 15: reclaim_older_than_ms: number 16: session_keepalive_interval_v2_ms: number 17: } 18: export const DEFAULT_POLL_CONFIG: PollIntervalConfig = { 19: poll_interval_ms_not_at_capacity: POLL_INTERVAL_MS_NOT_AT_CAPACITY, 20: poll_interval_ms_at_capacity: POLL_INTERVAL_MS_AT_CAPACITY, 21: non_exclusive_heartbeat_interval_ms: 0, 22: multisession_poll_interval_ms_not_at_capacity: 23: MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY, 24: multisession_poll_interval_ms_partial_capacity: 25: MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY, 26: multisession_poll_interval_ms_at_capacity: 27: MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY, 28: reclaim_older_than_ms: 5000, 29: session_keepalive_interval_v2_ms: 120_000, 30: }

File: src/bridge/remoteBridgeCore.ts

typescript 1: import { feature } from 'bun:bundle' 2: import axios from 'axios' 3: import { 4: createV2ReplTransport, 5: type ReplBridgeTransport, 6: } from './replBridgeTransport.js' 7: import { buildCCRv2SdkUrl } from './workSecret.js' 8: import { toCompatSessionId } from './sessionIdCompat.js' 9: import { FlushGate } from './flushGate.js' 10: import { createTokenRefreshScheduler } from './jwtUtils.js' 11: import { getTrustedDeviceToken } from './trustedDevice.js' 12: import { 13: getEnvLessBridgeConfig, 14: type EnvLessBridgeConfig, 15: } from './envLessBridgeConfig.js' 16: import { 17: handleIngressMessage, 18: handleServerControlRequest, 19: makeResultMessage, 20: isEligibleBridgeMessage, 21: extractTitleText, 22: BoundedUUIDSet, 23: } from './bridgeMessaging.js' 24: import { logBridgeSkip } from './debugUtils.js' 25: import { logForDebugging } from '../utils/debug.js' 26: import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' 27: import { isInProtectedNamespace } from '../utils/envUtils.js' 28: import { errorMessage } from '../utils/errors.js' 29: import { sleep } from '../utils/sleep.js' 30: import { registerCleanup } from '../utils/cleanupRegistry.js' 31: import { 32: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 33: logEvent, 34: } from '../services/analytics/index.js' 35: import type { ReplBridgeHandle, BridgeState } from './replBridge.js' 36: import type { Message } from '../types/message.js' 37: import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 38: import type { 39: SDKControlRequest, 40: SDKControlResponse, 41: } from '../entrypoints/sdk/controlTypes.js' 42: import type { PermissionMode } from '../utils/permissions/PermissionMode.js' 43: const ANTHROPIC_VERSION = '2023-06-01' 44: type ConnectCause = 'initial' | 'proactive_refresh' | 'auth_401_recovery' 45: function oauthHeaders(accessToken: string): Record<string, string> { 46: return { 47: Authorization: `Bearer ${accessToken}`, 48: 'Content-Type': 'application/json', 49: 'anthropic-version': ANTHROPIC_VERSION, 50: } 51: } 52: export type EnvLessBridgeParams = { 53: baseUrl: string 54: orgUUID: string 55: title: string 56: getAccessToken: () => string | undefined 57: onAuth401?: (staleAccessToken: string) => Promise<boolean> 58: toSDKMessages: (messages: Message[]) => SDKMessage[] 59: initialHistoryCap: number 60: initialMessages?: Message[] 61: onInboundMessage?: (msg: SDKMessage) => void | Promise<void> 62: onUserMessage?: (text: string, sessionId: string) => boolean 63: onPermissionResponse?: (response: SDKControlResponse) => void 64: onInterrupt?: () => void 65: onSetModel?: (model: string | undefined) => void 66: onSetMaxThinkingTokens?: (maxTokens: number | null) => void 67: onSetPermissionMode?: ( 68: mode: PermissionMode, 69: ) => { ok: true } | { ok: false; error: string } 70: onStateChange?: (state: BridgeState, detail?: string) => void 71: outboundOnly?: boolean 72: tags?: string[] 73: } 74: export async function initEnvLessBridgeCore( 75: params: EnvLessBridgeParams, 76: ): Promise<ReplBridgeHandle | null> { 77: const { 78: baseUrl, 79: orgUUID, 80: title, 81: getAccessToken, 82: onAuth401, 83: toSDKMessages, 84: initialHistoryCap, 85: initialMessages, 86: onInboundMessage, 87: onUserMessage, 88: onPermissionResponse, 89: onInterrupt, 90: onSetModel, 91: onSetMaxThinkingTokens, 92: onSetPermissionMode, 93: onStateChange, 94: outboundOnly, 95: tags, 96: } = params 97: const cfg = await getEnvLessBridgeConfig() 98: const accessToken = getAccessToken() 99: if (!accessToken) { 100: logForDebugging('[remote-bridge] No OAuth token') 101: return null 102: } 103: const createdSessionId = await withRetry( 104: () => 105: createCodeSession(baseUrl, accessToken, title, cfg.http_timeout_ms, tags), 106: 'createCodeSession', 107: cfg, 108: ) 109: if (!createdSessionId) { 110: onStateChange?.('failed', 'Session creation failed — see debug log') 111: logBridgeSkip('v2_session_create_failed', undefined, true) 112: return null 113: } 114: const sessionId: string = createdSessionId 115: logForDebugging(`[remote-bridge] Created session ${sessionId}`) 116: logForDiagnosticsNoPII('info', 'bridge_repl_v2_session_created') 117: const credentials = await withRetry( 118: () => 119: fetchRemoteCredentials( 120: sessionId, 121: baseUrl, 122: accessToken, 123: cfg.http_timeout_ms, 124: ), 125: 'fetchRemoteCredentials', 126: cfg, 127: ) 128: if (!credentials) { 129: onStateChange?.('failed', 'Remote credentials fetch failed — see debug log') 130: logBridgeSkip('v2_remote_creds_failed', undefined, true) 131: void archiveSession( 132: sessionId, 133: baseUrl, 134: accessToken, 135: orgUUID, 136: cfg.http_timeout_ms, 137: ) 138: return null 139: } 140: logForDebugging( 141: `[remote-bridge] Fetched bridge credentials (expires_in=${credentials.expires_in}s)`, 142: ) 143: const sessionUrl = buildCCRv2SdkUrl(credentials.api_base_url, sessionId) 144: logForDebugging(`[remote-bridge] v2 session URL: ${sessionUrl}`) 145: let transport: ReplBridgeTransport 146: try { 147: transport = await createV2ReplTransport({ 148: sessionUrl, 149: ingressToken: credentials.worker_jwt, 150: sessionId, 151: epoch: credentials.worker_epoch, 152: heartbeatIntervalMs: cfg.heartbeat_interval_ms, 153: heartbeatJitterFraction: cfg.heartbeat_jitter_fraction, 154: getAuthToken: () => credentials.worker_jwt, 155: outboundOnly, 156: }) 157: } catch (err) { 158: logForDebugging( 159: `[remote-bridge] v2 transport setup failed: ${errorMessage(err)}`, 160: { level: 'error' }, 161: ) 162: onStateChange?.('failed', `Transport setup failed: ${errorMessage(err)}`) 163: logBridgeSkip('v2_transport_setup_failed', undefined, true) 164: void archiveSession( 165: sessionId, 166: baseUrl, 167: accessToken, 168: orgUUID, 169: cfg.http_timeout_ms, 170: ) 171: return null 172: } 173: logForDebugging( 174: `[remote-bridge] v2 transport created (epoch=${credentials.worker_epoch})`, 175: ) 176: onStateChange?.('ready') 177: const recentPostedUUIDs = new BoundedUUIDSet(cfg.uuid_dedup_buffer_size) 178: const initialMessageUUIDs = new Set<string>() 179: if (initialMessages) { 180: for (const msg of initialMessages) { 181: initialMessageUUIDs.add(msg.uuid) 182: recentPostedUUIDs.add(msg.uuid) 183: } 184: } 185: const recentInboundUUIDs = new BoundedUUIDSet(cfg.uuid_dedup_buffer_size) 186: const flushGate = new FlushGate<Message>() 187: let initialFlushDone = false 188: let tornDown = false 189: let authRecoveryInFlight = false 190: let userMessageCallbackDone = !onUserMessage 191: let connectCause: ConnectCause = 'initial' 192: let connectDeadline: ReturnType<typeof setTimeout> | undefined 193: function onConnectTimeout(cause: ConnectCause): void { 194: if (tornDown) return 195: logEvent('tengu_bridge_repl_connect_timeout', { 196: v2: true, 197: elapsed_ms: cfg.connect_timeout_ms, 198: cause: 199: cause as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 200: }) 201: } 202: const refresh = createTokenRefreshScheduler({ 203: refreshBufferMs: cfg.token_refresh_buffer_ms, 204: getAccessToken: async () => { 205: const stale = getAccessToken() 206: if (onAuth401) await onAuth401(stale ?? '') 207: return getAccessToken() ?? stale 208: }, 209: onRefresh: (sid, oauthToken) => { 210: void (async () => { 211: // Laptop wake: overdue proactive timer + SSE 401 fire ~simultaneously. 212: // Claim the flag BEFORE the /bridge fetch so the other path skips 213: // entirely — prevents double epoch bump (each /bridge call bumps; if 214: // both fetch, the first rebuild gets a stale epoch and 409s). 215: if (authRecoveryInFlight || tornDown) { 216: logForDebugging( 217: '[remote-bridge] Recovery already in flight, skipping proactive refresh', 218: ) 219: return 220: } 221: authRecoveryInFlight = true 222: try { 223: const fresh = await withRetry( 224: () => 225: fetchRemoteCredentials( 226: sid, 227: baseUrl, 228: oauthToken, 229: cfg.http_timeout_ms, 230: ), 231: 'fetchRemoteCredentials (proactive)', 232: cfg, 233: ) 234: if (!fresh || tornDown) return 235: await rebuildTransport(fresh, 'proactive_refresh') 236: logForDebugging( 237: '[remote-bridge] Transport rebuilt (proactive refresh)', 238: ) 239: } catch (err) { 240: logForDebugging( 241: `[remote-bridge] Proactive refresh rebuild failed: ${errorMessage(err)}`, 242: { level: 'error' }, 243: ) 244: logForDiagnosticsNoPII( 245: 'error', 246: 'bridge_repl_v2_proactive_refresh_failed', 247: ) 248: if (!tornDown) { 249: onStateChange?.('failed', `Refresh failed: ${errorMessage(err)}`) 250: } 251: } finally { 252: authRecoveryInFlight = false 253: } 254: })() 255: }, 256: label: 'remote', 257: }) 258: refresh.scheduleFromExpiresIn(sessionId, credentials.expires_in) 259: function wireTransportCallbacks(): void { 260: transport.setOnConnect(() => { 261: clearTimeout(connectDeadline) 262: logForDebugging('[remote-bridge] v2 transport connected') 263: logForDiagnosticsNoPII('info', 'bridge_repl_v2_transport_connected') 264: logEvent('tengu_bridge_repl_ws_connected', { 265: v2: true, 266: cause: 267: connectCause as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 268: }) 269: if (!initialFlushDone && initialMessages && initialMessages.length > 0) { 270: initialFlushDone = true 271: const flushTransport = transport 272: void flushHistory(initialMessages) 273: .catch(e => 274: logForDebugging(`[remote-bridge] flushHistory failed: ${e}`), 275: ) 276: .finally(() => { 277: if ( 278: transport !== flushTransport || 279: tornDown || 280: authRecoveryInFlight 281: ) { 282: return 283: } 284: drainFlushGate() 285: onStateChange?.('connected') 286: }) 287: } else if (!flushGate.active) { 288: onStateChange?.('connected') 289: } 290: }) 291: transport.setOnData((data: string) => { 292: handleIngressMessage( 293: data, 294: recentPostedUUIDs, 295: recentInboundUUIDs, 296: onInboundMessage, 297: onPermissionResponse 298: ? res => { 299: transport.reportState('running') 300: onPermissionResponse(res) 301: } 302: : undefined, 303: req => 304: handleServerControlRequest(req, { 305: transport, 306: sessionId, 307: onInterrupt, 308: onSetModel, 309: onSetMaxThinkingTokens, 310: onSetPermissionMode, 311: outboundOnly, 312: }), 313: ) 314: }) 315: transport.setOnClose((code?: number) => { 316: clearTimeout(connectDeadline) 317: if (tornDown) return 318: logForDebugging(`[remote-bridge] v2 transport closed (code=${code})`) 319: logEvent('tengu_bridge_repl_ws_closed', { code, v2: true }) 320: if (code === 401 && !authRecoveryInFlight) { 321: void recoverFromAuthFailure() 322: return 323: } 324: onStateChange?.('failed', `Transport closed (code ${code})`) 325: }) 326: } 327: async function rebuildTransport( 328: fresh: RemoteCredentials, 329: cause: Exclude<ConnectCause, 'initial'>, 330: ): Promise<void> { 331: connectCause = cause 332: flushGate.start() 333: try { 334: const seq = transport.getLastSequenceNum() 335: transport.close() 336: transport = await createV2ReplTransport({ 337: sessionUrl: buildCCRv2SdkUrl(fresh.api_base_url, sessionId), 338: ingressToken: fresh.worker_jwt, 339: sessionId, 340: epoch: fresh.worker_epoch, 341: heartbeatIntervalMs: cfg.heartbeat_interval_ms, 342: heartbeatJitterFraction: cfg.heartbeat_jitter_fraction, 343: initialSequenceNum: seq, 344: getAuthToken: () => fresh.worker_jwt, 345: outboundOnly, 346: }) 347: if (tornDown) { 348: transport.close() 349: return 350: } 351: wireTransportCallbacks() 352: transport.connect() 353: connectDeadline = setTimeout( 354: onConnectTimeout, 355: cfg.connect_timeout_ms, 356: connectCause, 357: ) 358: refresh.scheduleFromExpiresIn(sessionId, fresh.expires_in) 359: drainFlushGate() 360: } finally { 361: flushGate.drop() 362: } 363: } 364: async function recoverFromAuthFailure(): Promise<void> { 365: if (authRecoveryInFlight) return 366: authRecoveryInFlight = true 367: onStateChange?.('reconnecting', 'JWT expired — refreshing') 368: logForDebugging('[remote-bridge] 401 on SSE — attempting JWT refresh') 369: try { 370: const stale = getAccessToken() 371: if (onAuth401) await onAuth401(stale ?? '') 372: const oauthToken = getAccessToken() ?? stale 373: if (!oauthToken || tornDown) { 374: if (!tornDown) { 375: onStateChange?.('failed', 'JWT refresh failed: no OAuth token') 376: } 377: return 378: } 379: const fresh = await withRetry( 380: () => 381: fetchRemoteCredentials( 382: sessionId, 383: baseUrl, 384: oauthToken, 385: cfg.http_timeout_ms, 386: ), 387: 'fetchRemoteCredentials (recovery)', 388: cfg, 389: ) 390: if (!fresh || tornDown) { 391: if (!tornDown) { 392: onStateChange?.('failed', 'JWT refresh failed after 401') 393: } 394: return 395: } 396: initialFlushDone = false 397: await rebuildTransport(fresh, 'auth_401_recovery') 398: logForDebugging('[remote-bridge] Transport rebuilt after 401') 399: } catch (err) { 400: logForDebugging( 401: `[remote-bridge] 401 recovery failed: ${errorMessage(err)}`, 402: { level: 'error' }, 403: ) 404: logForDiagnosticsNoPII('error', 'bridge_repl_v2_jwt_refresh_failed') 405: if (!tornDown) { 406: onStateChange?.('failed', `JWT refresh failed: ${errorMessage(err)}`) 407: } 408: } finally { 409: authRecoveryInFlight = false 410: } 411: } 412: wireTransportCallbacks() 413: if (initialMessages && initialMessages.length > 0) { 414: flushGate.start() 415: } 416: transport.connect() 417: connectDeadline = setTimeout( 418: onConnectTimeout, 419: cfg.connect_timeout_ms, 420: connectCause, 421: ) 422: function drainFlushGate(): void { 423: const msgs = flushGate.end() 424: if (msgs.length === 0) return 425: for (const msg of msgs) recentPostedUUIDs.add(msg.uuid) 426: const events = toSDKMessages(msgs).map(m => ({ 427: ...m, 428: session_id: sessionId, 429: })) 430: if (msgs.some(m => m.type === 'user')) { 431: transport.reportState('running') 432: } 433: logForDebugging( 434: `[remote-bridge] Drained ${msgs.length} queued message(s) after flush`, 435: ) 436: void transport.writeBatch(events) 437: } 438: async function flushHistory(msgs: Message[]): Promise<void> { 439: const eligible = msgs.filter(isEligibleBridgeMessage) 440: const capped = 441: initialHistoryCap > 0 && eligible.length > initialHistoryCap 442: ? eligible.slice(-initialHistoryCap) 443: : eligible 444: if (capped.length < eligible.length) { 445: logForDebugging( 446: `[remote-bridge] Capped initial flush: ${eligible.length} -> ${capped.length} (cap=${initialHistoryCap})`, 447: ) 448: } 449: const events = toSDKMessages(capped).map(m => ({ 450: ...m, 451: session_id: sessionId, 452: })) 453: if (events.length === 0) return 454: if (eligible.at(-1)?.type === 'user') { 455: transport.reportState('running') 456: } 457: logForDebugging(`[remote-bridge] Flushing ${events.length} history events`) 458: await transport.writeBatch(events) 459: } 460: async function teardown(): Promise<void> { 461: if (tornDown) return 462: tornDown = true 463: refresh.cancelAll() 464: clearTimeout(connectDeadline) 465: flushGate.drop() 466: transport.reportState('idle') 467: void transport.write(makeResultMessage(sessionId)) 468: let token = getAccessToken() 469: let status = await archiveSession( 470: sessionId, 471: baseUrl, 472: token, 473: orgUUID, 474: cfg.teardown_archive_timeout_ms, 475: ) 476: if (status === 401 && onAuth401) { 477: try { 478: await onAuth401(token ?? '') 479: token = getAccessToken() 480: status = await archiveSession( 481: sessionId, 482: baseUrl, 483: token, 484: orgUUID, 485: cfg.teardown_archive_timeout_ms, 486: ) 487: } catch (err) { 488: logForDebugging( 489: `[remote-bridge] Teardown 401 retry threw: ${errorMessage(err)}`, 490: { level: 'error' }, 491: ) 492: } 493: } 494: transport.close() 495: const archiveStatus: ArchiveTelemetryStatus = 496: status === 'no_token' 497: ? 'skipped_no_token' 498: : status === 'timeout' || status === 'error' 499: ? 'network_error' 500: : status >= 500 501: ? 'server_5xx' 502: : status >= 400 503: ? 'server_4xx' 504: : 'ok' 505: logForDebugging(`[remote-bridge] Torn down (archive=${status})`) 506: logForDiagnosticsNoPII('info', 'bridge_repl_v2_teardown') 507: logEvent( 508: feature('CCR_MIRROR') && outboundOnly 509: ? 'tengu_ccr_mirror_teardown' 510: : 'tengu_bridge_repl_teardown', 511: { 512: v2: true, 513: archive_status: 514: archiveStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 515: archive_ok: typeof status === 'number' && status < 400, 516: archive_http_status: typeof status === 'number' ? status : undefined, 517: archive_timeout: status === 'timeout', 518: archive_no_token: status === 'no_token', 519: }, 520: ) 521: } 522: const unregister = registerCleanup(teardown) 523: if (feature('CCR_MIRROR') && outboundOnly) { 524: logEvent('tengu_ccr_mirror_started', { 525: v2: true, 526: expires_in_s: credentials.expires_in, 527: }) 528: } else { 529: logEvent('tengu_bridge_repl_started', { 530: has_initial_messages: !!(initialMessages && initialMessages.length > 0), 531: v2: true, 532: expires_in_s: credentials.expires_in, 533: inProtectedNamespace: isInProtectedNamespace(), 534: }) 535: } 536: return { 537: bridgeSessionId: sessionId, 538: environmentId: '', 539: sessionIngressUrl: credentials.api_base_url, 540: writeMessages(messages) { 541: const filtered = messages.filter( 542: m => 543: isEligibleBridgeMessage(m) && 544: !initialMessageUUIDs.has(m.uuid) && 545: !recentPostedUUIDs.has(m.uuid), 546: ) 547: if (filtered.length === 0) return 548: // Fire onUserMessage for title derivation. Scan before the flushGate 549: // check — prompts are title-worthy even if they queue. Keeps calling 550: // on every title-worthy message until the callback returns true; the 551: // caller owns the policy (derive at 1st and 3rd, skip if explicit). 552: if (!userMessageCallbackDone) { 553: for (const m of filtered) { 554: const text = extractTitleText(m) 555: if (text !== undefined && onUserMessage?.(text, sessionId)) { 556: userMessageCallbackDone = true 557: break 558: } 559: } 560: } 561: if (flushGate.enqueue(...filtered)) { 562: logForDebugging( 563: `[remote-bridge] Queued ${filtered.length} message(s) during flush`, 564: ) 565: return 566: } 567: for (const msg of filtered) recentPostedUUIDs.add(msg.uuid) 568: const events = toSDKMessages(filtered).map(m => ({ 569: ...m, 570: session_id: sessionId, 571: })) 572: // v2 does not derive worker_status from events server-side (unlike v1 573: // session-ingress session_status_updater.go). Push it from here so the 574: // CCR web session list shows Running instead of stuck on Idle. A user 575: // message in the batch marks turn start. CCRClient.reportState dedupes 576: // consecutive same-state pushes. 577: if (filtered.some(m => m.type === 'user')) { 578: transport.reportState('running') 579: } 580: logForDebugging(`[remote-bridge] Sending ${filtered.length} message(s)`) 581: void transport.writeBatch(events) 582: }, 583: writeSdkMessages(messages: SDKMessage[]) { 584: const filtered = messages.filter( 585: m => !m.uuid || !recentPostedUUIDs.has(m.uuid), 586: ) 587: if (filtered.length === 0) return 588: for (const msg of filtered) { 589: if (msg.uuid) recentPostedUUIDs.add(msg.uuid) 590: } 591: const events = filtered.map(m => ({ ...m, session_id: sessionId })) 592: void transport.writeBatch(events) 593: }, 594: sendControlRequest(request: SDKControlRequest) { 595: if (authRecoveryInFlight) { 596: logForDebugging( 597: `[remote-bridge] Dropping control_request during 401 recovery: ${request.request_id}`, 598: ) 599: return 600: } 601: const event = { ...request, session_id: sessionId } 602: if (request.request.subtype === 'can_use_tool') { 603: transport.reportState('requires_action') 604: } 605: void transport.write(event) 606: logForDebugging( 607: `[remote-bridge] Sent control_request request_id=${request.request_id}`, 608: ) 609: }, 610: sendControlResponse(response: SDKControlResponse) { 611: if (authRecoveryInFlight) { 612: logForDebugging( 613: '[remote-bridge] Dropping control_response during 401 recovery', 614: ) 615: return 616: } 617: const event = { ...response, session_id: sessionId } 618: transport.reportState('running') 619: void transport.write(event) 620: logForDebugging('[remote-bridge] Sent control_response') 621: }, 622: sendControlCancelRequest(requestId: string) { 623: if (authRecoveryInFlight) { 624: logForDebugging( 625: `[remote-bridge] Dropping control_cancel_request during 401 recovery: ${requestId}`, 626: ) 627: return 628: } 629: const event = { 630: type: 'control_cancel_request' as const, 631: request_id: requestId, 632: session_id: sessionId, 633: } 634: transport.reportState('running') 635: void transport.write(event) 636: logForDebugging( 637: `[remote-bridge] Sent control_cancel_request request_id=${requestId}`, 638: ) 639: }, 640: sendResult() { 641: if (authRecoveryInFlight) { 642: logForDebugging('[remote-bridge] Dropping result during 401 recovery') 643: return 644: } 645: transport.reportState('idle') 646: void transport.write(makeResultMessage(sessionId)) 647: logForDebugging(`[remote-bridge] Sent result`) 648: }, 649: async teardown() { 650: unregister() 651: await teardown() 652: }, 653: } 654: } 655: async function withRetry<T>( 656: fn: () => Promise<T | null>, 657: label: string, 658: cfg: EnvLessBridgeConfig, 659: ): Promise<T | null> { 660: const max = cfg.init_retry_max_attempts 661: for (let attempt = 1; attempt <= max; attempt++) { 662: const result = await fn() 663: if (result !== null) return result 664: if (attempt < max) { 665: const base = cfg.init_retry_base_delay_ms * 2 ** (attempt - 1) 666: const jitter = 667: base * cfg.init_retry_jitter_fraction * (2 * Math.random() - 1) 668: const delay = Math.min(base + jitter, cfg.init_retry_max_delay_ms) 669: logForDebugging( 670: `[remote-bridge] ${label} failed (attempt ${attempt}/${max}), retrying in ${Math.round(delay)}ms`, 671: ) 672: await sleep(delay) 673: } 674: } 675: return null 676: } 677: export { 678: createCodeSession, 679: type RemoteCredentials, 680: } from './codeSessionApi.js' 681: import { 682: createCodeSession, 683: fetchRemoteCredentials as fetchRemoteCredentialsRaw, 684: type RemoteCredentials, 685: } from './codeSessionApi.js' 686: import { getBridgeBaseUrlOverride } from './bridgeConfig.js' 687: export async function fetchRemoteCredentials( 688: sessionId: string, 689: baseUrl: string, 690: accessToken: string, 691: timeoutMs: number, 692: ): Promise<RemoteCredentials | null> { 693: const creds = await fetchRemoteCredentialsRaw( 694: sessionId, 695: baseUrl, 696: accessToken, 697: timeoutMs, 698: getTrustedDeviceToken(), 699: ) 700: if (!creds) return null 701: return getBridgeBaseUrlOverride() 702: ? { ...creds, api_base_url: baseUrl } 703: : creds 704: } 705: type ArchiveStatus = number | 'timeout' | 'error' | 'no_token' 706: type ArchiveTelemetryStatus = 707: | 'ok' 708: | 'skipped_no_token' 709: | 'network_error' 710: | 'server_4xx' 711: | 'server_5xx' 712: async function archiveSession( 713: sessionId: string, 714: baseUrl: string, 715: accessToken: string | undefined, 716: orgUUID: string, 717: timeoutMs: number, 718: ): Promise<ArchiveStatus> { 719: if (!accessToken) return 'no_token' 720: const compatId = toCompatSessionId(sessionId) 721: try { 722: const response = await axios.post( 723: `${baseUrl}/v1/sessions/${compatId}/archive`, 724: {}, 725: { 726: headers: { 727: ...oauthHeaders(accessToken), 728: 'anthropic-beta': 'ccr-byoc-2025-07-29', 729: 'x-organization-uuid': orgUUID, 730: }, 731: timeout: timeoutMs, 732: validateStatus: () => true, 733: }, 734: ) 735: logForDebugging( 736: `[remote-bridge] Archive ${compatId} status=${response.status}`, 737: ) 738: return response.status 739: } catch (err) { 740: const msg = errorMessage(err) 741: logForDebugging(`[remote-bridge] Archive failed: ${msg}`) 742: return axios.isAxiosError(err) && err.code === 'ECONNABORTED' 743: ? 'timeout' 744: : 'error' 745: } 746: }

File: src/bridge/replBridge.ts

typescript 1: import { randomUUID } from 'crypto' 2: import { 3: createBridgeApiClient, 4: BridgeFatalError, 5: isExpiredErrorType, 6: isSuppressible403, 7: } from './bridgeApi.js' 8: import type { BridgeConfig, BridgeApiClient } from './types.js' 9: import { logForDebugging } from '../utils/debug.js' 10: import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' 11: import { 12: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 13: logEvent, 14: } from '../services/analytics/index.js' 15: import { registerCleanup } from '../utils/cleanupRegistry.js' 16: import { 17: handleIngressMessage, 18: handleServerControlRequest, 19: makeResultMessage, 20: isEligibleBridgeMessage, 21: extractTitleText, 22: BoundedUUIDSet, 23: } from './bridgeMessaging.js' 24: import { 25: decodeWorkSecret, 26: buildSdkUrl, 27: buildCCRv2SdkUrl, 28: sameSessionId, 29: } from './workSecret.js' 30: import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js' 31: import { updateSessionBridgeId } from '../utils/concurrentSessions.js' 32: import { getTrustedDeviceToken } from './trustedDevice.js' 33: import { HybridTransport } from '../cli/transports/HybridTransport.js' 34: import { 35: type ReplBridgeTransport, 36: createV1ReplTransport, 37: createV2ReplTransport, 38: } from './replBridgeTransport.js' 39: import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js' 40: import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js' 41: import { validateBridgeId } from './bridgeApi.js' 42: import { 43: describeAxiosError, 44: extractHttpStatus, 45: logBridgeSkip, 46: } from './debugUtils.js' 47: import type { Message } from '../types/message.js' 48: import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 49: import type { PermissionMode } from '../utils/permissions/PermissionMode.js' 50: import type { 51: SDKControlRequest, 52: SDKControlResponse, 53: } from '../entrypoints/sdk/controlTypes.js' 54: import { createCapacityWake, type CapacitySignal } from './capacityWake.js' 55: import { FlushGate } from './flushGate.js' 56: import { 57: DEFAULT_POLL_CONFIG, 58: type PollIntervalConfig, 59: } from './pollConfigDefaults.js' 60: import { errorMessage } from '../utils/errors.js' 61: import { sleep } from '../utils/sleep.js' 62: import { 63: wrapApiForFaultInjection, 64: registerBridgeDebugHandle, 65: clearBridgeDebugHandle, 66: injectBridgeFault, 67: } from './bridgeDebug.js' 68: export type ReplBridgeHandle = { 69: bridgeSessionId: string 70: environmentId: string 71: sessionIngressUrl: string 72: writeMessages(messages: Message[]): void 73: writeSdkMessages(messages: SDKMessage[]): void 74: sendControlRequest(request: SDKControlRequest): void 75: sendControlResponse(response: SDKControlResponse): void 76: sendControlCancelRequest(requestId: string): void 77: sendResult(): void 78: teardown(): Promise<void> 79: } 80: export type BridgeState = 'ready' | 'connected' | 'reconnecting' | 'failed' 81: export type BridgeCoreParams = { 82: dir: string 83: machineName: string 84: branch: string 85: gitRepoUrl: string | null 86: title: string 87: baseUrl: string 88: sessionIngressUrl: string 89: workerType: string 90: getAccessToken: () => string | undefined 91: createSession: (opts: { 92: environmentId: string 93: title: string 94: gitRepoUrl: string | null 95: branch: string 96: signal: AbortSignal 97: }) => Promise<string | null> 98: archiveSession: (sessionId: string) => Promise<void> 99: getCurrentTitle?: () => string 100: toSDKMessages?: (messages: Message[]) => SDKMessage[] 101: onAuth401?: (staleAccessToken: string) => Promise<boolean> 102: getPollIntervalConfig?: () => PollIntervalConfig 103: initialHistoryCap?: number 104: initialMessages?: Message[] 105: previouslyFlushedUUIDs?: Set<string> 106: onInboundMessage?: (msg: SDKMessage) => void 107: onPermissionResponse?: (response: SDKControlResponse) => void 108: onInterrupt?: () => void 109: onSetModel?: (model: string | undefined) => void 110: onSetMaxThinkingTokens?: (maxTokens: number | null) => void 111: onSetPermissionMode?: ( 112: mode: PermissionMode, 113: ) => { ok: true } | { ok: false; error: string } 114: onStateChange?: (state: BridgeState, detail?: string) => void 115: onUserMessage?: (text: string, sessionId: string) => boolean 116: perpetual?: boolean 117: initialSSESequenceNum?: number 118: } 119: export type BridgeCoreHandle = ReplBridgeHandle & { 120: getSSESequenceNum(): number 121: } 122: const POLL_ERROR_INITIAL_DELAY_MS = 2_000 123: const POLL_ERROR_MAX_DELAY_MS = 60_000 124: const POLL_ERROR_GIVE_UP_MS = 15 * 60 * 1000 125: let initSequence = 0 126: export async function initBridgeCore( 127: params: BridgeCoreParams, 128: ): Promise<BridgeCoreHandle | null> { 129: const { 130: dir, 131: machineName, 132: branch, 133: gitRepoUrl, 134: title, 135: baseUrl, 136: sessionIngressUrl, 137: workerType, 138: getAccessToken, 139: createSession, 140: archiveSession, 141: getCurrentTitle = () => title, 142: toSDKMessages = () => { 143: throw new Error( 144: 'BridgeCoreParams.toSDKMessages not provided. Pass it if you use writeMessages() or initialMessages — daemon callers that only use writeSdkMessages() never hit this path.', 145: ) 146: }, 147: onAuth401, 148: getPollIntervalConfig = () => DEFAULT_POLL_CONFIG, 149: initialHistoryCap = 200, 150: initialMessages, 151: previouslyFlushedUUIDs, 152: onInboundMessage, 153: onPermissionResponse, 154: onInterrupt, 155: onSetModel, 156: onSetMaxThinkingTokens, 157: onSetPermissionMode, 158: onStateChange, 159: onUserMessage, 160: perpetual, 161: initialSSESequenceNum = 0, 162: } = params 163: const seq = ++initSequence 164: const { writeBridgePointer, clearBridgePointer, readBridgePointer } = 165: await import('./bridgePointer.js') 166: const rawPrior = perpetual ? await readBridgePointer(dir) : null 167: const prior = rawPrior?.source === 'repl' ? rawPrior : null 168: logForDebugging( 169: `[bridge:repl] initBridgeCore #${seq} starting (initialMessages=${initialMessages?.length ?? 0}${prior ? ` perpetual prior=env:${prior.environmentId}` : ''})`, 170: ) 171: const rawApi = createBridgeApiClient({ 172: baseUrl, 173: getAccessToken, 174: runnerVersion: MACRO.VERSION, 175: onDebug: logForDebugging, 176: onAuth401, 177: getTrustedDeviceToken, 178: }) 179: const api = 180: process.env.USER_TYPE === 'ant' ? wrapApiForFaultInjection(rawApi) : rawApi 181: const bridgeConfig: BridgeConfig = { 182: dir, 183: machineName, 184: branch, 185: gitRepoUrl, 186: maxSessions: 1, 187: spawnMode: 'single-session', 188: verbose: false, 189: sandbox: false, 190: bridgeId: randomUUID(), 191: workerType, 192: environmentId: randomUUID(), 193: reuseEnvironmentId: prior?.environmentId, 194: apiBaseUrl: baseUrl, 195: sessionIngressUrl, 196: } 197: let environmentId: string 198: let environmentSecret: string 199: try { 200: const reg = await api.registerBridgeEnvironment(bridgeConfig) 201: environmentId = reg.environment_id 202: environmentSecret = reg.environment_secret 203: } catch (err) { 204: logBridgeSkip( 205: 'registration_failed', 206: `[bridge:repl] Environment registration failed: ${errorMessage(err)}`, 207: ) 208: if (prior) { 209: await clearBridgePointer(dir) 210: } 211: onStateChange?.('failed', errorMessage(err)) 212: return null 213: } 214: logForDebugging(`[bridge:repl] Environment registered: ${environmentId}`) 215: logForDiagnosticsNoPII('info', 'bridge_repl_env_registered') 216: logEvent('tengu_bridge_repl_env_registered', {}) 217: async function tryReconnectInPlace( 218: requestedEnvId: string, 219: sessionId: string, 220: ): Promise<boolean> { 221: if (environmentId !== requestedEnvId) { 222: logForDebugging( 223: `[bridge:repl] Env mismatch (requested ${requestedEnvId}, got ${environmentId}) — cannot reconnect in place`, 224: ) 225: return false 226: } 227: const infraId = toInfraSessionId(sessionId) 228: const candidates = 229: infraId === sessionId ? [sessionId] : [sessionId, infraId] 230: for (const id of candidates) { 231: try { 232: await api.reconnectSession(environmentId, id) 233: logForDebugging( 234: `[bridge:repl] Reconnected session ${id} in place on env ${environmentId}`, 235: ) 236: return true 237: } catch (err) { 238: logForDebugging( 239: `[bridge:repl] reconnectSession(${id}) failed: ${errorMessage(err)}`, 240: ) 241: } 242: } 243: logForDebugging( 244: '[bridge:repl] reconnectSession exhausted — falling through to fresh session', 245: ) 246: return false 247: } 248: const reusedPriorSession = prior 249: ? await tryReconnectInPlace(prior.environmentId, prior.sessionId) 250: : false 251: if (prior && !reusedPriorSession) { 252: await clearBridgePointer(dir) 253: } 254: let currentSessionId: string 255: if (reusedPriorSession && prior) { 256: currentSessionId = prior.sessionId 257: logForDebugging( 258: `[bridge:repl] Perpetual session reused: ${currentSessionId}`, 259: ) 260: if (initialMessages && previouslyFlushedUUIDs) { 261: for (const msg of initialMessages) { 262: previouslyFlushedUUIDs.add(msg.uuid) 263: } 264: } 265: } else { 266: const createdSessionId = await createSession({ 267: environmentId, 268: title, 269: gitRepoUrl, 270: branch, 271: signal: AbortSignal.timeout(15_000), 272: }) 273: if (!createdSessionId) { 274: logForDebugging( 275: '[bridge:repl] Session creation failed, deregistering environment', 276: ) 277: logEvent('tengu_bridge_repl_session_failed', {}) 278: await api.deregisterEnvironment(environmentId).catch(() => {}) 279: onStateChange?.('failed', 'Session creation failed') 280: return null 281: } 282: currentSessionId = createdSessionId 283: logForDebugging(`[bridge:repl] Session created: ${currentSessionId}`) 284: } 285: await writeBridgePointer(dir, { 286: sessionId: currentSessionId, 287: environmentId, 288: source: 'repl', 289: }) 290: logForDiagnosticsNoPII('info', 'bridge_repl_session_created') 291: logEvent('tengu_bridge_repl_started', { 292: has_initial_messages: !!(initialMessages && initialMessages.length > 0), 293: inProtectedNamespace: isInProtectedNamespace(), 294: }) 295: const initialMessageUUIDs = new Set<string>() 296: if (initialMessages) { 297: for (const msg of initialMessages) { 298: initialMessageUUIDs.add(msg.uuid) 299: } 300: } 301: const recentPostedUUIDs = new BoundedUUIDSet(2000) 302: for (const uuid of initialMessageUUIDs) { 303: recentPostedUUIDs.add(uuid) 304: } 305: const recentInboundUUIDs = new BoundedUUIDSet(2000) 306: const pollController = new AbortController() 307: let transport: ReplBridgeTransport | null = null 308: let v2Generation = 0 309: let lastTransportSequenceNum = reusedPriorSession ? initialSSESequenceNum : 0 310: let currentWorkId: string | null = null 311: let currentIngressToken: string | null = null 312: const capacityWake = createCapacityWake(pollController.signal) 313: const wakePollLoop = capacityWake.wake 314: const capacitySignal = capacityWake.signal 315: const flushGate = new FlushGate<Message>() 316: let userMessageCallbackDone = !onUserMessage 317: const MAX_ENVIRONMENT_RECREATIONS = 3 318: let environmentRecreations = 0 319: let reconnectPromise: Promise<boolean> | null = null 320: async function reconnectEnvironmentWithSession(): Promise<boolean> { 321: if (reconnectPromise) { 322: return reconnectPromise 323: } 324: reconnectPromise = doReconnect() 325: try { 326: return await reconnectPromise 327: } finally { 328: reconnectPromise = null 329: } 330: } 331: async function doReconnect(): Promise<boolean> { 332: environmentRecreations++ 333: v2Generation++ 334: logForDebugging( 335: `[bridge:repl] Reconnecting after env lost (attempt ${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS})`, 336: ) 337: if (environmentRecreations > MAX_ENVIRONMENT_RECREATIONS) { 338: logForDebugging( 339: `[bridge:repl] Environment reconnect limit reached (${MAX_ENVIRONMENT_RECREATIONS}), giving up`, 340: ) 341: return false 342: } 343: if (transport) { 344: const seq = transport.getLastSequenceNum() 345: if (seq > lastTransportSequenceNum) { 346: lastTransportSequenceNum = seq 347: } 348: transport.close() 349: transport = null 350: } 351: wakePollLoop() 352: flushGate.drop() 353: if (currentWorkId) { 354: const workIdBeingCleared = currentWorkId 355: await api 356: .stopWork(environmentId, workIdBeingCleared, false) 357: .catch(() => {}) 358: if (currentWorkId !== workIdBeingCleared) { 359: logForDebugging( 360: '[bridge:repl] Poll loop recovered during stopWork await — deferring to it', 361: ) 362: environmentRecreations = 0 363: return true 364: } 365: currentWorkId = null 366: currentIngressToken = null 367: } 368: if (pollController.signal.aborted) { 369: logForDebugging('[bridge:repl] Reconnect aborted by teardown') 370: return false 371: } 372: const requestedEnvId = environmentId 373: bridgeConfig.reuseEnvironmentId = requestedEnvId 374: try { 375: const reg = await api.registerBridgeEnvironment(bridgeConfig) 376: environmentId = reg.environment_id 377: environmentSecret = reg.environment_secret 378: } catch (err) { 379: bridgeConfig.reuseEnvironmentId = undefined 380: logForDebugging( 381: `[bridge:repl] Environment re-registration failed: ${errorMessage(err)}`, 382: ) 383: return false 384: } 385: bridgeConfig.reuseEnvironmentId = undefined 386: logForDebugging( 387: `[bridge:repl] Re-registered: requested=${requestedEnvId} got=${environmentId}`, 388: ) 389: if (pollController.signal.aborted) { 390: logForDebugging( 391: '[bridge:repl] Reconnect aborted after env registration, cleaning up', 392: ) 393: await api.deregisterEnvironment(environmentId).catch(() => {}) 394: return false 395: } 396: if (transport !== null) { 397: logForDebugging( 398: '[bridge:repl] Poll loop recovered during registerBridgeEnvironment await — deferring to it', 399: ) 400: environmentRecreations = 0 401: return true 402: } 403: if (await tryReconnectInPlace(requestedEnvId, currentSessionId)) { 404: logEvent('tengu_bridge_repl_reconnected_in_place', {}) 405: environmentRecreations = 0 406: return true 407: } 408: if (environmentId !== requestedEnvId) { 409: logEvent('tengu_bridge_repl_env_expired_fresh_session', {}) 410: } 411: await archiveSession(currentSessionId) 412: if (pollController.signal.aborted) { 413: logForDebugging( 414: '[bridge:repl] Reconnect aborted after archive, cleaning up', 415: ) 416: await api.deregisterEnvironment(environmentId).catch(() => {}) 417: return false 418: } 419: const currentTitle = getCurrentTitle() 420: const newSessionId = await createSession({ 421: environmentId, 422: title: currentTitle, 423: gitRepoUrl, 424: branch, 425: signal: AbortSignal.timeout(15_000), 426: }) 427: if (!newSessionId) { 428: logForDebugging( 429: '[bridge:repl] Session creation failed during reconnection', 430: ) 431: return false 432: } 433: if (pollController.signal.aborted) { 434: logForDebugging( 435: '[bridge:repl] Reconnect aborted after session creation, cleaning up', 436: ) 437: await archiveSession(newSessionId) 438: return false 439: } 440: currentSessionId = newSessionId 441: void updateSessionBridgeId(toCompatSessionId(newSessionId)).catch(() => {}) 442: lastTransportSequenceNum = 0 443: recentInboundUUIDs.clear() 444: userMessageCallbackDone = !onUserMessage 445: logForDebugging(`[bridge:repl] Re-created session: ${currentSessionId}`) 446: await writeBridgePointer(dir, { 447: sessionId: currentSessionId, 448: environmentId, 449: source: 'repl', 450: }) 451: previouslyFlushedUUIDs?.clear() 452: environmentRecreations = 0 453: return true 454: } 455: function getOAuthToken(): string | undefined { 456: return getAccessToken() 457: } 458: function drainFlushGate(): void { 459: const msgs = flushGate.end() 460: if (msgs.length === 0) return 461: if (!transport) { 462: logForDebugging( 463: `[bridge:repl] Cannot drain ${msgs.length} pending message(s): no transport`, 464: ) 465: return 466: } 467: for (const msg of msgs) { 468: recentPostedUUIDs.add(msg.uuid) 469: } 470: const sdkMessages = toSDKMessages(msgs) 471: const events = sdkMessages.map(sdkMsg => ({ 472: ...sdkMsg, 473: session_id: currentSessionId, 474: })) 475: logForDebugging( 476: `[bridge:repl] Drained ${msgs.length} pending message(s) after flush`, 477: ) 478: void transport.writeBatch(events) 479: } 480: let doTeardownImpl: (() => Promise<void>) | null = null 481: function triggerTeardown(): void { 482: void doTeardownImpl?.() 483: } 484: function handleTransportPermanentClose(closeCode: number | undefined): void { 485: logForDebugging( 486: `[bridge:repl] Transport permanently closed: code=${closeCode}`, 487: ) 488: logEvent('tengu_bridge_repl_ws_closed', { 489: code: closeCode, 490: }) 491: if (transport) { 492: const closedSeq = transport.getLastSequenceNum() 493: if (closedSeq > lastTransportSequenceNum) { 494: lastTransportSequenceNum = closedSeq 495: } 496: transport = null 497: } 498: wakePollLoop() 499: const dropped = flushGate.drop() 500: if (dropped > 0) { 501: logForDebugging( 502: `[bridge:repl] Dropping ${dropped} pending message(s) on transport close (code=${closeCode})`, 503: { level: 'warn' }, 504: ) 505: } 506: if (closeCode === 1000) { 507: onStateChange?.('failed', 'session ended') 508: pollController.abort() 509: triggerTeardown() 510: return 511: } 512: onStateChange?.( 513: 'reconnecting', 514: `Remote Control connection lost (code ${closeCode})`, 515: ) 516: logForDebugging( 517: `[bridge:repl] Transport reconnect budget exhausted (code=${closeCode}), attempting env reconnect`, 518: ) 519: void reconnectEnvironmentWithSession().then(success => { 520: if (success) return 521: if (pollController.signal.aborted) return 522: logForDebugging( 523: '[bridge:repl] reconnectEnvironmentWithSession resolved false — tearing down', 524: ) 525: logEvent('tengu_bridge_repl_reconnect_failed', { 526: close_code: closeCode, 527: }) 528: onStateChange?.('failed', 'reconnection failed') 529: triggerTeardown() 530: }) 531: } 532: let sigusr2Handler: (() => void) | undefined 533: if (process.env.USER_TYPE === 'ant' && process.platform !== 'win32') { 534: sigusr2Handler = () => { 535: logForDebugging( 536: '[bridge:repl] SIGUSR2 received — forcing doReconnect() for testing', 537: ) 538: void reconnectEnvironmentWithSession() 539: } 540: process.on('SIGUSR2', sigusr2Handler) 541: } 542: let debugFireClose: ((code: number) => void) | null = null 543: if (process.env.USER_TYPE === 'ant') { 544: registerBridgeDebugHandle({ 545: fireClose: code => { 546: if (!debugFireClose) { 547: logForDebugging('[bridge:debug] fireClose: no transport wired yet') 548: return 549: } 550: logForDebugging(`[bridge:debug] fireClose(${code}) — injecting`) 551: debugFireClose(code) 552: }, 553: forceReconnect: () => { 554: logForDebugging('[bridge:debug] forceReconnect — injecting') 555: void reconnectEnvironmentWithSession() 556: }, 557: injectFault: injectBridgeFault, 558: wakePollLoop, 559: describe: () => 560: `env=${environmentId} session=${currentSessionId} transport=${transport?.getStateLabel() ?? 'null'} workId=${currentWorkId ?? 'null'}`, 561: }) 562: } 563: const pollOpts = { 564: api, 565: getCredentials: () => ({ environmentId, environmentSecret }), 566: signal: pollController.signal, 567: getPollIntervalConfig, 568: onStateChange, 569: getWsState: () => transport?.getStateLabel() ?? 'null', 570: isAtCapacity: () => transport !== null, 571: capacitySignal, 572: onFatalError: triggerTeardown, 573: getHeartbeatInfo: () => { 574: if (!currentWorkId || !currentIngressToken) { 575: return null 576: } 577: return { 578: environmentId, 579: workId: currentWorkId, 580: sessionToken: currentIngressToken, 581: } 582: }, 583: onHeartbeatFatal: (err: BridgeFatalError) => { 584: logForDebugging( 585: `[bridge:repl] heartbeatWork fatal (status=${err.status}) — tearing down work item for fast re-dispatch`, 586: ) 587: if (transport) { 588: const seq = transport.getLastSequenceNum() 589: if (seq > lastTransportSequenceNum) { 590: lastTransportSequenceNum = seq 591: } 592: transport.close() 593: transport = null 594: } 595: flushGate.drop() 596: if (currentWorkId) { 597: void api 598: .stopWork(environmentId, currentWorkId, false) 599: .catch((e: unknown) => { 600: logForDebugging( 601: `[bridge:repl] stopWork after heartbeat fatal: ${errorMessage(e)}`, 602: ) 603: }) 604: } 605: currentWorkId = null 606: currentIngressToken = null 607: wakePollLoop() 608: onStateChange?.( 609: 'reconnecting', 610: 'Work item lease expired, fetching fresh token', 611: ) 612: }, 613: async onEnvironmentLost() { 614: const success = await reconnectEnvironmentWithSession() 615: if (!success) { 616: return null 617: } 618: return { environmentId, environmentSecret } 619: }, 620: onWorkReceived: ( 621: workSessionId: string, 622: ingressToken: string, 623: workId: string, 624: serverUseCcrV2: boolean, 625: ) => { 626: if (transport?.isConnectedStatus()) { 627: logForDebugging( 628: `[bridge:repl] Work received while transport connected, replacing with fresh token (workId=${workId})`, 629: ) 630: } 631: logForDebugging( 632: `[bridge:repl] Work received: workId=${workId} workSessionId=${workSessionId} currentSessionId=${currentSessionId} match=${sameSessionId(workSessionId, currentSessionId)}`, 633: ) 634: void writeBridgePointer(dir, { 635: sessionId: currentSessionId, 636: environmentId, 637: source: 'repl', 638: }) 639: if (!sameSessionId(workSessionId, currentSessionId)) { 640: logForDebugging( 641: `[bridge:repl] Rejecting foreign session: expected=${currentSessionId} got=${workSessionId}`, 642: ) 643: return 644: } 645: currentWorkId = workId 646: currentIngressToken = ingressToken 647: const useCcrV2 = 648: serverUseCcrV2 || isEnvTruthy(process.env.CLAUDE_BRIDGE_USE_CCR_V2) 649: let v1OauthToken: string | undefined 650: if (!useCcrV2) { 651: v1OauthToken = getOAuthToken() 652: if (!v1OauthToken) { 653: logForDebugging( 654: '[bridge:repl] No OAuth token available for session ingress, skipping work', 655: ) 656: return 657: } 658: updateSessionIngressAuthToken(v1OauthToken) 659: } 660: logEvent('tengu_bridge_repl_work_received', {}) 661: if (transport) { 662: const oldTransport = transport 663: transport = null 664: const oldSeq = oldTransport.getLastSequenceNum() 665: if (oldSeq > lastTransportSequenceNum) { 666: lastTransportSequenceNum = oldSeq 667: } 668: oldTransport.close() 669: } 670: flushGate.deactivate() 671: const onServerControlRequest = (request: SDKControlRequest): void => 672: handleServerControlRequest(request, { 673: transport, 674: sessionId: currentSessionId, 675: onInterrupt, 676: onSetModel, 677: onSetMaxThinkingTokens, 678: onSetPermissionMode, 679: }) 680: let initialFlushDone = false 681: const wireTransport = (newTransport: ReplBridgeTransport): void => { 682: transport = newTransport 683: newTransport.setOnConnect(() => { 684: if (transport !== newTransport) return 685: logForDebugging('[bridge:repl] Ingress transport connected') 686: logEvent('tengu_bridge_repl_ws_connected', {}) 687: if (!useCcrV2) { 688: const freshToken = getOAuthToken() 689: if (freshToken) { 690: updateSessionIngressAuthToken(freshToken) 691: } 692: } 693: teardownStarted = false 694: if ( 695: !initialFlushDone && 696: initialMessages && 697: initialMessages.length > 0 698: ) { 699: initialFlushDone = true 700: const historyCap = initialHistoryCap 701: const eligibleMessages = initialMessages.filter( 702: m => 703: isEligibleBridgeMessage(m) && 704: !previouslyFlushedUUIDs?.has(m.uuid), 705: ) 706: const cappedMessages = 707: historyCap > 0 && eligibleMessages.length > historyCap 708: ? eligibleMessages.slice(-historyCap) 709: : eligibleMessages 710: if (cappedMessages.length < eligibleMessages.length) { 711: logForDebugging( 712: `[bridge:repl] Capped initial flush: ${eligibleMessages.length} -> ${cappedMessages.length} (cap=${historyCap})`, 713: ) 714: logEvent('tengu_bridge_repl_history_capped', { 715: eligible_count: eligibleMessages.length, 716: capped_count: cappedMessages.length, 717: }) 718: } 719: const sdkMessages = toSDKMessages(cappedMessages) 720: if (sdkMessages.length > 0) { 721: logForDebugging( 722: `[bridge:repl] Flushing ${sdkMessages.length} initial message(s) via transport`, 723: ) 724: const events = sdkMessages.map(sdkMsg => ({ 725: ...sdkMsg, 726: session_id: currentSessionId, 727: })) 728: const dropsBefore = newTransport.droppedBatchCount 729: void newTransport 730: .writeBatch(events) 731: .then(() => { 732: if (newTransport.droppedBatchCount > dropsBefore) { 733: logForDebugging( 734: `[bridge:repl] Initial flush dropped ${newTransport.droppedBatchCount - dropsBefore} batch(es) — not marking ${sdkMessages.length} UUID(s) as flushed`, 735: ) 736: return 737: } 738: if (previouslyFlushedUUIDs) { 739: for (const sdkMsg of sdkMessages) { 740: if (sdkMsg.uuid) { 741: previouslyFlushedUUIDs.add(sdkMsg.uuid) 742: } 743: } 744: } 745: }) 746: .catch(e => 747: logForDebugging(`[bridge:repl] Initial flush failed: ${e}`), 748: ) 749: .finally(() => { 750: if (transport !== newTransport) return 751: drainFlushGate() 752: onStateChange?.('connected') 753: }) 754: } else { 755: drainFlushGate() 756: onStateChange?.('connected') 757: } 758: } else if (!flushGate.active) { 759: onStateChange?.('connected') 760: } 761: }) 762: newTransport.setOnData(data => { 763: handleIngressMessage( 764: data, 765: recentPostedUUIDs, 766: recentInboundUUIDs, 767: onInboundMessage, 768: onPermissionResponse, 769: onServerControlRequest, 770: ) 771: }) 772: debugFireClose = handleTransportPermanentClose 773: newTransport.setOnClose(closeCode => { 774: if (transport !== newTransport) return 775: handleTransportPermanentClose(closeCode) 776: }) 777: if ( 778: !initialFlushDone && 779: initialMessages && 780: initialMessages.length > 0 781: ) { 782: flushGate.start() 783: } 784: newTransport.connect() 785: } 786: v2Generation++ 787: if (useCcrV2) { 788: const sessionUrl = buildCCRv2SdkUrl(baseUrl, workSessionId) 789: const thisGen = v2Generation 790: logForDebugging( 791: `[bridge:repl] CCR v2: sessionUrl=${sessionUrl} session=${workSessionId} gen=${thisGen}`, 792: ) 793: void createV2ReplTransport({ 794: sessionUrl, 795: ingressToken, 796: sessionId: workSessionId, 797: initialSequenceNum: lastTransportSequenceNum, 798: }).then( 799: t => { 800: if (pollController.signal.aborted) { 801: t.close() 802: return 803: } 804: if (thisGen !== v2Generation) { 805: logForDebugging( 806: `[bridge:repl] CCR v2: discarding stale handshake gen=${thisGen} current=${v2Generation}`, 807: ) 808: t.close() 809: return 810: } 811: wireTransport(t) 812: }, 813: (err: unknown) => { 814: logForDebugging( 815: `[bridge:repl] CCR v2: createV2ReplTransport failed: ${errorMessage(err)}`, 816: { level: 'error' }, 817: ) 818: logEvent('tengu_bridge_repl_ccr_v2_init_failed', {}) 819: if (thisGen !== v2Generation) return 820: if (currentWorkId) { 821: void api 822: .stopWork(environmentId, currentWorkId, false) 823: .catch((e: unknown) => { 824: logForDebugging( 825: `[bridge:repl] stopWork after v2 init failure: ${errorMessage(e)}`, 826: ) 827: }) 828: currentWorkId = null 829: currentIngressToken = null 830: } 831: wakePollLoop() 832: }, 833: ) 834: } else { 835: const wsUrl = buildSdkUrl(sessionIngressUrl, workSessionId) 836: logForDebugging(`[bridge:repl] Ingress URL: ${wsUrl}`) 837: logForDebugging( 838: `[bridge:repl] Creating HybridTransport: session=${workSessionId}`, 839: ) 840: const oauthToken = v1OauthToken ?? '' 841: wireTransport( 842: createV1ReplTransport( 843: new HybridTransport( 844: new URL(wsUrl), 845: { 846: Authorization: `Bearer ${oauthToken}`, 847: 'anthropic-version': '2023-06-01', 848: }, 849: workSessionId, 850: () => ({ 851: Authorization: `Bearer ${getOAuthToken() ?? oauthToken}`, 852: 'anthropic-version': '2023-06-01', 853: }), 854: { 855: maxConsecutiveFailures: 50, 856: isBridge: true, 857: onBatchDropped: () => { 858: onStateChange?.( 859: 'reconnecting', 860: 'Lost sync with Remote Control — events could not be delivered', 861: ) 862: wakePollLoop() 863: }, 864: }, 865: ), 866: ), 867: ) 868: } 869: }, 870: } 871: void startWorkPollLoop(pollOpts) 872: const pointerRefreshTimer = perpetual 873: ? setInterval(() => { 874: if (reconnectPromise) return 875: void writeBridgePointer(dir, { 876: sessionId: currentSessionId, 877: environmentId, 878: source: 'repl', 879: }) 880: }, 60 * 60_000) 881: : null 882: pointerRefreshTimer?.unref?.() 883: const keepAliveIntervalMs = 884: getPollIntervalConfig().session_keepalive_interval_v2_ms 885: const keepAliveTimer = 886: keepAliveIntervalMs > 0 887: ? setInterval(() => { 888: if (!transport) return 889: logForDebugging('[bridge:repl] keep_alive sent') 890: void transport.write({ type: 'keep_alive' }).catch((err: unknown) => { 891: logForDebugging( 892: `[bridge:repl] keep_alive write failed: ${errorMessage(err)}`, 893: ) 894: }) 895: }, keepAliveIntervalMs) 896: : null 897: keepAliveTimer?.unref?.() 898: let teardownStarted = false 899: doTeardownImpl = async (): Promise<void> => { 900: if (teardownStarted) { 901: logForDebugging( 902: `[bridge:repl] Teardown already in progress, skipping duplicate call env=${environmentId} session=${currentSessionId}`, 903: ) 904: return 905: } 906: teardownStarted = true 907: const teardownStart = Date.now() 908: logForDebugging( 909: `[bridge:repl] Teardown starting: env=${environmentId} session=${currentSessionId} workId=${currentWorkId ?? 'none'} transportState=${transport?.getStateLabel() ?? 'null'}`, 910: ) 911: if (pointerRefreshTimer !== null) { 912: clearInterval(pointerRefreshTimer) 913: } 914: if (keepAliveTimer !== null) { 915: clearInterval(keepAliveTimer) 916: } 917: if (sigusr2Handler) { 918: process.off('SIGUSR2', sigusr2Handler) 919: } 920: if (process.env.USER_TYPE === 'ant') { 921: clearBridgeDebugHandle() 922: debugFireClose = null 923: } 924: pollController.abort() 925: logForDebugging('[bridge:repl] Teardown: poll loop aborted') 926: if (transport) { 927: const finalSeq = transport.getLastSequenceNum() 928: if (finalSeq > lastTransportSequenceNum) { 929: lastTransportSequenceNum = finalSeq 930: } 931: } 932: if (perpetual) { 933: transport = null 934: flushGate.drop() 935: await writeBridgePointer(dir, { 936: sessionId: currentSessionId, 937: environmentId, 938: source: 'repl', 939: }) 940: logForDebugging( 941: `[bridge:repl] Teardown (perpetual): leaving env=${environmentId} session=${currentSessionId} alive on server, duration=${Date.now() - teardownStart}ms`, 942: ) 943: return 944: } 945: const teardownTransport = transport 946: transport = null 947: flushGate.drop() 948: if (teardownTransport) { 949: void teardownTransport.write(makeResultMessage(currentSessionId)) 950: } 951: const stopWorkP = currentWorkId 952: ? api 953: .stopWork(environmentId, currentWorkId, true) 954: .then(() => { 955: logForDebugging('[bridge:repl] Teardown: stopWork completed') 956: }) 957: .catch((err: unknown) => { 958: logForDebugging( 959: `[bridge:repl] Teardown stopWork failed: ${errorMessage(err)}`, 960: ) 961: }) 962: : Promise.resolve() 963: await Promise.all([stopWorkP, archiveSession(currentSessionId)]) 964: teardownTransport?.close() 965: logForDebugging('[bridge:repl] Teardown: transport closed') 966: await api.deregisterEnvironment(environmentId).catch((err: unknown) => { 967: logForDebugging( 968: `[bridge:repl] Teardown deregister failed: ${errorMessage(err)}`, 969: ) 970: }) 971: await clearBridgePointer(dir) 972: logForDebugging( 973: `[bridge:repl] Teardown complete: env=${environmentId} duration=${Date.now() - teardownStart}ms`, 974: ) 975: } 976: const unregister = registerCleanup(() => doTeardownImpl?.()) 977: logForDebugging( 978: `[bridge:repl] Ready: env=${environmentId} session=${currentSessionId}`, 979: ) 980: onStateChange?.('ready') 981: return { 982: get bridgeSessionId() { 983: return currentSessionId 984: }, 985: get environmentId() { 986: return environmentId 987: }, 988: getSSESequenceNum() { 989: const live = transport?.getLastSequenceNum() ?? 0 990: return Math.max(lastTransportSequenceNum, live) 991: }, 992: sessionIngressUrl, 993: writeMessages(messages) { 994: const filtered = messages.filter( 995: m => 996: isEligibleBridgeMessage(m) && 997: !initialMessageUUIDs.has(m.uuid) && 998: !recentPostedUUIDs.has(m.uuid), 999: ) 1000: if (filtered.length === 0) return 1001: if (!userMessageCallbackDone) { 1002: for (const m of filtered) { 1003: const text = extractTitleText(m) 1004: if (text !== undefined && onUserMessage?.(text, currentSessionId)) { 1005: userMessageCallbackDone = true 1006: break 1007: } 1008: } 1009: } 1010: if (flushGate.enqueue(...filtered)) { 1011: logForDebugging( 1012: `[bridge:repl] Queued ${filtered.length} message(s) during initial flush`, 1013: ) 1014: return 1015: } 1016: if (!transport) { 1017: const types = filtered.map(m => m.type).join(',') 1018: logForDebugging( 1019: `[bridge:repl] Transport not configured, dropping ${filtered.length} message(s) [${types}] for session=${currentSessionId}`, 1020: { level: 'warn' }, 1021: ) 1022: return 1023: } 1024: for (const msg of filtered) { 1025: recentPostedUUIDs.add(msg.uuid) 1026: } 1027: logForDebugging( 1028: `[bridge:repl] Sending ${filtered.length} message(s) via transport`, 1029: ) 1030: const sdkMessages = toSDKMessages(filtered) 1031: const events = sdkMessages.map(sdkMsg => ({ 1032: ...sdkMsg, 1033: session_id: currentSessionId, 1034: })) 1035: void transport.writeBatch(events) 1036: }, 1037: writeSdkMessages(messages) { 1038: const filtered = messages.filter( 1039: m => !m.uuid || !recentPostedUUIDs.has(m.uuid), 1040: ) 1041: if (filtered.length === 0) return 1042: if (!transport) { 1043: logForDebugging( 1044: `[bridge:repl] Transport not configured, dropping ${filtered.length} SDK message(s) for session=${currentSessionId}`, 1045: { level: 'warn' }, 1046: ) 1047: return 1048: } 1049: for (const msg of filtered) { 1050: if (msg.uuid) recentPostedUUIDs.add(msg.uuid) 1051: } 1052: const events = filtered.map(m => ({ ...m, session_id: currentSessionId })) 1053: void transport.writeBatch(events) 1054: }, 1055: sendControlRequest(request: SDKControlRequest) { 1056: if (!transport) { 1057: logForDebugging( 1058: '[bridge:repl] Transport not configured, skipping control_request', 1059: ) 1060: return 1061: } 1062: const event = { ...request, session_id: currentSessionId } 1063: void transport.write(event) 1064: logForDebugging( 1065: `[bridge:repl] Sent control_request request_id=${request.request_id}`, 1066: ) 1067: }, 1068: sendControlResponse(response: SDKControlResponse) { 1069: if (!transport) { 1070: logForDebugging( 1071: '[bridge:repl] Transport not configured, skipping control_response', 1072: ) 1073: return 1074: } 1075: const event = { ...response, session_id: currentSessionId } 1076: void transport.write(event) 1077: logForDebugging('[bridge:repl] Sent control_response') 1078: }, 1079: sendControlCancelRequest(requestId: string) { 1080: if (!transport) { 1081: logForDebugging( 1082: '[bridge:repl] Transport not configured, skipping control_cancel_request', 1083: ) 1084: return 1085: } 1086: const event = { 1087: type: 'control_cancel_request' as const, 1088: request_id: requestId, 1089: session_id: currentSessionId, 1090: } 1091: void transport.write(event) 1092: logForDebugging( 1093: `[bridge:repl] Sent control_cancel_request request_id=${requestId}`, 1094: ) 1095: }, 1096: sendResult() { 1097: if (!transport) { 1098: logForDebugging( 1099: `[bridge:repl] sendResult: skipping, transport not configured session=${currentSessionId}`, 1100: ) 1101: return 1102: } 1103: void transport.write(makeResultMessage(currentSessionId)) 1104: logForDebugging( 1105: `[bridge:repl] Sent result for session=${currentSessionId}`, 1106: ) 1107: }, 1108: async teardown() { 1109: unregister() 1110: await doTeardownImpl?.() 1111: logForDebugging('[bridge:repl] Torn down') 1112: logEvent('tengu_bridge_repl_teardown', {}) 1113: }, 1114: } 1115: } 1116: async function startWorkPollLoop({ 1117: api, 1118: getCredentials, 1119: signal, 1120: onStateChange, 1121: onWorkReceived, 1122: onEnvironmentLost, 1123: getWsState, 1124: isAtCapacity, 1125: capacitySignal, 1126: onFatalError, 1127: getPollIntervalConfig = () => DEFAULT_POLL_CONFIG, 1128: getHeartbeatInfo, 1129: onHeartbeatFatal, 1130: }: { 1131: api: BridgeApiClient 1132: getCredentials: () => { environmentId: string; environmentSecret: string } 1133: signal: AbortSignal 1134: onStateChange?: (state: BridgeState, detail?: string) => void 1135: onWorkReceived: ( 1136: sessionId: string, 1137: ingressToken: string, 1138: workId: string, 1139: useCodeSessions: boolean, 1140: ) => void 1141: onEnvironmentLost?: () => Promise<{ 1142: environmentId: string 1143: environmentSecret: string 1144: } | null> 1145: getWsState?: () => string 1146: isAtCapacity?: () => boolean 1147: capacitySignal?: () => CapacitySignal 1148: onFatalError?: () => void 1149: getPollIntervalConfig?: () => PollIntervalConfig 1150: getHeartbeatInfo?: () => { 1151: environmentId: string 1152: workId: string 1153: sessionToken: string 1154: } | null 1155: onHeartbeatFatal?: (err: BridgeFatalError) => void 1156: }): Promise<void> { 1157: const MAX_ENVIRONMENT_RECREATIONS = 3 1158: logForDebugging( 1159: `[bridge:repl] Starting work poll loop for env=${getCredentials().environmentId}`, 1160: ) 1161: let consecutiveErrors = 0 1162: let firstErrorTime: number | null = null 1163: let lastPollErrorTime: number | null = null 1164: let environmentRecreations = 0 1165: let suspensionDetected = false 1166: while (!signal.aborted) { 1167: const { environmentId: envId, environmentSecret: envSecret } = 1168: getCredentials() 1169: const pollConfig = getPollIntervalConfig() 1170: try { 1171: const work = await api.pollForWork( 1172: envId, 1173: envSecret, 1174: signal, 1175: pollConfig.reclaim_older_than_ms, 1176: ) 1177: environmentRecreations = 0 1178: if (consecutiveErrors > 0) { 1179: logForDebugging( 1180: `[bridge:repl] Poll recovered after ${consecutiveErrors} consecutive error(s)`, 1181: ) 1182: consecutiveErrors = 0 1183: firstErrorTime = null 1184: lastPollErrorTime = null 1185: onStateChange?.('ready') 1186: } 1187: if (!work) { 1188: const skipAtCapacityOnce = suspensionDetected 1189: suspensionDetected = false 1190: if (isAtCapacity?.() && capacitySignal && !skipAtCapacityOnce) { 1191: const atCapMs = pollConfig.poll_interval_ms_at_capacity 1192: if ( 1193: pollConfig.non_exclusive_heartbeat_interval_ms > 0 && 1194: getHeartbeatInfo 1195: ) { 1196: logEvent('tengu_bridge_heartbeat_mode_entered', { 1197: heartbeat_interval_ms: 1198: pollConfig.non_exclusive_heartbeat_interval_ms, 1199: }) 1200: const pollDeadline = atCapMs > 0 ? Date.now() + atCapMs : null 1201: let needsBackoff = false 1202: let hbCycles = 0 1203: while ( 1204: !signal.aborted && 1205: isAtCapacity() && 1206: (pollDeadline === null || Date.now() < pollDeadline) 1207: ) { 1208: const hbConfig = getPollIntervalConfig() 1209: if (hbConfig.non_exclusive_heartbeat_interval_ms <= 0) break 1210: const info = getHeartbeatInfo() 1211: if (!info) break 1212: const cap = capacitySignal() 1213: try { 1214: await api.heartbeatWork( 1215: info.environmentId, 1216: info.workId, 1217: info.sessionToken, 1218: ) 1219: } catch (err) { 1220: logForDebugging( 1221: `[bridge:repl:heartbeat] Failed: ${errorMessage(err)}`, 1222: ) 1223: if (err instanceof BridgeFatalError) { 1224: cap.cleanup() 1225: logEvent('tengu_bridge_heartbeat_error', { 1226: status: 1227: err.status as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1228: error_type: (err.status === 401 || err.status === 403 1229: ? 'auth_failed' 1230: : 'fatal') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1231: }) 1232: if (onHeartbeatFatal) { 1233: onHeartbeatFatal(err) 1234: logForDebugging( 1235: `[bridge:repl:heartbeat] Fatal (status=${err.status}), work state cleared — fast-polling for re-dispatch`, 1236: ) 1237: } else { 1238: needsBackoff = true 1239: } 1240: break 1241: } 1242: } 1243: hbCycles++ 1244: await sleep( 1245: hbConfig.non_exclusive_heartbeat_interval_ms, 1246: cap.signal, 1247: ) 1248: cap.cleanup() 1249: } 1250: const exitReason = needsBackoff 1251: ? 'error' 1252: : signal.aborted 1253: ? 'shutdown' 1254: : !isAtCapacity() 1255: ? 'capacity_changed' 1256: : pollDeadline !== null && Date.now() >= pollDeadline 1257: ? 'poll_due' 1258: : 'config_disabled' 1259: logEvent('tengu_bridge_heartbeat_mode_exited', { 1260: reason: 1261: exitReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1262: heartbeat_cycles: hbCycles, 1263: }) 1264: if (!needsBackoff) { 1265: if (exitReason === 'poll_due') { 1266: logForDebugging( 1267: `[bridge:repl] Heartbeat poll_due after ${hbCycles} cycles — falling through to pollForWork`, 1268: ) 1269: } 1270: continue 1271: } 1272: } 1273: const sleepMs = 1274: atCapMs > 0 1275: ? atCapMs 1276: : pollConfig.non_exclusive_heartbeat_interval_ms 1277: if (sleepMs > 0) { 1278: const cap = capacitySignal() 1279: const sleepStart = Date.now() 1280: await sleep(sleepMs, cap.signal) 1281: cap.cleanup() 1282: const overrun = Date.now() - sleepStart - sleepMs 1283: if (overrun > 60_000) { 1284: logForDebugging( 1285: `[bridge:repl] At-capacity sleep overran by ${Math.round(overrun / 1000)}s — process suspension detected, forcing one fast-poll cycle`, 1286: ) 1287: logEvent('tengu_bridge_repl_suspension_detected', { 1288: overrun_ms: overrun, 1289: }) 1290: suspensionDetected = true 1291: } 1292: } 1293: } else { 1294: await sleep(pollConfig.poll_interval_ms_not_at_capacity, signal) 1295: } 1296: continue 1297: } 1298: let secret 1299: try { 1300: secret = decodeWorkSecret(work.secret) 1301: } catch (err) { 1302: logForDebugging( 1303: `[bridge:repl] Failed to decode work secret: ${errorMessage(err)}`, 1304: ) 1305: logEvent('tengu_bridge_repl_work_secret_failed', {}) 1306: await api.stopWork(envId, work.id, false).catch(() => {}) 1307: continue 1308: } 1309: logForDebugging(`[bridge:repl] Acknowledging workId=${work.id}`) 1310: try { 1311: await api.acknowledgeWork(envId, work.id, secret.session_ingress_token) 1312: } catch (err) { 1313: logForDebugging( 1314: `[bridge:repl] Acknowledge failed workId=${work.id}: ${errorMessage(err)}`, 1315: ) 1316: } 1317: if (work.data.type === 'healthcheck') { 1318: logForDebugging('[bridge:repl] Healthcheck received') 1319: continue 1320: } 1321: if (work.data.type === 'session') { 1322: const workSessionId = work.data.id 1323: try { 1324: validateBridgeId(workSessionId, 'session_id') 1325: } catch { 1326: logForDebugging( 1327: `[bridge:repl] Invalid session_id in work: ${workSessionId}`, 1328: ) 1329: continue 1330: } 1331: onWorkReceived( 1332: workSessionId, 1333: secret.session_ingress_token, 1334: work.id, 1335: secret.use_code_sessions === true, 1336: ) 1337: logForDebugging('[bridge:repl] Work accepted, continuing poll loop') 1338: } 1339: } catch (err) { 1340: if (signal.aborted) break 1341: if ( 1342: err instanceof BridgeFatalError && 1343: err.status === 404 && 1344: onEnvironmentLost 1345: ) { 1346: const currentEnvId = getCredentials().environmentId 1347: if (envId !== currentEnvId) { 1348: logForDebugging( 1349: `[bridge:repl] Stale poll error for old env=${envId}, current env=${currentEnvId} — skipping onEnvironmentLost`, 1350: ) 1351: consecutiveErrors = 0 1352: firstErrorTime = null 1353: continue 1354: } 1355: environmentRecreations++ 1356: logForDebugging( 1357: `[bridge:repl] Environment deleted, attempting re-registration (attempt ${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS})`, 1358: ) 1359: logEvent('tengu_bridge_repl_env_lost', { 1360: attempt: environmentRecreations, 1361: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 1362: if (environmentRecreations > MAX_ENVIRONMENT_RECREATIONS) { 1363: logForDebugging( 1364: `[bridge:repl] Environment re-registration limit reached (${MAX_ENVIRONMENT_RECREATIONS}), giving up`, 1365: ) 1366: onStateChange?.( 1367: 'failed', 1368: 'Environment deleted and re-registration limit reached', 1369: ) 1370: onFatalError?.() 1371: break 1372: } 1373: onStateChange?.('reconnecting', 'environment lost, recreating session') 1374: const newCreds = await onEnvironmentLost() 1375: if (signal.aborted) break 1376: if (newCreds) { 1377: consecutiveErrors = 0 1378: firstErrorTime = null 1379: onStateChange?.('ready') 1380: logForDebugging( 1381: `[bridge:repl] Re-registered environment: ${newCreds.environmentId}`, 1382: ) 1383: continue 1384: } 1385: onStateChange?.( 1386: 'failed', 1387: 'Environment deleted and re-registration failed', 1388: ) 1389: onFatalError?.() 1390: break 1391: } 1392: if (err instanceof BridgeFatalError) { 1393: const isExpiry = isExpiredErrorType(err.errorType) 1394: const isSuppressible = isSuppressible403(err) 1395: logForDebugging( 1396: `[bridge:repl] Fatal poll error: ${err.message} (status=${err.status}, type=${err.errorType ?? 'unknown'})${isSuppressible ? ' (suppressed)' : ''}`, 1397: ) 1398: logEvent('tengu_bridge_repl_fatal_error', { 1399: status: err.status, 1400: error_type: 1401: err.errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1402: }) 1403: logForDiagnosticsNoPII( 1404: isExpiry ? 'info' : 'error', 1405: 'bridge_repl_fatal_error', 1406: { status: err.status, error_type: err.errorType }, 1407: ) 1408: if (!isSuppressible) { 1409: onStateChange?.( 1410: 'failed', 1411: isExpiry 1412: ? 'session expired · /remote-control to reconnect' 1413: : err.message, 1414: ) 1415: } 1416: onFatalError?.() 1417: break 1418: } 1419: const now = Date.now() 1420: if ( 1421: lastPollErrorTime !== null && 1422: now - lastPollErrorTime > POLL_ERROR_MAX_DELAY_MS * 2 1423: ) { 1424: logForDebugging( 1425: `[bridge:repl] Detected system sleep (${Math.round((now - lastPollErrorTime) / 1000)}s gap), resetting poll error budget`, 1426: ) 1427: logForDiagnosticsNoPII('info', 'bridge_repl_poll_sleep_detected', { 1428: gapMs: now - lastPollErrorTime, 1429: }) 1430: consecutiveErrors = 0 1431: firstErrorTime = null 1432: } 1433: lastPollErrorTime = now 1434: consecutiveErrors++ 1435: if (firstErrorTime === null) { 1436: firstErrorTime = now 1437: } 1438: const elapsed = now - firstErrorTime 1439: const httpStatus = extractHttpStatus(err) 1440: const errMsg = describeAxiosError(err) 1441: const wsLabel = getWsState?.() ?? 'unknown' 1442: logForDebugging( 1443: `[bridge:repl] Poll error (attempt ${consecutiveErrors}, elapsed ${Math.round(elapsed / 1000)}s, ws=${wsLabel}): ${errMsg}`, 1444: ) 1445: logEvent('tengu_bridge_repl_poll_error', { 1446: status: httpStatus, 1447: consecutiveErrors, 1448: elapsedMs: elapsed, 1449: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 1450: if (consecutiveErrors === 1) { 1451: onStateChange?.('reconnecting', errMsg) 1452: } 1453: if (elapsed >= POLL_ERROR_GIVE_UP_MS) { 1454: logForDebugging( 1455: `[bridge:repl] Poll failures exceeded ${POLL_ERROR_GIVE_UP_MS / 1000}s (${consecutiveErrors} errors), giving up`, 1456: ) 1457: logForDiagnosticsNoPII('info', 'bridge_repl_poll_give_up') 1458: logEvent('tengu_bridge_repl_poll_give_up', { 1459: consecutiveErrors, 1460: elapsedMs: elapsed, 1461: lastStatus: httpStatus, 1462: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 1463: onStateChange?.('failed', 'connection to server lost') 1464: break 1465: } 1466: const backoff = Math.min( 1467: POLL_ERROR_INITIAL_DELAY_MS * 2 ** (consecutiveErrors - 1), 1468: POLL_ERROR_MAX_DELAY_MS, 1469: ) 1470: if (getPollIntervalConfig().non_exclusive_heartbeat_interval_ms > 0) { 1471: const info = getHeartbeatInfo?.() 1472: if (info) { 1473: try { 1474: await api.heartbeatWork( 1475: info.environmentId, 1476: info.workId, 1477: info.sessionToken, 1478: ) 1479: } catch { 1480: } 1481: } 1482: } 1483: await sleep(backoff, signal) 1484: } 1485: } 1486: logForDebugging( 1487: `[bridge:repl] Work poll loop ended (aborted=${signal.aborted}) env=${getCredentials().environmentId}`, 1488: ) 1489: } 1490: export { 1491: startWorkPollLoop as _startWorkPollLoopForTesting, 1492: POLL_ERROR_INITIAL_DELAY_MS as _POLL_ERROR_INITIAL_DELAY_MS_ForTesting, 1493: POLL_ERROR_MAX_DELAY_MS as _POLL_ERROR_MAX_DELAY_MS_ForTesting, 1494: POLL_ERROR_GIVE_UP_MS as _POLL_ERROR_GIVE_UP_MS_ForTesting, 1495: }

File: src/bridge/replBridgeHandle.ts

typescript 1: import { updateSessionBridgeId } from '../utils/concurrentSessions.js' 2: import type { ReplBridgeHandle } from './replBridge.js' 3: import { toCompatSessionId } from './sessionIdCompat.js' 4: let handle: ReplBridgeHandle | null = null 5: export function setReplBridgeHandle(h: ReplBridgeHandle | null): void { 6: handle = h 7: void updateSessionBridgeId(getSelfBridgeCompatId() ?? null).catch(() => {}) 8: } 9: export function getReplBridgeHandle(): ReplBridgeHandle | null { 10: return handle 11: } 12: export function getSelfBridgeCompatId(): string | undefined { 13: const h = getReplBridgeHandle() 14: return h ? toCompatSessionId(h.bridgeSessionId) : undefined 15: }

File: src/bridge/replBridgeTransport.ts

typescript 1: import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js' 2: import { CCRClient } from '../cli/transports/ccrClient.js' 3: import type { HybridTransport } from '../cli/transports/HybridTransport.js' 4: import { SSETransport } from '../cli/transports/SSETransport.js' 5: import { logForDebugging } from '../utils/debug.js' 6: import { errorMessage } from '../utils/errors.js' 7: import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js' 8: import type { SessionState } from '../utils/sessionState.js' 9: import { registerWorker } from './workSecret.js' 10: export type ReplBridgeTransport = { 11: write(message: StdoutMessage): Promise<void> 12: writeBatch(messages: StdoutMessage[]): Promise<void> 13: close(): void 14: isConnectedStatus(): boolean 15: getStateLabel(): string 16: setOnData(callback: (data: string) => void): void 17: setOnClose(callback: (closeCode?: number) => void): void 18: setOnConnect(callback: () => void): void 19: connect(): void 20: getLastSequenceNum(): number 21: readonly droppedBatchCount: number 22: reportState(state: SessionState): void 23: reportMetadata(metadata: Record<string, unknown>): void 24: reportDelivery(eventId: string, status: 'processing' | 'processed'): void 25: flush(): Promise<void> 26: } 27: export function createV1ReplTransport( 28: hybrid: HybridTransport, 29: ): ReplBridgeTransport { 30: return { 31: write: msg => hybrid.write(msg), 32: writeBatch: msgs => hybrid.writeBatch(msgs), 33: close: () => hybrid.close(), 34: isConnectedStatus: () => hybrid.isConnectedStatus(), 35: getStateLabel: () => hybrid.getStateLabel(), 36: setOnData: cb => hybrid.setOnData(cb), 37: setOnClose: cb => hybrid.setOnClose(cb), 38: setOnConnect: cb => hybrid.setOnConnect(cb), 39: connect: () => void hybrid.connect(), 40: getLastSequenceNum: () => 0, 41: get droppedBatchCount() { 42: return hybrid.droppedBatchCount 43: }, 44: reportState: () => {}, 45: reportMetadata: () => {}, 46: reportDelivery: () => {}, 47: flush: () => Promise.resolve(), 48: } 49: } 50: export async function createV2ReplTransport(opts: { 51: sessionUrl: string 52: ingressToken: string 53: sessionId: string 54: initialSequenceNum?: number 55: epoch?: number 56: heartbeatIntervalMs?: number 57: heartbeatJitterFraction?: number 58: outboundOnly?: boolean 59: getAuthToken?: () => string | undefined 60: }): Promise<ReplBridgeTransport> { 61: const { 62: sessionUrl, 63: ingressToken, 64: sessionId, 65: initialSequenceNum, 66: getAuthToken, 67: } = opts 68: let getAuthHeaders: (() => Record<string, string>) | undefined 69: if (getAuthToken) { 70: getAuthHeaders = (): Record<string, string> => { 71: const token = getAuthToken() 72: if (!token) return {} 73: return { Authorization: `Bearer ${token}` } 74: } 75: } else { 76: updateSessionIngressAuthToken(ingressToken) 77: } 78: const epoch = opts.epoch ?? (await registerWorker(sessionUrl, ingressToken)) 79: logForDebugging( 80: `[bridge:repl] CCR v2: worker sessionId=${sessionId} epoch=${epoch}${opts.epoch !== undefined ? ' (from /bridge)' : ' (via registerWorker)'}`, 81: ) 82: const sseUrl = new URL(sessionUrl) 83: sseUrl.pathname = sseUrl.pathname.replace(/\/$/, '') + '/worker/events/stream' 84: const sse = new SSETransport( 85: sseUrl, 86: {}, 87: sessionId, 88: undefined, 89: initialSequenceNum, 90: getAuthHeaders, 91: ) 92: let onCloseCb: ((closeCode?: number) => void) | undefined 93: const ccr = new CCRClient(sse, new URL(sessionUrl), { 94: getAuthHeaders, 95: heartbeatIntervalMs: opts.heartbeatIntervalMs, 96: heartbeatJitterFraction: opts.heartbeatJitterFraction, 97: onEpochMismatch: () => { 98: logForDebugging( 99: '[bridge:repl] CCR v2: epoch superseded (409) — closing for poll-loop recovery', 100: ) 101: try { 102: ccr.close() 103: sse.close() 104: onCloseCb?.(4090) 105: } catch (closeErr: unknown) { 106: logForDebugging( 107: `[bridge:repl] CCR v2: error during epoch-mismatch cleanup: ${errorMessage(closeErr)}`, 108: { level: 'error' }, 109: ) 110: } 111: throw new Error('epoch superseded') 112: }, 113: }) 114: sse.setOnEvent(event => { 115: ccr.reportDelivery(event.event_id, 'received') 116: ccr.reportDelivery(event.event_id, 'processed') 117: }) 118: let onConnectCb: (() => void) | undefined 119: let ccrInitialized = false 120: let closed = false 121: return { 122: write(msg) { 123: return ccr.writeEvent(msg) 124: }, 125: async writeBatch(msgs) { 126: for (const m of msgs) { 127: if (closed) break 128: await ccr.writeEvent(m) 129: } 130: }, 131: close() { 132: closed = true 133: ccr.close() 134: sse.close() 135: }, 136: isConnectedStatus() { 137: return ccrInitialized 138: }, 139: getStateLabel() { 140: if (sse.isClosedStatus()) return 'closed' 141: if (sse.isConnectedStatus()) return ccrInitialized ? 'connected' : 'init' 142: return 'connecting' 143: }, 144: setOnData(cb) { 145: sse.setOnData(cb) 146: }, 147: setOnClose(cb) { 148: onCloseCb = cb 149: sse.setOnClose(code => { 150: ccr.close() 151: cb(code ?? 4092) 152: }) 153: }, 154: setOnConnect(cb) { 155: onConnectCb = cb 156: }, 157: getLastSequenceNum() { 158: return sse.getLastSequenceNum() 159: }, 160: droppedBatchCount: 0, 161: reportState(state) { 162: ccr.reportState(state) 163: }, 164: reportMetadata(metadata) { 165: ccr.reportMetadata(metadata) 166: }, 167: reportDelivery(eventId, status) { 168: ccr.reportDelivery(eventId, status) 169: }, 170: flush() { 171: return ccr.flush() 172: }, 173: connect() { 174: if (!opts.outboundOnly) { 175: void sse.connect() 176: } 177: void ccr.initialize(epoch).then( 178: () => { 179: ccrInitialized = true 180: logForDebugging( 181: `[bridge:repl] v2 transport ready for writes (epoch=${epoch}, sse=${sse.isConnectedStatus() ? 'open' : 'opening'})`, 182: ) 183: onConnectCb?.() 184: }, 185: (err: unknown) => { 186: logForDebugging( 187: `[bridge:repl] CCR v2 initialize failed: ${errorMessage(err)}`, 188: { level: 'error' }, 189: ) 190: ccr.close() 191: sse.close() 192: onCloseCb?.(4091) 193: }, 194: ) 195: }, 196: } 197: }

File: src/bridge/sessionIdCompat.ts

typescript 1: let _isCseShimEnabled: (() => boolean) | undefined 2: export function setCseShimGate(gate: () => boolean): void { 3: _isCseShimEnabled = gate 4: } 5: export function toCompatSessionId(id: string): string { 6: if (!id.startsWith('cse_')) return id 7: if (_isCseShimEnabled && !_isCseShimEnabled()) return id 8: return 'session_' + id.slice('cse_'.length) 9: } 10: export function toInfraSessionId(id: string): string { 11: if (!id.startsWith('session_')) return id 12: return 'cse_' + id.slice('session_'.length) 13: }

File: src/bridge/sessionRunner.ts

typescript 1: import { type ChildProcess, spawn } from 'child_process' 2: import { createWriteStream, type WriteStream } from 'fs' 3: import { tmpdir } from 'os' 4: import { dirname, join } from 'path' 5: import { createInterface } from 'readline' 6: import { jsonParse, jsonStringify } from '../utils/slowOperations.js' 7: import { debugTruncate } from './debugUtils.js' 8: import type { 9: SessionActivity, 10: SessionDoneStatus, 11: SessionHandle, 12: SessionSpawner, 13: SessionSpawnOpts, 14: } from './types.js' 15: const MAX_ACTIVITIES = 10 16: const MAX_STDERR_LINES = 10 17: export function safeFilenameId(id: string): string { 18: return id.replace(/[^a-zA-Z0-9_-]/g, '_') 19: } 20: export type PermissionRequest = { 21: type: 'control_request' 22: request_id: string 23: request: { 24: subtype: 'can_use_tool' 25: tool_name: string 26: input: Record<string, unknown> 27: tool_use_id: string 28: } 29: } 30: type SessionSpawnerDeps = { 31: execPath: string 32: scriptArgs: string[] 33: env: NodeJS.ProcessEnv 34: verbose: boolean 35: sandbox: boolean 36: debugFile?: string 37: permissionMode?: string 38: onDebug: (msg: string) => void 39: onActivity?: (sessionId: string, activity: SessionActivity) => void 40: onPermissionRequest?: ( 41: sessionId: string, 42: request: PermissionRequest, 43: accessToken: string, 44: ) => void 45: } 46: const TOOL_VERBS: Record<string, string> = { 47: Read: 'Reading', 48: Write: 'Writing', 49: Edit: 'Editing', 50: MultiEdit: 'Editing', 51: Bash: 'Running', 52: Glob: 'Searching', 53: Grep: 'Searching', 54: WebFetch: 'Fetching', 55: WebSearch: 'Searching', 56: Task: 'Running task', 57: FileReadTool: 'Reading', 58: FileWriteTool: 'Writing', 59: FileEditTool: 'Editing', 60: GlobTool: 'Searching', 61: GrepTool: 'Searching', 62: BashTool: 'Running', 63: NotebookEditTool: 'Editing notebook', 64: LSP: 'LSP', 65: } 66: function toolSummary(name: string, input: Record<string, unknown>): string { 67: const verb = TOOL_VERBS[name] ?? name 68: const target = 69: (input.file_path as string) ?? 70: (input.filePath as string) ?? 71: (input.pattern as string) ?? 72: (input.command as string | undefined)?.slice(0, 60) ?? 73: (input.url as string) ?? 74: (input.query as string) ?? 75: '' 76: if (target) { 77: return `${verb} ${target}` 78: } 79: return verb 80: } 81: function extractActivities( 82: line: string, 83: sessionId: string, 84: onDebug: (msg: string) => void, 85: ): SessionActivity[] { 86: let parsed: unknown 87: try { 88: parsed = jsonParse(line) 89: } catch { 90: return [] 91: } 92: if (!parsed || typeof parsed !== 'object') { 93: return [] 94: } 95: const msg = parsed as Record<string, unknown> 96: const activities: SessionActivity[] = [] 97: const now = Date.now() 98: switch (msg.type) { 99: case 'assistant': { 100: const message = msg.message as Record<string, unknown> | undefined 101: if (!message) break 102: const content = message.content 103: if (!Array.isArray(content)) break 104: for (const block of content) { 105: if (!block || typeof block !== 'object') continue 106: const b = block as Record<string, unknown> 107: if (b.type === 'tool_use') { 108: const name = (b.name as string) ?? 'Tool' 109: const input = (b.input as Record<string, unknown>) ?? {} 110: const summary = toolSummary(name, input) 111: activities.push({ 112: type: 'tool_start', 113: summary, 114: timestamp: now, 115: }) 116: onDebug( 117: `[bridge:activity] sessionId=${sessionId} tool_use name=${name} ${inputPreview(input)}`, 118: ) 119: } else if (b.type === 'text') { 120: const text = (b.text as string) ?? '' 121: if (text.length > 0) { 122: activities.push({ 123: type: 'text', 124: summary: text.slice(0, 80), 125: timestamp: now, 126: }) 127: onDebug( 128: `[bridge:activity] sessionId=${sessionId} text "${text.slice(0, 100)}"`, 129: ) 130: } 131: } 132: } 133: break 134: } 135: case 'result': { 136: const subtype = msg.subtype as string | undefined 137: if (subtype === 'success') { 138: activities.push({ 139: type: 'result', 140: summary: 'Session completed', 141: timestamp: now, 142: }) 143: onDebug( 144: `[bridge:activity] sessionId=${sessionId} result subtype=success`, 145: ) 146: } else if (subtype) { 147: const errors = msg.errors as string[] | undefined 148: const errorSummary = errors?.[0] ?? `Error: ${subtype}` 149: activities.push({ 150: type: 'error', 151: summary: errorSummary, 152: timestamp: now, 153: }) 154: onDebug( 155: `[bridge:activity] sessionId=${sessionId} result subtype=${subtype} error="${errorSummary}"`, 156: ) 157: } else { 158: onDebug( 159: `[bridge:activity] sessionId=${sessionId} result subtype=undefined`, 160: ) 161: } 162: break 163: } 164: default: 165: break 166: } 167: return activities 168: } 169: function extractUserMessageText( 170: msg: Record<string, unknown>, 171: ): string | undefined { 172: if (msg.parent_tool_use_id != null || msg.isSynthetic || msg.isReplay) 173: return undefined 174: const message = msg.message as Record<string, unknown> | undefined 175: const content = message?.content 176: let text: string | undefined 177: if (typeof content === 'string') { 178: text = content 179: } else if (Array.isArray(content)) { 180: for (const block of content) { 181: if ( 182: block && 183: typeof block === 'object' && 184: (block as Record<string, unknown>).type === 'text' 185: ) { 186: text = (block as Record<string, unknown>).text as string | undefined 187: break 188: } 189: } 190: } 191: text = text?.trim() 192: return text ? text : undefined 193: } 194: function inputPreview(input: Record<string, unknown>): string { 195: const parts: string[] = [] 196: for (const [key, val] of Object.entries(input)) { 197: if (typeof val === 'string') { 198: parts.push(`${key}="${val.slice(0, 100)}"`) 199: } 200: if (parts.length >= 3) break 201: } 202: return parts.join(' ') 203: } 204: export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner { 205: return { 206: spawn(opts: SessionSpawnOpts, dir: string): SessionHandle { 207: const safeId = safeFilenameId(opts.sessionId) 208: let debugFile: string | undefined 209: if (deps.debugFile) { 210: const ext = deps.debugFile.lastIndexOf('.') 211: if (ext > 0) { 212: debugFile = `${deps.debugFile.slice(0, ext)}-${safeId}${deps.debugFile.slice(ext)}` 213: } else { 214: debugFile = `${deps.debugFile}-${safeId}` 215: } 216: } else if (deps.verbose || process.env.USER_TYPE === 'ant') { 217: debugFile = join(tmpdir(), 'claude', `bridge-session-${safeId}.log`) 218: } 219: let transcriptStream: WriteStream | null = null 220: let transcriptPath: string | undefined 221: if (deps.debugFile) { 222: transcriptPath = join( 223: dirname(deps.debugFile), 224: `bridge-transcript-${safeId}.jsonl`, 225: ) 226: transcriptStream = createWriteStream(transcriptPath, { flags: 'a' }) 227: transcriptStream.on('error', err => { 228: deps.onDebug( 229: `[bridge:session] Transcript write error: ${err.message}`, 230: ) 231: transcriptStream = null 232: }) 233: deps.onDebug(`[bridge:session] Transcript log: ${transcriptPath}`) 234: } 235: const args = [ 236: ...deps.scriptArgs, 237: '--print', 238: '--sdk-url', 239: opts.sdkUrl, 240: '--session-id', 241: opts.sessionId, 242: '--input-format', 243: 'stream-json', 244: '--output-format', 245: 'stream-json', 246: '--replay-user-messages', 247: ...(deps.verbose ? ['--verbose'] : []), 248: ...(debugFile ? ['--debug-file', debugFile] : []), 249: ...(deps.permissionMode 250: ? ['--permission-mode', deps.permissionMode] 251: : []), 252: ] 253: const env: NodeJS.ProcessEnv = { 254: ...deps.env, 255: CLAUDE_CODE_OAUTH_TOKEN: undefined, 256: CLAUDE_CODE_ENVIRONMENT_KIND: 'bridge', 257: ...(deps.sandbox && { CLAUDE_CODE_FORCE_SANDBOX: '1' }), 258: CLAUDE_CODE_SESSION_ACCESS_TOKEN: opts.accessToken, 259: CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2: '1', 260: ...(opts.useCcrV2 && { 261: CLAUDE_CODE_USE_CCR_V2: '1', 262: CLAUDE_CODE_WORKER_EPOCH: String(opts.workerEpoch), 263: }), 264: } 265: deps.onDebug( 266: `[bridge:session] Spawning sessionId=${opts.sessionId} sdkUrl=${opts.sdkUrl} accessToken=${opts.accessToken ? 'present' : 'MISSING'}`, 267: ) 268: deps.onDebug(`[bridge:session] Child args: ${args.join(' ')}`) 269: if (debugFile) { 270: deps.onDebug(`[bridge:session] Debug log: ${debugFile}`) 271: } 272: const child: ChildProcess = spawn(deps.execPath, args, { 273: cwd: dir, 274: stdio: ['pipe', 'pipe', 'pipe'], 275: env, 276: windowsHide: true, 277: }) 278: deps.onDebug( 279: `[bridge:session] sessionId=${opts.sessionId} pid=${child.pid}`, 280: ) 281: const activities: SessionActivity[] = [] 282: let currentActivity: SessionActivity | null = null 283: const lastStderr: string[] = [] 284: let sigkillSent = false 285: let firstUserMessageSeen = false 286: if (child.stderr) { 287: const stderrRl = createInterface({ input: child.stderr }) 288: stderrRl.on('line', line => { 289: if (deps.verbose) { 290: process.stderr.write(line + '\n') 291: } 292: if (lastStderr.length >= MAX_STDERR_LINES) { 293: lastStderr.shift() 294: } 295: lastStderr.push(line) 296: }) 297: } 298: if (child.stdout) { 299: const rl = createInterface({ input: child.stdout }) 300: rl.on('line', line => { 301: if (transcriptStream) { 302: transcriptStream.write(line + '\n') 303: } 304: deps.onDebug( 305: `[bridge:ws] sessionId=${opts.sessionId} <<< ${debugTruncate(line)}`, 306: ) 307: if (deps.verbose) { 308: process.stderr.write(line + '\n') 309: } 310: const extracted = extractActivities( 311: line, 312: opts.sessionId, 313: deps.onDebug, 314: ) 315: for (const activity of extracted) { 316: if (activities.length >= MAX_ACTIVITIES) { 317: activities.shift() 318: } 319: activities.push(activity) 320: currentActivity = activity 321: deps.onActivity?.(opts.sessionId, activity) 322: } 323: { 324: let parsed: unknown 325: try { 326: parsed = jsonParse(line) 327: } catch { 328: } 329: if (parsed && typeof parsed === 'object') { 330: const msg = parsed as Record<string, unknown> 331: if (msg.type === 'control_request') { 332: const request = msg.request as 333: | Record<string, unknown> 334: | undefined 335: if ( 336: request?.subtype === 'can_use_tool' && 337: deps.onPermissionRequest 338: ) { 339: deps.onPermissionRequest( 340: opts.sessionId, 341: parsed as PermissionRequest, 342: opts.accessToken, 343: ) 344: } 345: } else if ( 346: msg.type === 'user' && 347: !firstUserMessageSeen && 348: opts.onFirstUserMessage 349: ) { 350: const text = extractUserMessageText(msg) 351: if (text) { 352: firstUserMessageSeen = true 353: opts.onFirstUserMessage(text) 354: } 355: } 356: } 357: } 358: }) 359: } 360: const done = new Promise<SessionDoneStatus>(resolve => { 361: child.on('close', (code, signal) => { 362: if (transcriptStream) { 363: transcriptStream.end() 364: transcriptStream = null 365: } 366: if (signal === 'SIGTERM' || signal === 'SIGINT') { 367: deps.onDebug( 368: `[bridge:session] sessionId=${opts.sessionId} interrupted signal=${signal} pid=${child.pid}`, 369: ) 370: resolve('interrupted') 371: } else if (code === 0) { 372: deps.onDebug( 373: `[bridge:session] sessionId=${opts.sessionId} completed exit_code=0 pid=${child.pid}`, 374: ) 375: resolve('completed') 376: } else { 377: deps.onDebug( 378: `[bridge:session] sessionId=${opts.sessionId} failed exit_code=${code} pid=${child.pid}`, 379: ) 380: resolve('failed') 381: } 382: }) 383: child.on('error', err => { 384: deps.onDebug( 385: `[bridge:session] sessionId=${opts.sessionId} spawn error: ${err.message}`, 386: ) 387: resolve('failed') 388: }) 389: }) 390: const handle: SessionHandle = { 391: sessionId: opts.sessionId, 392: done, 393: activities, 394: accessToken: opts.accessToken, 395: lastStderr, 396: get currentActivity(): SessionActivity | null { 397: return currentActivity 398: }, 399: kill(): void { 400: if (!child.killed) { 401: deps.onDebug( 402: `[bridge:session] Sending SIGTERM to sessionId=${opts.sessionId} pid=${child.pid}`, 403: ) 404: if (process.platform === 'win32') { 405: child.kill() 406: } else { 407: child.kill('SIGTERM') 408: } 409: } 410: }, 411: forceKill(): void { 412: if (!sigkillSent && child.pid) { 413: sigkillSent = true 414: deps.onDebug( 415: `[bridge:session] Sending SIGKILL to sessionId=${opts.sessionId} pid=${child.pid}`, 416: ) 417: if (process.platform === 'win32') { 418: child.kill() 419: } else { 420: child.kill('SIGKILL') 421: } 422: } 423: }, 424: writeStdin(data: string): void { 425: if (child.stdin && !child.stdin.destroyed) { 426: deps.onDebug( 427: `[bridge:ws] sessionId=${opts.sessionId} >>> ${debugTruncate(data)}`, 428: ) 429: child.stdin.write(data) 430: } 431: }, 432: updateAccessToken(token: string): void { 433: handle.accessToken = token 434: handle.writeStdin( 435: jsonStringify({ 436: type: 'update_environment_variables', 437: variables: { CLAUDE_CODE_SESSION_ACCESS_TOKEN: token }, 438: }) + '\n', 439: ) 440: deps.onDebug( 441: `[bridge:session] Sent token refresh via stdin for sessionId=${opts.sessionId}`, 442: ) 443: }, 444: } 445: return handle 446: }, 447: } 448: } 449: export { extractActivities as _extractActivitiesForTesting }

File: src/bridge/trustedDevice.ts

typescript 1: import axios from 'axios' 2: import memoize from 'lodash-es/memoize.js' 3: import { hostname } from 'os' 4: import { getOauthConfig } from '../constants/oauth.js' 5: import { 6: checkGate_CACHED_OR_BLOCKING, 7: getFeatureValue_CACHED_MAY_BE_STALE, 8: } from '../services/analytics/growthbook.js' 9: import { logForDebugging } from '../utils/debug.js' 10: import { errorMessage } from '../utils/errors.js' 11: import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' 12: import { getSecureStorage } from '../utils/secureStorage/index.js' 13: import { jsonStringify } from '../utils/slowOperations.js' 14: const TRUSTED_DEVICE_GATE = 'tengu_sessions_elevated_auth_enforcement' 15: function isGateEnabled(): boolean { 16: return getFeatureValue_CACHED_MAY_BE_STALE(TRUSTED_DEVICE_GATE, false) 17: } 18: const readStoredToken = memoize((): string | undefined => { 19: const envToken = process.env.CLAUDE_TRUSTED_DEVICE_TOKEN 20: if (envToken) { 21: return envToken 22: } 23: return getSecureStorage().read()?.trustedDeviceToken 24: }) 25: export function getTrustedDeviceToken(): string | undefined { 26: if (!isGateEnabled()) { 27: return undefined 28: } 29: return readStoredToken() 30: } 31: export function clearTrustedDeviceTokenCache(): void { 32: readStoredToken.cache?.clear?.() 33: } 34: export function clearTrustedDeviceToken(): void { 35: if (!isGateEnabled()) { 36: return 37: } 38: const secureStorage = getSecureStorage() 39: try { 40: const data = secureStorage.read() 41: if (data?.trustedDeviceToken) { 42: delete data.trustedDeviceToken 43: secureStorage.update(data) 44: } 45: } catch { 46: } 47: readStoredToken.cache?.clear?.() 48: } 49: export async function enrollTrustedDevice(): Promise<void> { 50: try { 51: if (!(await checkGate_CACHED_OR_BLOCKING(TRUSTED_DEVICE_GATE))) { 52: logForDebugging( 53: `[trusted-device] Gate ${TRUSTED_DEVICE_GATE} is off, skipping enrollment`, 54: ) 55: return 56: } 57: if (process.env.CLAUDE_TRUSTED_DEVICE_TOKEN) { 58: logForDebugging( 59: '[trusted-device] CLAUDE_TRUSTED_DEVICE_TOKEN env var is set, skipping enrollment (env var takes precedence)', 60: ) 61: return 62: } 63: const { getClaudeAIOAuthTokens } = 64: require('../utils/auth.js') as typeof import('../utils/auth.js') 65: const accessToken = getClaudeAIOAuthTokens()?.accessToken 66: if (!accessToken) { 67: logForDebugging('[trusted-device] No OAuth token, skipping enrollment') 68: return 69: } 70: const secureStorage = getSecureStorage() 71: if (isEssentialTrafficOnly()) { 72: logForDebugging( 73: '[trusted-device] Essential traffic only, skipping enrollment', 74: ) 75: return 76: } 77: const baseUrl = getOauthConfig().BASE_API_URL 78: let response 79: try { 80: response = await axios.post<{ 81: device_token?: string 82: device_id?: string 83: }>( 84: `${baseUrl}/api/auth/trusted_devices`, 85: { display_name: `Claude Code on ${hostname()} · ${process.platform}` }, 86: { 87: headers: { 88: Authorization: `Bearer ${accessToken}`, 89: 'Content-Type': 'application/json', 90: }, 91: timeout: 10_000, 92: validateStatus: s => s < 500, 93: }, 94: ) 95: } catch (err: unknown) { 96: logForDebugging( 97: `[trusted-device] Enrollment request failed: ${errorMessage(err)}`, 98: ) 99: return 100: } 101: if (response.status !== 200 && response.status !== 201) { 102: logForDebugging( 103: `[trusted-device] Enrollment failed ${response.status}: ${jsonStringify(response.data).slice(0, 200)}`, 104: ) 105: return 106: } 107: const token = response.data?.device_token 108: if (!token || typeof token !== 'string') { 109: logForDebugging( 110: '[trusted-device] Enrollment response missing device_token field', 111: ) 112: return 113: } 114: try { 115: const storageData = secureStorage.read() 116: if (!storageData) { 117: logForDebugging( 118: '[trusted-device] Cannot read storage, skipping token persist', 119: ) 120: return 121: } 122: storageData.trustedDeviceToken = token 123: const result = secureStorage.update(storageData) 124: if (!result.success) { 125: logForDebugging( 126: `[trusted-device] Failed to persist token: ${result.warning ?? 'unknown'}`, 127: ) 128: return 129: } 130: readStoredToken.cache?.clear?.() 131: logForDebugging( 132: `[trusted-device] Enrolled device_id=${response.data.device_id ?? 'unknown'}`, 133: ) 134: } catch (err: unknown) { 135: logForDebugging( 136: `[trusted-device] Storage write failed: ${errorMessage(err)}`, 137: ) 138: } 139: } catch (err: unknown) { 140: logForDebugging(`[trusted-device] Enrollment error: ${errorMessage(err)}`) 141: } 142: }

File: src/bridge/types.ts

typescript 1: export const DEFAULT_SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000 2: export const BRIDGE_LOGIN_INSTRUCTION = 3: 'Remote Control is only available with claude.ai subscriptions. Please use `/login` to sign in with your claude.ai account.' 4: export const BRIDGE_LOGIN_ERROR = 5: 'Error: You must be logged in to use Remote Control.\n\n' + 6: BRIDGE_LOGIN_INSTRUCTION 7: export const REMOTE_CONTROL_DISCONNECTED_MSG = 'Remote Control disconnected.' 8: export type WorkData = { 9: type: 'session' | 'healthcheck' 10: id: string 11: } 12: export type WorkResponse = { 13: id: string 14: type: 'work' 15: environment_id: string 16: state: string 17: data: WorkData 18: secret: string 19: created_at: string 20: } 21: export type WorkSecret = { 22: version: number 23: session_ingress_token: string 24: api_base_url: string 25: sources: Array<{ 26: type: string 27: git_info?: { type: string; repo: string; ref?: string; token?: string } 28: }> 29: auth: Array<{ type: string; token: string }> 30: claude_code_args?: Record<string, string> | null 31: mcp_config?: unknown | null 32: environment_variables?: Record<string, string> | null 33: use_code_sessions?: boolean 34: } 35: export type SessionDoneStatus = 'completed' | 'failed' | 'interrupted' 36: export type SessionActivityType = 'tool_start' | 'text' | 'result' | 'error' 37: export type SessionActivity = { 38: type: SessionActivityType 39: summary: string 40: timestamp: number 41: } 42: export type SpawnMode = 'single-session' | 'worktree' | 'same-dir' 43: export type BridgeWorkerType = 'claude_code' | 'claude_code_assistant' 44: export type BridgeConfig = { 45: dir: string 46: machineName: string 47: branch: string 48: gitRepoUrl: string | null 49: maxSessions: number 50: spawnMode: SpawnMode 51: verbose: boolean 52: sandbox: boolean 53: bridgeId: string 54: workerType: string 55: environmentId: string 56: reuseEnvironmentId?: string 57: apiBaseUrl: string 58: sessionIngressUrl: string 59: debugFile?: string 60: sessionTimeoutMs?: number 61: } 62: export type PermissionResponseEvent = { 63: type: 'control_response' 64: response: { 65: subtype: 'success' 66: request_id: string 67: response: Record<string, unknown> 68: } 69: } 70: export type BridgeApiClient = { 71: registerBridgeEnvironment(config: BridgeConfig): Promise<{ 72: environment_id: string 73: environment_secret: string 74: }> 75: pollForWork( 76: environmentId: string, 77: environmentSecret: string, 78: signal?: AbortSignal, 79: reclaimOlderThanMs?: number, 80: ): Promise<WorkResponse | null> 81: acknowledgeWork( 82: environmentId: string, 83: workId: string, 84: sessionToken: string, 85: ): Promise<void> 86: stopWork(environmentId: string, workId: string, force: boolean): Promise<void> 87: deregisterEnvironment(environmentId: string): Promise<void> 88: sendPermissionResponseEvent( 89: sessionId: string, 90: event: PermissionResponseEvent, 91: sessionToken: string, 92: ): Promise<void> 93: archiveSession(sessionId: string): Promise<void> 94: reconnectSession(environmentId: string, sessionId: string): Promise<void> 95: heartbeatWork( 96: environmentId: string, 97: workId: string, 98: sessionToken: string, 99: ): Promise<{ lease_extended: boolean; state: string }> 100: } 101: export type SessionHandle = { 102: sessionId: string 103: done: Promise<SessionDoneStatus> 104: kill(): void 105: forceKill(): void 106: activities: SessionActivity[] 107: currentActivity: SessionActivity | null 108: accessToken: string 109: lastStderr: string[] 110: writeStdin(data: string): void 111: updateAccessToken(token: string): void 112: } 113: export type SessionSpawnOpts = { 114: sessionId: string 115: sdkUrl: string 116: accessToken: string 117: useCcrV2?: boolean 118: workerEpoch?: number 119: onFirstUserMessage?: (text: string) => void 120: } 121: export type SessionSpawner = { 122: spawn(opts: SessionSpawnOpts, dir: string): SessionHandle 123: } 124: export type BridgeLogger = { 125: printBanner(config: BridgeConfig, environmentId: string): void 126: logSessionStart(sessionId: string, prompt: string): void 127: logSessionComplete(sessionId: string, durationMs: number): void 128: logSessionFailed(sessionId: string, error: string): void 129: logStatus(message: string): void 130: logVerbose(message: string): void 131: logError(message: string): void 132: logReconnected(disconnectedMs: number): void 133: updateIdleStatus(): void 134: updateReconnectingStatus(delayStr: string, elapsedStr: string): void 135: updateSessionStatus( 136: sessionId: string, 137: elapsed: string, 138: activity: SessionActivity, 139: trail: string[], 140: ): void 141: clearStatus(): void 142: setRepoInfo(repoName: string, branch: string): void 143: setDebugLogPath(path: string): void 144: setAttached(sessionId: string): void 145: updateFailedStatus(error: string): void 146: toggleQr(): void 147: updateSessionCount(active: number, max: number, mode: SpawnMode): void 148: setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void 149: addSession(sessionId: string, url: string): void 150: updateSessionActivity(sessionId: string, activity: SessionActivity): void 151: setSessionTitle(sessionId: string, title: string): void 152: removeSession(sessionId: string): void 153: refreshDisplay(): void 154: }

File: src/bridge/workSecret.ts

typescript 1: import axios from 'axios' 2: import { jsonParse, jsonStringify } from '../utils/slowOperations.js' 3: import type { WorkSecret } from './types.js' 4: export function decodeWorkSecret(secret: string): WorkSecret { 5: const json = Buffer.from(secret, 'base64url').toString('utf-8') 6: const parsed: unknown = jsonParse(json) 7: if ( 8: !parsed || 9: typeof parsed !== 'object' || 10: !('version' in parsed) || 11: parsed.version !== 1 12: ) { 13: throw new Error( 14: `Unsupported work secret version: ${parsed && typeof parsed === 'object' && 'version' in parsed ? parsed.version : 'unknown'}`, 15: ) 16: } 17: const obj = parsed as Record<string, unknown> 18: if ( 19: typeof obj.session_ingress_token !== 'string' || 20: obj.session_ingress_token.length === 0 21: ) { 22: throw new Error( 23: 'Invalid work secret: missing or empty session_ingress_token', 24: ) 25: } 26: if (typeof obj.api_base_url !== 'string') { 27: throw new Error('Invalid work secret: missing api_base_url') 28: } 29: return parsed as WorkSecret 30: } 31: export function buildSdkUrl(apiBaseUrl: string, sessionId: string): string { 32: const isLocalhost = 33: apiBaseUrl.includes('localhost') || apiBaseUrl.includes('127.0.0.1') 34: const protocol = isLocalhost ? 'ws' : 'wss' 35: const version = isLocalhost ? 'v2' : 'v1' 36: const host = apiBaseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '') 37: return `${protocol}://${host}/${version}/session_ingress/ws/${sessionId}` 38: } 39: /** 40: * Compare two session IDs regardless of their tagged-ID prefix. 41: * 42: * Tagged IDs have the form {tag}_{body} or {tag}_staging_{body}, where the 43: * body encodes a UUID. CCR v2's compat layer returns `session_*` to v1 API 44: * clients (compat/convert.go:41) but the infrastructure layer (sandbox-gateway 45: * work queue, work poll response) uses `cse_*` (compat/CLAUDE.md:13). Both 46: * have the same underlying UUID. 47: * 48: * Without this, replBridge rejects its own session as "foreign" at the 49: * work-received check when the ccr_v2_compat_enabled gate is on. 50: */ 51: export function sameSessionId(a: string, b: string): boolean { 52: if (a === b) return true 53: const aBody = a.slice(a.lastIndexOf('_') + 1) 54: const bBody = b.slice(b.lastIndexOf('_') + 1) 55: return aBody.length >= 4 && aBody === bBody 56: } 57: export function buildCCRv2SdkUrl( 58: apiBaseUrl: string, 59: sessionId: string, 60: ): string { 61: const base = apiBaseUrl.replace(/\/+$/, '') 62: return `${base}/v1/code/sessions/${sessionId}` 63: } 64: /** 65: * Register this bridge as the worker for a CCR v2 session. 66: * Returns the worker_epoch, which must be passed to the child CC process 67: * so its CCRClient can include it in every heartbeat/state/event request. 68: * 69: * Mirrors what environment-manager does in the container path 70: * (api-go/environment-manager/cmd/cmd_task_run.go RegisterWorker). 71: */ 72: export async function registerWorker( 73: sessionUrl: string, 74: accessToken: string, 75: ): Promise<number> { 76: const response = await axios.post( 77: `${sessionUrl}/worker/register`, 78: {}, 79: { 80: headers: { 81: Authorization: `Bearer ${accessToken}`, 82: 'Content-Type': 'application/json', 83: 'anthropic-version': '2023-06-01', 84: }, 85: timeout: 10_000, 86: }, 87: ) 88: const raw = response.data?.worker_epoch 89: const epoch = typeof raw === 'string' ? Number(raw) : raw 90: if ( 91: typeof epoch !== 'number' || 92: !Number.isFinite(epoch) || 93: !Number.isSafeInteger(epoch) 94: ) { 95: throw new Error( 96: `registerWorker: invalid worker_epoch in response: ${jsonStringify(response.data)}`, 97: ) 98: } 99: return epoch 100: }

File: src/buddy/companion.ts

typescript 1: import { getGlobalConfig } from '../utils/config.js' 2: import { 3: type Companion, 4: type CompanionBones, 5: EYES, 6: HATS, 7: RARITIES, 8: RARITY_WEIGHTS, 9: type Rarity, 10: SPECIES, 11: STAT_NAMES, 12: type StatName, 13: } from './types.js' 14: function mulberry32(seed: number): () => number { 15: let a = seed >>> 0 16: return function () { 17: a |= 0 18: a = (a + 0x6d2b79f5) | 0 19: let t = Math.imul(a ^ (a >>> 15), 1 | a) 20: t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t 21: return ((t ^ (t >>> 14)) >>> 0) / 4294967296 22: } 23: } 24: function hashString(s: string): number { 25: if (typeof Bun !== 'undefined') { 26: return Number(BigInt(Bun.hash(s)) & 0xffffffffn) 27: } 28: let h = 2166136261 29: for (let i = 0; i < s.length; i++) { 30: h ^= s.charCodeAt(i) 31: h = Math.imul(h, 16777619) 32: } 33: return h >>> 0 34: } 35: function pick<T>(rng: () => number, arr: readonly T[]): T { 36: return arr[Math.floor(rng() * arr.length)]! 37: } 38: function rollRarity(rng: () => number): Rarity { 39: const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0) 40: let roll = rng() * total 41: for (const rarity of RARITIES) { 42: roll -= RARITY_WEIGHTS[rarity] 43: if (roll < 0) return rarity 44: } 45: return 'common' 46: } 47: const RARITY_FLOOR: Record<Rarity, number> = { 48: common: 5, 49: uncommon: 15, 50: rare: 25, 51: epic: 35, 52: legendary: 50, 53: } 54: function rollStats( 55: rng: () => number, 56: rarity: Rarity, 57: ): Record<StatName, number> { 58: const floor = RARITY_FLOOR[rarity] 59: const peak = pick(rng, STAT_NAMES) 60: let dump = pick(rng, STAT_NAMES) 61: while (dump === peak) dump = pick(rng, STAT_NAMES) 62: const stats = {} as Record<StatName, number> 63: for (const name of STAT_NAMES) { 64: if (name === peak) { 65: stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30)) 66: } else if (name === dump) { 67: stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15)) 68: } else { 69: stats[name] = floor + Math.floor(rng() * 40) 70: } 71: } 72: return stats 73: } 74: const SALT = 'friend-2026-401' 75: export type Roll = { 76: bones: CompanionBones 77: inspirationSeed: number 78: } 79: function rollFrom(rng: () => number): Roll { 80: const rarity = rollRarity(rng) 81: const bones: CompanionBones = { 82: rarity, 83: species: pick(rng, SPECIES), 84: eye: pick(rng, EYES), 85: hat: rarity === 'common' ? 'none' : pick(rng, HATS), 86: shiny: rng() < 0.01, 87: stats: rollStats(rng, rarity), 88: } 89: return { bones, inspirationSeed: Math.floor(rng() * 1e9) } 90: } 91: let rollCache: { key: string; value: Roll } | undefined 92: export function roll(userId: string): Roll { 93: const key = userId + SALT 94: if (rollCache?.key === key) return rollCache.value 95: const value = rollFrom(mulberry32(hashString(key))) 96: rollCache = { key, value } 97: return value 98: } 99: export function rollWithSeed(seed: string): Roll { 100: return rollFrom(mulberry32(hashString(seed))) 101: } 102: export function companionUserId(): string { 103: const config = getGlobalConfig() 104: return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon' 105: } 106: export function getCompanion(): Companion | undefined { 107: const stored = getGlobalConfig().companion 108: if (!stored) return undefined 109: const { bones } = roll(companionUserId()) 110: return { ...stored, ...bones } 111: }

File: src/buddy/CompanionSprite.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { feature } from 'bun:bundle'; 3: import figures from 'figures'; 4: import React, { useEffect, useRef, useState } from 'react'; 5: import { useTerminalSize } from '../hooks/useTerminalSize.js'; 6: import { stringWidth } from '../ink/stringWidth.js'; 7: import { Box, Text } from '../ink.js'; 8: import { useAppState, useSetAppState } from '../state/AppState.js'; 9: import type { AppState } from '../state/AppStateStore.js'; 10: import { getGlobalConfig } from '../utils/config.js'; 11: import { isFullscreenActive } from '../utils/fullscreen.js'; 12: import type { Theme } from '../utils/theme.js'; 13: import { getCompanion } from './companion.js'; 14: import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'; 15: import { RARITY_COLORS } from './types.js'; 16: const TICK_MS = 500; 17: const BUBBLE_SHOW = 20; 18: const FADE_WINDOW = 6; 19: const PET_BURST_MS = 2500; 20: const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]; 21: const H = figures.heart; 22: const PET_HEARTS = [` ${H} ${H} `, ` ${H} ${H} ${H} `, ` ${H} ${H} ${H} `, `${H} ${H} ${H} `, '· · · ']; 23: function wrap(text: string, width: number): string[] { 24: const words = text.split(' '); 25: const lines: string[] = []; 26: let cur = ''; 27: for (const w of words) { 28: if (cur.length + w.length + 1 > width && cur) { 29: lines.push(cur); 30: cur = w; 31: } else { 32: cur = cur ? `${cur} ${w}` : w; 33: } 34: } 35: if (cur) lines.push(cur); 36: return lines; 37: } 38: function SpeechBubble(t0) { 39: const $ = _c(31); 40: const { 41: text, 42: color, 43: fading, 44: tail 45: } = t0; 46: let T0; 47: let borderColor; 48: let t1; 49: let t2; 50: let t3; 51: let t4; 52: let t5; 53: let t6; 54: if ($[0] !== color || $[1] !== fading || $[2] !== text) { 55: const lines = wrap(text, 30); 56: borderColor = fading ? "inactive" : color; 57: T0 = Box; 58: t1 = "column"; 59: t2 = "round"; 60: t3 = borderColor; 61: t4 = 1; 62: t5 = 34; 63: let t7; 64: if ($[11] !== fading) { 65: t7 = (l, i) => <Text key={i} italic={true} dimColor={!fading} color={fading ? "inactive" : undefined}>{l}</Text>; 66: $[11] = fading; 67: $[12] = t7; 68: } else { 69: t7 = $[12]; 70: } 71: t6 = lines.map(t7); 72: $[0] = color; 73: $[1] = fading; 74: $[2] = text; 75: $[3] = T0; 76: $[4] = borderColor; 77: $[5] = t1; 78: $[6] = t2; 79: $[7] = t3; 80: $[8] = t4; 81: $[9] = t5; 82: $[10] = t6; 83: } else { 84: T0 = $[3]; 85: borderColor = $[4]; 86: t1 = $[5]; 87: t2 = $[6]; 88: t3 = $[7]; 89: t4 = $[8]; 90: t5 = $[9]; 91: t6 = $[10]; 92: } 93: let t7; 94: if ($[13] !== T0 || $[14] !== t1 || $[15] !== t2 || $[16] !== t3 || $[17] !== t4 || $[18] !== t5 || $[19] !== t6) { 95: t7 = <T0 flexDirection={t1} borderStyle={t2} borderColor={t3} paddingX={t4} width={t5}>{t6}</T0>; 96: $[13] = T0; 97: $[14] = t1; 98: $[15] = t2; 99: $[16] = t3; 100: $[17] = t4; 101: $[18] = t5; 102: $[19] = t6; 103: $[20] = t7; 104: } else { 105: t7 = $[20]; 106: } 107: const bubble = t7; 108: if (tail === "right") { 109: let t8; 110: if ($[21] !== borderColor) { 111: t8 = <Text color={borderColor}>─</Text>; 112: $[21] = borderColor; 113: $[22] = t8; 114: } else { 115: t8 = $[22]; 116: } 117: let t9; 118: if ($[23] !== bubble || $[24] !== t8) { 119: t9 = <Box flexDirection="row" alignItems="center">{bubble}{t8}</Box>; 120: $[23] = bubble; 121: $[24] = t8; 122: $[25] = t9; 123: } else { 124: t9 = $[25]; 125: } 126: return t9; 127: } 128: let t8; 129: if ($[26] !== borderColor) { 130: t8 = <Box flexDirection="column" alignItems="flex-end" paddingRight={6}><Text color={borderColor}>╲ </Text><Text color={borderColor}>╲</Text></Box>; 131: $[26] = borderColor; 132: $[27] = t8; 133: } else { 134: t8 = $[27]; 135: } 136: let t9; 137: if ($[28] !== bubble || $[29] !== t8) { 138: t9 = <Box flexDirection="column" alignItems="flex-end" marginRight={1}>{bubble}{t8}</Box>; 139: $[28] = bubble; 140: $[29] = t8; 141: $[30] = t9; 142: } else { 143: t9 = $[30]; 144: } 145: return t9; 146: } 147: export const MIN_COLS_FOR_FULL_SPRITE = 100; 148: const SPRITE_BODY_WIDTH = 12; 149: const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name ` 150: const SPRITE_PADDING_X = 2; 151: const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column 152: const NARROW_QUIP_CAP = 24; 153: function spriteColWidth(nameWidth: number): number { 154: return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD); 155: } 156: // Width the sprite area consumes. PromptInput subtracts this so text wraps 157: // correctly. In fullscreen the bubble floats over scrollback (no extra 158: // width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more. 159: // Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row 160: // (above input in fullscreen, below in scrollback), so no reservation. 161: export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { 162: if (!feature('BUDDY')) return 0; 163: const companion = getCompanion(); 164: if (!companion || getGlobalConfig().companionMuted) return 0; 165: if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0; 166: const nameWidth = stringWidth(companion.name); 167: const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0; 168: return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble; 169: } 170: export function CompanionSprite(): React.ReactNode { 171: const reaction = useAppState(s => s.companionReaction); 172: const petAt = useAppState(s => s.companionPetAt); 173: const focused = useAppState(s => s.footerSelection === 'companion'); 174: const setAppState = useSetAppState(); 175: const { 176: columns 177: } = useTerminalSize(); 178: const [tick, setTick] = useState(0); 179: const lastSpokeTick = useRef(0); 180: const [{ 181: petStartTick, 182: forPetAt 183: }, setPetStart] = useState({ 184: petStartTick: 0, 185: forPetAt: petAt 186: }); 187: if (petAt !== forPetAt) { 188: setPetStart({ 189: petStartTick: tick, 190: forPetAt: petAt 191: }); 192: } 193: useEffect(() => { 194: const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick); 195: return () => clearInterval(timer); 196: }, []); 197: useEffect(() => { 198: if (!reaction) return; 199: lastSpokeTick.current = tick; 200: const timer = setTimeout(setA => setA((prev: AppState) => prev.companionReaction === undefined ? prev : { 201: ...prev, 202: companionReaction: undefined 203: }), BUBBLE_SHOW * TICK_MS, setAppState); 204: return () => clearTimeout(timer); 205: }, [reaction, setAppState]); 206: if (!feature('BUDDY')) return null; 207: const companion = getCompanion(); 208: if (!companion || getGlobalConfig().companionMuted) return null; 209: const color = RARITY_COLORS[companion.rarity]; 210: const colWidth = spriteColWidth(stringWidth(companion.name)); 211: const bubbleAge = reaction ? tick - lastSpokeTick.current : 0; 212: const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW; 213: const petAge = petAt ? tick - petStartTick : Infinity; 214: const petting = petAge * TICK_MS < PET_BURST_MS; 215: if (columns < MIN_COLS_FOR_FULL_SPRITE) { 216: const quip = reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction; 217: const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name; 218: return <Box paddingX={1} alignSelf="flex-end"> 219: <Text> 220: {petting && <Text color="autoAccept">{figures.heart} </Text>} 221: <Text bold color={color}> 222: {renderFace(companion)} 223: </Text>{' '} 224: <Text italic dimColor={!focused && !reaction} bold={focused} inverse={focused && !reaction} color={reaction ? fading ? 'inactive' : color : focused ? color : undefined}> 225: {label} 226: </Text> 227: </Text> 228: </Box>; 229: } 230: const frameCount = spriteFrameCount(companion.species); 231: const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null; 232: let spriteFrame: number; 233: let blink = false; 234: if (reaction || petting) { 235: spriteFrame = tick % frameCount; 236: } else { 237: const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!; 238: if (step === -1) { 239: spriteFrame = 0; 240: blink = true; 241: } else { 242: spriteFrame = step % frameCount; 243: } 244: } 245: const body = renderSprite(companion, spriteFrame).map(line => blink ? line.replaceAll(companion.eye, '-') : line); 246: const sprite = heartFrame ? [heartFrame, ...body] : body; 247: const spriteColumn = <Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}> 248: {sprite.map((line, i) => <Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}> 249: {line} 250: </Text>)} 251: <Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}> 252: {focused ? ` ${companion.name} ` : companion.name} 253: </Text> 254: </Box>; 255: if (!reaction) { 256: return <Box paddingX={1}>{spriteColumn}</Box>; 257: } 258: if (isFullscreenActive()) { 259: return <Box paddingX={1}>{spriteColumn}</Box>; 260: } 261: return <Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}> 262: <SpeechBubble text={reaction} color={color} fading={fading} tail="right" /> 263: {spriteColumn} 264: </Box>; 265: } 266: export function CompanionFloatingBubble() { 267: const $ = _c(8); 268: const reaction = useAppState(_temp); 269: let t0; 270: if ($[0] !== reaction) { 271: t0 = { 272: tick: 0, 273: forReaction: reaction 274: }; 275: $[0] = reaction; 276: $[1] = t0; 277: } else { 278: t0 = $[1]; 279: } 280: const [t1, setTick] = useState(t0); 281: const { 282: tick, 283: forReaction 284: } = t1; 285: if (reaction !== forReaction) { 286: setTick({ 287: tick: 0, 288: forReaction: reaction 289: }); 290: } 291: let t2; 292: let t3; 293: if ($[2] !== reaction) { 294: t2 = () => { 295: if (!reaction) { 296: return; 297: } 298: const timer = setInterval(_temp3, TICK_MS, setTick); 299: return () => clearInterval(timer); 300: }; 301: t3 = [reaction]; 302: $[2] = reaction; 303: $[3] = t2; 304: $[4] = t3; 305: } else { 306: t2 = $[3]; 307: t3 = $[4]; 308: } 309: useEffect(t2, t3); 310: if (!feature("BUDDY") || !reaction) { 311: return null; 312: } 313: const companion = getCompanion(); 314: if (!companion || getGlobalConfig().companionMuted) { 315: return null; 316: } 317: const t4 = tick >= BUBBLE_SHOW - FADE_WINDOW; 318: let t5; 319: if ($[5] !== reaction || $[6] !== t4) { 320: t5 = <SpeechBubble text={reaction} color={RARITY_COLORS[companion.rarity]} fading={t4} tail="down" />; 321: $[5] = reaction; 322: $[6] = t4; 323: $[7] = t5; 324: } else { 325: t5 = $[7]; 326: } 327: return t5; 328: } 329: function _temp3(set) { 330: return set(_temp2); 331: } 332: function _temp2(s_0) { 333: return { 334: ...s_0, 335: tick: s_0.tick + 1 336: }; 337: } 338: function _temp(s) { 339: return s.companionReaction; 340: }

File: src/buddy/prompt.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { Message } from '../types/message.js' 3: import type { Attachment } from '../utils/attachments.js' 4: import { getGlobalConfig } from '../utils/config.js' 5: import { getCompanion } from './companion.js' 6: export function companionIntroText(name: string, species: string): string { 7: return `# Companion 8: A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher. 9: When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.` 10: } 11: export function getCompanionIntroAttachment( 12: messages: Message[] | undefined, 13: ): Attachment[] { 14: if (!feature('BUDDY')) return [] 15: const companion = getCompanion() 16: if (!companion || getGlobalConfig().companionMuted) return [] 17: for (const msg of messages ?? []) { 18: if (msg.type !== 'attachment') continue 19: if (msg.attachment.type !== 'companion_intro') continue 20: if (msg.attachment.name === companion.name) return [] 21: } 22: return [ 23: { 24: type: 'companion_intro', 25: name: companion.name, 26: species: companion.species, 27: }, 28: ] 29: }

File: src/buddy/sprites.ts

typescript 1: import type { CompanionBones, Eye, Hat, Species } from './types.js' 2: import { 3: axolotl, 4: blob, 5: cactus, 6: capybara, 7: cat, 8: chonk, 9: dragon, 10: duck, 11: ghost, 12: goose, 13: mushroom, 14: octopus, 15: owl, 16: penguin, 17: rabbit, 18: robot, 19: snail, 20: turtle, 21: } from './types.js' 22: const BODIES: Record<Species, string[][]> = { 23: [duck]: [ 24: [ 25: ' ', 26: ' __ ', 27: ' <({E} )___ ', 28: ' ( ._> ', 29: ' `--´ ', 30: ], 31: [ 32: ' ', 33: ' __ ', 34: ' <({E} )___ ', 35: ' ( ._> ', 36: ' `--´~ ', 37: ], 38: [ 39: ' ', 40: ' __ ', 41: ' <({E} )___ ', 42: ' ( .__> ', 43: ' `--´ ', 44: ], 45: ], 46: [goose]: [ 47: [ 48: ' ', 49: ' ({E}> ', 50: ' || ', 51: ' _(__)_ ', 52: ' ^^^^ ', 53: ], 54: [ 55: ' ', 56: ' ({E}> ', 57: ' || ', 58: ' _(__)_ ', 59: ' ^^^^ ', 60: ], 61: [ 62: ' ', 63: ' ({E}>> ', 64: ' || ', 65: ' _(__)_ ', 66: ' ^^^^ ', 67: ], 68: ], 69: [blob]: [ 70: [ 71: ' ', 72: ' .----. ', 73: ' ( {E} {E} ) ', 74: ' ( ) ', 75: ' `----´ ', 76: ], 77: [ 78: ' ', 79: ' .------. ', 80: ' ( {E} {E} ) ', 81: ' ( ) ', 82: ' `------´ ', 83: ], 84: [ 85: ' ', 86: ' .--. ', 87: ' ({E} {E}) ', 88: ' ( ) ', 89: ' `--´ ', 90: ], 91: ], 92: [cat]: [ 93: [ 94: ' ', 95: ' /\\_/\\ ', 96: ' ( {E} {E}) ', 97: ' ( ω ) ', 98: ' (")_(") ', 99: ], 100: [ 101: ' ', 102: ' /\\_/\\ ', 103: ' ( {E} {E}) ', 104: ' ( ω ) ', 105: ' (")_(")~ ', 106: ], 107: [ 108: ' ', 109: ' /\\-/\\ ', 110: ' ( {E} {E}) ', 111: ' ( ω ) ', 112: ' (")_(") ', 113: ], 114: ], 115: [dragon]: [ 116: [ 117: ' ', 118: ' /^\\ /^\\ ', 119: ' < {E} {E} > ', 120: ' ( ~~ ) ', 121: ' `-vvvv-´ ', 122: ], 123: [ 124: ' ', 125: ' /^\\ /^\\ ', 126: ' < {E} {E} > ', 127: ' ( ) ', 128: ' `-vvvv-´ ', 129: ], 130: [ 131: ' ~ ~ ', 132: ' /^\\ /^\\ ', 133: ' < {E} {E} > ', 134: ' ( ~~ ) ', 135: ' `-vvvv-´ ', 136: ], 137: ], 138: [octopus]: [ 139: [ 140: ' ', 141: ' .----. ', 142: ' ( {E} {E} ) ', 143: ' (______) ', 144: ' /\\/\\/\\/\\ ', 145: ], 146: [ 147: ' ', 148: ' .----. ', 149: ' ( {E} {E} ) ', 150: ' (______) ', 151: ' \\/\\/\\/\\/ ', 152: ], 153: [ 154: ' o ', 155: ' .----. ', 156: ' ( {E} {E} ) ', 157: ' (______) ', 158: ' /\\/\\/\\/\\ ', 159: ], 160: ], 161: [owl]: [ 162: [ 163: ' ', 164: ' /\\ /\\ ', 165: ' (({E})({E})) ', 166: ' ( >< ) ', 167: ' `----´ ', 168: ], 169: [ 170: ' ', 171: ' /\\ /\\ ', 172: ' (({E})({E})) ', 173: ' ( >< ) ', 174: ' .----. ', 175: ], 176: [ 177: ' ', 178: ' /\\ /\\ ', 179: ' (({E})(-)) ', 180: ' ( >< ) ', 181: ' `----´ ', 182: ], 183: ], 184: [penguin]: [ 185: [ 186: ' ', 187: ' .---. ', 188: ' ({E}>{E}) ', 189: ' /( )\\ ', 190: ' `---´ ', 191: ], 192: [ 193: ' ', 194: ' .---. ', 195: ' ({E}>{E}) ', 196: ' |( )| ', 197: ' `---´ ', 198: ], 199: [ 200: ' .---. ', 201: ' ({E}>{E}) ', 202: ' /( )\\ ', 203: ' `---´ ', 204: ' ~ ~ ', 205: ], 206: ], 207: [turtle]: [ 208: [ 209: ' ', 210: ' _,--._ ', 211: ' ( {E} {E} ) ', 212: ' /[______]\\ ', 213: ' `` `` ', 214: ], 215: [ 216: ' ', 217: ' _,--._ ', 218: ' ( {E} {E} ) ', 219: ' /[______]\\ ', 220: ' `` `` ', 221: ], 222: [ 223: ' ', 224: ' _,--._ ', 225: ' ( {E} {E} ) ', 226: ' /[======]\\ ', 227: ' `` `` ', 228: ], 229: ], 230: [snail]: [ 231: [ 232: ' ', 233: ' {E} .--. ', 234: ' \\ ( @ ) ', 235: ' \\_`--´ ', 236: ' ~~~~~~~ ', 237: ], 238: [ 239: ' ', 240: ' {E} .--. ', 241: ' | ( @ ) ', 242: ' \\_`--´ ', 243: ' ~~~~~~~ ', 244: ], 245: [ 246: ' ', 247: ' {E} .--. ', 248: ' \\ ( @ ) ', 249: ' \\_`--´ ', 250: ' ~~~~~~ ', 251: ], 252: ], 253: [ghost]: [ 254: [ 255: ' ', 256: ' .----. ', 257: ' / {E} {E} \\ ', 258: ' | | ', 259: ' ~`~``~`~ ', 260: ], 261: [ 262: ' ', 263: ' .----. ', 264: ' / {E} {E} \\ ', 265: ' | | ', 266: ' `~`~~`~` ', 267: ], 268: [ 269: ' ~ ~ ', 270: ' .----. ', 271: ' / {E} {E} \\ ', 272: ' | | ', 273: ' ~~`~~`~~ ', 274: ], 275: ], 276: [axolotl]: [ 277: [ 278: ' ', 279: '}~(______)~{', 280: '}~({E} .. {E})~{', 281: ' ( .--. ) ', 282: ' (_/ \\_) ', 283: ], 284: [ 285: ' ', 286: '~}(______){~', 287: '~}({E} .. {E}){~', 288: ' ( .--. ) ', 289: ' (_/ \\_) ', 290: ], 291: [ 292: ' ', 293: '}~(______)~{', 294: '}~({E} .. {E})~{', 295: ' ( -- ) ', 296: ' ~_/ \\_~ ', 297: ], 298: ], 299: [capybara]: [ 300: [ 301: ' ', 302: ' n______n ', 303: ' ( {E} {E} ) ', 304: ' ( oo ) ', 305: ' `------´ ', 306: ], 307: [ 308: ' ', 309: ' n______n ', 310: ' ( {E} {E} ) ', 311: ' ( Oo ) ', 312: ' `------´ ', 313: ], 314: [ 315: ' ~ ~ ', 316: ' u______n ', 317: ' ( {E} {E} ) ', 318: ' ( oo ) ', 319: ' `------´ ', 320: ], 321: ], 322: [cactus]: [ 323: [ 324: ' ', 325: ' n ____ n ', 326: ' | |{E} {E}| | ', 327: ' |_| |_| ', 328: ' | | ', 329: ], 330: [ 331: ' ', 332: ' ____ ', 333: ' n |{E} {E}| n ', 334: ' |_| |_| ', 335: ' | | ', 336: ], 337: [ 338: ' n n ', 339: ' | ____ | ', 340: ' | |{E} {E}| | ', 341: ' |_| |_| ', 342: ' | | ', 343: ], 344: ], 345: [robot]: [ 346: [ 347: ' ', 348: ' .[||]. ', 349: ' [ {E} {E} ] ', 350: ' [ ==== ] ', 351: ' `------´ ', 352: ], 353: [ 354: ' ', 355: ' .[||]. ', 356: ' [ {E} {E} ] ', 357: ' [ -==- ] ', 358: ' `------´ ', 359: ], 360: [ 361: ' * ', 362: ' .[||]. ', 363: ' [ {E} {E} ] ', 364: ' [ ==== ] ', 365: ' `------´ ', 366: ], 367: ], 368: [rabbit]: [ 369: [ 370: ' ', 371: ' (\\__/) ', 372: ' ( {E} {E} ) ', 373: ' =( .. )= ', 374: ' (")__(") ', 375: ], 376: [ 377: ' ', 378: ' (|__/) ', 379: ' ( {E} {E} ) ', 380: ' =( .. )= ', 381: ' (")__(") ', 382: ], 383: [ 384: ' ', 385: ' (\\__/) ', 386: ' ( {E} {E} ) ', 387: ' =( . . )= ', 388: ' (")__(") ', 389: ], 390: ], 391: [mushroom]: [ 392: [ 393: ' ', 394: ' .-o-OO-o-. ', 395: '(__________)', 396: ' |{E} {E}| ', 397: ' |____| ', 398: ], 399: [ 400: ' ', 401: ' .-O-oo-O-. ', 402: '(__________)', 403: ' |{E} {E}| ', 404: ' |____| ', 405: ], 406: [ 407: ' . o . ', 408: ' .-o-OO-o-. ', 409: '(__________)', 410: ' |{E} {E}| ', 411: ' |____| ', 412: ], 413: ], 414: [chonk]: [ 415: [ 416: ' ', 417: ' /\\ /\\ ', 418: ' ( {E} {E} ) ', 419: ' ( .. ) ', 420: ' `------´ ', 421: ], 422: [ 423: ' ', 424: ' /\\ /| ', 425: ' ( {E} {E} ) ', 426: ' ( .. ) ', 427: ' `------´ ', 428: ], 429: [ 430: ' ', 431: ' /\\ /\\ ', 432: ' ( {E} {E} ) ', 433: ' ( .. ) ', 434: ' `------´~ ', 435: ], 436: ], 437: } 438: const HAT_LINES: Record<Hat, string> = { 439: none: '', 440: crown: ' \\^^^/ ', 441: tophat: ' [___] ', 442: propeller: ' -+- ', 443: halo: ' ( ) ', 444: wizard: ' /^\\ ', 445: beanie: ' (___) ', 446: tinyduck: ' ,> ', 447: } 448: export function renderSprite(bones: CompanionBones, frame = 0): string[] { 449: const frames = BODIES[bones.species] 450: const body = frames[frame % frames.length]!.map(line => 451: line.replaceAll('{E}', bones.eye), 452: ) 453: const lines = [...body] 454: // Only replace with hat if line 0 is empty (some fidget frames use it for smoke etc) 455: if (bones.hat !== 'none' && !lines[0]!.trim()) { 456: lines[0] = HAT_LINES[bones.hat] 457: } 458: if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift() 459: return lines 460: } 461: export function spriteFrameCount(species: Species): number { 462: return BODIES[species].length 463: } 464: export function renderFace(bones: CompanionBones): string { 465: const eye: Eye = bones.eye 466: switch (bones.species) { 467: case duck: 468: case goose: 469: return `(${eye}>` 470: case blob: 471: return `(${eye}${eye})` 472: case cat: 473: return `=${eye}ω${eye}=` 474: case dragon: 475: return `<${eye}~${eye}>` 476: case octopus: 477: return `~(${eye}${eye})~` 478: case owl: 479: return `(${eye})(${eye})` 480: case penguin: 481: return `(${eye}>)` 482: case turtle: 483: return `[${eye}_${eye}]` 484: case snail: 485: return `${eye}(@)` 486: case ghost: 487: return `/${eye}${eye}\\` 488: case axolotl: 489: return `}${eye}.${eye}{` 490: case capybara: 491: return `(${eye}oo${eye})` 492: case cactus: 493: return `|${eye} ${eye}|` 494: case robot: 495: return `[${eye}${eye}]` 496: case rabbit: 497: return `(${eye}..${eye})` 498: case mushroom: 499: return `|${eye} ${eye}|` 500: case chonk: 501: return `(${eye}.${eye})` 502: } 503: }

File: src/buddy/types.ts

typescript 1: export const RARITIES = [ 2: 'common', 3: 'uncommon', 4: 'rare', 5: 'epic', 6: 'legendary', 7: ] as const 8: export type Rarity = (typeof RARITIES)[number] 9: const c = String.fromCharCode 10: export const duck = c(0x64,0x75,0x63,0x6b) as 'duck' 11: export const goose = c(0x67, 0x6f, 0x6f, 0x73, 0x65) as 'goose' 12: export const blob = c(0x62, 0x6c, 0x6f, 0x62) as 'blob' 13: export const cat = c(0x63, 0x61, 0x74) as 'cat' 14: export const dragon = c(0x64, 0x72, 0x61, 0x67, 0x6f, 0x6e) as 'dragon' 15: export const octopus = c(0x6f, 0x63, 0x74, 0x6f, 0x70, 0x75, 0x73) as 'octopus' 16: export const owl = c(0x6f, 0x77, 0x6c) as 'owl' 17: export const penguin = c(0x70, 0x65, 0x6e, 0x67, 0x75, 0x69, 0x6e) as 'penguin' 18: export const turtle = c(0x74, 0x75, 0x72, 0x74, 0x6c, 0x65) as 'turtle' 19: export const snail = c(0x73, 0x6e, 0x61, 0x69, 0x6c) as 'snail' 20: export const ghost = c(0x67, 0x68, 0x6f, 0x73, 0x74) as 'ghost' 21: export const axolotl = c(0x61, 0x78, 0x6f, 0x6c, 0x6f, 0x74, 0x6c) as 'axolotl' 22: export const capybara = c( 23: 0x63, 24: 0x61, 25: 0x70, 26: 0x79, 27: 0x62, 28: 0x61, 29: 0x72, 30: 0x61, 31: ) as 'capybara' 32: export const cactus = c(0x63, 0x61, 0x63, 0x74, 0x75, 0x73) as 'cactus' 33: export const robot = c(0x72, 0x6f, 0x62, 0x6f, 0x74) as 'robot' 34: export const rabbit = c(0x72, 0x61, 0x62, 0x62, 0x69, 0x74) as 'rabbit' 35: export const mushroom = c( 36: 0x6d, 37: 0x75, 38: 0x73, 39: 0x68, 40: 0x72, 41: 0x6f, 42: 0x6f, 43: 0x6d, 44: ) as 'mushroom' 45: export const chonk = c(0x63, 0x68, 0x6f, 0x6e, 0x6b) as 'chonk' 46: export const SPECIES = [ 47: duck, 48: goose, 49: blob, 50: cat, 51: dragon, 52: octopus, 53: owl, 54: penguin, 55: turtle, 56: snail, 57: ghost, 58: axolotl, 59: capybara, 60: cactus, 61: robot, 62: rabbit, 63: mushroom, 64: chonk, 65: ] as const 66: export type Species = (typeof SPECIES)[number] 67: export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const 68: export type Eye = (typeof EYES)[number] 69: export const HATS = [ 70: 'none', 71: 'crown', 72: 'tophat', 73: 'propeller', 74: 'halo', 75: 'wizard', 76: 'beanie', 77: 'tinyduck', 78: ] as const 79: export type Hat = (typeof HATS)[number] 80: export const STAT_NAMES = [ 81: 'DEBUGGING', 82: 'PATIENCE', 83: 'CHAOS', 84: 'WISDOM', 85: 'SNARK', 86: ] as const 87: export type StatName = (typeof STAT_NAMES)[number] 88: export type CompanionBones = { 89: rarity: Rarity 90: species: Species 91: eye: Eye 92: hat: Hat 93: shiny: boolean 94: stats: Record<StatName, number> 95: } 96: export type CompanionSoul = { 97: name: string 98: personality: string 99: } 100: export type Companion = CompanionBones & 101: CompanionSoul & { 102: hatchedAt: number 103: } 104: export type StoredCompanion = CompanionSoul & { hatchedAt: number } 105: export const RARITY_WEIGHTS = { 106: common: 60, 107: uncommon: 25, 108: rare: 10, 109: epic: 4, 110: legendary: 1, 111: } as const satisfies Record<Rarity, number> 112: export const RARITY_STARS = { 113: common: '★', 114: uncommon: '★★', 115: rare: '★★★', 116: epic: '★★★★', 117: legendary: '★★★★★', 118: } as const satisfies Record<Rarity, string> 119: export const RARITY_COLORS = { 120: common: 'inactive', 121: uncommon: 'success', 122: rare: 'permission', 123: epic: 'autoAccept', 124: legendary: 'warning', 125: } as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>

File: src/buddy/useBuddyNotification.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { feature } from 'bun:bundle'; 3: import React, { useEffect } from 'react'; 4: import { useNotifications } from '../context/notifications.js'; 5: import { Text } from '../ink.js'; 6: import { getGlobalConfig } from '../utils/config.js'; 7: import { getRainbowColor } from '../utils/thinking.js'; 8: export function isBuddyTeaserWindow(): boolean { 9: if ("external" === 'ant') return true; 10: const d = new Date(); 11: return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7; 12: } 13: export function isBuddyLive(): boolean { 14: if ("external" === 'ant') return true; 15: const d = new Date(); 16: return d.getFullYear() > 2026 || d.getFullYear() === 2026 && d.getMonth() >= 3; 17: } 18: function RainbowText(t0) { 19: const $ = _c(2); 20: const { 21: text 22: } = t0; 23: let t1; 24: if ($[0] !== text) { 25: t1 = <>{[...text].map(_temp)}</>; 26: $[0] = text; 27: $[1] = t1; 28: } else { 29: t1 = $[1]; 30: } 31: return t1; 32: } 33: function _temp(ch, i) { 34: return <Text key={i} color={getRainbowColor(i)}>{ch}</Text>; 35: } 36: export function useBuddyNotification() { 37: const $ = _c(4); 38: const { 39: addNotification, 40: removeNotification 41: } = useNotifications(); 42: let t0; 43: let t1; 44: if ($[0] !== addNotification || $[1] !== removeNotification) { 45: t0 = () => { 46: if (!feature("BUDDY")) { 47: return; 48: } 49: const config = getGlobalConfig(); 50: if (config.companion || !isBuddyTeaserWindow()) { 51: return; 52: } 53: addNotification({ 54: key: "buddy-teaser", 55: jsx: <RainbowText text="/buddy" />, 56: priority: "immediate", 57: timeoutMs: 15000 58: }); 59: return () => removeNotification("buddy-teaser"); 60: }; 61: t1 = [addNotification, removeNotification]; 62: $[0] = addNotification; 63: $[1] = removeNotification; 64: $[2] = t0; 65: $[3] = t1; 66: } else { 67: t0 = $[2]; 68: t1 = $[3]; 69: } 70: useEffect(t0, t1); 71: } 72: export function findBuddyTriggerPositions(text: string): Array<{ 73: start: number; 74: end: number; 75: }> { 76: if (!feature('BUDDY')) return []; 77: const triggers: Array<{ 78: start: number; 79: end: number; 80: }> = []; 81: const re = /\/buddy\b/g; 82: let m: RegExpExecArray | null; 83: while ((m = re.exec(text)) !== null) { 84: triggers.push({ 85: start: m.index, 86: end: m.index + m[0].length 87: }); 88: } 89: return triggers; 90: }

File: src/cli/handlers/agents.ts

typescript 1: import { 2: AGENT_SOURCE_GROUPS, 3: compareAgentsByName, 4: getOverrideSourceLabel, 5: type ResolvedAgent, 6: resolveAgentModelDisplay, 7: resolveAgentOverrides, 8: } from '../../tools/AgentTool/agentDisplay.js' 9: import { 10: getActiveAgentsFromList, 11: getAgentDefinitionsWithOverrides, 12: } from '../../tools/AgentTool/loadAgentsDir.js' 13: import { getCwd } from '../../utils/cwd.js' 14: function formatAgent(agent: ResolvedAgent): string { 15: const model = resolveAgentModelDisplay(agent) 16: const parts = [agent.agentType] 17: if (model) { 18: parts.push(model) 19: } 20: if (agent.memory) { 21: parts.push(`${agent.memory} memory`) 22: } 23: return parts.join(' · ') 24: } 25: export async function agentsHandler(): Promise<void> { 26: const cwd = getCwd() 27: const { allAgents } = await getAgentDefinitionsWithOverrides(cwd) 28: const activeAgents = getActiveAgentsFromList(allAgents) 29: const resolvedAgents = resolveAgentOverrides(allAgents, activeAgents) 30: const lines: string[] = [] 31: let totalActive = 0 32: for (const { label, source } of AGENT_SOURCE_GROUPS) { 33: const groupAgents = resolvedAgents 34: .filter(a => a.source === source) 35: .sort(compareAgentsByName) 36: if (groupAgents.length === 0) continue 37: lines.push(`${label}:`) 38: for (const agent of groupAgents) { 39: if (agent.overriddenBy) { 40: const winnerSource = getOverrideSourceLabel(agent.overriddenBy) 41: lines.push(` (shadowed by ${winnerSource}) ${formatAgent(agent)}`) 42: } else { 43: lines.push(` ${formatAgent(agent)}`) 44: totalActive++ 45: } 46: } 47: lines.push('') 48: } 49: if (lines.length === 0) { 50: // biome-ignore lint/suspicious/noConsole:: intentional console output 51: console.log('No agents found.') 52: } else { 53: // biome-ignore lint/suspicious/noConsole:: intentional console output 54: console.log(`${totalActive} active agents\n`) 55: // biome-ignore lint/suspicious/noConsole:: intentional console output 56: console.log(lines.join('\n').trimEnd()) 57: } 58: }

File: src/cli/handlers/auth.ts

typescript 1: import { 2: clearAuthRelatedCaches, 3: performLogout, 4: } from '../../commands/logout/logout.js' 5: import { 6: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 7: logEvent, 8: } from '../../services/analytics/index.js' 9: import { getSSLErrorHint } from '../../services/api/errorUtils.js' 10: import { fetchAndStoreClaudeCodeFirstTokenDate } from '../../services/api/firstTokenDate.js' 11: import { 12: createAndStoreApiKey, 13: fetchAndStoreUserRoles, 14: refreshOAuthToken, 15: shouldUseClaudeAIAuth, 16: storeOAuthAccountInfo, 17: } from '../../services/oauth/client.js' 18: import { getOauthProfileFromOauthToken } from '../../services/oauth/getOauthProfile.js' 19: import { OAuthService } from '../../services/oauth/index.js' 20: import type { OAuthTokens } from '../../services/oauth/types.js' 21: import { 22: clearOAuthTokenCache, 23: getAnthropicApiKeyWithSource, 24: getAuthTokenSource, 25: getOauthAccountInfo, 26: getSubscriptionType, 27: isUsing3PServices, 28: saveOAuthTokensIfNeeded, 29: validateForceLoginOrg, 30: } from '../../utils/auth.js' 31: import { saveGlobalConfig } from '../../utils/config.js' 32: import { logForDebugging } from '../../utils/debug.js' 33: import { isRunningOnHomespace } from '../../utils/envUtils.js' 34: import { errorMessage } from '../../utils/errors.js' 35: import { logError } from '../../utils/log.js' 36: import { getAPIProvider } from '../../utils/model/providers.js' 37: import { getInitialSettings } from '../../utils/settings/settings.js' 38: import { jsonStringify } from '../../utils/slowOperations.js' 39: import { 40: buildAccountProperties, 41: buildAPIProviderProperties, 42: } from '../../utils/status.js' 43: export async function installOAuthTokens(tokens: OAuthTokens): Promise<void> { 44: await performLogout({ clearOnboarding: false }) 45: const profile = 46: tokens.profile ?? (await getOauthProfileFromOauthToken(tokens.accessToken)) 47: if (profile) { 48: storeOAuthAccountInfo({ 49: accountUuid: profile.account.uuid, 50: emailAddress: profile.account.email, 51: organizationUuid: profile.organization.uuid, 52: displayName: profile.account.display_name || undefined, 53: hasExtraUsageEnabled: 54: profile.organization.has_extra_usage_enabled ?? undefined, 55: billingType: profile.organization.billing_type ?? undefined, 56: subscriptionCreatedAt: 57: profile.organization.subscription_created_at ?? undefined, 58: accountCreatedAt: profile.account.created_at, 59: }) 60: } else if (tokens.tokenAccount) { 61: storeOAuthAccountInfo({ 62: accountUuid: tokens.tokenAccount.uuid, 63: emailAddress: tokens.tokenAccount.emailAddress, 64: organizationUuid: tokens.tokenAccount.organizationUuid, 65: }) 66: } 67: const storageResult = saveOAuthTokensIfNeeded(tokens) 68: clearOAuthTokenCache() 69: if (storageResult.warning) { 70: logEvent('tengu_oauth_storage_warning', { 71: warning: 72: storageResult.warning as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 73: }) 74: } 75: await fetchAndStoreUserRoles(tokens.accessToken).catch(err => 76: logForDebugging(String(err), { level: 'error' }), 77: ) 78: if (shouldUseClaudeAIAuth(tokens.scopes)) { 79: await fetchAndStoreClaudeCodeFirstTokenDate().catch(err => 80: logForDebugging(String(err), { level: 'error' }), 81: ) 82: } else { 83: const apiKey = await createAndStoreApiKey(tokens.accessToken) 84: if (!apiKey) { 85: throw new Error( 86: 'Unable to create API key. The server accepted the request but did not return a key.', 87: ) 88: } 89: } 90: await clearAuthRelatedCaches() 91: } 92: export async function authLogin({ 93: email, 94: sso, 95: console: useConsole, 96: claudeai, 97: }: { 98: email?: string 99: sso?: boolean 100: console?: boolean 101: claudeai?: boolean 102: }): Promise<void> { 103: if (useConsole && claudeai) { 104: process.stderr.write( 105: 'Error: --console and --claudeai cannot be used together.\n', 106: ) 107: process.exit(1) 108: } 109: const settings = getInitialSettings() 110: const loginWithClaudeAi = settings.forceLoginMethod 111: ? settings.forceLoginMethod === 'claudeai' 112: : !useConsole 113: const orgUUID = settings.forceLoginOrgUUID 114: const envRefreshToken = process.env.CLAUDE_CODE_OAUTH_REFRESH_TOKEN 115: if (envRefreshToken) { 116: const envScopes = process.env.CLAUDE_CODE_OAUTH_SCOPES 117: if (!envScopes) { 118: process.stderr.write( 119: 'CLAUDE_CODE_OAUTH_SCOPES is required when using CLAUDE_CODE_OAUTH_REFRESH_TOKEN.\n' + 120: 'Set it to the space-separated scopes the refresh token was issued with\n' + 121: '(e.g. "user:inference" or "user:profile user:inference user:sessions:claude_code user:mcp_servers").\n', 122: ) 123: process.exit(1) 124: } 125: const scopes = envScopes.split(/\s+/).filter(Boolean) 126: try { 127: logEvent('tengu_login_from_refresh_token', {}) 128: const tokens = await refreshOAuthToken(envRefreshToken, { scopes }) 129: await installOAuthTokens(tokens) 130: const orgResult = await validateForceLoginOrg() 131: if (!orgResult.valid) { 132: process.stderr.write(orgResult.message + '\n') 133: process.exit(1) 134: } 135: saveGlobalConfig(current => { 136: if (current.hasCompletedOnboarding) return current 137: return { ...current, hasCompletedOnboarding: true } 138: }) 139: logEvent('tengu_oauth_success', { 140: loginWithClaudeAi: shouldUseClaudeAIAuth(tokens.scopes), 141: }) 142: process.stdout.write('Login successful.\n') 143: process.exit(0) 144: } catch (err) { 145: logError(err) 146: const sslHint = getSSLErrorHint(err) 147: process.stderr.write( 148: `Login failed: ${errorMessage(err)}\n${sslHint ? sslHint + '\n' : ''}`, 149: ) 150: process.exit(1) 151: } 152: } 153: const resolvedLoginMethod = sso ? 'sso' : undefined 154: const oauthService = new OAuthService() 155: try { 156: logEvent('tengu_oauth_flow_start', { loginWithClaudeAi }) 157: const result = await oauthService.startOAuthFlow( 158: async url => { 159: process.stdout.write('Opening browser to sign in…\n') 160: process.stdout.write(`If the browser didn't open, visit: ${url}\n`) 161: }, 162: { 163: loginWithClaudeAi, 164: loginHint: email, 165: loginMethod: resolvedLoginMethod, 166: orgUUID, 167: }, 168: ) 169: await installOAuthTokens(result) 170: const orgResult = await validateForceLoginOrg() 171: if (!orgResult.valid) { 172: process.stderr.write(orgResult.message + '\n') 173: process.exit(1) 174: } 175: logEvent('tengu_oauth_success', { loginWithClaudeAi }) 176: process.stdout.write('Login successful.\n') 177: process.exit(0) 178: } catch (err) { 179: logError(err) 180: const sslHint = getSSLErrorHint(err) 181: process.stderr.write( 182: `Login failed: ${errorMessage(err)}\n${sslHint ? sslHint + '\n' : ''}`, 183: ) 184: process.exit(1) 185: } finally { 186: oauthService.cleanup() 187: } 188: } 189: export async function authStatus(opts: { 190: json?: boolean 191: text?: boolean 192: }): Promise<void> { 193: const { source: authTokenSource, hasToken } = getAuthTokenSource() 194: const { source: apiKeySource } = getAnthropicApiKeyWithSource() 195: const hasApiKeyEnvVar = 196: !!process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace() 197: const oauthAccount = getOauthAccountInfo() 198: const subscriptionType = getSubscriptionType() 199: const using3P = isUsing3PServices() 200: const loggedIn = 201: hasToken || apiKeySource !== 'none' || hasApiKeyEnvVar || using3P 202: let authMethod: string = 'none' 203: if (using3P) { 204: authMethod = 'third_party' 205: } else if (authTokenSource === 'claude.ai') { 206: authMethod = 'claude.ai' 207: } else if (authTokenSource === 'apiKeyHelper') { 208: authMethod = 'api_key_helper' 209: } else if (authTokenSource !== 'none') { 210: authMethod = 'oauth_token' 211: } else if (apiKeySource === 'ANTHROPIC_API_KEY' || hasApiKeyEnvVar) { 212: authMethod = 'api_key' 213: } else if (apiKeySource === '/login managed key') { 214: authMethod = 'claude.ai' 215: } 216: if (opts.text) { 217: const properties = [ 218: ...buildAccountProperties(), 219: ...buildAPIProviderProperties(), 220: ] 221: let hasAuthProperty = false 222: for (const prop of properties) { 223: const value = 224: typeof prop.value === 'string' 225: ? prop.value 226: : Array.isArray(prop.value) 227: ? prop.value.join(', ') 228: : null 229: if (value === null || value === 'none') { 230: continue 231: } 232: hasAuthProperty = true 233: if (prop.label) { 234: process.stdout.write(`${prop.label}: ${value}\n`) 235: } else { 236: process.stdout.write(`${value}\n`) 237: } 238: } 239: if (!hasAuthProperty && hasApiKeyEnvVar) { 240: process.stdout.write('API key: ANTHROPIC_API_KEY\n') 241: } 242: if (!loggedIn) { 243: process.stdout.write( 244: 'Not logged in. Run claude auth login to authenticate.\n', 245: ) 246: } 247: } else { 248: const apiProvider = getAPIProvider() 249: const resolvedApiKeySource = 250: apiKeySource !== 'none' 251: ? apiKeySource 252: : hasApiKeyEnvVar 253: ? 'ANTHROPIC_API_KEY' 254: : null 255: const output: Record<string, string | boolean | null> = { 256: loggedIn, 257: authMethod, 258: apiProvider, 259: } 260: if (resolvedApiKeySource) { 261: output.apiKeySource = resolvedApiKeySource 262: } 263: if (authMethod === 'claude.ai') { 264: output.email = oauthAccount?.emailAddress ?? null 265: output.orgId = oauthAccount?.organizationUuid ?? null 266: output.orgName = oauthAccount?.organizationName ?? null 267: output.subscriptionType = subscriptionType ?? null 268: } 269: process.stdout.write(jsonStringify(output, null, 2) + '\n') 270: } 271: process.exit(loggedIn ? 0 : 1) 272: } 273: export async function authLogout(): Promise<void> { 274: try { 275: await performLogout({ clearOnboarding: false }) 276: } catch { 277: process.stderr.write('Failed to log out.\n') 278: process.exit(1) 279: } 280: process.stdout.write('Successfully logged out from your Anthropic account.\n') 281: process.exit(0) 282: }

File: src/cli/handlers/autoMode.ts

typescript 1: import { errorMessage } from '../../utils/errors.js' 2: import { 3: getMainLoopModel, 4: parseUserSpecifiedModel, 5: } from '../../utils/model/model.js' 6: import { 7: type AutoModeRules, 8: buildDefaultExternalSystemPrompt, 9: getDefaultExternalAutoModeRules, 10: } from '../../utils/permissions/yoloClassifier.js' 11: import { getAutoModeConfig } from '../../utils/settings/settings.js' 12: import { sideQuery } from '../../utils/sideQuery.js' 13: import { jsonStringify } from '../../utils/slowOperations.js' 14: function writeRules(rules: AutoModeRules): void { 15: process.stdout.write(jsonStringify(rules, null, 2) + '\n') 16: } 17: export function autoModeDefaultsHandler(): void { 18: writeRules(getDefaultExternalAutoModeRules()) 19: } 20: export function autoModeConfigHandler(): void { 21: const config = getAutoModeConfig() 22: const defaults = getDefaultExternalAutoModeRules() 23: writeRules({ 24: allow: config?.allow?.length ? config.allow : defaults.allow, 25: soft_deny: config?.soft_deny?.length 26: ? config.soft_deny 27: : defaults.soft_deny, 28: environment: config?.environment?.length 29: ? config.environment 30: : defaults.environment, 31: }) 32: } 33: const CRITIQUE_SYSTEM_PROMPT = 34: 'You are an expert reviewer of auto mode classifier rules for Claude Code.\n' + 35: '\n' + 36: 'Claude Code has an "auto mode" that uses an AI classifier to decide whether ' + 37: 'tool calls should be auto-approved or require user confirmation. Users can ' + 38: 'write custom rules in three categories:\n' + 39: '\n' + 40: '- **allow**: Actions the classifier should auto-approve\n' + 41: '- **soft_deny**: Actions the classifier should block (require user confirmation)\n' + 42: "- **environment**: Context about the user's setup that helps the classifier make decisions\n" + 43: '\n' + 44: "Your job is to critique the user's custom rules for clarity, completeness, " + 45: 'and potential issues. The classifier is an LLM that reads these rules as ' + 46: 'part of its system prompt.\n' + 47: '\n' + 48: 'For each rule, evaluate:\n' + 49: '1. **Clarity**: Is the rule unambiguous? Could the classifier misinterpret it?\n' + 50: "2. **Completeness**: Are there gaps or edge cases the rule doesn't cover?\n" + 51: '3. **Conflicts**: Do any of the rules conflict with each other?\n' + 52: '4. **Actionability**: Is the rule specific enough for the classifier to act on?\n' + 53: '\n' + 54: 'Be concise and constructive. Only comment on rules that could be improved. ' + 55: 'If all rules look good, say so.' 56: export async function autoModeCritiqueHandler(options: { 57: model?: string 58: }): Promise<void> { 59: const config = getAutoModeConfig() 60: const hasCustomRules = 61: (config?.allow?.length ?? 0) > 0 || 62: (config?.soft_deny?.length ?? 0) > 0 || 63: (config?.environment?.length ?? 0) > 0 64: if (!hasCustomRules) { 65: process.stdout.write( 66: 'No custom auto mode rules found.\n\n' + 67: 'Add rules to your settings file under autoMode.{allow, soft_deny, environment}.\n' + 68: 'Run `claude auto-mode defaults` to see the default rules for reference.\n', 69: ) 70: return 71: } 72: const model = options.model 73: ? parseUserSpecifiedModel(options.model) 74: : getMainLoopModel() 75: const defaults = getDefaultExternalAutoModeRules() 76: const classifierPrompt = buildDefaultExternalSystemPrompt() 77: const userRulesSummary = 78: formatRulesForCritique('allow', config?.allow ?? [], defaults.allow) + 79: formatRulesForCritique( 80: 'soft_deny', 81: config?.soft_deny ?? [], 82: defaults.soft_deny, 83: ) + 84: formatRulesForCritique( 85: 'environment', 86: config?.environment ?? [], 87: defaults.environment, 88: ) 89: process.stdout.write('Analyzing your auto mode rules…\n\n') 90: let response 91: try { 92: response = await sideQuery({ 93: querySource: 'auto_mode_critique', 94: model, 95: system: CRITIQUE_SYSTEM_PROMPT, 96: skipSystemPromptPrefix: true, 97: max_tokens: 4096, 98: messages: [ 99: { 100: role: 'user', 101: content: 102: 'Here is the full classifier system prompt that the auto mode classifier receives:\n\n' + 103: '<classifier_system_prompt>\n' + 104: classifierPrompt + 105: '\n</classifier_system_prompt>\n\n' + 106: "Here are the user's custom rules that REPLACE the corresponding default sections:\n\n" + 107: userRulesSummary + 108: '\nPlease critique these custom rules.', 109: }, 110: ], 111: }) 112: } catch (error) { 113: process.stderr.write( 114: 'Failed to analyze rules: ' + errorMessage(error) + '\n', 115: ) 116: process.exitCode = 1 117: return 118: } 119: const textBlock = response.content.find(block => block.type === 'text') 120: if (textBlock?.type === 'text') { 121: process.stdout.write(textBlock.text + '\n') 122: } else { 123: process.stdout.write('No critique was generated. Please try again.\n') 124: } 125: } 126: function formatRulesForCritique( 127: section: string, 128: userRules: string[], 129: defaultRules: string[], 130: ): string { 131: if (userRules.length === 0) return '' 132: const customLines = userRules.map(r => '- ' + r).join('\n') 133: const defaultLines = defaultRules.map(r => '- ' + r).join('\n') 134: return ( 135: '## ' + 136: section + 137: ' (custom rules replacing defaults)\n' + 138: 'Custom:\n' + 139: customLines + 140: '\n\n' + 141: 'Defaults being replaced:\n' + 142: defaultLines + 143: '\n\n' 144: ) 145: }

File: src/cli/handlers/mcp.tsx

typescript 1: import { stat } from 'fs/promises'; 2: import pMap from 'p-map'; 3: import { cwd } from 'process'; 4: import React from 'react'; 5: import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'; 6: import { render } from '../../ink.js'; 7: import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; 8: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; 9: import { clearMcpClientConfig, clearServerTokensFromLocalStorage, getMcpClientConfig, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js'; 10: import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js'; 11: import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js'; 12: import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js'; 13: import { describeMcpConfigFilePath, ensureConfigScope, getScopeLabel } from '../../services/mcp/utils.js'; 14: import { AppStateProvider } from '../../state/AppState.js'; 15: import { getCurrentProjectConfig, getGlobalConfig, saveCurrentProjectConfig } from '../../utils/config.js'; 16: import { isFsInaccessible } from '../../utils/errors.js'; 17: import { gracefulShutdown } from '../../utils/gracefulShutdown.js'; 18: import { safeParseJSON } from '../../utils/json.js'; 19: import { getPlatform } from '../../utils/platform.js'; 20: import { cliError, cliOk } from '../exit.js'; 21: async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise<string> { 22: try { 23: const result = await connectToServer(name, server); 24: if (result.type === 'connected') { 25: return '✓ Connected'; 26: } else if (result.type === 'needs-auth') { 27: return '! Needs authentication'; 28: } else { 29: return '✗ Failed to connect'; 30: } 31: } catch (_error) { 32: return '✗ Connection error'; 33: } 34: } 35: export async function mcpServeHandler({ 36: debug, 37: verbose 38: }: { 39: debug?: boolean; 40: verbose?: boolean; 41: }): Promise<void> { 42: const providedCwd = cwd(); 43: logEvent('tengu_mcp_start', {}); 44: try { 45: await stat(providedCwd); 46: } catch (error) { 47: if (isFsInaccessible(error)) { 48: cliError(`Error: Directory ${providedCwd} does not exist`); 49: } 50: throw error; 51: } 52: try { 53: const { 54: setup 55: } = await import('../../setup.js'); 56: await setup(providedCwd, 'default', false, false, undefined, false); 57: const { 58: startMCPServer 59: } = await import('../../entrypoints/mcp.js'); 60: await startMCPServer(providedCwd, debug ?? false, verbose ?? false); 61: } catch (error) { 62: cliError(`Error: Failed to start MCP server: ${error}`); 63: } 64: } 65: export async function mcpRemoveHandler(name: string, options: { 66: scope?: string; 67: }): Promise<void> { 68: const serverBeforeRemoval = getMcpConfigByName(name); 69: const cleanupSecureStorage = () => { 70: if (serverBeforeRemoval && (serverBeforeRemoval.type === 'sse' || serverBeforeRemoval.type === 'http')) { 71: clearServerTokensFromLocalStorage(name, serverBeforeRemoval); 72: clearMcpClientConfig(name, serverBeforeRemoval); 73: } 74: }; 75: try { 76: if (options.scope) { 77: const scope = ensureConfigScope(options.scope); 78: logEvent('tengu_mcp_delete', { 79: name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 80: scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 81: }); 82: await removeMcpConfig(name, scope); 83: cleanupSecureStorage(); 84: process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`); 85: cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); 86: } 87: const projectConfig = getCurrentProjectConfig(); 88: const globalConfig = getGlobalConfig(); 89: const { 90: servers: projectServers 91: } = getMcpConfigsByScope('project'); 92: const mcpJsonExists = !!projectServers[name]; 93: const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = []; 94: if (projectConfig.mcpServers?.[name]) scopes.push('local'); 95: if (mcpJsonExists) scopes.push('project'); 96: if (globalConfig.mcpServers?.[name]) scopes.push('user'); 97: if (scopes.length === 0) { 98: cliError(`No MCP server found with name: "${name}"`); 99: } else if (scopes.length === 1) { 100: const scope = scopes[0]!; 101: logEvent('tengu_mcp_delete', { 102: name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 103: scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 104: }); 105: await removeMcpConfig(name, scope); 106: cleanupSecureStorage(); 107: process.stdout.write(`Removed MCP server "${name}" from ${scope} config\n`); 108: cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); 109: } else { 110: process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`); 111: scopes.forEach(scope => { 112: process.stderr.write(` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`); 113: }); 114: process.stderr.write('\nTo remove from a specific scope, use:\n'); 115: scopes.forEach(scope => { 116: process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`); 117: }); 118: cliError(); 119: } 120: } catch (error) { 121: cliError((error as Error).message); 122: } 123: } 124: export async function mcpListHandler(): Promise<void> { 125: logEvent('tengu_mcp_list', {}); 126: const { 127: servers: configs 128: } = await getAllMcpConfigs(); 129: if (Object.keys(configs).length === 0) { 130: console.log('No MCP servers configured. Use `claude mcp add` to add a server.'); 131: } else { 132: console.log('Checking MCP server health...\n'); 133: const entries = Object.entries(configs); 134: const results = await pMap(entries, async ([name, server]) => ({ 135: name, 136: server, 137: status: await checkMcpServerHealth(name, server) 138: }), { 139: concurrency: getMcpServerConnectionBatchSize() 140: }); 141: for (const { 142: name, 143: server, 144: status 145: } of results) { 146: if (server.type === 'sse') { 147: console.log(`${name}: ${server.url} (SSE) - ${status}`); 148: } else if (server.type === 'http') { 149: console.log(`${name}: ${server.url} (HTTP) - ${status}`); 150: } else if (server.type === 'claudeai-proxy') { 151: console.log(`${name}: ${server.url} - ${status}`); 152: } else if (!server.type || server.type === 'stdio') { 153: const args = Array.isArray(server.args) ? server.args : []; 154: console.log(`${name}: ${server.command} ${args.join(' ')} - ${status}`); 155: } 156: } 157: } 158: await gracefulShutdown(0); 159: } 160: export async function mcpGetHandler(name: string): Promise<void> { 161: logEvent('tengu_mcp_get', { 162: name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 163: }); 164: const server = getMcpConfigByName(name); 165: if (!server) { 166: cliError(`No MCP server found with name: ${name}`); 167: } 168: console.log(`${name}:`); 169: console.log(` Scope: ${getScopeLabel(server.scope)}`); 170: const status = await checkMcpServerHealth(name, server); 171: console.log(` Status: ${status}`); 172: if (server.type === 'sse') { 173: console.log(` Type: sse`); 174: console.log(` URL: ${server.url}`); 175: if (server.headers) { 176: console.log(' Headers:'); 177: for (const [key, value] of Object.entries(server.headers)) { 178: console.log(` ${key}: ${value}`); 179: } 180: } 181: if (server.oauth?.clientId || server.oauth?.callbackPort) { 182: const parts: string[] = []; 183: if (server.oauth.clientId) { 184: parts.push('client_id configured'); 185: const clientConfig = getMcpClientConfig(name, server); 186: if (clientConfig?.clientSecret) parts.push('client_secret configured'); 187: } 188: if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); 189: console.log(` OAuth: ${parts.join(', ')}`); 190: } 191: } else if (server.type === 'http') { 192: console.log(` Type: http`); 193: console.log(` URL: ${server.url}`); 194: if (server.headers) { 195: console.log(' Headers:'); 196: for (const [key, value] of Object.entries(server.headers)) { 197: console.log(` ${key}: ${value}`); 198: } 199: } 200: if (server.oauth?.clientId || server.oauth?.callbackPort) { 201: const parts: string[] = []; 202: if (server.oauth.clientId) { 203: parts.push('client_id configured'); 204: const clientConfig = getMcpClientConfig(name, server); 205: if (clientConfig?.clientSecret) parts.push('client_secret configured'); 206: } 207: if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); 208: console.log(` OAuth: ${parts.join(', ')}`); 209: } 210: } else if (server.type === 'stdio') { 211: console.log(` Type: stdio`); 212: console.log(` Command: ${server.command}`); 213: const args = Array.isArray(server.args) ? server.args : []; 214: console.log(` Args: ${args.join(' ')}`); 215: if (server.env) { 216: console.log(' Environment:'); 217: for (const [key, value] of Object.entries(server.env)) { 218: console.log(` ${key}=${value}`); 219: } 220: } 221: } 222: console.log(`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`); 223: await gracefulShutdown(0); 224: } 225: export async function mcpAddJsonHandler(name: string, json: string, options: { 226: scope?: string; 227: clientSecret?: true; 228: }): Promise<void> { 229: try { 230: const scope = ensureConfigScope(options.scope); 231: const parsedJson = safeParseJSON(json); 232: const needsSecret = options.clientSecret && parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson && (parsedJson.type === 'sse' || parsedJson.type === 'http') && 'url' in parsedJson && typeof parsedJson.url === 'string' && 'oauth' in parsedJson && parsedJson.oauth && typeof parsedJson.oauth === 'object' && 'clientId' in parsedJson.oauth; 233: const clientSecret = needsSecret ? await readClientSecret() : undefined; 234: await addMcpConfig(name, parsedJson, scope); 235: const transportType = parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson ? String(parsedJson.type || 'stdio') : 'stdio'; 236: if (clientSecret && parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson && (parsedJson.type === 'sse' || parsedJson.type === 'http') && 'url' in parsedJson && typeof parsedJson.url === 'string') { 237: saveMcpClientSecret(name, { 238: type: parsedJson.type, 239: url: parsedJson.url 240: }, clientSecret); 241: } 242: logEvent('tengu_mcp_add', { 243: scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 244: source: 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 245: type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 246: }); 247: cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`); 248: } catch (error) { 249: cliError((error as Error).message); 250: } 251: } 252: export async function mcpAddFromDesktopHandler(options: { 253: scope?: string; 254: }): Promise<void> { 255: try { 256: const scope = ensureConfigScope(options.scope); 257: const platform = getPlatform(); 258: logEvent('tengu_mcp_add', { 259: scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 260: platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 261: source: 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 262: }); 263: const { 264: readClaudeDesktopMcpServers 265: } = await import('../../utils/claudeDesktop.js'); 266: const servers = await readClaudeDesktopMcpServers(); 267: if (Object.keys(servers).length === 0) { 268: cliOk('No MCP servers found in Claude Desktop configuration or configuration file does not exist.'); 269: } 270: const { 271: unmount 272: } = await render(<AppStateProvider> 273: <KeybindingSetup> 274: <MCPServerDesktopImportDialog servers={servers} scope={scope} onDone={() => { 275: unmount(); 276: }} /> 277: </KeybindingSetup> 278: </AppStateProvider>, { 279: exitOnCtrlC: true 280: }); 281: } catch (error) { 282: cliError((error as Error).message); 283: } 284: } 285: export async function mcpResetChoicesHandler(): Promise<void> { 286: logEvent('tengu_mcp_reset_mcpjson_choices', {}); 287: saveCurrentProjectConfig(current => ({ 288: ...current, 289: enabledMcpjsonServers: [], 290: disabledMcpjsonServers: [], 291: enableAllProjectMcpServers: false 292: })); 293: cliOk('All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + 'You will be prompted for approval next time you start Claude Code.'); 294: }

File: src/cli/handlers/plugins.ts

typescript 1: import figures from 'figures' 2: import { basename, dirname } from 'path' 3: import { setUseCoworkPlugins } from '../../bootstrap/state.js' 4: import { 5: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 6: type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 7: logEvent, 8: } from '../../services/analytics/index.js' 9: import { 10: disableAllPlugins, 11: disablePlugin, 12: enablePlugin, 13: installPlugin, 14: uninstallPlugin, 15: updatePluginCli, 16: VALID_INSTALLABLE_SCOPES, 17: VALID_UPDATE_SCOPES, 18: } from '../../services/plugins/pluginCliCommands.js' 19: import { getPluginErrorMessage } from '../../types/plugin.js' 20: import { errorMessage } from '../../utils/errors.js' 21: import { logError } from '../../utils/log.js' 22: import { clearAllCaches } from '../../utils/plugins/cacheUtils.js' 23: import { getInstallCounts } from '../../utils/plugins/installCounts.js' 24: import { 25: isPluginInstalled, 26: loadInstalledPluginsV2, 27: } from '../../utils/plugins/installedPluginsManager.js' 28: import { 29: createPluginId, 30: loadMarketplacesWithGracefulDegradation, 31: } from '../../utils/plugins/marketplaceHelpers.js' 32: import { 33: addMarketplaceSource, 34: loadKnownMarketplacesConfig, 35: refreshAllMarketplaces, 36: refreshMarketplace, 37: removeMarketplaceSource, 38: saveMarketplaceToSettings, 39: } from '../../utils/plugins/marketplaceManager.js' 40: import { loadPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js' 41: import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js' 42: import { 43: parsePluginIdentifier, 44: scopeToSettingSource, 45: } from '../../utils/plugins/pluginIdentifier.js' 46: import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js' 47: import type { PluginSource } from '../../utils/plugins/schemas.js' 48: import { 49: type ValidationResult, 50: validateManifest, 51: validatePluginContents, 52: } from '../../utils/plugins/validatePlugin.js' 53: import { jsonStringify } from '../../utils/slowOperations.js' 54: import { plural } from '../../utils/stringUtils.js' 55: import { cliError, cliOk } from '../exit.js' 56: export { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } 57: export function handleMarketplaceError(error: unknown, action: string): never { 58: logError(error) 59: cliError(`${figures.cross} Failed to ${action}: ${errorMessage(error)}`) 60: } 61: function printValidationResult(result: ValidationResult): void { 62: if (result.errors.length > 0) { 63: console.log( 64: `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`, 65: ) 66: result.errors.forEach(error => { 67: console.log(` ${figures.pointer} ${error.path}: ${error.message}`) 68: }) 69: console.log('') 70: } 71: if (result.warnings.length > 0) { 72: // biome-ignore lint/suspicious/noConsole:: intentional console output 73: console.log( 74: `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`, 75: ) 76: result.warnings.forEach(warning => { 77: console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`) 78: }) 79: console.log('') 80: } 81: } 82: // plugin validate 83: export async function pluginValidateHandler( 84: manifestPath: string, 85: options: { cowork?: boolean }, 86: ): Promise<void> { 87: if (options.cowork) setUseCoworkPlugins(true) 88: try { 89: const result = await validateManifest(manifestPath) 90: // biome-ignore lint/suspicious/noConsole:: intentional console output 91: console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`) 92: printValidationResult(result) 93: // If this is a plugin manifest located inside a .claude-plugin directory, 94: // also validate the plugin's content files (skills, agents, commands, 95: let contentResults: ValidationResult[] = [] 96: if (result.fileType === 'plugin') { 97: const manifestDir = dirname(result.filePath) 98: if (basename(manifestDir) === '.claude-plugin') { 99: contentResults = await validatePluginContents(dirname(manifestDir)) 100: for (const r of contentResults) { 101: console.log(`Validating ${r.fileType}: ${r.filePath}\n`) 102: printValidationResult(r) 103: } 104: } 105: } 106: const allSuccess = result.success && contentResults.every(r => r.success) 107: const hasWarnings = 108: result.warnings.length > 0 || 109: contentResults.some(r => r.warnings.length > 0) 110: if (allSuccess) { 111: cliOk( 112: hasWarnings 113: ? `${figures.tick} Validation passed with warnings` 114: : `${figures.tick} Validation passed`, 115: ) 116: } else { 117: console.log(`${figures.cross} Validation failed`) 118: process.exit(1) 119: } 120: } catch (error) { 121: logError(error) 122: console.error( 123: `${figures.cross} Unexpected error during validation: ${errorMessage(error)}`, 124: ) 125: process.exit(2) 126: } 127: } 128: export async function pluginListHandler(options: { 129: json?: boolean 130: available?: boolean 131: cowork?: boolean 132: }): Promise<void> { 133: if (options.cowork) setUseCoworkPlugins(true) 134: logEvent('tengu_plugin_list_command', {}) 135: const installedData = loadInstalledPluginsV2() 136: const { getPluginEditableScopes } = await import( 137: '../../utils/plugins/pluginStartupCheck.js' 138: ) 139: const enabledPlugins = getPluginEditableScopes() 140: const pluginIds = Object.keys(installedData.plugins) 141: const { 142: enabled: loadedEnabled, 143: disabled: loadedDisabled, 144: errors: loadErrors, 145: } = await loadAllPlugins() 146: const allLoadedPlugins = [...loadedEnabled, ...loadedDisabled] 147: const inlinePlugins = allLoadedPlugins.filter(p => 148: p.source.endsWith('@inline'), 149: ) 150: const inlineLoadErrors = loadErrors.filter( 151: e => e.source.endsWith('@inline') || e.source.startsWith('inline['), 152: ) 153: if (options.json) { 154: const loadedPluginMap = new Map(allLoadedPlugins.map(p => [p.source, p])) 155: const plugins: Array<{ 156: id: string 157: version: string 158: scope: string 159: enabled: boolean 160: installPath: string 161: installedAt?: string 162: lastUpdated?: string 163: projectPath?: string 164: mcpServers?: Record<string, unknown> 165: errors?: string[] 166: }> = [] 167: for (const pluginId of pluginIds.sort()) { 168: const installations = installedData.plugins[pluginId] 169: if (!installations || installations.length === 0) continue 170: const pluginName = parsePluginIdentifier(pluginId).name 171: const pluginErrors = loadErrors 172: .filter( 173: e => 174: e.source === pluginId || ('plugin' in e && e.plugin === pluginName), 175: ) 176: .map(getPluginErrorMessage) 177: for (const installation of installations) { 178: const loadedPlugin = loadedPluginMap.get(pluginId) 179: let mcpServers: Record<string, unknown> | undefined 180: if (loadedPlugin) { 181: const servers = 182: loadedPlugin.mcpServers || 183: (await loadPluginMcpServers(loadedPlugin)) 184: if (servers && Object.keys(servers).length > 0) { 185: mcpServers = servers 186: } 187: } 188: plugins.push({ 189: id: pluginId, 190: version: installation.version || 'unknown', 191: scope: installation.scope, 192: enabled: enabledPlugins.has(pluginId), 193: installPath: installation.installPath, 194: installedAt: installation.installedAt, 195: lastUpdated: installation.lastUpdated, 196: projectPath: installation.projectPath, 197: mcpServers, 198: errors: pluginErrors.length > 0 ? pluginErrors : undefined, 199: }) 200: } 201: } 202: for (const p of inlinePlugins) { 203: const servers = p.mcpServers || (await loadPluginMcpServers(p)) 204: const pErrors = inlineLoadErrors 205: .filter( 206: e => e.source === p.source || ('plugin' in e && e.plugin === p.name), 207: ) 208: .map(getPluginErrorMessage) 209: plugins.push({ 210: id: p.source, 211: version: p.manifest.version ?? 'unknown', 212: scope: 'session', 213: enabled: p.enabled !== false, 214: installPath: p.path, 215: mcpServers: 216: servers && Object.keys(servers).length > 0 ? servers : undefined, 217: errors: pErrors.length > 0 ? pErrors : undefined, 218: }) 219: } 220: for (const e of inlineLoadErrors.filter(e => 221: e.source.startsWith('inline['), 222: )) { 223: plugins.push({ 224: id: e.source, 225: version: 'unknown', 226: scope: 'session', 227: enabled: false, 228: installPath: 'path' in e ? e.path : '', 229: errors: [getPluginErrorMessage(e)], 230: }) 231: } 232: // If --available is set, also load available plugins from marketplaces 233: if (options.available) { 234: const available: Array<{ 235: pluginId: string 236: name: string 237: description?: string 238: marketplaceName: string 239: version?: string 240: source: PluginSource 241: installCount?: number 242: }> = [] 243: try { 244: const [config, installCounts] = await Promise.all([ 245: loadKnownMarketplacesConfig(), 246: getInstallCounts(), 247: ]) 248: const { marketplaces } = 249: await loadMarketplacesWithGracefulDegradation(config) 250: for (const { 251: name: marketplaceName, 252: data: marketplace, 253: } of marketplaces) { 254: if (marketplace) { 255: for (const entry of marketplace.plugins) { 256: const pluginId = createPluginId(entry.name, marketplaceName) 257: // Only include plugins that are not already installed 258: if (!isPluginInstalled(pluginId)) { 259: available.push({ 260: pluginId, 261: name: entry.name, 262: description: entry.description, 263: marketplaceName, 264: version: entry.version, 265: source: entry.source, 266: installCount: installCounts?.get(pluginId), 267: }) 268: } 269: } 270: } 271: } 272: } catch { 273: // Silently ignore marketplace loading errors 274: } 275: cliOk(jsonStringify({ installed: plugins, available }, null, 2)) 276: } else { 277: cliOk(jsonStringify(plugins, null, 2)) 278: } 279: } 280: if (pluginIds.length === 0 && inlinePlugins.length === 0) { 281: // inlineLoadErrors can exist with zero inline plugins (e.g. --plugin-dir 282: // points at a nonexistent path). Don't early-exit over them — fall 283: if (inlineLoadErrors.length === 0) { 284: cliOk( 285: 'No plugins installed. Use `claude plugin install` to install a plugin.', 286: ) 287: } 288: } 289: if (pluginIds.length > 0) { 290: console.log('Installed plugins:\n') 291: } 292: for (const pluginId of pluginIds.sort()) { 293: const installations = installedData.plugins[pluginId] 294: if (!installations || installations.length === 0) continue 295: const pluginName = parsePluginIdentifier(pluginId).name 296: const pluginErrors = loadErrors.filter( 297: e => e.source === pluginId || ('plugin' in e && e.plugin === pluginName), 298: ) 299: for (const installation of installations) { 300: const isEnabled = enabledPlugins.has(pluginId) 301: const status = 302: pluginErrors.length > 0 303: ? `${figures.cross} failed to load` 304: : isEnabled 305: ? `${figures.tick} enabled` 306: : `${figures.cross} disabled` 307: const version = installation.version || 'unknown' 308: const scope = installation.scope 309: console.log(` ${figures.pointer} ${pluginId}`) 310: console.log(` Version: ${version}`) 311: console.log(` Scope: ${scope}`) 312: console.log(` Status: ${status}`) 313: for (const error of pluginErrors) { 314: console.log(` Error: ${getPluginErrorMessage(error)}`) 315: } 316: console.log('') 317: } 318: } 319: if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) { 320: // biome-ignore lint/suspicious/noConsole:: intentional console output 321: console.log('Session-only plugins (--plugin-dir):\n') 322: for (const p of inlinePlugins) { 323: const pErrors = inlineLoadErrors.filter( 324: e => e.source === p.source || ('plugin' in e && e.plugin === p.name), 325: ) 326: const status = 327: pErrors.length > 0 328: ? `${figures.cross} loaded with errors` 329: : `${figures.tick} loaded` 330: console.log(` ${figures.pointer} ${p.source}`) 331: console.log(` Version: ${p.manifest.version ?? 'unknown'}`) 332: console.log(` Path: ${p.path}`) 333: console.log(` Status: ${status}`) 334: for (const e of pErrors) { 335: console.log(` Error: ${getPluginErrorMessage(e)}`) 336: } 337: console.log('') 338: } 339: // Path-level failures: no LoadedPlugin object exists. Show them so 340: // `--plugin-dir /typo` doesn't just silently produce nothing. 341: for (const e of inlineLoadErrors.filter(e => 342: e.source.startsWith('inline['), 343: )) { 344: console.log( 345: ` ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`, 346: ) 347: } 348: } 349: cliOk() 350: } 351: export async function marketplaceAddHandler( 352: source: string, 353: options: { cowork?: boolean; sparse?: string[]; scope?: string }, 354: ): Promise<void> { 355: if (options.cowork) setUseCoworkPlugins(true) 356: try { 357: const parsed = await parseMarketplaceInput(source) 358: if (!parsed) { 359: cliError( 360: `${figures.cross} Invalid marketplace source format. Try: owner/repo, https://..., or ./path`, 361: ) 362: } 363: if ('error' in parsed) { 364: cliError(`${figures.cross} ${parsed.error}`) 365: } 366: const scope = options.scope ?? 'user' 367: if (scope !== 'user' && scope !== 'project' && scope !== 'local') { 368: cliError( 369: `${figures.cross} Invalid scope '${scope}'. Use: user, project, or local`, 370: ) 371: } 372: const settingSource = scopeToSettingSource(scope) 373: let marketplaceSource = parsed 374: if (options.sparse && options.sparse.length > 0) { 375: if ( 376: marketplaceSource.source === 'github' || 377: marketplaceSource.source === 'git' 378: ) { 379: marketplaceSource = { 380: ...marketplaceSource, 381: sparsePaths: options.sparse, 382: } 383: } else { 384: cliError( 385: `${figures.cross} --sparse is only supported for github and git marketplace sources (got: ${marketplaceSource.source})`, 386: ) 387: } 388: } 389: console.log('Adding marketplace...') 390: const { name, alreadyMaterialized, resolvedSource } = 391: await addMarketplaceSource(marketplaceSource, message => { 392: console.log(message) 393: }) 394: saveMarketplaceToSettings(name, { source: resolvedSource }, settingSource) 395: clearAllCaches() 396: let sourceType = marketplaceSource.source 397: if (marketplaceSource.source === 'github') { 398: sourceType = 399: marketplaceSource.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 400: } 401: logEvent('tengu_marketplace_added', { 402: source_type: 403: sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 404: }) 405: cliOk( 406: alreadyMaterialized 407: ? `${figures.tick} Marketplace '${name}' already on disk — declared in ${scope} settings` 408: : `${figures.tick} Successfully added marketplace: ${name} (declared in ${scope} settings)`, 409: ) 410: } catch (error) { 411: handleMarketplaceError(error, 'add marketplace') 412: } 413: } 414: export async function marketplaceListHandler(options: { 415: json?: boolean 416: cowork?: boolean 417: }): Promise<void> { 418: if (options.cowork) setUseCoworkPlugins(true) 419: try { 420: const config = await loadKnownMarketplacesConfig() 421: const names = Object.keys(config) 422: if (options.json) { 423: const marketplaces = names.sort().map(name => { 424: const marketplace = config[name] 425: const source = marketplace?.source 426: return { 427: name, 428: source: source?.source, 429: ...(source?.source === 'github' && { repo: source.repo }), 430: ...(source?.source === 'git' && { url: source.url }), 431: ...(source?.source === 'url' && { url: source.url }), 432: ...(source?.source === 'directory' && { path: source.path }), 433: ...(source?.source === 'file' && { path: source.path }), 434: installLocation: marketplace?.installLocation, 435: } 436: }) 437: cliOk(jsonStringify(marketplaces, null, 2)) 438: } 439: if (names.length === 0) { 440: cliOk('No marketplaces configured') 441: } 442: console.log('Configured marketplaces:\n') 443: names.forEach(name => { 444: const marketplace = config[name] 445: console.log(` ${figures.pointer} ${name}`) 446: if (marketplace?.source) { 447: const src = marketplace.source 448: if (src.source === 'github') { 449: console.log(` Source: GitHub (${src.repo})`) 450: } else if (src.source === 'git') { 451: console.log(` Source: Git (${src.url})`) 452: } else if (src.source === 'url') { 453: console.log(` Source: URL (${src.url})`) 454: } else if (src.source === 'directory') { 455: console.log(` Source: Directory (${src.path})`) 456: } else if (src.source === 'file') { 457: console.log(` Source: File (${src.path})`) 458: } 459: } 460: console.log('') 461: }) 462: cliOk() 463: } catch (error) { 464: handleMarketplaceError(error, 'list marketplaces') 465: } 466: } 467: export async function marketplaceRemoveHandler( 468: name: string, 469: options: { cowork?: boolean }, 470: ): Promise<void> { 471: if (options.cowork) setUseCoworkPlugins(true) 472: try { 473: await removeMarketplaceSource(name) 474: clearAllCaches() 475: logEvent('tengu_marketplace_removed', { 476: marketplace_name: 477: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 478: }) 479: cliOk(`${figures.tick} Successfully removed marketplace: ${name}`) 480: } catch (error) { 481: handleMarketplaceError(error, 'remove marketplace') 482: } 483: } 484: export async function marketplaceUpdateHandler( 485: name: string | undefined, 486: options: { cowork?: boolean }, 487: ): Promise<void> { 488: if (options.cowork) setUseCoworkPlugins(true) 489: try { 490: if (name) { 491: console.log(`Updating marketplace: ${name}...`) 492: await refreshMarketplace(name, message => { 493: console.log(message) 494: }) 495: clearAllCaches() 496: logEvent('tengu_marketplace_updated', { 497: marketplace_name: 498: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 499: }) 500: cliOk(`${figures.tick} Successfully updated marketplace: ${name}`) 501: } else { 502: const config = await loadKnownMarketplacesConfig() 503: const marketplaceNames = Object.keys(config) 504: if (marketplaceNames.length === 0) { 505: cliOk('No marketplaces configured') 506: } 507: console.log(`Updating ${marketplaceNames.length} marketplace(s)...`) 508: await refreshAllMarketplaces() 509: clearAllCaches() 510: logEvent('tengu_marketplace_updated_all', { 511: count: 512: marketplaceNames.length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 513: }) 514: cliOk( 515: `${figures.tick} Successfully updated ${marketplaceNames.length} marketplace(s)`, 516: ) 517: } 518: } catch (error) { 519: handleMarketplaceError(error, 'update marketplace(s)') 520: } 521: } 522: export async function pluginInstallHandler( 523: plugin: string, 524: options: { scope?: string; cowork?: boolean }, 525: ): Promise<void> { 526: if (options.cowork) setUseCoworkPlugins(true) 527: const scope = options.scope || 'user' 528: if (options.cowork && scope !== 'user') { 529: cliError('--cowork can only be used with user scope') 530: } 531: if ( 532: !VALID_INSTALLABLE_SCOPES.includes( 533: scope as (typeof VALID_INSTALLABLE_SCOPES)[number], 534: ) 535: ) { 536: cliError( 537: `Invalid scope: ${scope}. Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}.`, 538: ) 539: } 540: const { name, marketplace } = parsePluginIdentifier(plugin) 541: logEvent('tengu_plugin_install_command', { 542: _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 543: ...(marketplace && { 544: _PROTO_marketplace_name: 545: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 546: }), 547: scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 548: }) 549: await installPlugin(plugin, scope as 'user' | 'project' | 'local') 550: } 551: export async function pluginUninstallHandler( 552: plugin: string, 553: options: { scope?: string; cowork?: boolean; keepData?: boolean }, 554: ): Promise<void> { 555: if (options.cowork) setUseCoworkPlugins(true) 556: const scope = options.scope || 'user' 557: if (options.cowork && scope !== 'user') { 558: cliError('--cowork can only be used with user scope') 559: } 560: if ( 561: !VALID_INSTALLABLE_SCOPES.includes( 562: scope as (typeof VALID_INSTALLABLE_SCOPES)[number], 563: ) 564: ) { 565: cliError( 566: `Invalid scope: ${scope}. Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}.`, 567: ) 568: } 569: const { name, marketplace } = parsePluginIdentifier(plugin) 570: logEvent('tengu_plugin_uninstall_command', { 571: _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 572: ...(marketplace && { 573: _PROTO_marketplace_name: 574: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 575: }), 576: scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 577: }) 578: await uninstallPlugin( 579: plugin, 580: scope as 'user' | 'project' | 'local', 581: options.keepData, 582: ) 583: } 584: export async function pluginEnableHandler( 585: plugin: string, 586: options: { scope?: string; cowork?: boolean }, 587: ): Promise<void> { 588: if (options.cowork) setUseCoworkPlugins(true) 589: let scope: (typeof VALID_INSTALLABLE_SCOPES)[number] | undefined 590: if (options.scope) { 591: if ( 592: !VALID_INSTALLABLE_SCOPES.includes( 593: options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number], 594: ) 595: ) { 596: cliError( 597: `Invalid scope "${options.scope}". Valid scopes: ${VALID_INSTALLABLE_SCOPES.join(', ')}`, 598: ) 599: } 600: scope = options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number] 601: } 602: if (options.cowork && scope !== undefined && scope !== 'user') { 603: cliError('--cowork can only be used with user scope') 604: } 605: if (options.cowork && scope === undefined) { 606: scope = 'user' 607: } 608: const { name, marketplace } = parsePluginIdentifier(plugin) 609: logEvent('tengu_plugin_enable_command', { 610: _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 611: ...(marketplace && { 612: _PROTO_marketplace_name: 613: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 614: }), 615: scope: (scope ?? 616: 'auto') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 617: }) 618: await enablePlugin(plugin, scope) 619: } 620: export async function pluginDisableHandler( 621: plugin: string | undefined, 622: options: { scope?: string; cowork?: boolean; all?: boolean }, 623: ): Promise<void> { 624: if (options.all && plugin) { 625: cliError('Cannot use --all with a specific plugin') 626: } 627: if (!options.all && !plugin) { 628: cliError('Please specify a plugin name or use --all to disable all plugins') 629: } 630: if (options.cowork) setUseCoworkPlugins(true) 631: if (options.all) { 632: if (options.scope) { 633: cliError('Cannot use --scope with --all') 634: } 635: logEvent('tengu_plugin_disable_command', {}) 636: await disableAllPlugins() 637: return 638: } 639: let scope: (typeof VALID_INSTALLABLE_SCOPES)[number] | undefined 640: if (options.scope) { 641: if ( 642: !VALID_INSTALLABLE_SCOPES.includes( 643: options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number], 644: ) 645: ) { 646: cliError( 647: `Invalid scope "${options.scope}". Valid scopes: ${VALID_INSTALLABLE_SCOPES.join(', ')}`, 648: ) 649: } 650: scope = options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number] 651: } 652: if (options.cowork && scope !== undefined && scope !== 'user') { 653: cliError('--cowork can only be used with user scope') 654: } 655: if (options.cowork && scope === undefined) { 656: scope = 'user' 657: } 658: const { name, marketplace } = parsePluginIdentifier(plugin!) 659: logEvent('tengu_plugin_disable_command', { 660: _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 661: ...(marketplace && { 662: _PROTO_marketplace_name: 663: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 664: }), 665: scope: (scope ?? 666: 'auto') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 667: }) 668: await disablePlugin(plugin!, scope) 669: } 670: export async function pluginUpdateHandler( 671: plugin: string, 672: options: { scope?: string; cowork?: boolean }, 673: ): Promise<void> { 674: if (options.cowork) setUseCoworkPlugins(true) 675: const { name, marketplace } = parsePluginIdentifier(plugin) 676: logEvent('tengu_plugin_update_command', { 677: _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 678: ...(marketplace && { 679: _PROTO_marketplace_name: 680: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 681: }), 682: }) 683: let scope: (typeof VALID_UPDATE_SCOPES)[number] = 'user' 684: if (options.scope) { 685: if ( 686: !VALID_UPDATE_SCOPES.includes( 687: options.scope as (typeof VALID_UPDATE_SCOPES)[number], 688: ) 689: ) { 690: cliError( 691: `Invalid scope "${options.scope}". Valid scopes: ${VALID_UPDATE_SCOPES.join(', ')}`, 692: ) 693: } 694: scope = options.scope as (typeof VALID_UPDATE_SCOPES)[number] 695: } 696: if (options.cowork && scope !== 'user') { 697: cliError('--cowork can only be used with user scope') 698: } 699: await updatePluginCli(plugin, scope) 700: }

File: src/cli/handlers/util.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { cwd } from 'process'; 3: import React from 'react'; 4: import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'; 5: import { useManagePlugins } from '../../hooks/useManagePlugins.js'; 6: import type { Root } from '../../ink.js'; 7: import { Box, Text } from '../../ink.js'; 8: import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; 9: import { logEvent } from '../../services/analytics/index.js'; 10: import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'; 11: import { AppStateProvider } from '../../state/AppState.js'; 12: import { onChangeAppState } from '../../state/onChangeAppState.js'; 13: import { isAnthropicAuthEnabled } from '../../utils/auth.js'; 14: export async function setupTokenHandler(root: Root): Promise<void> { 15: logEvent('tengu_setup_token_command', {}); 16: const showAuthWarning = !isAnthropicAuthEnabled(); 17: const { 18: ConsoleOAuthFlow 19: } = await import('../../components/ConsoleOAuthFlow.js'); 20: await new Promise<void>(resolve => { 21: root.render(<AppStateProvider onChangeAppState={onChangeAppState}> 22: <KeybindingSetup> 23: <Box flexDirection="column" gap={1}> 24: <WelcomeV2 /> 25: {showAuthWarning && <Box flexDirection="column"> 26: <Text color="warning"> 27: Warning: You already have authentication configured via 28: environment variable or API key helper. 29: </Text> 30: <Text color="warning"> 31: The setup-token command will create a new OAuth token which 32: you can use instead. 33: </Text> 34: </Box>} 35: <ConsoleOAuthFlow onDone={() => { 36: void resolve(); 37: }} mode="setup-token" startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required." /> 38: </Box> 39: </KeybindingSetup> 40: </AppStateProvider>); 41: }); 42: root.unmount(); 43: process.exit(0); 44: } 45: const DoctorLazy = React.lazy(() => import('../../screens/Doctor.js').then(m => ({ 46: default: m.Doctor 47: }))); 48: function DoctorWithPlugins(t0) { 49: const $ = _c(2); 50: const { 51: onDone 52: } = t0; 53: useManagePlugins(); 54: let t1; 55: if ($[0] !== onDone) { 56: t1 = <React.Suspense fallback={null}><DoctorLazy onDone={onDone} /></React.Suspense>; 57: $[0] = onDone; 58: $[1] = t1; 59: } else { 60: t1 = $[1]; 61: } 62: return t1; 63: } 64: export async function doctorHandler(root: Root): Promise<void> { 65: logEvent('tengu_doctor_command', {}); 66: await new Promise<void>(resolve => { 67: root.render(<AppStateProvider> 68: <KeybindingSetup> 69: <MCPConnectionManager dynamicMcpConfig={undefined} isStrictMcpConfig={false}> 70: <DoctorWithPlugins onDone={() => { 71: void resolve(); 72: }} /> 73: </MCPConnectionManager> 74: </KeybindingSetup> 75: </AppStateProvider>); 76: }); 77: root.unmount(); 78: process.exit(0); 79: } 80: export async function installHandler(target: string | undefined, options: { 81: force?: boolean; 82: }): Promise<void> { 83: const { 84: setup 85: } = await import('../../setup.js'); 86: await setup(cwd(), 'default', false, false, undefined, false); 87: const { 88: install 89: } = await import('../../commands/install.js'); 90: await new Promise<void>(resolve => { 91: const args: string[] = []; 92: if (target) args.push(target); 93: if (options.force) args.push('--force'); 94: void install.call(result => { 95: void resolve(); 96: process.exit(result.includes('failed') ? 1 : 0); 97: }, {}, args); 98: }); 99: }

File: src/cli/transports/ccrClient.ts

typescript 1: import { randomUUID } from 'crypto' 2: import type { 3: SDKPartialAssistantMessage, 4: StdoutMessage, 5: } from 'src/entrypoints/sdk/controlTypes.js' 6: import { decodeJwtExpiry } from '../../bridge/jwtUtils.js' 7: import { logForDebugging } from '../../utils/debug.js' 8: import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js' 9: import { errorMessage, getErrnoCode } from '../../utils/errors.js' 10: import { createAxiosInstance } from '../../utils/proxy.js' 11: import { 12: registerSessionActivityCallback, 13: unregisterSessionActivityCallback, 14: } from '../../utils/sessionActivity.js' 15: import { 16: getSessionIngressAuthHeaders, 17: getSessionIngressAuthToken, 18: } from '../../utils/sessionIngressAuth.js' 19: import type { 20: RequiresActionDetails, 21: SessionState, 22: } from '../../utils/sessionState.js' 23: import { sleep } from '../../utils/sleep.js' 24: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 25: import { 26: RetryableError, 27: SerialBatchEventUploader, 28: } from './SerialBatchEventUploader.js' 29: import type { SSETransport, StreamClientEvent } from './SSETransport.js' 30: import { WorkerStateUploader } from './WorkerStateUploader.js' 31: const DEFAULT_HEARTBEAT_INTERVAL_MS = 20_000 32: const STREAM_EVENT_FLUSH_INTERVAL_MS = 100 33: function alwaysValidStatus(): boolean { 34: return true 35: } 36: export type CCRInitFailReason = 37: | 'no_auth_headers' 38: | 'missing_epoch' 39: | 'worker_register_failed' 40: export class CCRInitError extends Error { 41: constructor(readonly reason: CCRInitFailReason) { 42: super(`CCRClient init failed: ${reason}`) 43: } 44: } 45: const MAX_CONSECUTIVE_AUTH_FAILURES = 10 46: type EventPayload = { 47: uuid: string 48: type: string 49: [key: string]: unknown 50: } 51: type ClientEvent = { 52: payload: EventPayload 53: ephemeral?: boolean 54: } 55: type CoalescedStreamEvent = { 56: type: 'stream_event' 57: uuid: string 58: session_id: string 59: parent_tool_use_id: string | null 60: event: { 61: type: 'content_block_delta' 62: index: number 63: delta: { type: 'text_delta'; text: string } 64: } 65: } 66: export type StreamAccumulatorState = { 67: byMessage: Map<string, string[][]> 68: scopeToMessage: Map<string, string> 69: } 70: export function createStreamAccumulator(): StreamAccumulatorState { 71: return { byMessage: new Map(), scopeToMessage: new Map() } 72: } 73: function scopeKey(m: { 74: session_id: string 75: parent_tool_use_id: string | null 76: }): string { 77: return `${m.session_id}:${m.parent_tool_use_id ?? ''}` 78: } 79: export function accumulateStreamEvents( 80: buffer: SDKPartialAssistantMessage[], 81: state: StreamAccumulatorState, 82: ): EventPayload[] { 83: const out: EventPayload[] = [] 84: const touched = new Map<string[], CoalescedStreamEvent>() 85: for (const msg of buffer) { 86: switch (msg.event.type) { 87: case 'message_start': { 88: const id = msg.event.message.id 89: const prevId = state.scopeToMessage.get(scopeKey(msg)) 90: if (prevId) state.byMessage.delete(prevId) 91: state.scopeToMessage.set(scopeKey(msg), id) 92: state.byMessage.set(id, []) 93: out.push(msg) 94: break 95: } 96: case 'content_block_delta': { 97: if (msg.event.delta.type !== 'text_delta') { 98: out.push(msg) 99: break 100: } 101: const messageId = state.scopeToMessage.get(scopeKey(msg)) 102: const blocks = messageId ? state.byMessage.get(messageId) : undefined 103: if (!blocks) { 104: out.push(msg) 105: break 106: } 107: const chunks = (blocks[msg.event.index] ??= []) 108: chunks.push(msg.event.delta.text) 109: const existing = touched.get(chunks) 110: if (existing) { 111: existing.event.delta.text = chunks.join('') 112: break 113: } 114: const snapshot: CoalescedStreamEvent = { 115: type: 'stream_event', 116: uuid: msg.uuid, 117: session_id: msg.session_id, 118: parent_tool_use_id: msg.parent_tool_use_id, 119: event: { 120: type: 'content_block_delta', 121: index: msg.event.index, 122: delta: { type: 'text_delta', text: chunks.join('') }, 123: }, 124: } 125: touched.set(chunks, snapshot) 126: out.push(snapshot) 127: break 128: } 129: default: 130: out.push(msg) 131: } 132: } 133: return out 134: } 135: /** 136: * Clear accumulator entries for a completed assistant message. Called from 137: * writeEvent when the SDKAssistantMessage arrives — the reliable end-of-stream 138: * signal that fires even when abort/interrupt/error skip SSE stop events. 139: */ 140: export function clearStreamAccumulatorForMessage( 141: state: StreamAccumulatorState, 142: assistant: { 143: session_id: string 144: parent_tool_use_id: string | null 145: message: { id: string } 146: }, 147: ): void { 148: state.byMessage.delete(assistant.message.id) 149: const scope = scopeKey(assistant) 150: if (state.scopeToMessage.get(scope) === assistant.message.id) { 151: state.scopeToMessage.delete(scope) 152: } 153: } 154: type RequestResult = { ok: true } | { ok: false; retryAfterMs?: number } 155: type WorkerEvent = { 156: payload: EventPayload 157: is_compaction?: boolean 158: agent_id?: string 159: } 160: export type InternalEvent = { 161: event_id: string 162: event_type: string 163: payload: Record<string, unknown> 164: event_metadata?: Record<string, unknown> | null 165: is_compaction: boolean 166: created_at: string 167: agent_id?: string 168: } 169: type ListInternalEventsResponse = { 170: data: InternalEvent[] 171: next_cursor?: string 172: } 173: type WorkerStateResponse = { 174: worker?: { 175: external_metadata?: Record<string, unknown> 176: } 177: } 178: /** 179: * Manages the worker lifecycle protocol with CCR v2: 180: * - Epoch management: reads worker_epoch from CLAUDE_CODE_WORKER_EPOCH env var 181: * - Runtime state reporting: PUT /sessions/{id}/worker 182: * - Heartbeat: POST /sessions/{id}/worker/heartbeat for liveness detection 183: * 184: * All writes go through this.request(). 185: */ 186: export class CCRClient { 187: private workerEpoch = 0 188: private readonly heartbeatIntervalMs: number 189: private readonly heartbeatJitterFraction: number 190: private heartbeatTimer: NodeJS.Timeout | null = null 191: private heartbeatInFlight = false 192: private closed = false 193: private consecutiveAuthFailures = 0 194: private currentState: SessionState | null = null 195: private readonly sessionBaseUrl: string 196: private readonly sessionId: string 197: private readonly http = createAxiosInstance({ keepAlive: true }) 198: // stream_event delay buffer — accumulates content deltas for up to 199: // STREAM_EVENT_FLUSH_INTERVAL_MS before enqueueing (reduces POST count 200: // and enables text_delta coalescing). Mirrors HybridTransport's pattern. 201: private streamEventBuffer: SDKPartialAssistantMessage[] = [] 202: private streamEventTimer: ReturnType<typeof setTimeout> | null = null 203: private streamTextAccumulator = createStreamAccumulator() 204: private readonly workerState: WorkerStateUploader 205: private readonly eventUploader: SerialBatchEventUploader<ClientEvent> 206: private readonly internalEventUploader: SerialBatchEventUploader<WorkerEvent> 207: private readonly deliveryUploader: SerialBatchEventUploader<{ 208: eventId: string 209: status: 'received' | 'processing' | 'processed' 210: }> 211: private readonly onEpochMismatch: () => never 212: private readonly getAuthHeaders: () => Record<string, string> 213: constructor( 214: transport: SSETransport, 215: sessionUrl: URL, 216: opts?: { 217: onEpochMismatch?: () => never 218: heartbeatIntervalMs?: number 219: heartbeatJitterFraction?: number 220: getAuthHeaders?: () => Record<string, string> 221: }, 222: ) { 223: this.onEpochMismatch = 224: opts?.onEpochMismatch ?? 225: (() => { 226: process.exit(1) 227: }) 228: this.heartbeatIntervalMs = 229: opts?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS 230: this.heartbeatJitterFraction = opts?.heartbeatJitterFraction ?? 0 231: this.getAuthHeaders = opts?.getAuthHeaders ?? getSessionIngressAuthHeaders 232: if (sessionUrl.protocol !== 'http:' && sessionUrl.protocol !== 'https:') { 233: throw new Error( 234: `CCRClient: Expected http(s) URL, got ${sessionUrl.protocol}`, 235: ) 236: } 237: const pathname = sessionUrl.pathname.replace(/\/$/, '') 238: this.sessionBaseUrl = `${sessionUrl.protocol}//${sessionUrl.host}${pathname}` 239: // Extract session ID from the URL path (last segment) 240: this.sessionId = pathname.split('/').pop() || '' 241: this.workerState = new WorkerStateUploader({ 242: send: body => 243: this.request( 244: 'put', 245: '/worker', 246: { worker_epoch: this.workerEpoch, ...body }, 247: 'PUT worker', 248: ).then(r => r.ok), 249: baseDelayMs: 500, 250: maxDelayMs: 30_000, 251: jitterMs: 500, 252: }) 253: this.eventUploader = new SerialBatchEventUploader<ClientEvent>({ 254: maxBatchSize: 100, 255: maxBatchBytes: 10 * 1024 * 1024, 256: maxQueueSize: 100_000, 257: send: async batch => { 258: const result = await this.request( 259: 'post', 260: '/worker/events', 261: { worker_epoch: this.workerEpoch, events: batch }, 262: 'client events', 263: ) 264: if (!result.ok) { 265: throw new RetryableError( 266: 'client event POST failed', 267: result.retryAfterMs, 268: ) 269: } 270: }, 271: baseDelayMs: 500, 272: maxDelayMs: 30_000, 273: jitterMs: 500, 274: }) 275: this.internalEventUploader = new SerialBatchEventUploader<WorkerEvent>({ 276: maxBatchSize: 100, 277: maxBatchBytes: 10 * 1024 * 1024, 278: maxQueueSize: 200, 279: send: async batch => { 280: const result = await this.request( 281: 'post', 282: '/worker/internal-events', 283: { worker_epoch: this.workerEpoch, events: batch }, 284: 'internal events', 285: ) 286: if (!result.ok) { 287: throw new RetryableError( 288: 'internal event POST failed', 289: result.retryAfterMs, 290: ) 291: } 292: }, 293: baseDelayMs: 500, 294: maxDelayMs: 30_000, 295: jitterMs: 500, 296: }) 297: this.deliveryUploader = new SerialBatchEventUploader<{ 298: eventId: string 299: status: 'received' | 'processing' | 'processed' 300: }>({ 301: maxBatchSize: 64, 302: maxQueueSize: 64, 303: send: async batch => { 304: const result = await this.request( 305: 'post', 306: '/worker/events/delivery', 307: { 308: worker_epoch: this.workerEpoch, 309: updates: batch.map(d => ({ 310: event_id: d.eventId, 311: status: d.status, 312: })), 313: }, 314: 'delivery batch', 315: ) 316: if (!result.ok) { 317: throw new RetryableError('delivery POST failed', result.retryAfterMs) 318: } 319: }, 320: baseDelayMs: 500, 321: maxDelayMs: 30_000, 322: jitterMs: 500, 323: }) 324: transport.setOnEvent((event: StreamClientEvent) => { 325: this.reportDelivery(event.event_id, 'received') 326: }) 327: } 328: async initialize(epoch?: number): Promise<Record<string, unknown> | null> { 329: const startMs = Date.now() 330: if (Object.keys(this.getAuthHeaders()).length === 0) { 331: throw new CCRInitError('no_auth_headers') 332: } 333: if (epoch === undefined) { 334: const rawEpoch = process.env.CLAUDE_CODE_WORKER_EPOCH 335: epoch = rawEpoch ? parseInt(rawEpoch, 10) : NaN 336: } 337: if (isNaN(epoch)) { 338: throw new CCRInitError('missing_epoch') 339: } 340: this.workerEpoch = epoch 341: const restoredPromise = this.getWorkerState() 342: const result = await this.request( 343: 'put', 344: '/worker', 345: { 346: worker_status: 'idle', 347: worker_epoch: this.workerEpoch, 348: external_metadata: { 349: pending_action: null, 350: task_summary: null, 351: }, 352: }, 353: 'PUT worker (init)', 354: ) 355: if (!result.ok) { 356: throw new CCRInitError('worker_register_failed') 357: } 358: this.currentState = 'idle' 359: this.startHeartbeat() 360: registerSessionActivityCallback(() => { 361: void this.writeEvent({ type: 'keep_alive' }) 362: }) 363: logForDebugging(`CCRClient: initialized, epoch=${this.workerEpoch}`) 364: logForDiagnosticsNoPII('info', 'cli_worker_lifecycle_initialized', { 365: epoch: this.workerEpoch, 366: duration_ms: Date.now() - startMs, 367: }) 368: const { metadata, durationMs } = await restoredPromise 369: if (!this.closed) { 370: logForDiagnosticsNoPII('info', 'cli_worker_state_restored', { 371: duration_ms: durationMs, 372: had_state: metadata !== null, 373: }) 374: } 375: return metadata 376: } 377: private async getWorkerState(): Promise<{ 378: metadata: Record<string, unknown> | null 379: durationMs: number 380: }> { 381: const startMs = Date.now() 382: const authHeaders = this.getAuthHeaders() 383: if (Object.keys(authHeaders).length === 0) { 384: return { metadata: null, durationMs: 0 } 385: } 386: const data = await this.getWithRetry<WorkerStateResponse>( 387: `${this.sessionBaseUrl}/worker`, 388: authHeaders, 389: 'worker_state', 390: ) 391: return { 392: metadata: data?.worker?.external_metadata ?? null, 393: durationMs: Date.now() - startMs, 394: } 395: } 396: private async request( 397: method: 'post' | 'put', 398: path: string, 399: body: unknown, 400: label: string, 401: { timeout = 10_000 }: { timeout?: number } = {}, 402: ): Promise<RequestResult> { 403: const authHeaders = this.getAuthHeaders() 404: if (Object.keys(authHeaders).length === 0) return { ok: false } 405: try { 406: const response = await this.http[method]( 407: `${this.sessionBaseUrl}${path}`, 408: body, 409: { 410: headers: { 411: ...authHeaders, 412: 'Content-Type': 'application/json', 413: 'anthropic-version': '2023-06-01', 414: 'User-Agent': getClaudeCodeUserAgent(), 415: }, 416: validateStatus: alwaysValidStatus, 417: timeout, 418: }, 419: ) 420: if (response.status >= 200 && response.status < 300) { 421: this.consecutiveAuthFailures = 0 422: return { ok: true } 423: } 424: if (response.status === 409) { 425: this.handleEpochMismatch() 426: } 427: if (response.status === 401 || response.status === 403) { 428: const tok = getSessionIngressAuthToken() 429: const exp = tok ? decodeJwtExpiry(tok) : null 430: if (exp !== null && exp * 1000 < Date.now()) { 431: logForDebugging( 432: `CCRClient: session_token expired (exp=${new Date(exp * 1000).toISOString()}) — no refresh was delivered, exiting`, 433: { level: 'error' }, 434: ) 435: logForDiagnosticsNoPII('error', 'cli_worker_token_expired_no_refresh') 436: this.onEpochMismatch() 437: } 438: this.consecutiveAuthFailures++ 439: if (this.consecutiveAuthFailures >= MAX_CONSECUTIVE_AUTH_FAILURES) { 440: logForDebugging( 441: `CCRClient: ${this.consecutiveAuthFailures} consecutive auth failures with a valid-looking token — server-side auth unrecoverable, exiting`, 442: { level: 'error' }, 443: ) 444: logForDiagnosticsNoPII('error', 'cli_worker_auth_failures_exhausted') 445: this.onEpochMismatch() 446: } 447: } 448: logForDebugging(`CCRClient: ${label} returned ${response.status}`, { 449: level: 'warn', 450: }) 451: logForDiagnosticsNoPII('warn', 'cli_worker_request_failed', { 452: method, 453: path, 454: status: response.status, 455: }) 456: if (response.status === 429) { 457: const raw = response.headers?.['retry-after'] 458: const seconds = typeof raw === 'string' ? parseInt(raw, 10) : NaN 459: if (!isNaN(seconds) && seconds >= 0) { 460: return { ok: false, retryAfterMs: seconds * 1000 } 461: } 462: } 463: return { ok: false } 464: } catch (error) { 465: logForDebugging(`CCRClient: ${label} failed: ${errorMessage(error)}`, { 466: level: 'warn', 467: }) 468: logForDiagnosticsNoPII('warn', 'cli_worker_request_error', { 469: method, 470: path, 471: error_code: getErrnoCode(error), 472: }) 473: return { ok: false } 474: } 475: } 476: reportState(state: SessionState, details?: RequiresActionDetails): void { 477: if (state === this.currentState && !details) return 478: this.currentState = state 479: this.workerState.enqueue({ 480: worker_status: state, 481: requires_action_details: details 482: ? { 483: tool_name: details.tool_name, 484: action_description: details.action_description, 485: request_id: details.request_id, 486: } 487: : null, 488: }) 489: } 490: reportMetadata(metadata: Record<string, unknown>): void { 491: this.workerState.enqueue({ external_metadata: metadata }) 492: } 493: private handleEpochMismatch(): never { 494: logForDebugging('CCRClient: Epoch mismatch (409), shutting down', { 495: level: 'error', 496: }) 497: logForDiagnosticsNoPII('error', 'cli_worker_epoch_mismatch') 498: this.onEpochMismatch() 499: } 500: private startHeartbeat(): void { 501: this.stopHeartbeat() 502: const schedule = (): void => { 503: const jitter = 504: this.heartbeatIntervalMs * 505: this.heartbeatJitterFraction * 506: (2 * Math.random() - 1) 507: this.heartbeatTimer = setTimeout(tick, this.heartbeatIntervalMs + jitter) 508: } 509: const tick = (): void => { 510: void this.sendHeartbeat() 511: if (this.heartbeatTimer === null) return 512: schedule() 513: } 514: schedule() 515: } 516: private stopHeartbeat(): void { 517: if (this.heartbeatTimer) { 518: clearTimeout(this.heartbeatTimer) 519: this.heartbeatTimer = null 520: } 521: } 522: private async sendHeartbeat(): Promise<void> { 523: if (this.heartbeatInFlight) return 524: this.heartbeatInFlight = true 525: try { 526: const result = await this.request( 527: 'post', 528: '/worker/heartbeat', 529: { session_id: this.sessionId, worker_epoch: this.workerEpoch }, 530: 'Heartbeat', 531: { timeout: 5_000 }, 532: ) 533: if (result.ok) { 534: logForDebugging('CCRClient: Heartbeat sent') 535: } 536: } finally { 537: this.heartbeatInFlight = false 538: } 539: } 540: async writeEvent(message: StdoutMessage): Promise<void> { 541: if (message.type === 'stream_event') { 542: this.streamEventBuffer.push(message) 543: if (!this.streamEventTimer) { 544: this.streamEventTimer = setTimeout( 545: () => void this.flushStreamEventBuffer(), 546: STREAM_EVENT_FLUSH_INTERVAL_MS, 547: ) 548: } 549: return 550: } 551: await this.flushStreamEventBuffer() 552: if (message.type === 'assistant') { 553: clearStreamAccumulatorForMessage(this.streamTextAccumulator, message) 554: } 555: await this.eventUploader.enqueue(this.toClientEvent(message)) 556: } 557: private toClientEvent(message: StdoutMessage): ClientEvent { 558: const msg = message as unknown as Record<string, unknown> 559: return { 560: payload: { 561: ...msg, 562: uuid: typeof msg.uuid === 'string' ? msg.uuid : randomUUID(), 563: } as EventPayload, 564: } 565: } 566: private async flushStreamEventBuffer(): Promise<void> { 567: if (this.streamEventTimer) { 568: clearTimeout(this.streamEventTimer) 569: this.streamEventTimer = null 570: } 571: if (this.streamEventBuffer.length === 0) return 572: const buffered = this.streamEventBuffer 573: this.streamEventBuffer = [] 574: const payloads = accumulateStreamEvents( 575: buffered, 576: this.streamTextAccumulator, 577: ) 578: await this.eventUploader.enqueue( 579: payloads.map(payload => ({ payload, ephemeral: true })), 580: ) 581: } 582: async writeInternalEvent( 583: eventType: string, 584: payload: Record<string, unknown>, 585: { 586: isCompaction = false, 587: agentId, 588: }: { 589: isCompaction?: boolean 590: agentId?: string 591: } = {}, 592: ): Promise<void> { 593: const event: WorkerEvent = { 594: payload: { 595: type: eventType, 596: ...payload, 597: uuid: typeof payload.uuid === 'string' ? payload.uuid : randomUUID(), 598: } as EventPayload, 599: ...(isCompaction && { is_compaction: true }), 600: ...(agentId && { agent_id: agentId }), 601: } 602: await this.internalEventUploader.enqueue(event) 603: } 604: flushInternalEvents(): Promise<void> { 605: return this.internalEventUploader.flush() 606: } 607: async flush(): Promise<void> { 608: await this.flushStreamEventBuffer() 609: return this.eventUploader.flush() 610: } 611: async readInternalEvents(): Promise<InternalEvent[] | null> { 612: return this.paginatedGet('/worker/internal-events', {}, 'internal_events') 613: } 614: async readSubagentInternalEvents(): Promise<InternalEvent[] | null> { 615: return this.paginatedGet( 616: '/worker/internal-events', 617: { subagents: 'true' }, 618: 'subagent_events', 619: ) 620: } 621: private async paginatedGet( 622: path: string, 623: params: Record<string, string>, 624: context: string, 625: ): Promise<InternalEvent[] | null> { 626: const authHeaders = this.getAuthHeaders() 627: if (Object.keys(authHeaders).length === 0) return null 628: const allEvents: InternalEvent[] = [] 629: let cursor: string | undefined 630: do { 631: const url = new URL(`${this.sessionBaseUrl}${path}`) 632: for (const [k, v] of Object.entries(params)) { 633: url.searchParams.set(k, v) 634: } 635: if (cursor) { 636: url.searchParams.set('cursor', cursor) 637: } 638: const page = await this.getWithRetry<ListInternalEventsResponse>( 639: url.toString(), 640: authHeaders, 641: context, 642: ) 643: if (!page) return null 644: allEvents.push(...(page.data ?? [])) 645: cursor = page.next_cursor 646: } while (cursor) 647: logForDebugging( 648: `CCRClient: Read ${allEvents.length} internal events from ${path}${params.subagents ? ' (subagents)' : ''}`, 649: ) 650: return allEvents 651: } 652: private async getWithRetry<T>( 653: url: string, 654: authHeaders: Record<string, string>, 655: context: string, 656: ): Promise<T | null> { 657: for (let attempt = 1; attempt <= 10; attempt++) { 658: let response 659: try { 660: response = await this.http.get<T>(url, { 661: headers: { 662: ...authHeaders, 663: 'anthropic-version': '2023-06-01', 664: 'User-Agent': getClaudeCodeUserAgent(), 665: }, 666: validateStatus: alwaysValidStatus, 667: timeout: 30_000, 668: }) 669: } catch (error) { 670: logForDebugging( 671: `CCRClient: GET ${url} failed (attempt ${attempt}/10): ${errorMessage(error)}`, 672: { level: 'warn' }, 673: ) 674: if (attempt < 10) { 675: const delay = 676: Math.min(500 * 2 ** (attempt - 1), 30_000) + Math.random() * 500 677: await sleep(delay) 678: } 679: continue 680: } 681: if (response.status >= 200 && response.status < 300) { 682: return response.data 683: } 684: if (response.status === 409) { 685: this.handleEpochMismatch() 686: } 687: logForDebugging( 688: `CCRClient: GET ${url} returned ${response.status} (attempt ${attempt}/10)`, 689: { level: 'warn' }, 690: ) 691: if (attempt < 10) { 692: const delay = 693: Math.min(500 * 2 ** (attempt - 1), 30_000) + Math.random() * 500 694: await sleep(delay) 695: } 696: } 697: logForDebugging('CCRClient: GET retries exhausted', { level: 'error' }) 698: logForDiagnosticsNoPII('error', 'cli_worker_get_retries_exhausted', { 699: context, 700: }) 701: return null 702: } 703: reportDelivery( 704: eventId: string, 705: status: 'received' | 'processing' | 'processed', 706: ): void { 707: void this.deliveryUploader.enqueue({ eventId, status }) 708: } 709: getWorkerEpoch(): number { 710: return this.workerEpoch 711: } 712: get internalEventsPending(): number { 713: return this.internalEventUploader.pendingCount 714: } 715: close(): void { 716: this.closed = true 717: this.stopHeartbeat() 718: unregisterSessionActivityCallback() 719: if (this.streamEventTimer) { 720: clearTimeout(this.streamEventTimer) 721: this.streamEventTimer = null 722: } 723: this.streamEventBuffer = [] 724: this.streamTextAccumulator.byMessage.clear() 725: this.streamTextAccumulator.scopeToMessage.clear() 726: this.workerState.close() 727: this.eventUploader.close() 728: this.internalEventUploader.close() 729: this.deliveryUploader.close() 730: } 731: }

File: src/cli/transports/HybridTransport.ts

typescript 1: import axios, { type AxiosError } from 'axios' 2: import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js' 3: import { logForDebugging } from '../../utils/debug.js' 4: import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js' 5: import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js' 6: import { SerialBatchEventUploader } from './SerialBatchEventUploader.js' 7: import { 8: WebSocketTransport, 9: type WebSocketTransportOptions, 10: } from './WebSocketTransport.js' 11: const BATCH_FLUSH_INTERVAL_MS = 100 12: const POST_TIMEOUT_MS = 15_000 13: const CLOSE_GRACE_MS = 3000 14: export class HybridTransport extends WebSocketTransport { 15: private postUrl: string 16: private uploader: SerialBatchEventUploader<StdoutMessage> 17: private streamEventBuffer: StdoutMessage[] = [] 18: private streamEventTimer: ReturnType<typeof setTimeout> | null = null 19: constructor( 20: url: URL, 21: headers: Record<string, string> = {}, 22: sessionId?: string, 23: refreshHeaders?: () => Record<string, string>, 24: options?: WebSocketTransportOptions & { 25: maxConsecutiveFailures?: number 26: onBatchDropped?: (batchSize: number, failures: number) => void 27: }, 28: ) { 29: super(url, headers, sessionId, refreshHeaders, options) 30: const { maxConsecutiveFailures, onBatchDropped } = options ?? {} 31: this.postUrl = convertWsUrlToPostUrl(url) 32: this.uploader = new SerialBatchEventUploader<StdoutMessage>({ 33: maxBatchSize: 500, 34: maxQueueSize: 100_000, 35: baseDelayMs: 500, 36: maxDelayMs: 8000, 37: jitterMs: 1000, 38: maxConsecutiveFailures, 39: onBatchDropped: (batchSize, failures) => { 40: logForDiagnosticsNoPII( 41: 'error', 42: 'cli_hybrid_batch_dropped_max_failures', 43: { 44: batchSize, 45: failures, 46: }, 47: ) 48: onBatchDropped?.(batchSize, failures) 49: }, 50: send: batch => this.postOnce(batch), 51: }) 52: logForDebugging(`HybridTransport: POST URL = ${this.postUrl}`) 53: logForDiagnosticsNoPII('info', 'cli_hybrid_transport_initialized') 54: } 55: override async write(message: StdoutMessage): Promise<void> { 56: if (message.type === 'stream_event') { 57: this.streamEventBuffer.push(message) 58: if (!this.streamEventTimer) { 59: this.streamEventTimer = setTimeout( 60: () => this.flushStreamEvents(), 61: BATCH_FLUSH_INTERVAL_MS, 62: ) 63: } 64: return 65: } 66: await this.uploader.enqueue([...this.takeStreamEvents(), message]) 67: return this.uploader.flush() 68: } 69: async writeBatch(messages: StdoutMessage[]): Promise<void> { 70: await this.uploader.enqueue([...this.takeStreamEvents(), ...messages]) 71: return this.uploader.flush() 72: } 73: get droppedBatchCount(): number { 74: return this.uploader.droppedBatchCount 75: } 76: flush(): Promise<void> { 77: void this.uploader.enqueue(this.takeStreamEvents()) 78: return this.uploader.flush() 79: } 80: private takeStreamEvents(): StdoutMessage[] { 81: if (this.streamEventTimer) { 82: clearTimeout(this.streamEventTimer) 83: this.streamEventTimer = null 84: } 85: const buffered = this.streamEventBuffer 86: this.streamEventBuffer = [] 87: return buffered 88: } 89: private flushStreamEvents(): void { 90: this.streamEventTimer = null 91: void this.uploader.enqueue(this.takeStreamEvents()) 92: } 93: override close(): void { 94: if (this.streamEventTimer) { 95: clearTimeout(this.streamEventTimer) 96: this.streamEventTimer = null 97: } 98: this.streamEventBuffer = [] 99: const uploader = this.uploader 100: let graceTimer: ReturnType<typeof setTimeout> | undefined 101: void Promise.race([ 102: uploader.flush(), 103: new Promise<void>(r => { 104: graceTimer = setTimeout(r, CLOSE_GRACE_MS) 105: }), 106: ]).finally(() => { 107: clearTimeout(graceTimer) 108: uploader.close() 109: }) 110: super.close() 111: } 112: private async postOnce(events: StdoutMessage[]): Promise<void> { 113: const sessionToken = getSessionIngressAuthToken() 114: if (!sessionToken) { 115: logForDebugging('HybridTransport: No session token available for POST') 116: logForDiagnosticsNoPII('warn', 'cli_hybrid_post_no_token') 117: return 118: } 119: const headers: Record<string, string> = { 120: Authorization: `Bearer ${sessionToken}`, 121: 'Content-Type': 'application/json', 122: } 123: let response 124: try { 125: response = await axios.post( 126: this.postUrl, 127: { events }, 128: { 129: headers, 130: validateStatus: () => true, 131: timeout: POST_TIMEOUT_MS, 132: }, 133: ) 134: } catch (error) { 135: const axiosError = error as AxiosError 136: logForDebugging(`HybridTransport: POST error: ${axiosError.message}`) 137: logForDiagnosticsNoPII('warn', 'cli_hybrid_post_network_error') 138: throw error 139: } 140: if (response.status >= 200 && response.status < 300) { 141: logForDebugging(`HybridTransport: POST success count=${events.length}`) 142: return 143: } 144: if ( 145: response.status >= 400 && 146: response.status < 500 && 147: response.status !== 429 148: ) { 149: logForDebugging( 150: `HybridTransport: POST returned ${response.status} (permanent), dropping`, 151: ) 152: logForDiagnosticsNoPII('warn', 'cli_hybrid_post_client_error', { 153: status: response.status, 154: }) 155: return 156: } 157: logForDebugging( 158: `HybridTransport: POST returned ${response.status} (retryable)`, 159: ) 160: logForDiagnosticsNoPII('warn', 'cli_hybrid_post_retryable_error', { 161: status: response.status, 162: }) 163: throw new Error(`POST failed with ${response.status}`) 164: } 165: } 166: function convertWsUrlToPostUrl(wsUrl: URL): string { 167: const protocol = wsUrl.protocol === 'wss:' ? 'https:' : 'http:' 168: let pathname = wsUrl.pathname 169: pathname = pathname.replace('/ws/', '/session/') 170: if (!pathname.endsWith('/events')) { 171: pathname = pathname.endsWith('/') 172: ? pathname + 'events' 173: : pathname + '/events' 174: } 175: return `${protocol}//${wsUrl.host}${pathname}${wsUrl.search}` 176: }

File: src/cli/transports/SerialBatchEventUploader.ts

typescript 1: import { jsonStringify } from '../../utils/slowOperations.js' 2: export class RetryableError extends Error { 3: constructor( 4: message: string, 5: readonly retryAfterMs?: number, 6: ) { 7: super(message) 8: } 9: } 10: type SerialBatchEventUploaderConfig<T> = { 11: maxBatchSize: number 12: maxBatchBytes?: number 13: maxQueueSize: number 14: send: (batch: T[]) => Promise<void> 15: baseDelayMs: number 16: maxDelayMs: number 17: jitterMs: number 18: maxConsecutiveFailures?: number 19: onBatchDropped?: (batchSize: number, failures: number) => void 20: } 21: export class SerialBatchEventUploader<T> { 22: private pending: T[] = [] 23: private pendingAtClose = 0 24: private draining = false 25: private closed = false 26: private backpressureResolvers: Array<() => void> = [] 27: private sleepResolve: (() => void) | null = null 28: private flushResolvers: Array<() => void> = [] 29: private droppedBatches = 0 30: private readonly config: SerialBatchEventUploaderConfig<T> 31: constructor(config: SerialBatchEventUploaderConfig<T>) { 32: this.config = config 33: } 34: get droppedBatchCount(): number { 35: return this.droppedBatches 36: } 37: get pendingCount(): number { 38: return this.closed ? this.pendingAtClose : this.pending.length 39: } 40: async enqueue(events: T | T[]): Promise<void> { 41: if (this.closed) return 42: const items = Array.isArray(events) ? events : [events] 43: if (items.length === 0) return 44: while ( 45: this.pending.length + items.length > this.config.maxQueueSize && 46: !this.closed 47: ) { 48: await new Promise<void>(resolve => { 49: this.backpressureResolvers.push(resolve) 50: }) 51: } 52: if (this.closed) return 53: this.pending.push(...items) 54: void this.drain() 55: } 56: flush(): Promise<void> { 57: if (this.pending.length === 0 && !this.draining) { 58: return Promise.resolve() 59: } 60: void this.drain() 61: return new Promise<void>(resolve => { 62: this.flushResolvers.push(resolve) 63: }) 64: } 65: close(): void { 66: if (this.closed) return 67: this.closed = true 68: this.pendingAtClose = this.pending.length 69: this.pending = [] 70: this.sleepResolve?.() 71: this.sleepResolve = null 72: for (const resolve of this.backpressureResolvers) resolve() 73: this.backpressureResolvers = [] 74: for (const resolve of this.flushResolvers) resolve() 75: this.flushResolvers = [] 76: } 77: private async drain(): Promise<void> { 78: if (this.draining || this.closed) return 79: this.draining = true 80: let failures = 0 81: try { 82: while (this.pending.length > 0 && !this.closed) { 83: const batch = this.takeBatch() 84: if (batch.length === 0) continue 85: try { 86: await this.config.send(batch) 87: failures = 0 88: } catch (err) { 89: failures++ 90: if ( 91: this.config.maxConsecutiveFailures !== undefined && 92: failures >= this.config.maxConsecutiveFailures 93: ) { 94: this.droppedBatches++ 95: this.config.onBatchDropped?.(batch.length, failures) 96: failures = 0 97: this.releaseBackpressure() 98: continue 99: } 100: this.pending = batch.concat(this.pending) 101: const retryAfterMs = 102: err instanceof RetryableError ? err.retryAfterMs : undefined 103: await this.sleep(this.retryDelay(failures, retryAfterMs)) 104: continue 105: } 106: this.releaseBackpressure() 107: } 108: } finally { 109: this.draining = false 110: if (this.pending.length === 0) { 111: for (const resolve of this.flushResolvers) resolve() 112: this.flushResolvers = [] 113: } 114: } 115: } 116: private takeBatch(): T[] { 117: const { maxBatchSize, maxBatchBytes } = this.config 118: if (maxBatchBytes === undefined) { 119: return this.pending.splice(0, maxBatchSize) 120: } 121: let bytes = 0 122: let count = 0 123: while (count < this.pending.length && count < maxBatchSize) { 124: let itemBytes: number 125: try { 126: itemBytes = Buffer.byteLength(jsonStringify(this.pending[count])) 127: } catch { 128: this.pending.splice(count, 1) 129: continue 130: } 131: if (count > 0 && bytes + itemBytes > maxBatchBytes) break 132: bytes += itemBytes 133: count++ 134: } 135: return this.pending.splice(0, count) 136: } 137: private retryDelay(failures: number, retryAfterMs?: number): number { 138: const jitter = Math.random() * this.config.jitterMs 139: if (retryAfterMs !== undefined) { 140: const clamped = Math.max( 141: this.config.baseDelayMs, 142: Math.min(retryAfterMs, this.config.maxDelayMs), 143: ) 144: return clamped + jitter 145: } 146: const exponential = Math.min( 147: this.config.baseDelayMs * 2 ** (failures - 1), 148: this.config.maxDelayMs, 149: ) 150: return exponential + jitter 151: } 152: private releaseBackpressure(): void { 153: const resolvers = this.backpressureResolvers 154: this.backpressureResolvers = [] 155: for (const resolve of resolvers) resolve() 156: } 157: private sleep(ms: number): Promise<void> { 158: return new Promise(resolve => { 159: this.sleepResolve = resolve 160: setTimeout( 161: (self, resolve) => { 162: self.sleepResolve = null 163: resolve() 164: }, 165: ms, 166: this, 167: resolve, 168: ) 169: }) 170: } 171: }

File: src/cli/transports/SSETransport.ts

typescript 1: import axios, { type AxiosError } from 'axios' 2: import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js' 3: import { logForDebugging } from '../../utils/debug.js' 4: import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js' 5: import { errorMessage } from '../../utils/errors.js' 6: import { getSessionIngressAuthHeaders } from '../../utils/sessionIngressAuth.js' 7: import { sleep } from '../../utils/sleep.js' 8: import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' 9: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 10: import type { Transport } from './Transport.js' 11: const RECONNECT_BASE_DELAY_MS = 1000 12: const RECONNECT_MAX_DELAY_MS = 30_000 13: const RECONNECT_GIVE_UP_MS = 600_000 14: const LIVENESS_TIMEOUT_MS = 45_000 15: const PERMANENT_HTTP_CODES = new Set([401, 403, 404]) 16: const POST_MAX_RETRIES = 10 17: const POST_BASE_DELAY_MS = 500 18: const POST_MAX_DELAY_MS = 8000 19: const STREAM_DECODE_OPTS: TextDecodeOptions = { stream: true } 20: function alwaysValidStatus(): boolean { 21: return true 22: } 23: type SSEFrame = { 24: event?: string 25: id?: string 26: data?: string 27: } 28: export function parseSSEFrames(buffer: string): { 29: frames: SSEFrame[] 30: remaining: string 31: } { 32: const frames: SSEFrame[] = [] 33: let pos = 0 34: let idx: number 35: while ((idx = buffer.indexOf('\n\n', pos)) !== -1) { 36: const rawFrame = buffer.slice(pos, idx) 37: pos = idx + 2 38: if (!rawFrame.trim()) continue 39: const frame: SSEFrame = {} 40: let isComment = false 41: for (const line of rawFrame.split('\n')) { 42: if (line.startsWith(':')) { 43: isComment = true 44: continue 45: } 46: const colonIdx = line.indexOf(':') 47: if (colonIdx === -1) continue 48: const field = line.slice(0, colonIdx) 49: const value = 50: line[colonIdx + 1] === ' ' 51: ? line.slice(colonIdx + 2) 52: : line.slice(colonIdx + 1) 53: switch (field) { 54: case 'event': 55: frame.event = value 56: break 57: case 'id': 58: frame.id = value 59: break 60: case 'data': 61: frame.data = frame.data ? frame.data + '\n' + value : value 62: break 63: } 64: } 65: if (frame.data || isComment) { 66: frames.push(frame) 67: } 68: } 69: return { frames, remaining: buffer.slice(pos) } 70: } 71: type SSETransportState = 72: | 'idle' 73: | 'connected' 74: | 'reconnecting' 75: | 'closing' 76: | 'closed' 77: export type StreamClientEvent = { 78: event_id: string 79: sequence_num: number 80: event_type: string 81: source: string 82: payload: Record<string, unknown> 83: created_at: string 84: } 85: export class SSETransport implements Transport { 86: private state: SSETransportState = 'idle' 87: private onData?: (data: string) => void 88: private onCloseCallback?: (closeCode?: number) => void 89: private onEventCallback?: (event: StreamClientEvent) => void 90: private headers: Record<string, string> 91: private sessionId?: string 92: private refreshHeaders?: () => Record<string, string> 93: private readonly getAuthHeaders: () => Record<string, string> 94: private abortController: AbortController | null = null 95: private lastSequenceNum = 0 96: private seenSequenceNums = new Set<number>() 97: private reconnectAttempts = 0 98: private reconnectStartTime: number | null = null 99: private reconnectTimer: NodeJS.Timeout | null = null 100: private livenessTimer: NodeJS.Timeout | null = null 101: private postUrl: string 102: constructor( 103: private readonly url: URL, 104: headers: Record<string, string> = {}, 105: sessionId?: string, 106: refreshHeaders?: () => Record<string, string>, 107: initialSequenceNum?: number, 108: getAuthHeaders?: () => Record<string, string>, 109: ) { 110: this.headers = headers 111: this.sessionId = sessionId 112: this.refreshHeaders = refreshHeaders 113: this.getAuthHeaders = getAuthHeaders ?? getSessionIngressAuthHeaders 114: this.postUrl = convertSSEUrlToPostUrl(url) 115: if (initialSequenceNum !== undefined && initialSequenceNum > 0) { 116: this.lastSequenceNum = initialSequenceNum 117: } 118: logForDebugging(`SSETransport: SSE URL = ${url.href}`) 119: logForDebugging(`SSETransport: POST URL = ${this.postUrl}`) 120: logForDiagnosticsNoPII('info', 'cli_sse_transport_initialized') 121: } 122: getLastSequenceNum(): number { 123: return this.lastSequenceNum 124: } 125: async connect(): Promise<void> { 126: if (this.state !== 'idle' && this.state !== 'reconnecting') { 127: logForDebugging( 128: `SSETransport: Cannot connect, current state is ${this.state}`, 129: { level: 'error' }, 130: ) 131: logForDiagnosticsNoPII('error', 'cli_sse_connect_failed') 132: return 133: } 134: this.state = 'reconnecting' 135: const connectStartTime = Date.now() 136: const sseUrl = new URL(this.url.href) 137: if (this.lastSequenceNum > 0) { 138: sseUrl.searchParams.set('from_sequence_num', String(this.lastSequenceNum)) 139: } 140: const authHeaders = this.getAuthHeaders() 141: const headers: Record<string, string> = { 142: ...this.headers, 143: ...authHeaders, 144: Accept: 'text/event-stream', 145: 'anthropic-version': '2023-06-01', 146: 'User-Agent': getClaudeCodeUserAgent(), 147: } 148: if (authHeaders['Cookie']) { 149: delete headers['Authorization'] 150: } 151: if (this.lastSequenceNum > 0) { 152: headers['Last-Event-ID'] = String(this.lastSequenceNum) 153: } 154: logForDebugging(`SSETransport: Opening ${sseUrl.href}`) 155: logForDiagnosticsNoPII('info', 'cli_sse_connect_opening') 156: this.abortController = new AbortController() 157: try { 158: const response = await fetch(sseUrl.href, { 159: headers, 160: signal: this.abortController.signal, 161: }) 162: if (!response.ok) { 163: const isPermanent = PERMANENT_HTTP_CODES.has(response.status) 164: logForDebugging( 165: `SSETransport: HTTP ${response.status}${isPermanent ? ' (permanent)' : ''}`, 166: { level: 'error' }, 167: ) 168: logForDiagnosticsNoPII('error', 'cli_sse_connect_http_error', { 169: status: response.status, 170: }) 171: if (isPermanent) { 172: this.state = 'closed' 173: this.onCloseCallback?.(response.status) 174: return 175: } 176: this.handleConnectionError() 177: return 178: } 179: if (!response.body) { 180: logForDebugging('SSETransport: No response body') 181: this.handleConnectionError() 182: return 183: } 184: const connectDuration = Date.now() - connectStartTime 185: logForDebugging('SSETransport: Connected') 186: logForDiagnosticsNoPII('info', 'cli_sse_connect_connected', { 187: duration_ms: connectDuration, 188: }) 189: this.state = 'connected' 190: this.reconnectAttempts = 0 191: this.reconnectStartTime = null 192: this.resetLivenessTimer() 193: await this.readStream(response.body) 194: } catch (error) { 195: if (this.abortController?.signal.aborted) { 196: return 197: } 198: logForDebugging( 199: `SSETransport: Connection error: ${errorMessage(error)}`, 200: { level: 'error' }, 201: ) 202: logForDiagnosticsNoPII('error', 'cli_sse_connect_error') 203: this.handleConnectionError() 204: } 205: } 206: private async readStream(body: ReadableStream<Uint8Array>): Promise<void> { 207: const reader = body.getReader() 208: const decoder = new TextDecoder() 209: let buffer = '' 210: try { 211: while (true) { 212: const { done, value } = await reader.read() 213: if (done) break 214: buffer += decoder.decode(value, STREAM_DECODE_OPTS) 215: const { frames, remaining } = parseSSEFrames(buffer) 216: buffer = remaining 217: for (const frame of frames) { 218: // Any frame (including keepalive comments) proves the connection is alive 219: this.resetLivenessTimer() 220: if (frame.id) { 221: const seqNum = parseInt(frame.id, 10) 222: if (!isNaN(seqNum)) { 223: if (this.seenSequenceNums.has(seqNum)) { 224: logForDebugging( 225: `SSETransport: DUPLICATE frame seq=${seqNum} (lastSequenceNum=${this.lastSequenceNum}, seenCount=${this.seenSequenceNums.size})`, 226: { level: 'warn' }, 227: ) 228: logForDiagnosticsNoPII('warn', 'cli_sse_duplicate_sequence') 229: } else { 230: this.seenSequenceNums.add(seqNum) 231: if (this.seenSequenceNums.size > 1000) { 232: const threshold = this.lastSequenceNum - 200 233: for (const s of this.seenSequenceNums) { 234: if (s < threshold) { 235: this.seenSequenceNums.delete(s) 236: } 237: } 238: } 239: } 240: if (seqNum > this.lastSequenceNum) { 241: this.lastSequenceNum = seqNum 242: } 243: } 244: } 245: if (frame.event && frame.data) { 246: this.handleSSEFrame(frame.event, frame.data) 247: } else if (frame.data) { 248: logForDebugging( 249: 'SSETransport: Frame has data: but no event: field — dropped', 250: { level: 'warn' }, 251: ) 252: logForDiagnosticsNoPII('warn', 'cli_sse_frame_missing_event_field') 253: } 254: } 255: } 256: } catch (error) { 257: if (this.abortController?.signal.aborted) return 258: logForDebugging( 259: `SSETransport: Stream read error: ${errorMessage(error)}`, 260: { level: 'error' }, 261: ) 262: logForDiagnosticsNoPII('error', 'cli_sse_stream_read_error') 263: } finally { 264: reader.releaseLock() 265: } 266: if (this.state !== 'closing' && this.state !== 'closed') { 267: logForDebugging('SSETransport: Stream ended, reconnecting') 268: this.handleConnectionError() 269: } 270: } 271: private handleSSEFrame(eventType: string, data: string): void { 272: if (eventType !== 'client_event') { 273: logForDebugging( 274: `SSETransport: Unexpected SSE event type '${eventType}' on worker stream`, 275: { level: 'warn' }, 276: ) 277: logForDiagnosticsNoPII('warn', 'cli_sse_unexpected_event_type', { 278: event_type: eventType, 279: }) 280: return 281: } 282: let ev: StreamClientEvent 283: try { 284: ev = jsonParse(data) as StreamClientEvent 285: } catch (error) { 286: logForDebugging( 287: `SSETransport: Failed to parse client_event data: ${errorMessage(error)}`, 288: { level: 'error' }, 289: ) 290: return 291: } 292: const payload = ev.payload 293: if (payload && typeof payload === 'object' && 'type' in payload) { 294: const sessionLabel = this.sessionId ? ` session=${this.sessionId}` : '' 295: logForDebugging( 296: `SSETransport: Event seq=${ev.sequence_num} event_id=${ev.event_id} event_type=${ev.event_type} payload_type=${String(payload.type)}${sessionLabel}`, 297: ) 298: logForDiagnosticsNoPII('info', 'cli_sse_message_received') 299: this.onData?.(jsonStringify(payload) + '\n') 300: } else { 301: logForDebugging( 302: `SSETransport: Ignoring client_event with no type in payload: event_id=${ev.event_id}`, 303: ) 304: } 305: this.onEventCallback?.(ev) 306: } 307: private handleConnectionError(): void { 308: this.clearLivenessTimer() 309: if (this.state === 'closing' || this.state === 'closed') return 310: this.abortController?.abort() 311: this.abortController = null 312: const now = Date.now() 313: if (!this.reconnectStartTime) { 314: this.reconnectStartTime = now 315: } 316: const elapsed = now - this.reconnectStartTime 317: if (elapsed < RECONNECT_GIVE_UP_MS) { 318: if (this.reconnectTimer) { 319: clearTimeout(this.reconnectTimer) 320: this.reconnectTimer = null 321: } 322: if (this.refreshHeaders) { 323: const freshHeaders = this.refreshHeaders() 324: Object.assign(this.headers, freshHeaders) 325: logForDebugging('SSETransport: Refreshed headers for reconnect') 326: } 327: this.state = 'reconnecting' 328: this.reconnectAttempts++ 329: const baseDelay = Math.min( 330: RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1), 331: RECONNECT_MAX_DELAY_MS, 332: ) 333: const delay = Math.max( 334: 0, 335: baseDelay + baseDelay * 0.25 * (2 * Math.random() - 1), 336: ) 337: logForDebugging( 338: `SSETransport: Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts}, ${Math.round(elapsed / 1000)}s elapsed)`, 339: ) 340: logForDiagnosticsNoPII('error', 'cli_sse_reconnect_attempt', { 341: reconnectAttempts: this.reconnectAttempts, 342: }) 343: this.reconnectTimer = setTimeout(() => { 344: this.reconnectTimer = null 345: void this.connect() 346: }, delay) 347: } else { 348: logForDebugging( 349: `SSETransport: Reconnection time budget exhausted after ${Math.round(elapsed / 1000)}s`, 350: { level: 'error' }, 351: ) 352: logForDiagnosticsNoPII('error', 'cli_sse_reconnect_exhausted', { 353: reconnectAttempts: this.reconnectAttempts, 354: elapsedMs: elapsed, 355: }) 356: this.state = 'closed' 357: this.onCloseCallback?.() 358: } 359: } 360: private readonly onLivenessTimeout = (): void => { 361: this.livenessTimer = null 362: logForDebugging('SSETransport: Liveness timeout, reconnecting', { 363: level: 'error', 364: }) 365: logForDiagnosticsNoPII('error', 'cli_sse_liveness_timeout') 366: this.abortController?.abort() 367: this.handleConnectionError() 368: } 369: private resetLivenessTimer(): void { 370: this.clearLivenessTimer() 371: this.livenessTimer = setTimeout(this.onLivenessTimeout, LIVENESS_TIMEOUT_MS) 372: } 373: private clearLivenessTimer(): void { 374: if (this.livenessTimer) { 375: clearTimeout(this.livenessTimer) 376: this.livenessTimer = null 377: } 378: } 379: async write(message: StdoutMessage): Promise<void> { 380: const authHeaders = this.getAuthHeaders() 381: if (Object.keys(authHeaders).length === 0) { 382: logForDebugging('SSETransport: No session token available for POST') 383: logForDiagnosticsNoPII('warn', 'cli_sse_post_no_token') 384: return 385: } 386: const headers: Record<string, string> = { 387: ...authHeaders, 388: 'Content-Type': 'application/json', 389: 'anthropic-version': '2023-06-01', 390: 'User-Agent': getClaudeCodeUserAgent(), 391: } 392: logForDebugging( 393: `SSETransport: POST body keys=${Object.keys(message as Record<string, unknown>).join(',')}`, 394: ) 395: for (let attempt = 1; attempt <= POST_MAX_RETRIES; attempt++) { 396: try { 397: const response = await axios.post(this.postUrl, message, { 398: headers, 399: validateStatus: alwaysValidStatus, 400: }) 401: if (response.status === 200 || response.status === 201) { 402: logForDebugging(`SSETransport: POST success type=${message.type}`) 403: return 404: } 405: logForDebugging( 406: `SSETransport: POST ${response.status} body=${jsonStringify(response.data).slice(0, 200)}`, 407: ) 408: if ( 409: response.status >= 400 && 410: response.status < 500 && 411: response.status !== 429 412: ) { 413: logForDebugging( 414: `SSETransport: POST returned ${response.status} (client error), not retrying`, 415: ) 416: logForDiagnosticsNoPII('warn', 'cli_sse_post_client_error', { 417: status: response.status, 418: }) 419: return 420: } 421: logForDebugging( 422: `SSETransport: POST returned ${response.status}, attempt ${attempt}/${POST_MAX_RETRIES}`, 423: ) 424: logForDiagnosticsNoPII('warn', 'cli_sse_post_retryable_error', { 425: status: response.status, 426: attempt, 427: }) 428: } catch (error) { 429: const axiosError = error as AxiosError 430: logForDebugging( 431: `SSETransport: POST error: ${axiosError.message}, attempt ${attempt}/${POST_MAX_RETRIES}`, 432: ) 433: logForDiagnosticsNoPII('warn', 'cli_sse_post_network_error', { 434: attempt, 435: }) 436: } 437: if (attempt === POST_MAX_RETRIES) { 438: logForDebugging( 439: `SSETransport: POST failed after ${POST_MAX_RETRIES} attempts, continuing`, 440: ) 441: logForDiagnosticsNoPII('warn', 'cli_sse_post_retries_exhausted') 442: return 443: } 444: const delayMs = Math.min( 445: POST_BASE_DELAY_MS * Math.pow(2, attempt - 1), 446: POST_MAX_DELAY_MS, 447: ) 448: await sleep(delayMs) 449: } 450: } 451: isConnectedStatus(): boolean { 452: return this.state === 'connected' 453: } 454: isClosedStatus(): boolean { 455: return this.state === 'closed' 456: } 457: setOnData(callback: (data: string) => void): void { 458: this.onData = callback 459: } 460: setOnClose(callback: (closeCode?: number) => void): void { 461: this.onCloseCallback = callback 462: } 463: setOnEvent(callback: (event: StreamClientEvent) => void): void { 464: this.onEventCallback = callback 465: } 466: close(): void { 467: if (this.reconnectTimer) { 468: clearTimeout(this.reconnectTimer) 469: this.reconnectTimer = null 470: } 471: this.clearLivenessTimer() 472: this.state = 'closing' 473: this.abortController?.abort() 474: this.abortController = null 475: } 476: } 477: function convertSSEUrlToPostUrl(sseUrl: URL): string { 478: let pathname = sseUrl.pathname 479: if (pathname.endsWith('/stream')) { 480: pathname = pathname.slice(0, -'/stream'.length) 481: } 482: return `${sseUrl.protocol}//${sseUrl.host}${pathname}` 483: }

File: src/cli/transports/transportUtils.ts

typescript 1: import { URL } from 'url' 2: import { isEnvTruthy } from '../../utils/envUtils.js' 3: import { HybridTransport } from './HybridTransport.js' 4: import { SSETransport } from './SSETransport.js' 5: import type { Transport } from './Transport.js' 6: import { WebSocketTransport } from './WebSocketTransport.js' 7: export function getTransportForUrl( 8: url: URL, 9: headers: Record<string, string> = {}, 10: sessionId?: string, 11: refreshHeaders?: () => Record<string, string>, 12: ): Transport { 13: if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CCR_V2)) { 14: const sseUrl = new URL(url.href) 15: if (sseUrl.protocol === 'wss:') { 16: sseUrl.protocol = 'https:' 17: } else if (sseUrl.protocol === 'ws:') { 18: sseUrl.protocol = 'http:' 19: } 20: sseUrl.pathname = 21: sseUrl.pathname.replace(/\/$/, '') + '/worker/events/stream' 22: return new SSETransport(sseUrl, headers, sessionId, refreshHeaders) 23: } 24: if (url.protocol === 'ws:' || url.protocol === 'wss:') { 25: if (isEnvTruthy(process.env.CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2)) { 26: return new HybridTransport(url, headers, sessionId, refreshHeaders) 27: } 28: return new WebSocketTransport(url, headers, sessionId, refreshHeaders) 29: } else { 30: throw new Error(`Unsupported protocol: ${url.protocol}`) 31: } 32: }

File: src/cli/transports/WebSocketTransport.ts

typescript 1: import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js' 2: import type WsWebSocket from 'ws' 3: import { logEvent } from '../../services/analytics/index.js' 4: import { CircularBuffer } from '../../utils/CircularBuffer.js' 5: import { logForDebugging } from '../../utils/debug.js' 6: import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js' 7: import { isEnvTruthy } from '../../utils/envUtils.js' 8: import { getWebSocketTLSOptions } from '../../utils/mtls.js' 9: import { 10: getWebSocketProxyAgent, 11: getWebSocketProxyUrl, 12: } from '../../utils/proxy.js' 13: import { 14: registerSessionActivityCallback, 15: unregisterSessionActivityCallback, 16: } from '../../utils/sessionActivity.js' 17: import { jsonStringify } from '../../utils/slowOperations.js' 18: import type { Transport } from './Transport.js' 19: const KEEP_ALIVE_FRAME = '{"type":"keep_alive"}\n' 20: const DEFAULT_MAX_BUFFER_SIZE = 1000 21: const DEFAULT_BASE_RECONNECT_DELAY = 1000 22: const DEFAULT_MAX_RECONNECT_DELAY = 30000 23: const DEFAULT_RECONNECT_GIVE_UP_MS = 600_000 24: const DEFAULT_PING_INTERVAL = 10000 25: const DEFAULT_KEEPALIVE_INTERVAL = 300_000 26: const SLEEP_DETECTION_THRESHOLD_MS = DEFAULT_MAX_RECONNECT_DELAY * 2 27: const PERMANENT_CLOSE_CODES = new Set([ 28: 1002, 29: 4001, 30: 4003, 31: ]) 32: export type WebSocketTransportOptions = { 33: autoReconnect?: boolean 34: isBridge?: boolean 35: } 36: type WebSocketTransportState = 37: | 'idle' 38: | 'connected' 39: | 'reconnecting' 40: | 'closing' 41: | 'closed' 42: type WebSocketLike = { 43: close(): void 44: send(data: string): void 45: ping?(): void 46: } 47: export class WebSocketTransport implements Transport { 48: private ws: WebSocketLike | null = null 49: private lastSentId: string | null = null 50: protected url: URL 51: protected state: WebSocketTransportState = 'idle' 52: protected onData?: (data: string) => void 53: private onCloseCallback?: (closeCode?: number) => void 54: private onConnectCallback?: () => void 55: private headers: Record<string, string> 56: private sessionId?: string 57: private autoReconnect: boolean 58: private isBridge: boolean 59: private reconnectAttempts = 0 60: private reconnectStartTime: number | null = null 61: private reconnectTimer: NodeJS.Timeout | null = null 62: private lastReconnectAttemptTime: number | null = null 63: private lastActivityTime = 0 64: private pingInterval: NodeJS.Timeout | null = null 65: private pongReceived = true 66: private keepAliveInterval: NodeJS.Timeout | null = null 67: private messageBuffer: CircularBuffer<StdoutMessage> 68: private isBunWs = false 69: private connectStartTime = 0 70: private refreshHeaders?: () => Record<string, string> 71: constructor( 72: url: URL, 73: headers: Record<string, string> = {}, 74: sessionId?: string, 75: refreshHeaders?: () => Record<string, string>, 76: options?: WebSocketTransportOptions, 77: ) { 78: this.url = url 79: this.headers = headers 80: this.sessionId = sessionId 81: this.refreshHeaders = refreshHeaders 82: this.autoReconnect = options?.autoReconnect ?? true 83: this.isBridge = options?.isBridge ?? false 84: this.messageBuffer = new CircularBuffer(DEFAULT_MAX_BUFFER_SIZE) 85: } 86: public async connect(): Promise<void> { 87: if (this.state !== 'idle' && this.state !== 'reconnecting') { 88: logForDebugging( 89: `WebSocketTransport: Cannot connect, current state is ${this.state}`, 90: { level: 'error' }, 91: ) 92: logForDiagnosticsNoPII('error', 'cli_websocket_connect_failed') 93: return 94: } 95: this.state = 'reconnecting' 96: this.connectStartTime = Date.now() 97: logForDebugging(`WebSocketTransport: Opening ${this.url.href}`) 98: logForDiagnosticsNoPII('info', 'cli_websocket_connect_opening') 99: const headers = { ...this.headers } 100: if (this.lastSentId) { 101: headers['X-Last-Request-Id'] = this.lastSentId 102: logForDebugging( 103: `WebSocketTransport: Adding X-Last-Request-Id header: ${this.lastSentId}`, 104: ) 105: } 106: if (typeof Bun !== 'undefined') { 107: const ws = new globalThis.WebSocket(this.url.href, { 108: headers, 109: proxy: getWebSocketProxyUrl(this.url.href), 110: tls: getWebSocketTLSOptions() || undefined, 111: } as unknown as string[]) 112: this.ws = ws 113: this.isBunWs = true 114: ws.addEventListener('open', this.onBunOpen) 115: ws.addEventListener('message', this.onBunMessage) 116: ws.addEventListener('error', this.onBunError) 117: ws.addEventListener('close', this.onBunClose) 118: ws.addEventListener('pong', this.onPong) 119: } else { 120: const { default: WS } = await import('ws') 121: const ws = new WS(this.url.href, { 122: headers, 123: agent: getWebSocketProxyAgent(this.url.href), 124: ...getWebSocketTLSOptions(), 125: }) 126: this.ws = ws 127: this.isBunWs = false 128: ws.on('open', this.onNodeOpen) 129: ws.on('message', this.onNodeMessage) 130: ws.on('error', this.onNodeError) 131: ws.on('close', this.onNodeClose) 132: ws.on('pong', this.onPong) 133: } 134: } 135: private onBunOpen = () => { 136: this.handleOpenEvent() 137: if (this.lastSentId) { 138: this.replayBufferedMessages('') 139: } 140: } 141: private onBunMessage = (event: MessageEvent) => { 142: const message = 143: typeof event.data === 'string' ? event.data : String(event.data) 144: this.lastActivityTime = Date.now() 145: logForDiagnosticsNoPII('info', 'cli_websocket_message_received', { 146: length: message.length, 147: }) 148: if (this.onData) { 149: this.onData(message) 150: } 151: } 152: private onBunError = () => { 153: logForDebugging('WebSocketTransport: Error', { 154: level: 'error', 155: }) 156: logForDiagnosticsNoPII('error', 'cli_websocket_connect_error') 157: } 158: private onBunClose = (event: CloseEvent) => { 159: const isClean = event.code === 1000 || event.code === 1001 160: logForDebugging( 161: `WebSocketTransport: Closed: ${event.code}`, 162: isClean ? undefined : { level: 'error' }, 163: ) 164: logForDiagnosticsNoPII('error', 'cli_websocket_connect_closed') 165: this.handleConnectionError(event.code) 166: } 167: private onNodeOpen = () => { 168: const ws = this.ws 169: this.handleOpenEvent() 170: if (!ws) return 171: const nws = ws as unknown as WsWebSocket & { 172: upgradeReq?: { headers?: Record<string, string> } 173: } 174: const upgradeResponse = nws.upgradeReq 175: if (upgradeResponse?.headers?.['x-last-request-id']) { 176: const serverLastId = upgradeResponse.headers['x-last-request-id'] 177: this.replayBufferedMessages(serverLastId) 178: } 179: } 180: private onNodeMessage = (data: Buffer) => { 181: const message = data.toString() 182: this.lastActivityTime = Date.now() 183: logForDiagnosticsNoPII('info', 'cli_websocket_message_received', { 184: length: message.length, 185: }) 186: if (this.onData) { 187: this.onData(message) 188: } 189: } 190: private onNodeError = (err: Error) => { 191: logForDebugging(`WebSocketTransport: Error: ${err.message}`, { 192: level: 'error', 193: }) 194: logForDiagnosticsNoPII('error', 'cli_websocket_connect_error') 195: } 196: private onNodeClose = (code: number, _reason: Buffer) => { 197: const isClean = code === 1000 || code === 1001 198: logForDebugging( 199: `WebSocketTransport: Closed: ${code}`, 200: isClean ? undefined : { level: 'error' }, 201: ) 202: logForDiagnosticsNoPII('error', 'cli_websocket_connect_closed') 203: this.handleConnectionError(code) 204: } 205: private onPong = () => { 206: this.pongReceived = true 207: } 208: private handleOpenEvent(): void { 209: const connectDuration = Date.now() - this.connectStartTime 210: logForDebugging('WebSocketTransport: Connected') 211: logForDiagnosticsNoPII('info', 'cli_websocket_connect_connected', { 212: duration_ms: connectDuration, 213: }) 214: if (this.isBridge && this.reconnectStartTime !== null) { 215: logEvent('tengu_ws_transport_reconnected', { 216: attempts: this.reconnectAttempts, 217: downtimeMs: Date.now() - this.reconnectStartTime, 218: }) 219: } 220: this.reconnectAttempts = 0 221: this.reconnectStartTime = null 222: this.lastReconnectAttemptTime = null 223: this.lastActivityTime = Date.now() 224: this.state = 'connected' 225: this.onConnectCallback?.() 226: this.startPingInterval() 227: this.startKeepaliveInterval() 228: registerSessionActivityCallback(() => { 229: void this.write({ type: 'keep_alive' }) 230: }) 231: } 232: protected sendLine(line: string): boolean { 233: if (!this.ws || this.state !== 'connected') { 234: logForDebugging('WebSocketTransport: Not connected') 235: logForDiagnosticsNoPII('info', 'cli_websocket_send_not_connected') 236: return false 237: } 238: try { 239: this.ws.send(line) 240: this.lastActivityTime = Date.now() 241: return true 242: } catch (error) { 243: logForDebugging(`WebSocketTransport: Failed to send: ${error}`, { 244: level: 'error', 245: }) 246: logForDiagnosticsNoPII('error', 'cli_websocket_send_error') 247: this.handleConnectionError() 248: return false 249: } 250: } 251: private removeWsListeners(ws: WebSocketLike): void { 252: if (this.isBunWs) { 253: const nws = ws as unknown as globalThis.WebSocket 254: nws.removeEventListener('open', this.onBunOpen) 255: nws.removeEventListener('message', this.onBunMessage) 256: nws.removeEventListener('error', this.onBunError) 257: nws.removeEventListener('close', this.onBunClose) 258: nws.removeEventListener('pong' as 'message', this.onPong) 259: } else { 260: const nws = ws as unknown as WsWebSocket 261: nws.off('open', this.onNodeOpen) 262: nws.off('message', this.onNodeMessage) 263: nws.off('error', this.onNodeError) 264: nws.off('close', this.onNodeClose) 265: nws.off('pong', this.onPong) 266: } 267: } 268: protected doDisconnect(): void { 269: this.stopPingInterval() 270: this.stopKeepaliveInterval() 271: unregisterSessionActivityCallback() 272: if (this.ws) { 273: this.removeWsListeners(this.ws) 274: this.ws.close() 275: this.ws = null 276: } 277: } 278: private handleConnectionError(closeCode?: number): void { 279: logForDebugging( 280: `WebSocketTransport: Disconnected from ${this.url.href}` + 281: (closeCode != null ? ` (code ${closeCode})` : ''), 282: ) 283: logForDiagnosticsNoPII('info', 'cli_websocket_disconnected') 284: if (this.isBridge) { 285: logEvent('tengu_ws_transport_closed', { 286: closeCode, 287: msSinceLastActivity: 288: this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1, 289: wasConnected: this.state === 'connected', 290: reconnectAttempts: this.reconnectAttempts, 291: }) 292: } 293: this.doDisconnect() 294: if (this.state === 'closing' || this.state === 'closed') return 295: let headersRefreshed = false 296: if (closeCode === 4003 && this.refreshHeaders) { 297: const freshHeaders = this.refreshHeaders() 298: if (freshHeaders.Authorization !== this.headers.Authorization) { 299: Object.assign(this.headers, freshHeaders) 300: headersRefreshed = true 301: logForDebugging( 302: 'WebSocketTransport: 4003 received but headers refreshed, scheduling reconnect', 303: ) 304: logForDiagnosticsNoPII('info', 'cli_websocket_4003_token_refreshed') 305: } 306: } 307: if ( 308: closeCode != null && 309: PERMANENT_CLOSE_CODES.has(closeCode) && 310: !headersRefreshed 311: ) { 312: logForDebugging( 313: `WebSocketTransport: Permanent close code ${closeCode}, not reconnecting`, 314: { level: 'error' }, 315: ) 316: logForDiagnosticsNoPII('error', 'cli_websocket_permanent_close', { 317: closeCode, 318: }) 319: this.state = 'closed' 320: this.onCloseCallback?.(closeCode) 321: return 322: } 323: if (!this.autoReconnect) { 324: this.state = 'closed' 325: this.onCloseCallback?.(closeCode) 326: return 327: } 328: const now = Date.now() 329: if (!this.reconnectStartTime) { 330: this.reconnectStartTime = now 331: } 332: if ( 333: this.lastReconnectAttemptTime !== null && 334: now - this.lastReconnectAttemptTime > SLEEP_DETECTION_THRESHOLD_MS 335: ) { 336: logForDebugging( 337: `WebSocketTransport: Detected system sleep (${Math.round((now - this.lastReconnectAttemptTime) / 1000)}s gap), resetting reconnection budget`, 338: ) 339: logForDiagnosticsNoPII('info', 'cli_websocket_sleep_detected', { 340: gapMs: now - this.lastReconnectAttemptTime, 341: }) 342: this.reconnectStartTime = now 343: this.reconnectAttempts = 0 344: } 345: this.lastReconnectAttemptTime = now 346: const elapsed = now - this.reconnectStartTime 347: if (elapsed < DEFAULT_RECONNECT_GIVE_UP_MS) { 348: if (this.reconnectTimer) { 349: clearTimeout(this.reconnectTimer) 350: this.reconnectTimer = null 351: } 352: if (!headersRefreshed && this.refreshHeaders) { 353: const freshHeaders = this.refreshHeaders() 354: Object.assign(this.headers, freshHeaders) 355: logForDebugging('WebSocketTransport: Refreshed headers for reconnect') 356: } 357: this.state = 'reconnecting' 358: this.reconnectAttempts++ 359: const baseDelay = Math.min( 360: DEFAULT_BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1), 361: DEFAULT_MAX_RECONNECT_DELAY, 362: ) 363: const delay = Math.max( 364: 0, 365: baseDelay + baseDelay * 0.25 * (2 * Math.random() - 1), 366: ) 367: logForDebugging( 368: `WebSocketTransport: Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts}, ${Math.round(elapsed / 1000)}s elapsed)`, 369: ) 370: logForDiagnosticsNoPII('error', 'cli_websocket_reconnect_attempt', { 371: reconnectAttempts: this.reconnectAttempts, 372: }) 373: if (this.isBridge) { 374: logEvent('tengu_ws_transport_reconnecting', { 375: attempt: this.reconnectAttempts, 376: elapsedMs: elapsed, 377: delayMs: Math.round(delay), 378: }) 379: } 380: this.reconnectTimer = setTimeout(() => { 381: this.reconnectTimer = null 382: void this.connect() 383: }, delay) 384: } else { 385: logForDebugging( 386: `WebSocketTransport: Reconnection time budget exhausted after ${Math.round(elapsed / 1000)}s for ${this.url.href}`, 387: { level: 'error' }, 388: ) 389: logForDiagnosticsNoPII('error', 'cli_websocket_reconnect_exhausted', { 390: reconnectAttempts: this.reconnectAttempts, 391: elapsedMs: elapsed, 392: }) 393: this.state = 'closed' 394: if (this.onCloseCallback) { 395: this.onCloseCallback(closeCode) 396: } 397: } 398: } 399: close(): void { 400: if (this.reconnectTimer) { 401: clearTimeout(this.reconnectTimer) 402: this.reconnectTimer = null 403: } 404: this.stopPingInterval() 405: this.stopKeepaliveInterval() 406: unregisterSessionActivityCallback() 407: this.state = 'closing' 408: this.doDisconnect() 409: } 410: private replayBufferedMessages(lastId: string): void { 411: const messages = this.messageBuffer.toArray() 412: if (messages.length === 0) return 413: let startIndex = 0 414: if (lastId) { 415: const lastConfirmedIndex = messages.findIndex( 416: message => 'uuid' in message && message.uuid === lastId, 417: ) 418: if (lastConfirmedIndex >= 0) { 419: startIndex = lastConfirmedIndex + 1 420: const remaining = messages.slice(startIndex) 421: this.messageBuffer.clear() 422: this.messageBuffer.addAll(remaining) 423: if (remaining.length === 0) { 424: this.lastSentId = null 425: } 426: logForDebugging( 427: `WebSocketTransport: Evicted ${startIndex} confirmed messages, ${remaining.length} remaining`, 428: ) 429: logForDiagnosticsNoPII( 430: 'info', 431: 'cli_websocket_evicted_confirmed_messages', 432: { 433: evicted: startIndex, 434: remaining: remaining.length, 435: }, 436: ) 437: } 438: } 439: const messagesToReplay = messages.slice(startIndex) 440: if (messagesToReplay.length === 0) { 441: logForDebugging('WebSocketTransport: No new messages to replay') 442: logForDiagnosticsNoPII('info', 'cli_websocket_no_messages_to_replay') 443: return 444: } 445: logForDebugging( 446: `WebSocketTransport: Replaying ${messagesToReplay.length} buffered messages`, 447: ) 448: logForDiagnosticsNoPII('info', 'cli_websocket_messages_to_replay', { 449: count: messagesToReplay.length, 450: }) 451: for (const message of messagesToReplay) { 452: const line = jsonStringify(message) + '\n' 453: const success = this.sendLine(line) 454: if (!success) { 455: this.handleConnectionError() 456: break 457: } 458: } 459: } 460: isConnectedStatus(): boolean { 461: return this.state === 'connected' 462: } 463: isClosedStatus(): boolean { 464: return this.state === 'closed' 465: } 466: setOnData(callback: (data: string) => void): void { 467: this.onData = callback 468: } 469: setOnConnect(callback: () => void): void { 470: this.onConnectCallback = callback 471: } 472: setOnClose(callback: (closeCode?: number) => void): void { 473: this.onCloseCallback = callback 474: } 475: getStateLabel(): string { 476: return this.state 477: } 478: async write(message: StdoutMessage): Promise<void> { 479: if ('uuid' in message && typeof message.uuid === 'string') { 480: this.messageBuffer.add(message) 481: this.lastSentId = message.uuid 482: } 483: const line = jsonStringify(message) + '\n' 484: if (this.state !== 'connected') { 485: return 486: } 487: const sessionLabel = this.sessionId ? ` session=${this.sessionId}` : '' 488: const detailLabel = this.getControlMessageDetailLabel(message) 489: logForDebugging( 490: `WebSocketTransport: Sending message type=${message.type}${sessionLabel}${detailLabel}`, 491: ) 492: this.sendLine(line) 493: } 494: private getControlMessageDetailLabel(message: StdoutMessage): string { 495: if (message.type === 'control_request') { 496: const { request_id, request } = message 497: const toolName = 498: request.subtype === 'can_use_tool' ? request.tool_name : '' 499: return ` subtype=${request.subtype} request_id=${request_id}${toolName ? ` tool=${toolName}` : ''}` 500: } 501: if (message.type === 'control_response') { 502: const { subtype, request_id } = message.response 503: return ` subtype=${subtype} request_id=${request_id}` 504: } 505: return '' 506: } 507: private startPingInterval(): void { 508: // Clear any existing interval 509: this.stopPingInterval() 510: this.pongReceived = true 511: let lastTickTime = Date.now() 512: // Send ping periodically to detect dead connections. 513: // If the previous ping got no pong, treat the connection as dead. 514: this.pingInterval = setInterval(() => { 515: if (this.state === 'connected' && this.ws) { 516: const now = Date.now() 517: const gap = now - lastTickTime 518: lastTickTime = now 519: if (gap > SLEEP_DETECTION_THRESHOLD_MS) { 520: logForDebugging( 521: `WebSocketTransport: ${Math.round(gap / 1000)}s tick gap detected — process was suspended, forcing reconnect`, 522: ) 523: logForDiagnosticsNoPII( 524: 'info', 525: 'cli_websocket_sleep_detected_on_ping', 526: { gapMs: gap }, 527: ) 528: this.handleConnectionError() 529: return 530: } 531: if (!this.pongReceived) { 532: logForDebugging( 533: 'WebSocketTransport: No pong received, connection appears dead', 534: { level: 'error' }, 535: ) 536: logForDiagnosticsNoPII('error', 'cli_websocket_pong_timeout') 537: this.handleConnectionError() 538: return 539: } 540: this.pongReceived = false 541: try { 542: this.ws.ping?.() 543: } catch (error) { 544: logForDebugging(`WebSocketTransport: Ping failed: ${error}`, { 545: level: 'error', 546: }) 547: logForDiagnosticsNoPII('error', 'cli_websocket_ping_failed') 548: } 549: } 550: }, DEFAULT_PING_INTERVAL) 551: } 552: private stopPingInterval(): void { 553: if (this.pingInterval) { 554: clearInterval(this.pingInterval) 555: this.pingInterval = null 556: } 557: } 558: private startKeepaliveInterval(): void { 559: this.stopKeepaliveInterval() 560: if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { 561: return 562: } 563: this.keepAliveInterval = setInterval(() => { 564: if (this.state === 'connected' && this.ws) { 565: try { 566: this.ws.send(KEEP_ALIVE_FRAME) 567: this.lastActivityTime = Date.now() 568: logForDebugging( 569: 'WebSocketTransport: Sent periodic keep_alive data frame', 570: ) 571: } catch (error) { 572: logForDebugging( 573: `WebSocketTransport: Periodic keep_alive failed: ${error}`, 574: { level: 'error' }, 575: ) 576: logForDiagnosticsNoPII('error', 'cli_websocket_keepalive_failed') 577: } 578: } 579: }, DEFAULT_KEEPALIVE_INTERVAL) 580: } 581: private stopKeepaliveInterval(): void { 582: if (this.keepAliveInterval) { 583: clearInterval(this.keepAliveInterval) 584: this.keepAliveInterval = null 585: } 586: } 587: }

File: src/cli/transports/WorkerStateUploader.ts

typescript 1: import { sleep } from '../../utils/sleep.js' 2: type WorkerStateUploaderConfig = { 3: send: (body: Record<string, unknown>) => Promise<boolean> 4: baseDelayMs: number 5: maxDelayMs: number 6: jitterMs: number 7: } 8: export class WorkerStateUploader { 9: private inflight: Promise<void> | null = null 10: private pending: Record<string, unknown> | null = null 11: private closed = false 12: private readonly config: WorkerStateUploaderConfig 13: constructor(config: WorkerStateUploaderConfig) { 14: this.config = config 15: } 16: enqueue(patch: Record<string, unknown>): void { 17: if (this.closed) return 18: this.pending = this.pending ? coalescePatches(this.pending, patch) : patch 19: void this.drain() 20: } 21: close(): void { 22: this.closed = true 23: this.pending = null 24: } 25: private async drain(): Promise<void> { 26: if (this.inflight || this.closed) return 27: if (!this.pending) return 28: const payload = this.pending 29: this.pending = null 30: this.inflight = this.sendWithRetry(payload).then(() => { 31: this.inflight = null 32: if (this.pending && !this.closed) { 33: void this.drain() 34: } 35: }) 36: } 37: private async sendWithRetry(payload: Record<string, unknown>): Promise<void> { 38: let current = payload 39: let failures = 0 40: while (!this.closed) { 41: const ok = await this.config.send(current) 42: if (ok) return 43: failures++ 44: await sleep(this.retryDelay(failures)) 45: if (this.pending && !this.closed) { 46: current = coalescePatches(current, this.pending) 47: this.pending = null 48: } 49: } 50: } 51: private retryDelay(failures: number): number { 52: const exponential = Math.min( 53: this.config.baseDelayMs * 2 ** (failures - 1), 54: this.config.maxDelayMs, 55: ) 56: const jitter = Math.random() * this.config.jitterMs 57: return exponential + jitter 58: } 59: } 60: function coalescePatches( 61: base: Record<string, unknown>, 62: overlay: Record<string, unknown>, 63: ): Record<string, unknown> { 64: const merged = { ...base } 65: for (const [key, value] of Object.entries(overlay)) { 66: if ( 67: (key === 'external_metadata' || key === 'internal_metadata') && 68: merged[key] && 69: typeof merged[key] === 'object' && 70: typeof value === 'object' && 71: value !== null 72: ) { 73: merged[key] = { 74: ...(merged[key] as Record<string, unknown>), 75: ...(value as Record<string, unknown>), 76: } 77: } else { 78: merged[key] = value 79: } 80: } 81: return merged 82: }

File: src/cli/exit.ts

typescript 1: export function cliError(msg?: string): never { 2: if (msg) console.error(msg) 3: process.exit(1) 4: return undefined as never 5: } 6: export function cliOk(msg?: string): never { 7: if (msg) process.stdout.write(msg + '\n') 8: process.exit(0) 9: return undefined as never 10: }

File: src/cli/ndjsonSafeStringify.ts

typescript 1: import { jsonStringify } from '../utils/slowOperations.js' 2: const JS_LINE_TERMINATORS = /\u2028|\u2029/g 3: function escapeJsLineTerminators(json: string): string { 4: return json.replace(JS_LINE_TERMINATORS, c => 5: c === '\u2028' ? '\\u2028' : '\\u2029', 6: ) 7: } 8: export function ndjsonSafeStringify(value: unknown): string { 9: return escapeJsLineTerminators(jsonStringify(value)) 10: }

File: src/cli/print.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { readFile, stat } from 'fs/promises' 3: import { dirname } from 'path' 4: import { 5: downloadUserSettings, 6: redownloadUserSettings, 7: } from 'src/services/settingsSync/index.js' 8: import { waitForRemoteManagedSettingsToLoad } from 'src/services/remoteManagedSettings/index.js' 9: import { StructuredIO } from 'src/cli/structuredIO.js' 10: import { RemoteIO } from 'src/cli/remoteIO.js' 11: import { 12: type Command, 13: formatDescriptionWithSource, 14: getCommandName, 15: } from 'src/commands.js' 16: import { createStreamlinedTransformer } from 'src/utils/streamlinedTransform.js' 17: import { installStreamJsonStdoutGuard } from 'src/utils/streamJsonStdoutGuard.js' 18: import type { ToolPermissionContext } from 'src/Tool.js' 19: import type { ThinkingConfig } from 'src/utils/thinking.js' 20: import { assembleToolPool, filterToolsByDenyRules } from 'src/tools.js' 21: import uniqBy from 'lodash-es/uniqBy.js' 22: import { uniq } from 'src/utils/array.js' 23: import { mergeAndFilterTools } from 'src/utils/toolPool.js' 24: import { 25: logEvent, 26: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 27: } from 'src/services/analytics/index.js' 28: import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' 29: import { logForDebugging } from 'src/utils/debug.js' 30: import { 31: logForDiagnosticsNoPII, 32: withDiagnosticsTiming, 33: } from 'src/utils/diagLogs.js' 34: import { toolMatchesName, type Tool, type Tools } from 'src/Tool.js' 35: import { 36: type AgentDefinition, 37: isBuiltInAgent, 38: parseAgentsFromJson, 39: } from 'src/tools/AgentTool/loadAgentsDir.js' 40: import type { Message, NormalizedUserMessage } from 'src/types/message.js' 41: import type { QueuedCommand } from 'src/types/textInputTypes.js' 42: import { 43: dequeue, 44: dequeueAllMatching, 45: enqueue, 46: hasCommandsInQueue, 47: peek, 48: subscribeToCommandQueue, 49: getCommandsByMaxPriority, 50: } from 'src/utils/messageQueueManager.js' 51: import { notifyCommandLifecycle } from 'src/utils/commandLifecycle.js' 52: import { 53: getSessionState, 54: notifySessionStateChanged, 55: notifySessionMetadataChanged, 56: setPermissionModeChangedListener, 57: type RequiresActionDetails, 58: type SessionExternalMetadata, 59: } from 'src/utils/sessionState.js' 60: import { externalMetadataToAppState } from 'src/state/onChangeAppState.js' 61: import { getInMemoryErrors, logError, logMCPDebug } from 'src/utils/log.js' 62: import { 63: writeToStdout, 64: registerProcessOutputErrorHandlers, 65: } from 'src/utils/process.js' 66: import type { Stream } from 'src/utils/stream.js' 67: import { EMPTY_USAGE } from 'src/services/api/logging.js' 68: import { 69: loadConversationForResume, 70: type TurnInterruptionState, 71: } from 'src/utils/conversationRecovery.js' 72: import type { 73: MCPServerConnection, 74: McpSdkServerConfig, 75: ScopedMcpServerConfig, 76: } from 'src/services/mcp/types.js' 77: import { 78: ChannelMessageNotificationSchema, 79: gateChannelServer, 80: wrapChannelMessage, 81: findChannelEntry, 82: } from 'src/services/mcp/channelNotification.js' 83: import { 84: isChannelAllowlisted, 85: isChannelsEnabled, 86: } from 'src/services/mcp/channelAllowlist.js' 87: import { parsePluginIdentifier } from 'src/utils/plugins/pluginIdentifier.js' 88: import { validateUuid } from 'src/utils/uuid.js' 89: import { fromArray } from 'src/utils/generators.js' 90: import { ask } from 'src/QueryEngine.js' 91: import type { PermissionPromptTool } from 'src/utils/queryHelpers.js' 92: import { 93: createFileStateCacheWithSizeLimit, 94: mergeFileStateCaches, 95: READ_FILE_STATE_CACHE_SIZE, 96: } from 'src/utils/fileStateCache.js' 97: import { expandPath } from 'src/utils/path.js' 98: import { extractReadFilesFromMessages } from 'src/utils/queryHelpers.js' 99: import { registerHookEventHandler } from 'src/utils/hooks/hookEvents.js' 100: import { executeFilePersistence } from 'src/utils/filePersistence/filePersistence.js' 101: import { finalizePendingAsyncHooks } from 'src/utils/hooks/AsyncHookRegistry.js' 102: import { 103: gracefulShutdown, 104: gracefulShutdownSync, 105: isShuttingDown, 106: } from 'src/utils/gracefulShutdown.js' 107: import { registerCleanup } from 'src/utils/cleanupRegistry.js' 108: import { createIdleTimeoutManager } from 'src/utils/idleTimeout.js' 109: import type { 110: SDKStatus, 111: ModelInfo, 112: SDKMessage, 113: SDKUserMessage, 114: SDKUserMessageReplay, 115: PermissionResult, 116: McpServerConfigForProcessTransport, 117: McpServerStatus, 118: RewindFilesResult, 119: } from 'src/entrypoints/agentSdkTypes.js' 120: import type { 121: StdoutMessage, 122: SDKControlInitializeRequest, 123: SDKControlInitializeResponse, 124: SDKControlRequest, 125: SDKControlResponse, 126: SDKControlMcpSetServersResponse, 127: SDKControlReloadPluginsResponse, 128: } from 'src/entrypoints/sdk/controlTypes.js' 129: import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' 130: import type { PermissionMode as InternalPermissionMode } from 'src/types/permissions.js' 131: import { cwd } from 'process' 132: import { getCwd } from 'src/utils/cwd.js' 133: import omit from 'lodash-es/omit.js' 134: import reject from 'lodash-es/reject.js' 135: import { isPolicyAllowed } from 'src/services/policyLimits/index.js' 136: import type { ReplBridgeHandle } from 'src/bridge/replBridge.js' 137: import { getRemoteSessionUrl } from 'src/constants/product.js' 138: import { buildBridgeConnectUrl } from 'src/bridge/bridgeStatusUtil.js' 139: import { extractInboundMessageFields } from 'src/bridge/inboundMessages.js' 140: import { resolveAndPrepend } from 'src/bridge/inboundAttachments.js' 141: import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js' 142: import { hasPermissionsToUseTool } from 'src/utils/permissions/permissions.js' 143: import { safeParseJSON } from 'src/utils/json.js' 144: import { 145: outputSchema as permissionToolOutputSchema, 146: permissionPromptToolResultToPermissionDecision, 147: } from 'src/utils/permissions/PermissionPromptToolResultSchema.js' 148: import { createAbortController } from 'src/utils/abortController.js' 149: import { createCombinedAbortSignal } from 'src/utils/combinedAbortSignal.js' 150: import { generateSessionTitle } from 'src/utils/sessionTitle.js' 151: import { buildSideQuestionFallbackParams } from 'src/utils/queryContext.js' 152: import { runSideQuestion } from 'src/utils/sideQuestion.js' 153: import { 154: processSessionStartHooks, 155: processSetupHooks, 156: takeInitialUserMessage, 157: } from 'src/utils/sessionStart.js' 158: import { 159: DEFAULT_OUTPUT_STYLE_NAME, 160: getAllOutputStyles, 161: } from 'src/constants/outputStyles.js' 162: import { TEAMMATE_MESSAGE_TAG, TICK_TAG } from 'src/constants/xml.js' 163: import { 164: getSettings_DEPRECATED, 165: getSettingsWithSources, 166: } from 'src/utils/settings/settings.js' 167: import { settingsChangeDetector } from 'src/utils/settings/changeDetector.js' 168: import { applySettingsChange } from 'src/utils/settings/applySettingsChange.js' 169: import { 170: isFastModeAvailable, 171: isFastModeEnabled, 172: isFastModeSupportedByModel, 173: getFastModeState, 174: } from 'src/utils/fastMode.js' 175: import { 176: isAutoModeGateEnabled, 177: getAutoModeUnavailableNotification, 178: getAutoModeUnavailableReason, 179: isBypassPermissionsModeDisabled, 180: transitionPermissionMode, 181: } from 'src/utils/permissions/permissionSetup.js' 182: import { 183: tryGenerateSuggestion, 184: logSuggestionOutcome, 185: logSuggestionSuppressed, 186: type PromptVariant, 187: } from 'src/services/PromptSuggestion/promptSuggestion.js' 188: import { getLastCacheSafeParams } from 'src/utils/forkedAgent.js' 189: import { getAccountInformation } from 'src/utils/auth.js' 190: import { OAuthService } from 'src/services/oauth/index.js' 191: import { installOAuthTokens } from 'src/cli/handlers/auth.js' 192: import { getAPIProvider } from 'src/utils/model/providers.js' 193: import type { HookCallbackMatcher } from 'src/types/hooks.js' 194: import { AwsAuthStatusManager } from 'src/utils/awsAuthStatusManager.js' 195: import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' 196: import { 197: registerHookCallbacks, 198: setInitJsonSchema, 199: getInitJsonSchema, 200: setSdkAgentProgressSummariesEnabled, 201: } from 'src/bootstrap/state.js' 202: import { createSyntheticOutputTool } from 'src/tools/SyntheticOutputTool/SyntheticOutputTool.js' 203: import { parseSessionIdentifier } from 'src/utils/sessionUrl.js' 204: import { 205: hydrateRemoteSession, 206: hydrateFromCCRv2InternalEvents, 207: resetSessionFilePointer, 208: doesMessageExistInSession, 209: findUnresolvedToolUse, 210: recordAttributionSnapshot, 211: saveAgentSetting, 212: saveMode, 213: saveAiGeneratedTitle, 214: restoreSessionMetadata, 215: } from 'src/utils/sessionStorage.js' 216: import { incrementPromptCount } from 'src/utils/commitAttribution.js' 217: import { 218: setupSdkMcpClients, 219: connectToServer, 220: clearServerCache, 221: fetchToolsForClient, 222: areMcpConfigsEqual, 223: reconnectMcpServerImpl, 224: } from 'src/services/mcp/client.js' 225: import { 226: filterMcpServersByPolicy, 227: getMcpConfigByName, 228: isMcpServerDisabled, 229: setMcpServerEnabled, 230: } from 'src/services/mcp/config.js' 231: import { 232: performMCPOAuthFlow, 233: revokeServerTokens, 234: } from 'src/services/mcp/auth.js' 235: import { 236: runElicitationHooks, 237: runElicitationResultHooks, 238: } from 'src/services/mcp/elicitationHandler.js' 239: import { executeNotificationHooks } from 'src/utils/hooks.js' 240: import { 241: ElicitRequestSchema, 242: ElicitationCompleteNotificationSchema, 243: } from '@modelcontextprotocol/sdk/types.js' 244: import { getMcpPrefix } from 'src/services/mcp/mcpStringUtils.js' 245: import { 246: commandBelongsToServer, 247: filterToolsByServer, 248: } from 'src/services/mcp/utils.js' 249: import { setupVscodeSdkMcp } from 'src/services/mcp/vscodeSdkMcp.js' 250: import { getAllMcpConfigs } from 'src/services/mcp/config.js' 251: import { 252: isQualifiedForGrove, 253: checkGroveForNonInteractive, 254: } from 'src/services/api/grove.js' 255: import { 256: toInternalMessages, 257: toSDKRateLimitInfo, 258: } from 'src/utils/messages/mappers.js' 259: import { createModelSwitchBreadcrumbs } from 'src/utils/messages.js' 260: import { collectContextData } from 'src/commands/context/context-noninteractive.js' 261: import { LOCAL_COMMAND_STDOUT_TAG } from 'src/constants/xml.js' 262: import { 263: statusListeners, 264: type ClaudeAILimits, 265: } from 'src/services/claudeAiLimits.js' 266: import { 267: getDefaultMainLoopModel, 268: getMainLoopModel, 269: modelDisplayString, 270: parseUserSpecifiedModel, 271: } from 'src/utils/model/model.js' 272: import { getModelOptions } from 'src/utils/model/modelOptions.js' 273: import { 274: modelSupportsEffort, 275: modelSupportsMaxEffort, 276: EFFORT_LEVELS, 277: resolveAppliedEffort, 278: } from 'src/utils/effort.js' 279: import { modelSupportsAdaptiveThinking } from 'src/utils/thinking.js' 280: import { modelSupportsAutoMode } from 'src/utils/betas.js' 281: import { ensureModelStringsInitialized } from 'src/utils/model/modelStrings.js' 282: import { 283: getSessionId, 284: setMainLoopModelOverride, 285: setMainThreadAgentType, 286: switchSession, 287: isSessionPersistenceDisabled, 288: getIsRemoteMode, 289: getFlagSettingsInline, 290: setFlagSettingsInline, 291: getMainThreadAgentType, 292: getAllowedChannels, 293: setAllowedChannels, 294: type ChannelEntry, 295: } from 'src/bootstrap/state.js' 296: import { runWithWorkload, WORKLOAD_CRON } from 'src/utils/workloadContext.js' 297: import type { UUID } from 'crypto' 298: import { randomUUID } from 'crypto' 299: import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' 300: import type { AppState } from 'src/state/AppStateStore.js' 301: import { 302: fileHistoryRewind, 303: fileHistoryCanRestore, 304: fileHistoryEnabled, 305: fileHistoryGetDiffStats, 306: } from 'src/utils/fileHistory.js' 307: import { 308: restoreAgentFromSession, 309: restoreSessionStateFromLog, 310: } from 'src/utils/sessionRestore.js' 311: import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' 312: import { 313: headlessProfilerStartTurn, 314: headlessProfilerCheckpoint, 315: logHeadlessProfilerTurn, 316: } from 'src/utils/headlessProfiler.js' 317: import { 318: startQueryProfile, 319: logQueryProfileReport, 320: } from 'src/utils/queryProfiler.js' 321: import { asSessionId } from 'src/types/ids.js' 322: import { jsonStringify } from '../utils/slowOperations.js' 323: import { skillChangeDetector } from '../utils/skills/skillChangeDetector.js' 324: import { getCommands, clearCommandsCache } from '../commands.js' 325: import { 326: isBareMode, 327: isEnvTruthy, 328: isEnvDefinedFalsy, 329: } from '../utils/envUtils.js' 330: import { installPluginsForHeadless } from '../utils/plugins/headlessPluginInstall.js' 331: import { refreshActivePlugins } from '../utils/plugins/refresh.js' 332: import { loadAllPluginsCacheOnly } from '../utils/plugins/pluginLoader.js' 333: import { 334: isTeamLead, 335: hasActiveInProcessTeammates, 336: hasWorkingInProcessTeammates, 337: waitForTeammatesToBecomeIdle, 338: } from '../utils/teammate.js' 339: import { 340: readUnreadMessages, 341: markMessagesAsRead, 342: isShutdownApproved, 343: } from '../utils/teammateMailbox.js' 344: import { removeTeammateFromTeamFile } from '../utils/swarm/teamHelpers.js' 345: import { unassignTeammateTasks } from '../utils/tasks.js' 346: import { getRunningTasks } from '../utils/task/framework.js' 347: import { isBackgroundTask } from '../tasks/types.js' 348: import { stopTask } from '../tasks/stopTask.js' 349: import { drainSdkEvents } from '../utils/sdkEventQueue.js' 350: import { initializeGrowthBook } from '../services/analytics/growthbook.js' 351: import { errorMessage, toError } from '../utils/errors.js' 352: import { sleep } from '../utils/sleep.js' 353: import { isExtractModeActive } from '../memdir/paths.js' 354: const coordinatorModeModule = feature('COORDINATOR_MODE') 355: ? (require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js')) 356: : null 357: const proactiveModule = 358: feature('PROACTIVE') || feature('KAIROS') 359: ? (require('../proactive/index.js') as typeof import('../proactive/index.js')) 360: : null 361: const cronSchedulerModule = feature('AGENT_TRIGGERS') 362: ? (require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')) 363: : null 364: const cronJitterConfigModule = feature('AGENT_TRIGGERS') 365: ? (require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')) 366: : null 367: const cronGate = feature('AGENT_TRIGGERS') 368: ? (require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js')) 369: : null 370: const extractMemoriesModule = feature('EXTRACT_MEMORIES') 371: ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) 372: : null 373: const SHUTDOWN_TEAM_PROMPT = `<system-reminder> 374: You are running in non-interactive mode and cannot return a response to the user until your team is shut down. 375: You MUST shut down your team before preparing your final response: 376: 1. Use requestShutdown to ask each team member to shut down gracefully 377: 2. Wait for shutdown approvals 378: 3. Use the cleanup operation to clean up the team 379: 4. Only then provide your final response to the user 380: The user cannot receive your response until the team is completely shut down. 381: </system-reminder> 382: Shut down your team and prepare your final response for the user.` 383: const MAX_RECEIVED_UUIDS = 10_000 384: const receivedMessageUuids = new Set<UUID>() 385: const receivedMessageUuidsOrder: UUID[] = [] 386: function trackReceivedMessageUuid(uuid: UUID): boolean { 387: if (receivedMessageUuids.has(uuid)) { 388: return false 389: } 390: receivedMessageUuids.add(uuid) 391: receivedMessageUuidsOrder.push(uuid) 392: if (receivedMessageUuidsOrder.length > MAX_RECEIVED_UUIDS) { 393: const toEvict = receivedMessageUuidsOrder.splice( 394: 0, 395: receivedMessageUuidsOrder.length - MAX_RECEIVED_UUIDS, 396: ) 397: for (const old of toEvict) { 398: receivedMessageUuids.delete(old) 399: } 400: } 401: return true 402: } 403: type PromptValue = string | ContentBlockParam[] 404: function toBlocks(v: PromptValue): ContentBlockParam[] { 405: return typeof v === 'string' ? [{ type: 'text', text: v }] : v 406: } 407: export function joinPromptValues(values: PromptValue[]): PromptValue { 408: if (values.length === 1) return values[0]! 409: if (values.every(v => typeof v === 'string')) { 410: return values.join('\n') 411: } 412: return values.flatMap(toBlocks) 413: } 414: export function canBatchWith( 415: head: QueuedCommand, 416: next: QueuedCommand | undefined, 417: ): boolean { 418: return ( 419: next !== undefined && 420: next.mode === 'prompt' && 421: next.workload === head.workload && 422: next.isMeta === head.isMeta 423: ) 424: } 425: export async function runHeadless( 426: inputPrompt: string | AsyncIterable<string>, 427: getAppState: () => AppState, 428: setAppState: (f: (prev: AppState) => AppState) => void, 429: commands: Command[], 430: tools: Tools, 431: sdkMcpConfigs: Record<string, McpSdkServerConfig>, 432: agents: AgentDefinition[], 433: options: { 434: continue: boolean | undefined 435: resume: string | boolean | undefined 436: resumeSessionAt: string | undefined 437: verbose: boolean | undefined 438: outputFormat: string | undefined 439: jsonSchema: Record<string, unknown> | undefined 440: permissionPromptToolName: string | undefined 441: allowedTools: string[] | undefined 442: thinkingConfig: ThinkingConfig | undefined 443: maxTurns: number | undefined 444: maxBudgetUsd: number | undefined 445: taskBudget: { total: number } | undefined 446: systemPrompt: string | undefined 447: appendSystemPrompt: string | undefined 448: userSpecifiedModel: string | undefined 449: fallbackModel: string | undefined 450: teleport: string | true | null | undefined 451: sdkUrl: string | undefined 452: replayUserMessages: boolean | undefined 453: includePartialMessages: boolean | undefined 454: forkSession: boolean | undefined 455: rewindFiles: string | undefined 456: enableAuthStatus: boolean | undefined 457: agent: string | undefined 458: workload: string | undefined 459: setupTrigger?: 'init' | 'maintenance' | undefined 460: sessionStartHooksPromise?: ReturnType<typeof processSessionStartHooks> 461: setSDKStatus?: (status: SDKStatus) => void 462: }, 463: ): Promise<void> { 464: if ( 465: process.env.USER_TYPE === 'ant' && 466: isEnvTruthy(process.env.CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER) 467: ) { 468: process.stderr.write( 469: `\nStartup time: ${Math.round(process.uptime() * 1000)}ms\n`, 470: ) 471: process.exit(0) 472: } 473: if ( 474: feature('DOWNLOAD_USER_SETTINGS') && 475: (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) || getIsRemoteMode()) 476: ) { 477: void downloadUserSettings() 478: } 479: settingsChangeDetector.subscribe(source => { 480: applySettingsChange(source, setAppState) 481: if (isFastModeEnabled()) { 482: setAppState(prev => { 483: const s = prev.settings as Record<string, unknown> 484: const fastMode = s.fastMode === true && !s.fastModePerSessionOptIn 485: return { ...prev, fastMode } 486: }) 487: } 488: }) 489: if ( 490: (feature('PROACTIVE') || feature('KAIROS')) && 491: proactiveModule && 492: !proactiveModule.isProactiveActive() && 493: isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE) 494: ) { 495: proactiveModule.activateProactive('command') 496: } 497: if (typeof Bun !== 'undefined') { 498: const gcTimer = setInterval(Bun.gc, 1000) 499: gcTimer.unref() 500: } 501: headlessProfilerStartTurn() 502: headlessProfilerCheckpoint('runHeadless_entry') 503: if (await isQualifiedForGrove()) { 504: await checkGroveForNonInteractive() 505: } 506: headlessProfilerCheckpoint('after_grove_check') 507: void initializeGrowthBook() 508: if (options.resumeSessionAt && !options.resume) { 509: process.stderr.write(`Error: --resume-session-at requires --resume\n`) 510: gracefulShutdownSync(1) 511: return 512: } 513: if (options.rewindFiles && !options.resume) { 514: process.stderr.write(`Error: --rewind-files requires --resume\n`) 515: gracefulShutdownSync(1) 516: return 517: } 518: if (options.rewindFiles && inputPrompt) { 519: process.stderr.write( 520: `Error: --rewind-files is a standalone operation and cannot be used with a prompt\n`, 521: ) 522: gracefulShutdownSync(1) 523: return 524: } 525: const structuredIO = getStructuredIO(inputPrompt, options) 526: if (options.outputFormat === 'stream-json') { 527: installStreamJsonStdoutGuard() 528: } 529: const sandboxUnavailableReason = SandboxManager.getSandboxUnavailableReason() 530: if (sandboxUnavailableReason) { 531: if (SandboxManager.isSandboxRequired()) { 532: process.stderr.write( 533: `\nError: sandbox required but unavailable: ${sandboxUnavailableReason}\n` + 534: ` sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n`, 535: ) 536: gracefulShutdownSync(1) 537: return 538: } 539: process.stderr.write( 540: `\n⚠ Sandbox disabled: ${sandboxUnavailableReason}\n` + 541: ` Commands will run WITHOUT sandboxing. Network and filesystem restrictions will NOT be enforced.\n\n`, 542: ) 543: } else if (SandboxManager.isSandboxingEnabled()) { 544: try { 545: await SandboxManager.initialize(structuredIO.createSandboxAskCallback()) 546: } catch (err) { 547: process.stderr.write(`\n❌ Sandbox Error: ${errorMessage(err)}\n`) 548: gracefulShutdownSync(1, 'other') 549: return 550: } 551: } 552: if (options.outputFormat === 'stream-json' && options.verbose) { 553: registerHookEventHandler(event => { 554: const message: StdoutMessage = (() => { 555: switch (event.type) { 556: case 'started': 557: return { 558: type: 'system' as const, 559: subtype: 'hook_started' as const, 560: hook_id: event.hookId, 561: hook_name: event.hookName, 562: hook_event: event.hookEvent, 563: uuid: randomUUID(), 564: session_id: getSessionId(), 565: } 566: case 'progress': 567: return { 568: type: 'system' as const, 569: subtype: 'hook_progress' as const, 570: hook_id: event.hookId, 571: hook_name: event.hookName, 572: hook_event: event.hookEvent, 573: stdout: event.stdout, 574: stderr: event.stderr, 575: output: event.output, 576: uuid: randomUUID(), 577: session_id: getSessionId(), 578: } 579: case 'response': 580: return { 581: type: 'system' as const, 582: subtype: 'hook_response' as const, 583: hook_id: event.hookId, 584: hook_name: event.hookName, 585: hook_event: event.hookEvent, 586: output: event.output, 587: stdout: event.stdout, 588: stderr: event.stderr, 589: exit_code: event.exitCode, 590: outcome: event.outcome, 591: uuid: randomUUID(), 592: session_id: getSessionId(), 593: } 594: } 595: })() 596: void structuredIO.write(message) 597: }) 598: } 599: if (options.setupTrigger) { 600: await processSetupHooks(options.setupTrigger) 601: } 602: headlessProfilerCheckpoint('before_loadInitialMessages') 603: const appState = getAppState() 604: const { 605: messages: initialMessages, 606: turnInterruptionState, 607: agentSetting: resumedAgentSetting, 608: } = await loadInitialMessages(setAppState, { 609: continue: options.continue, 610: teleport: options.teleport, 611: resume: options.resume, 612: resumeSessionAt: options.resumeSessionAt, 613: forkSession: options.forkSession, 614: outputFormat: options.outputFormat, 615: sessionStartHooksPromise: options.sessionStartHooksPromise, 616: restoredWorkerState: structuredIO.restoredWorkerState, 617: }) 618: const hookInitialUserMessage = takeInitialUserMessage() 619: if (hookInitialUserMessage) { 620: structuredIO.prependUserMessage(hookInitialUserMessage) 621: } 622: if (!options.agent && !getMainThreadAgentType() && resumedAgentSetting) { 623: const { agentDefinition: restoredAgent } = restoreAgentFromSession( 624: resumedAgentSetting, 625: undefined, 626: { activeAgents: agents, allAgents: agents }, 627: ) 628: if (restoredAgent) { 629: setAppState(prev => ({ ...prev, agent: restoredAgent.agentType })) 630: if (!options.systemPrompt && !isBuiltInAgent(restoredAgent)) { 631: const agentSystemPrompt = restoredAgent.getSystemPrompt() 632: if (agentSystemPrompt) { 633: options.systemPrompt = agentSystemPrompt 634: } 635: } 636: saveAgentSetting(restoredAgent.agentType) 637: } 638: } 639: if (initialMessages.length === 0 && process.exitCode !== undefined) { 640: return 641: } 642: if (options.rewindFiles) { 643: const targetMessage = initialMessages.find( 644: m => m.uuid === options.rewindFiles, 645: ) 646: if (!targetMessage || targetMessage.type !== 'user') { 647: process.stderr.write( 648: `Error: --rewind-files requires a user message UUID, but ${options.rewindFiles} is not a user message in this session\n`, 649: ) 650: gracefulShutdownSync(1) 651: return 652: } 653: const currentAppState = getAppState() 654: const result = await handleRewindFiles( 655: options.rewindFiles as UUID, 656: currentAppState, 657: setAppState, 658: false, 659: ) 660: if (!result.canRewind) { 661: process.stderr.write(`Error: ${result.error || 'Unexpected error'}\n`) 662: gracefulShutdownSync(1) 663: return 664: } 665: process.stdout.write( 666: `Files rewound to state at message ${options.rewindFiles}\n`, 667: ) 668: gracefulShutdownSync(0) 669: return 670: } 671: const hasValidResumeSessionId = 672: typeof options.resume === 'string' && 673: (Boolean(validateUuid(options.resume)) || options.resume.endsWith('.jsonl')) 674: const isUsingSdkUrl = Boolean(options.sdkUrl) 675: if (!inputPrompt && !hasValidResumeSessionId && !isUsingSdkUrl) { 676: process.stderr.write( 677: `Error: Input must be provided either through stdin or as a prompt argument when using --print\n`, 678: ) 679: gracefulShutdownSync(1) 680: return 681: } 682: if (options.outputFormat === 'stream-json' && !options.verbose) { 683: process.stderr.write( 684: 'Error: When using --print, --output-format=stream-json requires --verbose\n', 685: ) 686: gracefulShutdownSync(1) 687: return 688: } 689: const allowedMcpTools = filterToolsByDenyRules( 690: appState.mcp.tools, 691: appState.toolPermissionContext, 692: ) 693: let filteredTools = [...tools, ...allowedMcpTools] 694: const effectivePermissionPromptToolName = options.sdkUrl 695: ? 'stdio' 696: : options.permissionPromptToolName 697: const onPermissionPrompt = (details: RequiresActionDetails) => { 698: if (feature('COMMIT_ATTRIBUTION')) { 699: setAppState(prev => ({ 700: ...prev, 701: attribution: { 702: ...prev.attribution, 703: permissionPromptCount: prev.attribution.permissionPromptCount + 1, 704: }, 705: })) 706: } 707: notifySessionStateChanged('requires_action', details) 708: } 709: const canUseTool = getCanUseToolFn( 710: effectivePermissionPromptToolName, 711: structuredIO, 712: () => getAppState().mcp.tools, 713: onPermissionPrompt, 714: ) 715: if (options.permissionPromptToolName) { 716: filteredTools = filteredTools.filter( 717: tool => !toolMatchesName(tool, options.permissionPromptToolName!), 718: ) 719: } 720: registerProcessOutputErrorHandlers() 721: headlessProfilerCheckpoint('after_loadInitialMessages') 722: await ensureModelStringsInitialized() 723: headlessProfilerCheckpoint('after_modelStrings') 724: const needsFullArray = options.outputFormat === 'json' && options.verbose 725: const messages: SDKMessage[] = [] 726: let lastMessage: SDKMessage | undefined 727: const transformToStreamlined = 728: feature('STREAMLINED_OUTPUT') && 729: isEnvTruthy(process.env.CLAUDE_CODE_STREAMLINED_OUTPUT) && 730: options.outputFormat === 'stream-json' 731: ? createStreamlinedTransformer() 732: : null 733: headlessProfilerCheckpoint('before_runHeadlessStreaming') 734: for await (const message of runHeadlessStreaming( 735: structuredIO, 736: appState.mcp.clients, 737: [...commands, ...appState.mcp.commands], 738: filteredTools, 739: initialMessages, 740: canUseTool, 741: sdkMcpConfigs, 742: getAppState, 743: setAppState, 744: agents, 745: options, 746: turnInterruptionState, 747: )) { 748: if (transformToStreamlined) { 749: const transformed = transformToStreamlined(message) 750: if (transformed) { 751: await structuredIO.write(transformed) 752: } 753: } else if (options.outputFormat === 'stream-json' && options.verbose) { 754: await structuredIO.write(message) 755: } 756: if ( 757: message.type !== 'control_response' && 758: message.type !== 'control_request' && 759: message.type !== 'control_cancel_request' && 760: !( 761: message.type === 'system' && 762: (message.subtype === 'session_state_changed' || 763: message.subtype === 'task_notification' || 764: message.subtype === 'task_started' || 765: message.subtype === 'task_progress' || 766: message.subtype === 'post_turn_summary') 767: ) && 768: message.type !== 'stream_event' && 769: message.type !== 'keep_alive' && 770: message.type !== 'streamlined_text' && 771: message.type !== 'streamlined_tool_use_summary' && 772: message.type !== 'prompt_suggestion' 773: ) { 774: if (needsFullArray) { 775: messages.push(message) 776: } 777: lastMessage = message 778: } 779: } 780: switch (options.outputFormat) { 781: case 'json': 782: if (!lastMessage || lastMessage.type !== 'result') { 783: throw new Error('No messages returned') 784: } 785: if (options.verbose) { 786: writeToStdout(jsonStringify(messages) + '\n') 787: break 788: } 789: writeToStdout(jsonStringify(lastMessage) + '\n') 790: break 791: case 'stream-json': 792: break 793: default: 794: if (!lastMessage || lastMessage.type !== 'result') { 795: throw new Error('No messages returned') 796: } 797: switch (lastMessage.subtype) { 798: case 'success': 799: writeToStdout( 800: lastMessage.result.endsWith('\n') 801: ? lastMessage.result 802: : lastMessage.result + '\n', 803: ) 804: break 805: case 'error_during_execution': 806: writeToStdout(`Execution error`) 807: break 808: case 'error_max_turns': 809: writeToStdout(`Error: Reached max turns (${options.maxTurns})`) 810: break 811: case 'error_max_budget_usd': 812: writeToStdout(`Error: Exceeded USD budget (${options.maxBudgetUsd})`) 813: break 814: case 'error_max_structured_output_retries': 815: writeToStdout( 816: `Error: Failed to provide valid structured output after maximum retries`, 817: ) 818: } 819: } 820: logHeadlessProfilerTurn() 821: if (feature('EXTRACT_MEMORIES') && isExtractModeActive()) { 822: await extractMemoriesModule!.drainPendingExtraction() 823: } 824: gracefulShutdownSync( 825: lastMessage?.type === 'result' && lastMessage?.is_error ? 1 : 0, 826: ) 827: } 828: function runHeadlessStreaming( 829: structuredIO: StructuredIO, 830: mcpClients: MCPServerConnection[], 831: commands: Command[], 832: tools: Tools, 833: initialMessages: Message[], 834: canUseTool: CanUseToolFn, 835: sdkMcpConfigs: Record<string, McpSdkServerConfig>, 836: getAppState: () => AppState, 837: setAppState: (f: (prev: AppState) => AppState) => void, 838: agents: AgentDefinition[], 839: options: { 840: verbose: boolean | undefined 841: jsonSchema: Record<string, unknown> | undefined 842: permissionPromptToolName: string | undefined 843: allowedTools: string[] | undefined 844: thinkingConfig: ThinkingConfig | undefined 845: maxTurns: number | undefined 846: maxBudgetUsd: number | undefined 847: taskBudget: { total: number } | undefined 848: systemPrompt: string | undefined 849: appendSystemPrompt: string | undefined 850: userSpecifiedModel: string | undefined 851: fallbackModel: string | undefined 852: replayUserMessages?: boolean | undefined 853: includePartialMessages?: boolean | undefined 854: enableAuthStatus?: boolean | undefined 855: agent?: string | undefined 856: setSDKStatus?: (status: SDKStatus) => void 857: promptSuggestions?: boolean | undefined 858: workload?: string | undefined 859: }, 860: turnInterruptionState?: TurnInterruptionState, 861: ): AsyncIterable<StdoutMessage> { 862: let running = false 863: let runPhase: 864: | 'draining_commands' 865: | 'waiting_for_agents' 866: | 'finally_flush' 867: | 'finally_post_flush' 868: | undefined 869: let inputClosed = false 870: let shutdownPromptInjected = false 871: let heldBackResult: StdoutMessage | null = null 872: let abortController: AbortController | undefined 873: const output = structuredIO.outbound 874: const sigintHandler = () => { 875: logForDiagnosticsNoPII('info', 'shutdown_signal', { signal: 'SIGINT' }) 876: if (abortController && !abortController.signal.aborted) { 877: abortController.abort() 878: } 879: void gracefulShutdown(0) 880: } 881: process.on('SIGINT', sigintHandler) 882: registerCleanup(async () => { 883: const bg: Record<string, number> = {} 884: for (const t of getRunningTasks(getAppState())) { 885: if (isBackgroundTask(t)) bg[t.type] = (bg[t.type] ?? 0) + 1 886: } 887: logForDiagnosticsNoPII('info', 'run_state_at_shutdown', { 888: run_active: running, 889: run_phase: runPhase, 890: worker_status: getSessionState(), 891: internal_events_pending: structuredIO.internalEventsPending, 892: bg_tasks: bg, 893: }) 894: }) 895: setPermissionModeChangedListener(newMode => { 896: if ( 897: newMode === 'default' || 898: newMode === 'acceptEdits' || 899: newMode === 'bypassPermissions' || 900: newMode === 'plan' || 901: newMode === (feature('TRANSCRIPT_CLASSIFIER') && 'auto') || 902: newMode === 'dontAsk' 903: ) { 904: output.enqueue({ 905: type: 'system', 906: subtype: 'status', 907: status: null, 908: permissionMode: newMode as PermissionMode, 909: uuid: randomUUID(), 910: session_id: getSessionId(), 911: }) 912: } 913: }) 914: const suggestionState: { 915: abortController: AbortController | null 916: inflightPromise: Promise<void> | null 917: lastEmitted: { 918: text: string 919: emittedAt: number 920: promptId: PromptVariant 921: generationRequestId: string | null 922: } | null 923: pendingSuggestion: { 924: type: 'prompt_suggestion' 925: suggestion: string 926: uuid: UUID 927: session_id: string 928: } | null 929: pendingLastEmittedEntry: { 930: text: string 931: promptId: PromptVariant 932: generationRequestId: string | null 933: } | null 934: } = { 935: abortController: null, 936: inflightPromise: null, 937: lastEmitted: null, 938: pendingSuggestion: null, 939: pendingLastEmittedEntry: null, 940: } 941: let unsubscribeAuthStatus: (() => void) | undefined 942: if (options.enableAuthStatus) { 943: const authStatusManager = AwsAuthStatusManager.getInstance() 944: unsubscribeAuthStatus = authStatusManager.subscribe(status => { 945: output.enqueue({ 946: type: 'auth_status', 947: isAuthenticating: status.isAuthenticating, 948: output: status.output, 949: error: status.error, 950: uuid: randomUUID(), 951: session_id: getSessionId(), 952: }) 953: }) 954: } 955: const rateLimitListener = (limits: ClaudeAILimits) => { 956: const rateLimitInfo = toSDKRateLimitInfo(limits) 957: if (rateLimitInfo) { 958: output.enqueue({ 959: type: 'rate_limit_event', 960: rate_limit_info: rateLimitInfo, 961: uuid: randomUUID(), 962: session_id: getSessionId(), 963: }) 964: } 965: } 966: statusListeners.add(rateLimitListener) 967: const mutableMessages: Message[] = initialMessages 968: let readFileState = extractReadFilesFromMessages( 969: initialMessages, 970: cwd(), 971: READ_FILE_STATE_CACHE_SIZE, 972: ) 973: const pendingSeeds = createFileStateCacheWithSizeLimit( 974: READ_FILE_STATE_CACHE_SIZE, 975: ) 976: const resumeInterruptedTurnEnv = 977: process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN 978: if ( 979: turnInterruptionState && 980: turnInterruptionState.kind !== 'none' && 981: resumeInterruptedTurnEnv 982: ) { 983: logForDebugging( 984: `[print.ts] Auto-resuming interrupted turn (kind: ${turnInterruptionState.kind})`, 985: ) 986: removeInterruptedMessage(mutableMessages, turnInterruptionState.message) 987: enqueue({ 988: mode: 'prompt', 989: value: turnInterruptionState.message.message.content, 990: uuid: randomUUID(), 991: }) 992: } 993: const modelOptions = getModelOptions() 994: const modelInfos = modelOptions.map(option => { 995: const modelId = option.value === null ? 'default' : option.value 996: const resolvedModel = 997: modelId === 'default' 998: ? getDefaultMainLoopModel() 999: : parseUserSpecifiedModel(modelId) 1000: const hasEffort = modelSupportsEffort(resolvedModel) 1001: const hasAdaptiveThinking = modelSupportsAdaptiveThinking(resolvedModel) 1002: const hasFastMode = isFastModeSupportedByModel(option.value) 1003: const hasAutoMode = modelSupportsAutoMode(resolvedModel) 1004: return { 1005: value: modelId, 1006: displayName: option.label, 1007: description: option.description, 1008: ...(hasEffort && { 1009: supportsEffort: true, 1010: supportedEffortLevels: modelSupportsMaxEffort(resolvedModel) 1011: ? [...EFFORT_LEVELS] 1012: : EFFORT_LEVELS.filter(l => l !== 'max'), 1013: }), 1014: ...(hasAdaptiveThinking && { supportsAdaptiveThinking: true }), 1015: ...(hasFastMode && { supportsFastMode: true }), 1016: ...(hasAutoMode && { supportsAutoMode: true }), 1017: } 1018: }) 1019: let activeUserSpecifiedModel = options.userSpecifiedModel 1020: function injectModelSwitchBreadcrumbs( 1021: modelArg: string, 1022: resolvedModel: string, 1023: ): void { 1024: const breadcrumbs = createModelSwitchBreadcrumbs( 1025: modelArg, 1026: modelDisplayString(resolvedModel), 1027: ) 1028: mutableMessages.push(...breadcrumbs) 1029: for (const crumb of breadcrumbs) { 1030: if ( 1031: typeof crumb.message.content === 'string' && 1032: crumb.message.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) 1033: ) { 1034: output.enqueue({ 1035: type: 'user', 1036: message: crumb.message, 1037: session_id: getSessionId(), 1038: parent_tool_use_id: null, 1039: uuid: crumb.uuid, 1040: timestamp: crumb.timestamp, 1041: isReplay: true, 1042: } satisfies SDKUserMessageReplay) 1043: } 1044: } 1045: } 1046: let sdkClients: MCPServerConnection[] = [] 1047: let sdkTools: Tools = [] 1048: const elicitationRegistered = new Set<string>() 1049: function registerElicitationHandlers(clients: MCPServerConnection[]): void { 1050: for (const connection of clients) { 1051: if ( 1052: connection.type !== 'connected' || 1053: elicitationRegistered.has(connection.name) 1054: ) { 1055: continue 1056: } 1057: if (connection.config.type === 'sdk') { 1058: continue 1059: } 1060: const serverName = connection.name 1061: try { 1062: connection.client.setRequestHandler( 1063: ElicitRequestSchema, 1064: async (request, extra) => { 1065: logMCPDebug( 1066: serverName, 1067: `Elicitation request received in print mode: ${jsonStringify(request)}`, 1068: ) 1069: const mode = request.params.mode === 'url' ? 'url' : 'form' 1070: logEvent('tengu_mcp_elicitation_shown', { 1071: mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1072: }) 1073: const hookResponse = await runElicitationHooks( 1074: serverName, 1075: request.params, 1076: extra.signal, 1077: ) 1078: if (hookResponse) { 1079: logMCPDebug( 1080: serverName, 1081: `Elicitation resolved by hook: ${jsonStringify(hookResponse)}`, 1082: ) 1083: logEvent('tengu_mcp_elicitation_response', { 1084: mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1085: action: 1086: hookResponse.action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1087: }) 1088: return hookResponse 1089: } 1090: const url = 1091: 'url' in request.params 1092: ? (request.params.url as string) 1093: : undefined 1094: const requestedSchema = 1095: 'requestedSchema' in request.params 1096: ? (request.params.requestedSchema as 1097: | Record<string, unknown> 1098: | undefined) 1099: : undefined 1100: const elicitationId = 1101: 'elicitationId' in request.params 1102: ? (request.params.elicitationId as string | undefined) 1103: : undefined 1104: const rawResult = await structuredIO.handleElicitation( 1105: serverName, 1106: request.params.message, 1107: requestedSchema, 1108: extra.signal, 1109: mode, 1110: url, 1111: elicitationId, 1112: ) 1113: const result = await runElicitationResultHooks( 1114: serverName, 1115: rawResult, 1116: extra.signal, 1117: mode, 1118: elicitationId, 1119: ) 1120: logEvent('tengu_mcp_elicitation_response', { 1121: mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1122: action: 1123: result.action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1124: }) 1125: return result 1126: }, 1127: ) 1128: connection.client.setNotificationHandler( 1129: ElicitationCompleteNotificationSchema, 1130: notification => { 1131: const { elicitationId } = notification.params 1132: logMCPDebug( 1133: serverName, 1134: `Elicitation completion notification: ${elicitationId}`, 1135: ) 1136: void executeNotificationHooks({ 1137: message: `MCP server "${serverName}" confirmed elicitation ${elicitationId} complete`, 1138: notificationType: 'elicitation_complete', 1139: }) 1140: output.enqueue({ 1141: type: 'system', 1142: subtype: 'elicitation_complete', 1143: mcp_server_name: serverName, 1144: elicitation_id: elicitationId, 1145: uuid: randomUUID(), 1146: session_id: getSessionId(), 1147: }) 1148: }, 1149: ) 1150: elicitationRegistered.add(serverName) 1151: } catch { 1152: } 1153: } 1154: } 1155: async function updateSdkMcp() { 1156: const currentServerNames = new Set(Object.keys(sdkMcpConfigs)) 1157: const connectedServerNames = new Set(sdkClients.map(c => c.name)) 1158: const hasNewServers = Array.from(currentServerNames).some( 1159: name => !connectedServerNames.has(name), 1160: ) 1161: const hasRemovedServers = Array.from(connectedServerNames).some( 1162: name => !currentServerNames.has(name), 1163: ) 1164: const hasPendingSdkClients = sdkClients.some(c => c.type === 'pending') 1165: const hasFailedSdkClients = sdkClients.some(c => c.type === 'failed') 1166: const haveServersChanged = 1167: hasNewServers || 1168: hasRemovedServers || 1169: hasPendingSdkClients || 1170: hasFailedSdkClients 1171: if (haveServersChanged) { 1172: for (const client of sdkClients) { 1173: if (!currentServerNames.has(client.name)) { 1174: if (client.type === 'connected') { 1175: await client.cleanup() 1176: } 1177: } 1178: } 1179: const sdkSetup = await setupSdkMcpClients( 1180: sdkMcpConfigs, 1181: (serverName, message) => 1182: structuredIO.sendMcpMessage(serverName, message), 1183: ) 1184: sdkClients = sdkSetup.clients 1185: sdkTools = sdkSetup.tools 1186: const allSdkNames = uniq([...connectedServerNames, ...currentServerNames]) 1187: setAppState(prev => ({ 1188: ...prev, 1189: mcp: { 1190: ...prev.mcp, 1191: tools: [ 1192: ...prev.mcp.tools.filter( 1193: t => 1194: !allSdkNames.some(name => 1195: t.name.startsWith(getMcpPrefix(name)), 1196: ), 1197: ), 1198: ...sdkTools, 1199: ], 1200: }, 1201: })) 1202: setupVscodeSdkMcp(sdkClients) 1203: } 1204: } 1205: void updateSdkMcp() 1206: let dynamicMcpState: DynamicMcpState = { 1207: clients: [], 1208: tools: [], 1209: configs: {}, 1210: } 1211: const buildAllTools = (appState: AppState): Tools => { 1212: const assembledTools = assembleToolPool( 1213: appState.toolPermissionContext, 1214: appState.mcp.tools, 1215: ) 1216: let allTools = uniqBy( 1217: mergeAndFilterTools( 1218: [...tools, ...sdkTools, ...dynamicMcpState.tools], 1219: assembledTools, 1220: appState.toolPermissionContext.mode, 1221: ), 1222: 'name', 1223: ) 1224: if (options.permissionPromptToolName) { 1225: allTools = allTools.filter( 1226: tool => !toolMatchesName(tool, options.permissionPromptToolName!), 1227: ) 1228: } 1229: const initJsonSchema = getInitJsonSchema() 1230: if (initJsonSchema && !options.jsonSchema) { 1231: const syntheticOutputResult = createSyntheticOutputTool(initJsonSchema) 1232: if ('tool' in syntheticOutputResult) { 1233: allTools = [...allTools, syntheticOutputResult.tool] 1234: } 1235: } 1236: return allTools 1237: } 1238: let bridgeHandle: ReplBridgeHandle | null = null 1239: let bridgeLastForwardedIndex = 0 1240: function forwardMessagesToBridge(): void { 1241: if (!bridgeHandle) return 1242: const startIndex = Math.min( 1243: bridgeLastForwardedIndex, 1244: mutableMessages.length, 1245: ) 1246: const newMessages = mutableMessages 1247: .slice(startIndex) 1248: .filter(m => m.type === 'user' || m.type === 'assistant') 1249: bridgeLastForwardedIndex = mutableMessages.length 1250: if (newMessages.length > 0) { 1251: bridgeHandle.writeMessages(newMessages) 1252: } 1253: } 1254: let mcpChangesPromise: Promise<{ 1255: response: SDKControlMcpSetServersResponse 1256: sdkServersChanged: boolean 1257: }> = Promise.resolve({ 1258: response: { 1259: added: [] as string[], 1260: removed: [] as string[], 1261: errors: {} as Record<string, string>, 1262: }, 1263: sdkServersChanged: false, 1264: }) 1265: function applyMcpServerChanges( 1266: servers: Record<string, McpServerConfigForProcessTransport>, 1267: ): Promise<{ 1268: response: SDKControlMcpSetServersResponse 1269: sdkServersChanged: boolean 1270: }> { 1271: const doWork = async (): Promise<{ 1272: response: SDKControlMcpSetServersResponse 1273: sdkServersChanged: boolean 1274: }> => { 1275: const oldSdkClientNames = new Set(sdkClients.map(c => c.name)) 1276: const result = await handleMcpSetServers( 1277: servers, 1278: { configs: sdkMcpConfigs, clients: sdkClients, tools: sdkTools }, 1279: dynamicMcpState, 1280: setAppState, 1281: ) 1282: for (const key of Object.keys(sdkMcpConfigs)) { 1283: delete sdkMcpConfigs[key] 1284: } 1285: Object.assign(sdkMcpConfigs, result.newSdkState.configs) 1286: sdkClients = result.newSdkState.clients 1287: sdkTools = result.newSdkState.tools 1288: dynamicMcpState = result.newDynamicState 1289: if (result.sdkServersChanged) { 1290: const newSdkClientNames = new Set(sdkClients.map(c => c.name)) 1291: const allSdkNames = uniq([...oldSdkClientNames, ...newSdkClientNames]) 1292: setAppState(prev => ({ 1293: ...prev, 1294: mcp: { 1295: ...prev.mcp, 1296: tools: [ 1297: ...prev.mcp.tools.filter( 1298: t => 1299: !allSdkNames.some(name => 1300: t.name.startsWith(getMcpPrefix(name)), 1301: ), 1302: ), 1303: ...sdkTools, 1304: ], 1305: }, 1306: })) 1307: } 1308: return { 1309: response: result.response, 1310: sdkServersChanged: result.sdkServersChanged, 1311: } 1312: } 1313: mcpChangesPromise = mcpChangesPromise.then(doWork, doWork) 1314: return mcpChangesPromise 1315: } 1316: function buildMcpServerStatuses(): McpServerStatus[] { 1317: const currentAppState = getAppState() 1318: const currentMcpClients = currentAppState.mcp.clients 1319: const allMcpTools = uniqBy( 1320: [...currentAppState.mcp.tools, ...dynamicMcpState.tools], 1321: 'name', 1322: ) 1323: const existingNames = new Set([ 1324: ...currentMcpClients.map(c => c.name), 1325: ...sdkClients.map(c => c.name), 1326: ]) 1327: return [ 1328: ...currentMcpClients, 1329: ...sdkClients, 1330: ...dynamicMcpState.clients.filter(c => !existingNames.has(c.name)), 1331: ].map(connection => { 1332: let config 1333: if ( 1334: connection.config.type === 'sse' || 1335: connection.config.type === 'http' 1336: ) { 1337: config = { 1338: type: connection.config.type, 1339: url: connection.config.url, 1340: headers: connection.config.headers, 1341: oauth: connection.config.oauth, 1342: } 1343: } else if (connection.config.type === 'claudeai-proxy') { 1344: config = { 1345: type: 'claudeai-proxy' as const, 1346: url: connection.config.url, 1347: id: connection.config.id, 1348: } 1349: } else if ( 1350: connection.config.type === 'stdio' || 1351: connection.config.type === undefined 1352: ) { 1353: config = { 1354: type: 'stdio' as const, 1355: command: connection.config.command, 1356: args: connection.config.args, 1357: } 1358: } 1359: const serverTools = 1360: connection.type === 'connected' 1361: ? filterToolsByServer(allMcpTools, connection.name).map(tool => ({ 1362: name: tool.mcpInfo?.toolName ?? tool.name, 1363: annotations: { 1364: readOnly: tool.isReadOnly({}) || undefined, 1365: destructive: tool.isDestructive?.({}) || undefined, 1366: openWorld: tool.isOpenWorld?.({}) || undefined, 1367: }, 1368: })) 1369: : undefined 1370: let capabilities: { experimental?: Record<string, unknown> } | undefined 1371: if ( 1372: (feature('KAIROS') || feature('KAIROS_CHANNELS')) && 1373: connection.type === 'connected' && 1374: connection.capabilities.experimental 1375: ) { 1376: const exp = { ...connection.capabilities.experimental } 1377: if ( 1378: exp['claude/channel'] && 1379: (!isChannelsEnabled() || 1380: !isChannelAllowlisted(connection.config.pluginSource)) 1381: ) { 1382: delete exp['claude/channel'] 1383: } 1384: if (Object.keys(exp).length > 0) { 1385: capabilities = { experimental: exp } 1386: } 1387: } 1388: return { 1389: name: connection.name, 1390: status: connection.type, 1391: serverInfo: 1392: connection.type === 'connected' ? connection.serverInfo : undefined, 1393: error: connection.type === 'failed' ? connection.error : undefined, 1394: config, 1395: scope: connection.config.scope, 1396: tools: serverTools, 1397: capabilities, 1398: } 1399: }) 1400: } 1401: async function installPluginsAndApplyMcpInBackground(): Promise<void> { 1402: try { 1403: await Promise.all([ 1404: feature('DOWNLOAD_USER_SETTINGS') && 1405: (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) || getIsRemoteMode()) 1406: ? withDiagnosticsTiming('headless_user_settings_download', () => 1407: downloadUserSettings(), 1408: ) 1409: : Promise.resolve(), 1410: withDiagnosticsTiming('headless_managed_settings_wait', () => 1411: waitForRemoteManagedSettingsToLoad(), 1412: ), 1413: ]) 1414: const pluginsInstalled = await installPluginsForHeadless() 1415: if (pluginsInstalled) { 1416: await applyPluginMcpDiff() 1417: } 1418: } catch (error) { 1419: logError(error) 1420: } 1421: } 1422: let pluginInstallPromise: Promise<void> | null = null 1423: if (!isBareMode()) { 1424: if (isEnvTruthy(process.env.CLAUDE_CODE_SYNC_PLUGIN_INSTALL)) { 1425: pluginInstallPromise = installPluginsAndApplyMcpInBackground() 1426: } else { 1427: void installPluginsAndApplyMcpInBackground() 1428: } 1429: } 1430: const idleTimeout = createIdleTimeoutManager(() => !running) 1431: let currentCommands = commands 1432: let currentAgents = agents 1433: async function refreshPluginState(): Promise<void> { 1434: const { agentDefinitions: freshAgentDefs } = 1435: await refreshActivePlugins(setAppState) 1436: currentCommands = await getCommands(cwd()) 1437: const sdkAgents = currentAgents.filter(a => a.source === 'flagSettings') 1438: currentAgents = [...freshAgentDefs.allAgents, ...sdkAgents] 1439: } 1440: async function applyPluginMcpDiff(): Promise<void> { 1441: const { servers: newConfigs } = await getAllMcpConfigs() 1442: const supportedConfigs: Record<string, McpServerConfigForProcessTransport> = 1443: {} 1444: for (const [name, config] of Object.entries(newConfigs)) { 1445: const type = config.type 1446: if ( 1447: type === undefined || 1448: type === 'stdio' || 1449: type === 'sse' || 1450: type === 'http' || 1451: type === 'sdk' 1452: ) { 1453: supportedConfigs[name] = config 1454: } 1455: } 1456: for (const [name, config] of Object.entries(sdkMcpConfigs)) { 1457: if (config.type === 'sdk' && !(name in supportedConfigs)) { 1458: supportedConfigs[name] = config 1459: } 1460: } 1461: const { response, sdkServersChanged } = 1462: await applyMcpServerChanges(supportedConfigs) 1463: if (sdkServersChanged) { 1464: void updateSdkMcp() 1465: } 1466: logForDebugging( 1467: `Headless MCP refresh: added=${response.added.length}, removed=${response.removed.length}`, 1468: ) 1469: } 1470: const unsubscribeSkillChanges = skillChangeDetector.subscribe(() => { 1471: clearCommandsCache() 1472: void getCommands(cwd()).then(newCommands => { 1473: currentCommands = newCommands 1474: }) 1475: }) 1476: const scheduleProactiveTick = 1477: feature('PROACTIVE') || feature('KAIROS') 1478: ? () => { 1479: setTimeout(() => { 1480: if ( 1481: !proactiveModule?.isProactiveActive() || 1482: proactiveModule.isProactivePaused() || 1483: inputClosed 1484: ) { 1485: return 1486: } 1487: const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>` 1488: enqueue({ 1489: mode: 'prompt' as const, 1490: value: tickContent, 1491: uuid: randomUUID(), 1492: priority: 'later', 1493: isMeta: true, 1494: }) 1495: void run() 1496: }, 0) 1497: } 1498: : undefined 1499: subscribeToCommandQueue(() => { 1500: if (abortController && getCommandsByMaxPriority('now').length > 0) { 1501: abortController.abort('interrupt') 1502: } 1503: }) 1504: const run = async () => { 1505: if (running) { 1506: return 1507: } 1508: running = true 1509: runPhase = undefined 1510: notifySessionStateChanged('running') 1511: idleTimeout.stop() 1512: headlessProfilerCheckpoint('run_entry') 1513: await updateSdkMcp() 1514: headlessProfilerCheckpoint('after_updateSdkMcp') 1515: if (pluginInstallPromise) { 1516: const timeoutMs = parseInt( 1517: process.env.CLAUDE_CODE_SYNC_PLUGIN_INSTALL_TIMEOUT_MS || '', 1518: 10, 1519: ) 1520: if (timeoutMs > 0) { 1521: const timeout = sleep(timeoutMs).then(() => 'timeout' as const) 1522: const result = await Promise.race([pluginInstallPromise, timeout]) 1523: if (result === 'timeout') { 1524: logError( 1525: new Error( 1526: `CLAUDE_CODE_SYNC_PLUGIN_INSTALL: plugin installation timed out after ${timeoutMs}ms`, 1527: ), 1528: ) 1529: logEvent('tengu_sync_plugin_install_timeout', { 1530: timeout_ms: timeoutMs, 1531: }) 1532: } 1533: } else { 1534: await pluginInstallPromise 1535: } 1536: pluginInstallPromise = null 1537: await refreshPluginState() 1538: const { setupPluginHookHotReload } = await import( 1539: '../utils/plugins/loadPluginHooks.js' 1540: ) 1541: setupPluginHookHotReload() 1542: } 1543: const isMainThread = (cmd: QueuedCommand) => cmd.agentId === undefined 1544: try { 1545: let command: QueuedCommand | undefined 1546: let waitingForAgents = false 1547: const drainCommandQueue = async () => { 1548: while ((command = dequeue(isMainThread))) { 1549: if ( 1550: command.mode !== 'prompt' && 1551: command.mode !== 'orphaned-permission' && 1552: command.mode !== 'task-notification' 1553: ) { 1554: throw new Error( 1555: 'only prompt commands are supported in streaming mode', 1556: ) 1557: } 1558: const batch: QueuedCommand[] = [command] 1559: if (command.mode === 'prompt') { 1560: while (canBatchWith(command, peek(isMainThread))) { 1561: batch.push(dequeue(isMainThread)!) 1562: } 1563: if (batch.length > 1) { 1564: command = { 1565: ...command, 1566: value: joinPromptValues(batch.map(c => c.value)), 1567: uuid: batch.findLast(c => c.uuid)?.uuid ?? command.uuid, 1568: } 1569: } 1570: } 1571: const batchUuids = batch.map(c => c.uuid).filter(u => u !== undefined) 1572: if (options.replayUserMessages && batch.length > 1) { 1573: for (const c of batch) { 1574: if (c.uuid && c.uuid !== command.uuid) { 1575: output.enqueue({ 1576: type: 'user', 1577: message: { role: 'user', content: c.value }, 1578: session_id: getSessionId(), 1579: parent_tool_use_id: null, 1580: uuid: c.uuid, 1581: isReplay: true, 1582: } satisfies SDKUserMessageReplay) 1583: } 1584: } 1585: } 1586: const appState = getAppState() 1587: const allMcpClients = [ 1588: ...appState.mcp.clients, 1589: ...sdkClients, 1590: ...dynamicMcpState.clients, 1591: ] 1592: registerElicitationHandlers(allMcpClients) 1593: for (const client of allMcpClients) { 1594: reregisterChannelHandlerAfterReconnect(client) 1595: } 1596: const allTools = buildAllTools(appState) 1597: for (const uuid of batchUuids) { 1598: notifyCommandLifecycle(uuid, 'started') 1599: } 1600: if (command.mode === 'task-notification') { 1601: const notificationText = 1602: typeof command.value === 'string' ? command.value : '' 1603: // Parse the XML-formatted notification 1604: const taskIdMatch = notificationText.match( 1605: /<task-id>([^<]+)<\/task-id>/, 1606: ) 1607: const toolUseIdMatch = notificationText.match( 1608: /<tool-use-id>([^<]+)<\/tool-use-id>/, 1609: ) 1610: const outputFileMatch = notificationText.match( 1611: /<output-file>([^<]+)<\/output-file>/, 1612: ) 1613: const statusMatch = notificationText.match( 1614: /<status>([^<]+)<\/status>/, 1615: ) 1616: const summaryMatch = notificationText.match( 1617: /<summary>([^<]+)<\/summary>/, 1618: ) 1619: const isValidStatus = ( 1620: s: string | undefined, 1621: ): s is 'completed' | 'failed' | 'stopped' | 'killed' => 1622: s === 'completed' || 1623: s === 'failed' || 1624: s === 'stopped' || 1625: s === 'killed' 1626: const rawStatus = statusMatch?.[1] 1627: const status = isValidStatus(rawStatus) 1628: ? rawStatus === 'killed' 1629: ? 'stopped' 1630: : rawStatus 1631: : 'completed' 1632: const usageMatch = notificationText.match( 1633: /<usage>([\s\S]*?)<\/usage>/, 1634: ) 1635: const usageContent = usageMatch?.[1] ?? '' 1636: const totalTokensMatch = usageContent.match( 1637: /<total_tokens>(\d+)<\/total_tokens>/, 1638: ) 1639: const toolUsesMatch = usageContent.match( 1640: /<tool_uses>(\d+)<\/tool_uses>/, 1641: ) 1642: const durationMsMatch = usageContent.match( 1643: /<duration_ms>(\d+)<\/duration_ms>/, 1644: ) 1645: // Only emit a task_notification SDK event when a <status> tag is 1646: // present — that means this is a terminal notification (completed/ 1647: // failed/stopped). Stream events from enqueueStreamEvent carry no 1648: // <status> (they're progress pings); emitting them here would 1649: if (statusMatch) { 1650: output.enqueue({ 1651: type: 'system', 1652: subtype: 'task_notification', 1653: task_id: taskIdMatch?.[1] ?? '', 1654: tool_use_id: toolUseIdMatch?.[1], 1655: status, 1656: output_file: outputFileMatch?.[1] ?? '', 1657: summary: summaryMatch?.[1] ?? '', 1658: usage: 1659: totalTokensMatch && toolUsesMatch 1660: ? { 1661: total_tokens: parseInt(totalTokensMatch[1]!, 10), 1662: tool_uses: parseInt(toolUsesMatch[1]!, 10), 1663: duration_ms: durationMsMatch 1664: ? parseInt(durationMsMatch[1]!, 10) 1665: : 0, 1666: } 1667: : undefined, 1668: session_id: getSessionId(), 1669: uuid: randomUUID(), 1670: }) 1671: } 1672: // No continue -- fall through to ask() so the model processes the result 1673: } 1674: const input = command.value 1675: if (structuredIO instanceof RemoteIO && command.mode === 'prompt') { 1676: logEvent('tengu_bridge_message_received', { 1677: is_repl: false, 1678: }) 1679: } 1680: suggestionState.abortController?.abort() 1681: suggestionState.abortController = null 1682: suggestionState.pendingSuggestion = null 1683: suggestionState.pendingLastEmittedEntry = null 1684: if (suggestionState.lastEmitted) { 1685: if (command.mode === 'prompt') { 1686: const inputText = 1687: typeof input === 'string' 1688: ? input 1689: : ( 1690: input.find(b => b.type === 'text') as 1691: | { type: 'text'; text: string } 1692: | undefined 1693: )?.text 1694: if (typeof inputText === 'string') { 1695: logSuggestionOutcome( 1696: suggestionState.lastEmitted.text, 1697: inputText, 1698: suggestionState.lastEmitted.emittedAt, 1699: suggestionState.lastEmitted.promptId, 1700: suggestionState.lastEmitted.generationRequestId, 1701: ) 1702: } 1703: suggestionState.lastEmitted = null 1704: } 1705: } 1706: abortController = createAbortController() 1707: const turnStartTime = feature('FILE_PERSISTENCE') 1708: ? Date.now() 1709: : undefined 1710: headlessProfilerCheckpoint('before_ask') 1711: startQueryProfile() 1712: const cmd = command 1713: await runWithWorkload(cmd.workload ?? options.workload, async () => { 1714: for await (const message of ask({ 1715: commands: uniqBy( 1716: [...currentCommands, ...appState.mcp.commands], 1717: 'name', 1718: ), 1719: prompt: input, 1720: promptUuid: cmd.uuid, 1721: isMeta: cmd.isMeta, 1722: cwd: cwd(), 1723: tools: allTools, 1724: verbose: options.verbose, 1725: mcpClients: allMcpClients, 1726: thinkingConfig: options.thinkingConfig, 1727: maxTurns: options.maxTurns, 1728: maxBudgetUsd: options.maxBudgetUsd, 1729: taskBudget: options.taskBudget, 1730: canUseTool, 1731: userSpecifiedModel: activeUserSpecifiedModel, 1732: fallbackModel: options.fallbackModel, 1733: jsonSchema: getInitJsonSchema() ?? options.jsonSchema, 1734: mutableMessages, 1735: getReadFileCache: () => 1736: pendingSeeds.size === 0 1737: ? readFileState 1738: : mergeFileStateCaches(readFileState, pendingSeeds), 1739: setReadFileCache: cache => { 1740: readFileState = cache 1741: for (const [path, seed] of pendingSeeds.entries()) { 1742: const existing = readFileState.get(path) 1743: if (!existing || seed.timestamp > existing.timestamp) { 1744: readFileState.set(path, seed) 1745: } 1746: } 1747: pendingSeeds.clear() 1748: }, 1749: customSystemPrompt: options.systemPrompt, 1750: appendSystemPrompt: options.appendSystemPrompt, 1751: getAppState, 1752: setAppState, 1753: abortController, 1754: replayUserMessages: options.replayUserMessages, 1755: includePartialMessages: options.includePartialMessages, 1756: handleElicitation: (serverName, params, elicitSignal) => 1757: structuredIO.handleElicitation( 1758: serverName, 1759: params.message, 1760: undefined, 1761: elicitSignal, 1762: params.mode, 1763: params.url, 1764: 'elicitationId' in params ? params.elicitationId : undefined, 1765: ), 1766: agents: currentAgents, 1767: orphanedPermission: cmd.orphanedPermission, 1768: setSDKStatus: status => { 1769: output.enqueue({ 1770: type: 'system', 1771: subtype: 'status', 1772: status, 1773: session_id: getSessionId(), 1774: uuid: randomUUID(), 1775: }) 1776: }, 1777: })) { 1778: forwardMessagesToBridge() 1779: if (message.type === 'result') { 1780: for (const event of drainSdkEvents()) { 1781: output.enqueue(event) 1782: } 1783: const currentState = getAppState() 1784: if ( 1785: getRunningTasks(currentState).some( 1786: t => 1787: (t.type === 'local_agent' || 1788: t.type === 'local_workflow') && 1789: isBackgroundTask(t), 1790: ) 1791: ) { 1792: heldBackResult = message 1793: } else { 1794: heldBackResult = null 1795: output.enqueue(message) 1796: } 1797: } else { 1798: for (const event of drainSdkEvents()) { 1799: output.enqueue(event) 1800: } 1801: output.enqueue(message) 1802: } 1803: } 1804: }) 1805: for (const uuid of batchUuids) { 1806: notifyCommandLifecycle(uuid, 'completed') 1807: } 1808: forwardMessagesToBridge() 1809: bridgeHandle?.sendResult() 1810: if (feature('FILE_PERSISTENCE') && turnStartTime !== undefined) { 1811: void executeFilePersistence( 1812: turnStartTime, 1813: abortController.signal, 1814: result => { 1815: output.enqueue({ 1816: type: 'system' as const, 1817: subtype: 'files_persisted' as const, 1818: files: result.files, 1819: failed: result.failed, 1820: processed_at: new Date().toISOString(), 1821: uuid: randomUUID(), 1822: session_id: getSessionId(), 1823: }) 1824: }, 1825: ) 1826: } 1827: if ( 1828: options.promptSuggestions && 1829: !isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION) 1830: ) { 1831: const state = suggestionState as unknown as typeof suggestionState 1832: state.abortController?.abort() 1833: const localAbort = new AbortController() 1834: suggestionState.abortController = localAbort 1835: const cacheSafeParams = getLastCacheSafeParams() 1836: if (!cacheSafeParams) { 1837: logSuggestionSuppressed( 1838: 'sdk_no_params', 1839: undefined, 1840: undefined, 1841: 'sdk', 1842: ) 1843: } else { 1844: const ref: { promise: Promise<void> | null } = { promise: null } 1845: ref.promise = (async () => { 1846: try { 1847: const result = await tryGenerateSuggestion( 1848: localAbort, 1849: mutableMessages, 1850: getAppState, 1851: cacheSafeParams, 1852: 'sdk', 1853: ) 1854: if (!result || localAbort.signal.aborted) return 1855: const suggestionMsg = { 1856: type: 'prompt_suggestion' as const, 1857: suggestion: result.suggestion, 1858: uuid: randomUUID(), 1859: session_id: getSessionId(), 1860: } 1861: const lastEmittedEntry = { 1862: text: result.suggestion, 1863: emittedAt: Date.now(), 1864: promptId: result.promptId, 1865: generationRequestId: result.generationRequestId, 1866: } 1867: if (heldBackResult) { 1868: suggestionState.pendingSuggestion = suggestionMsg 1869: suggestionState.pendingLastEmittedEntry = { 1870: text: lastEmittedEntry.text, 1871: promptId: lastEmittedEntry.promptId, 1872: generationRequestId: lastEmittedEntry.generationRequestId, 1873: } 1874: } else { 1875: suggestionState.lastEmitted = lastEmittedEntry 1876: output.enqueue(suggestionMsg) 1877: } 1878: } catch (error) { 1879: if ( 1880: error instanceof Error && 1881: (error.name === 'AbortError' || 1882: error.name === 'APIUserAbortError') 1883: ) { 1884: logSuggestionSuppressed( 1885: 'aborted', 1886: undefined, 1887: undefined, 1888: 'sdk', 1889: ) 1890: return 1891: } 1892: logError(toError(error)) 1893: } finally { 1894: if (suggestionState.inflightPromise === ref.promise) { 1895: suggestionState.inflightPromise = null 1896: } 1897: } 1898: })() 1899: suggestionState.inflightPromise = ref.promise 1900: } 1901: } 1902: logHeadlessProfilerTurn() 1903: logQueryProfileReport() 1904: headlessProfilerStartTurn() 1905: } 1906: } 1907: do { 1908: for (const event of drainSdkEvents()) { 1909: output.enqueue(event) 1910: } 1911: runPhase = 'draining_commands' 1912: await drainCommandQueue() 1913: waitingForAgents = false 1914: { 1915: const state = getAppState() 1916: const hasRunningBg = getRunningTasks(state).some( 1917: t => isBackgroundTask(t) && t.type !== 'in_process_teammate', 1918: ) 1919: const hasMainThreadQueued = peek(isMainThread) !== undefined 1920: if (hasRunningBg || hasMainThreadQueued) { 1921: waitingForAgents = true 1922: if (!hasMainThreadQueued) { 1923: runPhase = 'waiting_for_agents' 1924: await sleep(100) 1925: } 1926: } 1927: } 1928: } while (waitingForAgents) 1929: if (heldBackResult) { 1930: output.enqueue(heldBackResult) 1931: heldBackResult = null 1932: if (suggestionState.pendingSuggestion) { 1933: output.enqueue(suggestionState.pendingSuggestion) 1934: if (suggestionState.pendingLastEmittedEntry) { 1935: suggestionState.lastEmitted = { 1936: ...suggestionState.pendingLastEmittedEntry, 1937: emittedAt: Date.now(), 1938: } 1939: suggestionState.pendingLastEmittedEntry = null 1940: } 1941: suggestionState.pendingSuggestion = null 1942: } 1943: } 1944: } catch (error) { 1945: try { 1946: await structuredIO.write({ 1947: type: 'result', 1948: subtype: 'error_during_execution', 1949: duration_ms: 0, 1950: duration_api_ms: 0, 1951: is_error: true, 1952: num_turns: 0, 1953: stop_reason: null, 1954: session_id: getSessionId(), 1955: total_cost_usd: 0, 1956: usage: EMPTY_USAGE, 1957: modelUsage: {}, 1958: permission_denials: [], 1959: uuid: randomUUID(), 1960: errors: [ 1961: errorMessage(error), 1962: ...getInMemoryErrors().map(_ => _.error), 1963: ], 1964: }) 1965: } catch { 1966: } 1967: suggestionState.abortController?.abort() 1968: gracefulShutdownSync(1) 1969: return 1970: } finally { 1971: runPhase = 'finally_flush' 1972: await structuredIO.flushInternalEvents() 1973: runPhase = 'finally_post_flush' 1974: if (!isShuttingDown()) { 1975: notifySessionStateChanged('idle') 1976: for (const event of drainSdkEvents()) { 1977: output.enqueue(event) 1978: } 1979: } 1980: running = false 1981: idleTimeout.start() 1982: } 1983: if ( 1984: (feature('PROACTIVE') || feature('KAIROS')) && 1985: proactiveModule?.isProactiveActive() && 1986: !proactiveModule.isProactivePaused() 1987: ) { 1988: if (peek(isMainThread) === undefined && !inputClosed) { 1989: scheduleProactiveTick!() 1990: return 1991: } 1992: } 1993: if (peek(isMainThread) !== undefined) { 1994: void run() 1995: return 1996: } 1997: { 1998: const currentAppState = getAppState() 1999: const teamContext = currentAppState.teamContext 2000: if (teamContext && isTeamLead(teamContext)) { 2001: const agentName = 'team-lead' 2002: const POLL_INTERVAL_MS = 500 2003: while (true) { 2004: const refreshedState = getAppState() 2005: const hasActiveTeammates = 2006: hasActiveInProcessTeammates(refreshedState) || 2007: (refreshedState.teamContext && 2008: Object.keys(refreshedState.teamContext.teammates).length > 0) 2009: if (!hasActiveTeammates) { 2010: logForDebugging( 2011: '[print.ts] No more active teammates, stopping poll', 2012: ) 2013: break 2014: } 2015: const unread = await readUnreadMessages( 2016: agentName, 2017: refreshedState.teamContext?.teamName, 2018: ) 2019: if (unread.length > 0) { 2020: logForDebugging( 2021: `[print.ts] Team-lead found ${unread.length} unread messages`, 2022: ) 2023: await markMessagesAsRead( 2024: agentName, 2025: refreshedState.teamContext?.teamName, 2026: ) 2027: const teamName = refreshedState.teamContext?.teamName 2028: for (const m of unread) { 2029: const shutdownApproval = isShutdownApproved(m.text) 2030: if (shutdownApproval && teamName) { 2031: const teammateToRemove = shutdownApproval.from 2032: logForDebugging( 2033: `[print.ts] Processing shutdown_approved from ${teammateToRemove}`, 2034: ) 2035: const teammateId = refreshedState.teamContext?.teammates 2036: ? Object.entries(refreshedState.teamContext.teammates).find( 2037: ([, t]) => t.name === teammateToRemove, 2038: )?.[0] 2039: : undefined 2040: if (teammateId) { 2041: removeTeammateFromTeamFile(teamName, { 2042: agentId: teammateId, 2043: name: teammateToRemove, 2044: }) 2045: logForDebugging( 2046: `[print.ts] Removed ${teammateToRemove} from team file`, 2047: ) 2048: await unassignTeammateTasks( 2049: teamName, 2050: teammateId, 2051: teammateToRemove, 2052: 'shutdown', 2053: ) 2054: setAppState(prev => { 2055: if (!prev.teamContext?.teammates) return prev 2056: if (!(teammateId in prev.teamContext.teammates)) return prev 2057: const { [teammateId]: _, ...remainingTeammates } = 2058: prev.teamContext.teammates 2059: return { 2060: ...prev, 2061: teamContext: { 2062: ...prev.teamContext, 2063: teammates: remainingTeammates, 2064: }, 2065: } 2066: }) 2067: } 2068: } 2069: } 2070: const formatted = unread 2071: .map( 2072: (m: { from: string; text: string; color?: string }) => 2073: `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${m.color ? ` color="${m.color}"` : ''}>\n${m.text}\n</${TEAMMATE_MESSAGE_TAG}>`, 2074: ) 2075: .join('\n\n') 2076: enqueue({ 2077: mode: 'prompt', 2078: value: formatted, 2079: uuid: randomUUID(), 2080: }) 2081: void run() 2082: return 2083: } 2084: if (inputClosed && !shutdownPromptInjected) { 2085: shutdownPromptInjected = true 2086: logForDebugging( 2087: '[print.ts] Input closed with active teammates, injecting shutdown prompt', 2088: ) 2089: enqueue({ 2090: mode: 'prompt', 2091: value: SHUTDOWN_TEAM_PROMPT, 2092: uuid: randomUUID(), 2093: }) 2094: void run() 2095: return 2096: } 2097: await sleep(POLL_INTERVAL_MS) 2098: } 2099: } 2100: } 2101: if (inputClosed) { 2102: const hasActiveSwarm = await (async () => { 2103: const currentAppState = getAppState() 2104: if (hasWorkingInProcessTeammates(currentAppState)) { 2105: await waitForTeammatesToBecomeIdle(setAppState, currentAppState) 2106: } 2107: const refreshedAppState = getAppState() 2108: const refreshedTeamContext = refreshedAppState.teamContext 2109: const hasTeamMembersNotCleanedUp = 2110: refreshedTeamContext && 2111: Object.keys(refreshedTeamContext.teammates).length > 0 2112: return ( 2113: hasTeamMembersNotCleanedUp || 2114: hasActiveInProcessTeammates(refreshedAppState) 2115: ) 2116: })() 2117: if (hasActiveSwarm) { 2118: enqueue({ 2119: mode: 'prompt', 2120: value: SHUTDOWN_TEAM_PROMPT, 2121: uuid: randomUUID(), 2122: }) 2123: void run() 2124: } else { 2125: if (suggestionState.inflightPromise) { 2126: await Promise.race([suggestionState.inflightPromise, sleep(5000)]) 2127: } 2128: suggestionState.abortController?.abort() 2129: suggestionState.abortController = null 2130: await finalizePendingAsyncHooks() 2131: unsubscribeSkillChanges() 2132: unsubscribeAuthStatus?.() 2133: statusListeners.delete(rateLimitListener) 2134: output.done() 2135: } 2136: } 2137: } 2138: if (feature('UDS_INBOX')) { 2139: const { setOnEnqueue } = require('../utils/udsMessaging.js') 2140: setOnEnqueue(() => { 2141: if (!inputClosed) { 2142: void run() 2143: } 2144: }) 2145: } 2146: let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null = 2147: null 2148: if ( 2149: feature('AGENT_TRIGGERS') && 2150: cronSchedulerModule && 2151: cronGate?.isKairosCronEnabled() 2152: ) { 2153: cronScheduler = cronSchedulerModule.createCronScheduler({ 2154: onFire: prompt => { 2155: if (inputClosed) return 2156: enqueue({ 2157: mode: 'prompt', 2158: value: prompt, 2159: uuid: randomUUID(), 2160: priority: 'later', 2161: isMeta: true, 2162: workload: WORKLOAD_CRON, 2163: }) 2164: void run() 2165: }, 2166: isLoading: () => running || inputClosed, 2167: getJitterConfig: cronJitterConfigModule?.getCronJitterConfig, 2168: isKilled: () => !cronGate?.isKairosCronEnabled(), 2169: }) 2170: cronScheduler.start() 2171: } 2172: const sendControlResponseSuccess = function ( 2173: message: SDKControlRequest, 2174: response?: Record<string, unknown>, 2175: ) { 2176: output.enqueue({ 2177: type: 'control_response', 2178: response: { 2179: subtype: 'success', 2180: request_id: message.request_id, 2181: response: response, 2182: }, 2183: }) 2184: } 2185: const sendControlResponseError = function ( 2186: message: SDKControlRequest, 2187: errorMessage: string, 2188: ) { 2189: output.enqueue({ 2190: type: 'control_response', 2191: response: { 2192: subtype: 'error', 2193: request_id: message.request_id, 2194: error: errorMessage, 2195: }, 2196: }) 2197: } 2198: const handledOrphanedToolUseIds = new Set<string>() 2199: structuredIO.setUnexpectedResponseCallback(async message => { 2200: await handleOrphanedPermissionResponse({ 2201: message, 2202: setAppState, 2203: handledToolUseIds: handledOrphanedToolUseIds, 2204: onEnqueued: () => { 2205: void run() 2206: }, 2207: }) 2208: }) 2209: const activeOAuthFlows = new Map<string, AbortController>() 2210: const oauthCallbackSubmitters = new Map< 2211: string, 2212: (callbackUrl: string) => void 2213: >() 2214: const oauthManualCallbackUsed = new Set<string>() 2215: const oauthAuthPromises = new Map<string, Promise<void>>() 2216: let claudeOAuth: { 2217: service: OAuthService 2218: flow: Promise<void> 2219: } | null = null 2220: void (async () => { 2221: let initialized = false 2222: logForDiagnosticsNoPII('info', 'cli_message_loop_started') 2223: for await (const message of structuredIO.structuredInput) { 2224: const eventId = 'uuid' in message ? message.uuid : undefined 2225: if ( 2226: eventId && 2227: message.type !== 'user' && 2228: message.type !== 'control_response' 2229: ) { 2230: notifyCommandLifecycle(eventId, 'completed') 2231: } 2232: if (message.type === 'control_request') { 2233: if (message.request.subtype === 'interrupt') { 2234: if (feature('COMMIT_ATTRIBUTION')) { 2235: setAppState(prev => ({ 2236: ...prev, 2237: attribution: { 2238: ...prev.attribution, 2239: escapeCount: prev.attribution.escapeCount + 1, 2240: }, 2241: })) 2242: } 2243: if (abortController) { 2244: abortController.abort() 2245: } 2246: suggestionState.abortController?.abort() 2247: suggestionState.abortController = null 2248: suggestionState.lastEmitted = null 2249: suggestionState.pendingSuggestion = null 2250: sendControlResponseSuccess(message) 2251: } else if (message.request.subtype === 'end_session') { 2252: logForDebugging( 2253: `[print.ts] end_session received, reason=${message.request.reason ?? 'unspecified'}`, 2254: ) 2255: if (abortController) { 2256: abortController.abort() 2257: } 2258: suggestionState.abortController?.abort() 2259: suggestionState.abortController = null 2260: suggestionState.lastEmitted = null 2261: suggestionState.pendingSuggestion = null 2262: sendControlResponseSuccess(message) 2263: break 2264: } else if (message.request.subtype === 'initialize') { 2265: if ( 2266: message.request.sdkMcpServers && 2267: message.request.sdkMcpServers.length > 0 2268: ) { 2269: for (const serverName of message.request.sdkMcpServers) { 2270: sdkMcpConfigs[serverName] = { 2271: type: 'sdk', 2272: name: serverName, 2273: } 2274: } 2275: } 2276: await handleInitializeRequest( 2277: message.request, 2278: message.request_id, 2279: initialized, 2280: output, 2281: commands, 2282: modelInfos, 2283: structuredIO, 2284: !!options.enableAuthStatus, 2285: options, 2286: agents, 2287: getAppState, 2288: ) 2289: if (message.request.promptSuggestions) { 2290: setAppState(prev => { 2291: if (prev.promptSuggestionEnabled) return prev 2292: return { ...prev, promptSuggestionEnabled: true } 2293: }) 2294: } 2295: if ( 2296: message.request.agentProgressSummaries && 2297: getFeatureValue_CACHED_MAY_BE_STALE('tengu_slate_prism', true) 2298: ) { 2299: setSdkAgentProgressSummariesEnabled(true) 2300: } 2301: initialized = true 2302: if (hasCommandsInQueue()) { 2303: void run() 2304: } 2305: } else if (message.request.subtype === 'set_permission_mode') { 2306: const m = message.request 2307: setAppState(prev => ({ 2308: ...prev, 2309: toolPermissionContext: handleSetPermissionMode( 2310: m, 2311: message.request_id, 2312: prev.toolPermissionContext, 2313: output, 2314: ), 2315: isUltraplanMode: m.ultraplan ?? prev.isUltraplanMode, 2316: })) 2317: } else if (message.request.subtype === 'set_model') { 2318: const requestedModel = message.request.model ?? 'default' 2319: const model = 2320: requestedModel === 'default' 2321: ? getDefaultMainLoopModel() 2322: : requestedModel 2323: activeUserSpecifiedModel = model 2324: setMainLoopModelOverride(model) 2325: notifySessionMetadataChanged({ model }) 2326: injectModelSwitchBreadcrumbs(requestedModel, model) 2327: sendControlResponseSuccess(message) 2328: } else if (message.request.subtype === 'set_max_thinking_tokens') { 2329: if (message.request.max_thinking_tokens === null) { 2330: options.thinkingConfig = undefined 2331: } else if (message.request.max_thinking_tokens === 0) { 2332: options.thinkingConfig = { type: 'disabled' } 2333: } else { 2334: options.thinkingConfig = { 2335: type: 'enabled', 2336: budgetTokens: message.request.max_thinking_tokens, 2337: } 2338: } 2339: sendControlResponseSuccess(message) 2340: } else if (message.request.subtype === 'mcp_status') { 2341: sendControlResponseSuccess(message, { 2342: mcpServers: buildMcpServerStatuses(), 2343: }) 2344: } else if (message.request.subtype === 'get_context_usage') { 2345: try { 2346: const appState = getAppState() 2347: const data = await collectContextData({ 2348: messages: mutableMessages, 2349: getAppState, 2350: options: { 2351: mainLoopModel: getMainLoopModel(), 2352: tools: buildAllTools(appState), 2353: agentDefinitions: appState.agentDefinitions, 2354: customSystemPrompt: options.systemPrompt, 2355: appendSystemPrompt: options.appendSystemPrompt, 2356: }, 2357: }) 2358: sendControlResponseSuccess(message, { ...data }) 2359: } catch (error) { 2360: sendControlResponseError(message, errorMessage(error)) 2361: } 2362: } else if (message.request.subtype === 'mcp_message') { 2363: const mcpRequest = message.request 2364: const sdkClient = sdkClients.find( 2365: client => client.name === mcpRequest.server_name, 2366: ) 2367: if ( 2368: sdkClient && 2369: sdkClient.type === 'connected' && 2370: sdkClient.client?.transport?.onmessage 2371: ) { 2372: sdkClient.client.transport.onmessage(mcpRequest.message) 2373: } 2374: sendControlResponseSuccess(message) 2375: } else if (message.request.subtype === 'rewind_files') { 2376: const appState = getAppState() 2377: const result = await handleRewindFiles( 2378: message.request.user_message_id as UUID, 2379: appState, 2380: setAppState, 2381: message.request.dry_run ?? false, 2382: ) 2383: if (result.canRewind || message.request.dry_run) { 2384: sendControlResponseSuccess(message, result) 2385: } else { 2386: sendControlResponseError( 2387: message, 2388: result.error ?? 'Unexpected error', 2389: ) 2390: } 2391: } else if (message.request.subtype === 'cancel_async_message') { 2392: const targetUuid = message.request.message_uuid 2393: const removed = dequeueAllMatching(cmd => cmd.uuid === targetUuid) 2394: sendControlResponseSuccess(message, { 2395: cancelled: removed.length > 0, 2396: }) 2397: } else if (message.request.subtype === 'seed_read_state') { 2398: try { 2399: const normalizedPath = expandPath(message.request.path) 2400: const diskMtime = Math.floor((await stat(normalizedPath)).mtimeMs) 2401: if (diskMtime <= message.request.mtime) { 2402: const raw = await readFile(normalizedPath, 'utf-8') 2403: const content = ( 2404: raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw 2405: ).replaceAll('\r\n', '\n') 2406: pendingSeeds.set(normalizedPath, { 2407: content, 2408: timestamp: diskMtime, 2409: offset: undefined, 2410: limit: undefined, 2411: }) 2412: } 2413: } catch { 2414: } 2415: sendControlResponseSuccess(message) 2416: } else if (message.request.subtype === 'mcp_set_servers') { 2417: const { response, sdkServersChanged } = await applyMcpServerChanges( 2418: message.request.servers, 2419: ) 2420: sendControlResponseSuccess(message, response) 2421: if (sdkServersChanged) { 2422: void updateSdkMcp() 2423: } 2424: } else if (message.request.subtype === 'reload_plugins') { 2425: try { 2426: if ( 2427: feature('DOWNLOAD_USER_SETTINGS') && 2428: (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) || getIsRemoteMode()) 2429: ) { 2430: const applied = await redownloadUserSettings() 2431: if (applied) { 2432: settingsChangeDetector.notifyChange('userSettings') 2433: } 2434: } 2435: const r = await refreshActivePlugins(setAppState) 2436: const sdkAgents = currentAgents.filter( 2437: a => a.source === 'flagSettings', 2438: ) 2439: currentAgents = [...r.agentDefinitions.allAgents, ...sdkAgents] 2440: let plugins: SDKControlReloadPluginsResponse['plugins'] = [] 2441: const [cmdsR, mcpR, pluginsR] = await Promise.allSettled([ 2442: getCommands(cwd()), 2443: applyPluginMcpDiff(), 2444: loadAllPluginsCacheOnly(), 2445: ]) 2446: if (cmdsR.status === 'fulfilled') { 2447: currentCommands = cmdsR.value 2448: } else { 2449: logError(cmdsR.reason) 2450: } 2451: if (mcpR.status === 'rejected') { 2452: logError(mcpR.reason) 2453: } 2454: if (pluginsR.status === 'fulfilled') { 2455: plugins = pluginsR.value.enabled.map(p => ({ 2456: name: p.name, 2457: path: p.path, 2458: source: p.source, 2459: })) 2460: } else { 2461: logError(pluginsR.reason) 2462: } 2463: sendControlResponseSuccess(message, { 2464: commands: currentCommands 2465: .filter(cmd => cmd.userInvocable !== false) 2466: .map(cmd => ({ 2467: name: getCommandName(cmd), 2468: description: formatDescriptionWithSource(cmd), 2469: argumentHint: cmd.argumentHint || '', 2470: })), 2471: agents: currentAgents.map(a => ({ 2472: name: a.agentType, 2473: description: a.whenToUse, 2474: model: a.model === 'inherit' ? undefined : a.model, 2475: })), 2476: plugins, 2477: mcpServers: buildMcpServerStatuses(), 2478: error_count: r.error_count, 2479: } satisfies SDKControlReloadPluginsResponse) 2480: } catch (error) { 2481: sendControlResponseError(message, errorMessage(error)) 2482: } 2483: } else if (message.request.subtype === 'mcp_reconnect') { 2484: const currentAppState = getAppState() 2485: const { serverName } = message.request 2486: elicitationRegistered.delete(serverName) 2487: const config = 2488: getMcpConfigByName(serverName) ?? 2489: mcpClients.find(c => c.name === serverName)?.config ?? 2490: sdkClients.find(c => c.name === serverName)?.config ?? 2491: dynamicMcpState.clients.find(c => c.name === serverName)?.config ?? 2492: currentAppState.mcp.clients.find(c => c.name === serverName) 2493: ?.config ?? 2494: null 2495: if (!config) { 2496: sendControlResponseError(message, `Server not found: ${serverName}`) 2497: } else { 2498: const result = await reconnectMcpServerImpl(serverName, config) 2499: const prefix = getMcpPrefix(serverName) 2500: setAppState(prev => ({ 2501: ...prev, 2502: mcp: { 2503: ...prev.mcp, 2504: clients: prev.mcp.clients.map(c => 2505: c.name === serverName ? result.client : c, 2506: ), 2507: tools: [ 2508: ...reject(prev.mcp.tools, t => t.name?.startsWith(prefix)), 2509: ...result.tools, 2510: ], 2511: commands: [ 2512: ...reject(prev.mcp.commands, c => 2513: commandBelongsToServer(c, serverName), 2514: ), 2515: ...result.commands, 2516: ], 2517: resources: 2518: result.resources && result.resources.length > 0 2519: ? { ...prev.mcp.resources, [serverName]: result.resources } 2520: : omit(prev.mcp.resources, serverName), 2521: }, 2522: })) 2523: dynamicMcpState = { 2524: ...dynamicMcpState, 2525: clients: [ 2526: ...dynamicMcpState.clients.filter(c => c.name !== serverName), 2527: result.client, 2528: ], 2529: tools: [ 2530: ...dynamicMcpState.tools.filter( 2531: t => !t.name?.startsWith(prefix), 2532: ), 2533: ...result.tools, 2534: ], 2535: } 2536: if (result.client.type === 'connected') { 2537: registerElicitationHandlers([result.client]) 2538: reregisterChannelHandlerAfterReconnect(result.client) 2539: sendControlResponseSuccess(message) 2540: } else { 2541: const errorMessage = 2542: result.client.type === 'failed' 2543: ? (result.client.error ?? 'Connection failed') 2544: : `Server status: ${result.client.type}` 2545: sendControlResponseError(message, errorMessage) 2546: } 2547: } 2548: } else if (message.request.subtype === 'mcp_toggle') { 2549: const currentAppState = getAppState() 2550: const { serverName, enabled } = message.request 2551: elicitationRegistered.delete(serverName) 2552: const config = 2553: getMcpConfigByName(serverName) ?? 2554: mcpClients.find(c => c.name === serverName)?.config ?? 2555: sdkClients.find(c => c.name === serverName)?.config ?? 2556: dynamicMcpState.clients.find(c => c.name === serverName)?.config ?? 2557: currentAppState.mcp.clients.find(c => c.name === serverName) 2558: ?.config ?? 2559: null 2560: if (!config) { 2561: sendControlResponseError(message, `Server not found: ${serverName}`) 2562: } else if (!enabled) { 2563: setMcpServerEnabled(serverName, false) 2564: const client = [ 2565: ...mcpClients, 2566: ...sdkClients, 2567: ...dynamicMcpState.clients, 2568: ...currentAppState.mcp.clients, 2569: ].find(c => c.name === serverName) 2570: if (client && client.type === 'connected') { 2571: await clearServerCache(serverName, config) 2572: } 2573: const prefix = getMcpPrefix(serverName) 2574: setAppState(prev => ({ 2575: ...prev, 2576: mcp: { 2577: ...prev.mcp, 2578: clients: prev.mcp.clients.map(c => 2579: c.name === serverName 2580: ? { name: serverName, type: 'disabled' as const, config } 2581: : c, 2582: ), 2583: tools: reject(prev.mcp.tools, t => t.name?.startsWith(prefix)), 2584: commands: reject(prev.mcp.commands, c => 2585: commandBelongsToServer(c, serverName), 2586: ), 2587: resources: omit(prev.mcp.resources, serverName), 2588: }, 2589: })) 2590: sendControlResponseSuccess(message) 2591: } else { 2592: setMcpServerEnabled(serverName, true) 2593: const result = await reconnectMcpServerImpl(serverName, config) 2594: const prefix = getMcpPrefix(serverName) 2595: setAppState(prev => ({ 2596: ...prev, 2597: mcp: { 2598: ...prev.mcp, 2599: clients: prev.mcp.clients.map(c => 2600: c.name === serverName ? result.client : c, 2601: ), 2602: tools: [ 2603: ...reject(prev.mcp.tools, t => t.name?.startsWith(prefix)), 2604: ...result.tools, 2605: ], 2606: commands: [ 2607: ...reject(prev.mcp.commands, c => 2608: commandBelongsToServer(c, serverName), 2609: ), 2610: ...result.commands, 2611: ], 2612: resources: 2613: result.resources && result.resources.length > 0 2614: ? { ...prev.mcp.resources, [serverName]: result.resources } 2615: : omit(prev.mcp.resources, serverName), 2616: }, 2617: })) 2618: if (result.client.type === 'connected') { 2619: registerElicitationHandlers([result.client]) 2620: reregisterChannelHandlerAfterReconnect(result.client) 2621: sendControlResponseSuccess(message) 2622: } else { 2623: const errorMessage = 2624: result.client.type === 'failed' 2625: ? (result.client.error ?? 'Connection failed') 2626: : `Server status: ${result.client.type}` 2627: sendControlResponseError(message, errorMessage) 2628: } 2629: } 2630: } else if (message.request.subtype === 'channel_enable') { 2631: const currentAppState = getAppState() 2632: handleChannelEnable( 2633: message.request_id, 2634: message.request.serverName, 2635: [ 2636: ...currentAppState.mcp.clients, 2637: ...sdkClients, 2638: ...dynamicMcpState.clients, 2639: ], 2640: output, 2641: ) 2642: } else if (message.request.subtype === 'mcp_authenticate') { 2643: const { serverName } = message.request 2644: const currentAppState = getAppState() 2645: const config = 2646: getMcpConfigByName(serverName) ?? 2647: mcpClients.find(c => c.name === serverName)?.config ?? 2648: currentAppState.mcp.clients.find(c => c.name === serverName) 2649: ?.config ?? 2650: null 2651: if (!config) { 2652: sendControlResponseError(message, `Server not found: ${serverName}`) 2653: } else if (config.type !== 'sse' && config.type !== 'http') { 2654: sendControlResponseError( 2655: message, 2656: `Server type "${config.type}" does not support OAuth authentication`, 2657: ) 2658: } else { 2659: try { 2660: activeOAuthFlows.get(serverName)?.abort() 2661: const controller = new AbortController() 2662: activeOAuthFlows.set(serverName, controller) 2663: let resolveAuthUrl: (url: string) => void 2664: const authUrlPromise = new Promise<string>(resolve => { 2665: resolveAuthUrl = resolve 2666: }) 2667: const oauthPromise = performMCPOAuthFlow( 2668: serverName, 2669: config, 2670: url => resolveAuthUrl!(url), 2671: controller.signal, 2672: { 2673: skipBrowserOpen: true, 2674: onWaitingForCallback: submit => { 2675: oauthCallbackSubmitters.set(serverName, submit) 2676: }, 2677: }, 2678: ) 2679: const authUrl = await Promise.race([ 2680: authUrlPromise, 2681: oauthPromise.then(() => null as string | null), 2682: ]) 2683: if (authUrl) { 2684: sendControlResponseSuccess(message, { 2685: authUrl, 2686: requiresUserAction: true, 2687: }) 2688: } else { 2689: sendControlResponseSuccess(message, { 2690: requiresUserAction: false, 2691: }) 2692: } 2693: oauthAuthPromises.set(serverName, oauthPromise) 2694: const fullFlowPromise = oauthPromise 2695: .then(async () => { 2696: if (isMcpServerDisabled(serverName)) { 2697: return 2698: } 2699: if (oauthManualCallbackUsed.has(serverName)) { 2700: return 2701: } 2702: const result = await reconnectMcpServerImpl( 2703: serverName, 2704: config, 2705: ) 2706: const prefix = getMcpPrefix(serverName) 2707: setAppState(prev => ({ 2708: ...prev, 2709: mcp: { 2710: ...prev.mcp, 2711: clients: prev.mcp.clients.map(c => 2712: c.name === serverName ? result.client : c, 2713: ), 2714: tools: [ 2715: ...reject(prev.mcp.tools, t => 2716: t.name?.startsWith(prefix), 2717: ), 2718: ...result.tools, 2719: ], 2720: commands: [ 2721: ...reject(prev.mcp.commands, c => 2722: commandBelongsToServer(c, serverName), 2723: ), 2724: ...result.commands, 2725: ], 2726: resources: 2727: result.resources && result.resources.length > 0 2728: ? { 2729: ...prev.mcp.resources, 2730: [serverName]: result.resources, 2731: } 2732: : omit(prev.mcp.resources, serverName), 2733: }, 2734: })) 2735: dynamicMcpState = { 2736: ...dynamicMcpState, 2737: clients: [ 2738: ...dynamicMcpState.clients.filter( 2739: c => c.name !== serverName, 2740: ), 2741: result.client, 2742: ], 2743: tools: [ 2744: ...dynamicMcpState.tools.filter( 2745: t => !t.name?.startsWith(prefix), 2746: ), 2747: ...result.tools, 2748: ], 2749: } 2750: }) 2751: .catch(error => { 2752: logForDebugging( 2753: `MCP OAuth failed for ${serverName}: ${error}`, 2754: { level: 'error' }, 2755: ) 2756: }) 2757: .finally(() => { 2758: if (activeOAuthFlows.get(serverName) === controller) { 2759: activeOAuthFlows.delete(serverName) 2760: oauthCallbackSubmitters.delete(serverName) 2761: oauthManualCallbackUsed.delete(serverName) 2762: oauthAuthPromises.delete(serverName) 2763: } 2764: }) 2765: void fullFlowPromise 2766: } catch (error) { 2767: sendControlResponseError(message, errorMessage(error)) 2768: } 2769: } 2770: } else if (message.request.subtype === 'mcp_oauth_callback_url') { 2771: const { serverName, callbackUrl } = message.request 2772: const submit = oauthCallbackSubmitters.get(serverName) 2773: if (submit) { 2774: let hasCodeOrError = false 2775: try { 2776: const parsed = new URL(callbackUrl) 2777: hasCodeOrError = 2778: parsed.searchParams.has('code') || 2779: parsed.searchParams.has('error') 2780: } catch { 2781: } 2782: if (!hasCodeOrError) { 2783: sendControlResponseError( 2784: message, 2785: 'Invalid callback URL: missing authorization code. Please paste the full redirect URL including the code parameter.', 2786: ) 2787: } else { 2788: oauthManualCallbackUsed.add(serverName) 2789: submit(callbackUrl) 2790: const authPromise = oauthAuthPromises.get(serverName) 2791: if (authPromise) { 2792: try { 2793: await authPromise 2794: sendControlResponseSuccess(message) 2795: } catch (error) { 2796: sendControlResponseError( 2797: message, 2798: error instanceof Error 2799: ? error.message 2800: : 'OAuth authentication failed', 2801: ) 2802: } 2803: } else { 2804: sendControlResponseSuccess(message) 2805: } 2806: } 2807: } else { 2808: sendControlResponseError( 2809: message, 2810: `No active OAuth flow for server: ${serverName}`, 2811: ) 2812: } 2813: } else if (message.request.subtype === 'claude_authenticate') { 2814: const { loginWithClaudeAi } = message.request 2815: claudeOAuth?.service.cleanup() 2816: logEvent('tengu_oauth_flow_start', { 2817: loginWithClaudeAi: loginWithClaudeAi ?? true, 2818: }) 2819: const service = new OAuthService() 2820: let urlResolver!: (urls: { 2821: manualUrl: string 2822: automaticUrl: string 2823: }) => void 2824: const urlPromise = new Promise<{ 2825: manualUrl: string 2826: automaticUrl: string 2827: }>(resolve => { 2828: urlResolver = resolve 2829: }) 2830: const flow = service 2831: .startOAuthFlow( 2832: async (manualUrl, automaticUrl) => { 2833: urlResolver({ manualUrl, automaticUrl: automaticUrl! }) 2834: }, 2835: { 2836: loginWithClaudeAi: loginWithClaudeAi ?? true, 2837: skipBrowserOpen: true, 2838: }, 2839: ) 2840: .then(async tokens => { 2841: await installOAuthTokens(tokens) 2842: logEvent('tengu_oauth_success', { 2843: loginWithClaudeAi: loginWithClaudeAi ?? true, 2844: }) 2845: }) 2846: .finally(() => { 2847: service.cleanup() 2848: if (claudeOAuth?.service === service) { 2849: claudeOAuth = null 2850: } 2851: }) 2852: claudeOAuth = { service, flow } 2853: void flow.catch(err => 2854: logForDebugging(`claude_authenticate flow ended: ${err}`, { 2855: level: 'info', 2856: }), 2857: ) 2858: try { 2859: const { manualUrl, automaticUrl } = await Promise.race([ 2860: urlPromise, 2861: flow.then(() => { 2862: throw new Error( 2863: 'OAuth flow completed without producing auth URLs', 2864: ) 2865: }), 2866: ]) 2867: sendControlResponseSuccess(message, { 2868: manualUrl, 2869: automaticUrl, 2870: }) 2871: } catch (error) { 2872: sendControlResponseError(message, errorMessage(error)) 2873: } 2874: } else if ( 2875: message.request.subtype === 'claude_oauth_callback' || 2876: message.request.subtype === 'claude_oauth_wait_for_completion' 2877: ) { 2878: if (!claudeOAuth) { 2879: sendControlResponseError( 2880: message, 2881: 'No active claude_authenticate flow', 2882: ) 2883: } else { 2884: if (message.request.subtype === 'claude_oauth_callback') { 2885: claudeOAuth.service.handleManualAuthCodeInput({ 2886: authorizationCode: message.request.authorizationCode, 2887: state: message.request.state, 2888: }) 2889: } 2890: const { flow } = claudeOAuth 2891: void flow.then( 2892: () => { 2893: const accountInfo = getAccountInformation() 2894: sendControlResponseSuccess(message, { 2895: account: { 2896: email: accountInfo?.email, 2897: organization: accountInfo?.organization, 2898: subscriptionType: accountInfo?.subscription, 2899: tokenSource: accountInfo?.tokenSource, 2900: apiKeySource: accountInfo?.apiKeySource, 2901: apiProvider: getAPIProvider(), 2902: }, 2903: }) 2904: }, 2905: (error: unknown) => 2906: sendControlResponseError(message, errorMessage(error)), 2907: ) 2908: } 2909: } else if (message.request.subtype === 'mcp_clear_auth') { 2910: const { serverName } = message.request 2911: const currentAppState = getAppState() 2912: const config = 2913: getMcpConfigByName(serverName) ?? 2914: mcpClients.find(c => c.name === serverName)?.config ?? 2915: currentAppState.mcp.clients.find(c => c.name === serverName) 2916: ?.config ?? 2917: null 2918: if (!config) { 2919: sendControlResponseError(message, `Server not found: ${serverName}`) 2920: } else if (config.type !== 'sse' && config.type !== 'http') { 2921: sendControlResponseError( 2922: message, 2923: `Cannot clear auth for server type "${config.type}"`, 2924: ) 2925: } else { 2926: await revokeServerTokens(serverName, config) 2927: const result = await reconnectMcpServerImpl(serverName, config) 2928: const prefix = getMcpPrefix(serverName) 2929: setAppState(prev => ({ 2930: ...prev, 2931: mcp: { 2932: ...prev.mcp, 2933: clients: prev.mcp.clients.map(c => 2934: c.name === serverName ? result.client : c, 2935: ), 2936: tools: [ 2937: ...reject(prev.mcp.tools, t => t.name?.startsWith(prefix)), 2938: ...result.tools, 2939: ], 2940: commands: [ 2941: ...reject(prev.mcp.commands, c => 2942: commandBelongsToServer(c, serverName), 2943: ), 2944: ...result.commands, 2945: ], 2946: resources: 2947: result.resources && result.resources.length > 0 2948: ? { 2949: ...prev.mcp.resources, 2950: [serverName]: result.resources, 2951: } 2952: : omit(prev.mcp.resources, serverName), 2953: }, 2954: })) 2955: sendControlResponseSuccess(message, {}) 2956: } 2957: } else if (message.request.subtype === 'apply_flag_settings') { 2958: const prevModel = getMainLoopModel() 2959: const existing = getFlagSettingsInline() ?? {} 2960: const incoming = message.request.settings 2961: const merged = { ...existing, ...incoming } 2962: for (const key of Object.keys(merged)) { 2963: if (merged[key as keyof typeof merged] === null) { 2964: delete merged[key as keyof typeof merged] 2965: } 2966: } 2967: setFlagSettingsInline(merged) 2968: settingsChangeDetector.notifyChange('flagSettings') 2969: if ('model' in incoming) { 2970: if (incoming.model != null) { 2971: setMainLoopModelOverride(String(incoming.model)) 2972: } else { 2973: setMainLoopModelOverride(undefined) 2974: } 2975: } 2976: const newModel = getMainLoopModel() 2977: if (newModel !== prevModel) { 2978: activeUserSpecifiedModel = newModel 2979: const modelArg = incoming.model ? String(incoming.model) : 'default' 2980: notifySessionMetadataChanged({ model: newModel }) 2981: injectModelSwitchBreadcrumbs(modelArg, newModel) 2982: } 2983: sendControlResponseSuccess(message) 2984: } else if (message.request.subtype === 'get_settings') { 2985: const currentAppState = getAppState() 2986: const model = getMainLoopModel() 2987: const effort = modelSupportsEffort(model) 2988: ? resolveAppliedEffort(model, currentAppState.effortValue) 2989: : undefined 2990: sendControlResponseSuccess(message, { 2991: ...getSettingsWithSources(), 2992: applied: { 2993: model, 2994: effort: typeof effort === 'string' ? effort : null, 2995: }, 2996: }) 2997: } else if (message.request.subtype === 'stop_task') { 2998: const { task_id: taskId } = message.request 2999: try { 3000: await stopTask(taskId, { 3001: getAppState, 3002: setAppState, 3003: }) 3004: sendControlResponseSuccess(message, {}) 3005: } catch (error) { 3006: sendControlResponseError(message, errorMessage(error)) 3007: } 3008: } else if (message.request.subtype === 'generate_session_title') { 3009: const { description, persist } = message.request 3010: const titleSignal = ( 3011: abortController && !abortController.signal.aborted 3012: ? abortController 3013: : createAbortController() 3014: ).signal 3015: void (async () => { 3016: try { 3017: const title = await generateSessionTitle(description, titleSignal) 3018: if (title && persist) { 3019: try { 3020: saveAiGeneratedTitle(getSessionId() as UUID, title) 3021: } catch (e) { 3022: logError(e) 3023: } 3024: } 3025: sendControlResponseSuccess(message, { title }) 3026: } catch (e) { 3027: sendControlResponseError(message, errorMessage(e)) 3028: } 3029: })() 3030: } else if (message.request.subtype === 'side_question') { 3031: const { question } = message.request 3032: void (async () => { 3033: try { 3034: const saved = getLastCacheSafeParams() 3035: const cacheSafeParams = saved 3036: ? { 3037: ...saved, 3038: toolUseContext: { 3039: ...saved.toolUseContext, 3040: abortController: createAbortController(), 3041: }, 3042: } 3043: : await buildSideQuestionFallbackParams({ 3044: tools: buildAllTools(getAppState()), 3045: commands: currentCommands, 3046: mcpClients: [ 3047: ...getAppState().mcp.clients, 3048: ...sdkClients, 3049: ...dynamicMcpState.clients, 3050: ], 3051: messages: mutableMessages, 3052: readFileState, 3053: getAppState, 3054: setAppState, 3055: customSystemPrompt: options.systemPrompt, 3056: appendSystemPrompt: options.appendSystemPrompt, 3057: thinkingConfig: options.thinkingConfig, 3058: agents: currentAgents, 3059: }) 3060: const result = await runSideQuestion({ 3061: question, 3062: cacheSafeParams, 3063: }) 3064: sendControlResponseSuccess(message, { response: result.response }) 3065: } catch (e) { 3066: sendControlResponseError(message, errorMessage(e)) 3067: } 3068: })() 3069: } else if ( 3070: (feature('PROACTIVE') || feature('KAIROS')) && 3071: (message.request as { subtype: string }).subtype === 'set_proactive' 3072: ) { 3073: const req = message.request as unknown as { 3074: subtype: string 3075: enabled: boolean 3076: } 3077: if (req.enabled) { 3078: if (!proactiveModule!.isProactiveActive()) { 3079: proactiveModule!.activateProactive('command') 3080: scheduleProactiveTick!() 3081: } 3082: } else { 3083: proactiveModule!.deactivateProactive() 3084: } 3085: sendControlResponseSuccess(message) 3086: } else if (message.request.subtype === 'remote_control') { 3087: if (message.request.enabled) { 3088: if (bridgeHandle) { 3089: sendControlResponseSuccess(message, { 3090: session_url: getRemoteSessionUrl( 3091: bridgeHandle.bridgeSessionId, 3092: bridgeHandle.sessionIngressUrl, 3093: ), 3094: connect_url: buildBridgeConnectUrl( 3095: bridgeHandle.environmentId, 3096: bridgeHandle.sessionIngressUrl, 3097: ), 3098: environment_id: bridgeHandle.environmentId, 3099: }) 3100: } else { 3101: let bridgeFailureDetail: string | undefined 3102: try { 3103: const { initReplBridge } = await import( 3104: 'src/bridge/initReplBridge.js' 3105: ) 3106: const handle = await initReplBridge({ 3107: onInboundMessage(msg) { 3108: const fields = extractInboundMessageFields(msg) 3109: if (!fields) return 3110: const { content, uuid } = fields 3111: enqueue({ 3112: value: content, 3113: mode: 'prompt' as const, 3114: uuid, 3115: skipSlashCommands: true, 3116: }) 3117: void run() 3118: }, 3119: onPermissionResponse(response) { 3120: structuredIO.injectControlResponse(response) 3121: }, 3122: onInterrupt() { 3123: abortController?.abort() 3124: }, 3125: onSetModel(model) { 3126: const resolved = 3127: model === 'default' ? getDefaultMainLoopModel() : model 3128: activeUserSpecifiedModel = resolved 3129: setMainLoopModelOverride(resolved) 3130: }, 3131: onSetMaxThinkingTokens(maxTokens) { 3132: if (maxTokens === null) { 3133: options.thinkingConfig = undefined 3134: } else if (maxTokens === 0) { 3135: options.thinkingConfig = { type: 'disabled' } 3136: } else { 3137: options.thinkingConfig = { 3138: type: 'enabled', 3139: budgetTokens: maxTokens, 3140: } 3141: } 3142: }, 3143: onStateChange(state, detail) { 3144: if (state === 'failed') { 3145: bridgeFailureDetail = detail 3146: } 3147: logForDebugging( 3148: `[bridge:sdk] State change: ${state}${detail ? ` — ${detail}` : ''}`, 3149: ) 3150: output.enqueue({ 3151: type: 'system' as StdoutMessage['type'], 3152: subtype: 'bridge_state' as string, 3153: state, 3154: detail, 3155: uuid: randomUUID(), 3156: session_id: getSessionId(), 3157: } as StdoutMessage) 3158: }, 3159: initialMessages: 3160: mutableMessages.length > 0 ? mutableMessages : undefined, 3161: }) 3162: if (!handle) { 3163: sendControlResponseError( 3164: message, 3165: bridgeFailureDetail ?? 3166: 'Remote Control initialization failed', 3167: ) 3168: } else { 3169: bridgeHandle = handle 3170: bridgeLastForwardedIndex = mutableMessages.length 3171: structuredIO.setOnControlRequestSent(request => { 3172: handle.sendControlRequest(request) 3173: }) 3174: structuredIO.setOnControlRequestResolved(requestId => { 3175: handle.sendControlCancelRequest(requestId) 3176: }) 3177: sendControlResponseSuccess(message, { 3178: session_url: getRemoteSessionUrl( 3179: handle.bridgeSessionId, 3180: handle.sessionIngressUrl, 3181: ), 3182: connect_url: buildBridgeConnectUrl( 3183: handle.environmentId, 3184: handle.sessionIngressUrl, 3185: ), 3186: environment_id: handle.environmentId, 3187: }) 3188: } 3189: } catch (err) { 3190: sendControlResponseError(message, errorMessage(err)) 3191: } 3192: } 3193: } else { 3194: if (bridgeHandle) { 3195: structuredIO.setOnControlRequestSent(undefined) 3196: structuredIO.setOnControlRequestResolved(undefined) 3197: await bridgeHandle.teardown() 3198: bridgeHandle = null 3199: } 3200: sendControlResponseSuccess(message) 3201: } 3202: } else { 3203: sendControlResponseError( 3204: message, 3205: `Unsupported control request subtype: ${(message.request as { subtype: string }).subtype}`, 3206: ) 3207: } 3208: continue 3209: } else if (message.type === 'control_response') { 3210: if (options.replayUserMessages) { 3211: output.enqueue(message) 3212: } 3213: continue 3214: } else if (message.type === 'keep_alive') { 3215: continue 3216: } else if (message.type === 'update_environment_variables') { 3217: continue 3218: } else if (message.type === 'assistant' || message.type === 'system') { 3219: const internalMsgs = toInternalMessages([message]) 3220: mutableMessages.push(...internalMsgs) 3221: if (message.type === 'assistant' && options.replayUserMessages) { 3222: output.enqueue(message) 3223: } 3224: continue 3225: } 3226: if (message.type !== 'user') { 3227: continue 3228: } 3229: initialized = true 3230: if (message.uuid) { 3231: const sessionId = getSessionId() as UUID 3232: const existsInSession = await doesMessageExistInSession( 3233: sessionId, 3234: message.uuid, 3235: ) 3236: if (existsInSession || receivedMessageUuids.has(message.uuid)) { 3237: logForDebugging(`Skipping duplicate user message: ${message.uuid}`) 3238: if (options.replayUserMessages) { 3239: logForDebugging( 3240: `Sending acknowledgment for duplicate user message: ${message.uuid}`, 3241: ) 3242: output.enqueue({ 3243: type: 'user', 3244: message: message.message, 3245: session_id: sessionId, 3246: parent_tool_use_id: null, 3247: uuid: message.uuid, 3248: timestamp: message.timestamp, 3249: isReplay: true, 3250: } as SDKUserMessageReplay) 3251: } 3252: if (existsInSession) { 3253: notifyCommandLifecycle(message.uuid, 'completed') 3254: } 3255: continue 3256: } 3257: trackReceivedMessageUuid(message.uuid) 3258: } 3259: enqueue({ 3260: mode: 'prompt' as const, 3261: value: await resolveAndPrepend(message, message.message.content), 3262: uuid: message.uuid, 3263: priority: message.priority, 3264: }) 3265: if (feature('COMMIT_ATTRIBUTION')) { 3266: setAppState(prev => ({ 3267: ...prev, 3268: attribution: incrementPromptCount(prev.attribution, snapshot => { 3269: void recordAttributionSnapshot(snapshot).catch(error => { 3270: logForDebugging(`Attribution: Failed to save snapshot: ${error}`) 3271: }) 3272: }), 3273: })) 3274: } 3275: void run() 3276: } 3277: inputClosed = true 3278: cronScheduler?.stop() 3279: if (!running) { 3280: if (suggestionState.inflightPromise) { 3281: await Promise.race([suggestionState.inflightPromise, sleep(5000)]) 3282: } 3283: suggestionState.abortController?.abort() 3284: suggestionState.abortController = null 3285: await finalizePendingAsyncHooks() 3286: unsubscribeSkillChanges() 3287: unsubscribeAuthStatus?.() 3288: statusListeners.delete(rateLimitListener) 3289: output.done() 3290: } 3291: })() 3292: return output 3293: } 3294: export function createCanUseToolWithPermissionPrompt( 3295: permissionPromptTool: PermissionPromptTool, 3296: ): CanUseToolFn { 3297: const canUseTool: CanUseToolFn = async ( 3298: tool, 3299: input, 3300: toolUseContext, 3301: assistantMessage, 3302: toolUseId, 3303: forceDecision, 3304: ) => { 3305: const mainPermissionResult = 3306: forceDecision ?? 3307: (await hasPermissionsToUseTool( 3308: tool, 3309: input, 3310: toolUseContext, 3311: assistantMessage, 3312: toolUseId, 3313: )) 3314: if ( 3315: mainPermissionResult.behavior === 'allow' || 3316: mainPermissionResult.behavior === 'deny' 3317: ) { 3318: return mainPermissionResult 3319: } 3320: const { signal: combinedSignal, cleanup: cleanupAbortListener } = 3321: createCombinedAbortSignal(toolUseContext.abortController.signal) 3322: if (combinedSignal.aborted) { 3323: cleanupAbortListener() 3324: return { 3325: behavior: 'deny', 3326: message: 'Permission prompt was aborted.', 3327: decisionReason: { 3328: type: 'permissionPromptTool' as const, 3329: permissionPromptToolName: tool.name, 3330: toolResult: undefined, 3331: }, 3332: } 3333: } 3334: const abortPromise = new Promise<'aborted'>(resolve => { 3335: combinedSignal.addEventListener('abort', () => resolve('aborted'), { 3336: once: true, 3337: }) 3338: }) 3339: const toolCallPromise = permissionPromptTool.call( 3340: { 3341: tool_name: tool.name, 3342: input, 3343: tool_use_id: toolUseId, 3344: }, 3345: toolUseContext, 3346: canUseTool, 3347: assistantMessage, 3348: ) 3349: const raceResult = await Promise.race([toolCallPromise, abortPromise]) 3350: cleanupAbortListener() 3351: if (raceResult === 'aborted' || combinedSignal.aborted) { 3352: return { 3353: behavior: 'deny', 3354: message: 'Permission prompt was aborted.', 3355: decisionReason: { 3356: type: 'permissionPromptTool' as const, 3357: permissionPromptToolName: tool.name, 3358: toolResult: undefined, 3359: }, 3360: } 3361: } 3362: const result = raceResult as Awaited<typeof toolCallPromise> 3363: const permissionToolResultBlockParam = 3364: permissionPromptTool.mapToolResultToToolResultBlockParam(result.data, '1') 3365: if ( 3366: !permissionToolResultBlockParam.content || 3367: !Array.isArray(permissionToolResultBlockParam.content) || 3368: !permissionToolResultBlockParam.content[0] || 3369: permissionToolResultBlockParam.content[0].type !== 'text' || 3370: typeof permissionToolResultBlockParam.content[0].text !== 'string' 3371: ) { 3372: throw new Error( 3373: 'Permission prompt tool returned an invalid result. Expected a single text block param with type="text" and a string text value.', 3374: ) 3375: } 3376: return permissionPromptToolResultToPermissionDecision( 3377: permissionToolOutputSchema().parse( 3378: safeParseJSON(permissionToolResultBlockParam.content[0].text), 3379: ), 3380: permissionPromptTool, 3381: input, 3382: toolUseContext, 3383: ) 3384: } 3385: return canUseTool 3386: } 3387: export function getCanUseToolFn( 3388: permissionPromptToolName: string | undefined, 3389: structuredIO: StructuredIO, 3390: getMcpTools: () => Tool[], 3391: onPermissionPrompt?: (details: RequiresActionDetails) => void, 3392: ): CanUseToolFn { 3393: if (permissionPromptToolName === 'stdio') { 3394: return structuredIO.createCanUseTool(onPermissionPrompt) 3395: } 3396: if (!permissionPromptToolName) { 3397: return async ( 3398: tool, 3399: input, 3400: toolUseContext, 3401: assistantMessage, 3402: toolUseId, 3403: forceDecision, 3404: ) => 3405: forceDecision ?? 3406: (await hasPermissionsToUseTool( 3407: tool, 3408: input, 3409: toolUseContext, 3410: assistantMessage, 3411: toolUseId, 3412: )) 3413: } 3414: let resolved: CanUseToolFn | null = null 3415: return async ( 3416: tool, 3417: input, 3418: toolUseContext, 3419: assistantMessage, 3420: toolUseId, 3421: forceDecision, 3422: ) => { 3423: if (!resolved) { 3424: const mcpTools = getMcpTools() 3425: const permissionPromptTool = mcpTools.find(t => 3426: toolMatchesName(t, permissionPromptToolName), 3427: ) as PermissionPromptTool | undefined 3428: if (!permissionPromptTool) { 3429: const error = `Error: MCP tool ${permissionPromptToolName} (passed via --permission-prompt-tool) not found. Available MCP tools: ${mcpTools.map(t => t.name).join(', ') || 'none'}` 3430: process.stderr.write(`${error}\n`) 3431: gracefulShutdownSync(1) 3432: throw new Error(error) 3433: } 3434: if (!permissionPromptTool.inputJSONSchema) { 3435: const error = `Error: tool ${permissionPromptToolName} (passed via --permission-prompt-tool) must be an MCP tool` 3436: process.stderr.write(`${error}\n`) 3437: gracefulShutdownSync(1) 3438: throw new Error(error) 3439: } 3440: resolved = createCanUseToolWithPermissionPrompt(permissionPromptTool) 3441: } 3442: return resolved( 3443: tool, 3444: input, 3445: toolUseContext, 3446: assistantMessage, 3447: toolUseId, 3448: forceDecision, 3449: ) 3450: } 3451: } 3452: async function handleInitializeRequest( 3453: request: SDKControlInitializeRequest, 3454: requestId: string, 3455: initialized: boolean, 3456: output: Stream<StdoutMessage>, 3457: commands: Command[], 3458: modelInfos: ModelInfo[], 3459: structuredIO: StructuredIO, 3460: enableAuthStatus: boolean, 3461: options: { 3462: systemPrompt: string | undefined 3463: appendSystemPrompt: string | undefined 3464: agent?: string | undefined 3465: userSpecifiedModel?: string | undefined 3466: [key: string]: unknown 3467: }, 3468: agents: AgentDefinition[], 3469: getAppState: () => AppState, 3470: ): Promise<void> { 3471: if (initialized) { 3472: output.enqueue({ 3473: type: 'control_response', 3474: response: { 3475: subtype: 'error', 3476: error: 'Already initialized', 3477: request_id: requestId, 3478: pending_permission_requests: 3479: structuredIO.getPendingPermissionRequests(), 3480: }, 3481: }) 3482: return 3483: } 3484: if (request.systemPrompt !== undefined) { 3485: options.systemPrompt = request.systemPrompt 3486: } 3487: if (request.appendSystemPrompt !== undefined) { 3488: options.appendSystemPrompt = request.appendSystemPrompt 3489: } 3490: if (request.promptSuggestions !== undefined) { 3491: options.promptSuggestions = request.promptSuggestions 3492: } 3493: if (request.agents) { 3494: const stdinAgents = parseAgentsFromJson(request.agents, 'flagSettings') 3495: agents.push(...stdinAgents) 3496: } 3497: if (options.agent) { 3498: const alreadyResolved = getMainThreadAgentType() === options.agent 3499: const mainThreadAgent = agents.find(a => a.agentType === options.agent) 3500: if (mainThreadAgent && !alreadyResolved) { 3501: setMainThreadAgentType(mainThreadAgent.agentType) 3502: if (!options.systemPrompt && !isBuiltInAgent(mainThreadAgent)) { 3503: const agentSystemPrompt = mainThreadAgent.getSystemPrompt() 3504: if (agentSystemPrompt) { 3505: options.systemPrompt = agentSystemPrompt 3506: } 3507: } 3508: if ( 3509: !options.userSpecifiedModel && 3510: mainThreadAgent.model && 3511: mainThreadAgent.model !== 'inherit' 3512: ) { 3513: const agentModel = parseUserSpecifiedModel(mainThreadAgent.model) 3514: setMainLoopModelOverride(agentModel) 3515: } 3516: if (mainThreadAgent.initialPrompt) { 3517: structuredIO.prependUserMessage(mainThreadAgent.initialPrompt) 3518: } 3519: } else if (mainThreadAgent?.initialPrompt) { 3520: structuredIO.prependUserMessage(mainThreadAgent.initialPrompt) 3521: } 3522: } 3523: const settings = getSettings_DEPRECATED() 3524: const outputStyle = settings?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME 3525: const availableOutputStyles = await getAllOutputStyles(getCwd()) 3526: const accountInfo = getAccountInformation() 3527: if (request.hooks) { 3528: const hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>> = {} 3529: for (const [event, matchers] of Object.entries(request.hooks)) { 3530: hooks[event as HookEvent] = matchers.map(matcher => { 3531: const callbacks = matcher.hookCallbackIds.map(callbackId => { 3532: return structuredIO.createHookCallback(callbackId, matcher.timeout) 3533: }) 3534: return { 3535: matcher: matcher.matcher, 3536: hooks: callbacks, 3537: } 3538: }) 3539: } 3540: registerHookCallbacks(hooks) 3541: } 3542: if (request.jsonSchema) { 3543: setInitJsonSchema(request.jsonSchema) 3544: } 3545: const initResponse: SDKControlInitializeResponse = { 3546: commands: commands 3547: .filter(cmd => cmd.userInvocable !== false) 3548: .map(cmd => ({ 3549: name: getCommandName(cmd), 3550: description: formatDescriptionWithSource(cmd), 3551: argumentHint: cmd.argumentHint || '', 3552: })), 3553: agents: agents.map(agent => ({ 3554: name: agent.agentType, 3555: description: agent.whenToUse, 3556: // 'inherit' is an internal sentinel; normalize to undefined for the public API 3557: model: agent.model === 'inherit' ? undefined : agent.model, 3558: })), 3559: output_style: outputStyle, 3560: available_output_styles: Object.keys(availableOutputStyles), 3561: models: modelInfos, 3562: account: { 3563: email: accountInfo?.email, 3564: organization: accountInfo?.organization, 3565: subscriptionType: accountInfo?.subscription, 3566: tokenSource: accountInfo?.tokenSource, 3567: apiKeySource: accountInfo?.apiKeySource, 3568: apiProvider: getAPIProvider(), 3569: }, 3570: pid: process.pid, 3571: } 3572: if (isFastModeEnabled() && isFastModeAvailable()) { 3573: const appState = getAppState() 3574: initResponse.fast_mode_state = getFastModeState( 3575: options.userSpecifiedModel ?? null, 3576: appState.fastMode, 3577: ) 3578: } 3579: output.enqueue({ 3580: type: 'control_response', 3581: response: { 3582: subtype: 'success', 3583: request_id: requestId, 3584: response: initResponse, 3585: }, 3586: }) 3587: if (enableAuthStatus) { 3588: const authStatusManager = AwsAuthStatusManager.getInstance() 3589: const status = authStatusManager.getStatus() 3590: if (status) { 3591: output.enqueue({ 3592: type: 'auth_status', 3593: isAuthenticating: status.isAuthenticating, 3594: output: status.output, 3595: error: status.error, 3596: uuid: randomUUID(), 3597: session_id: getSessionId(), 3598: }) 3599: } 3600: } 3601: } 3602: async function handleRewindFiles( 3603: userMessageId: UUID, 3604: appState: AppState, 3605: setAppState: (updater: (prev: AppState) => AppState) => void, 3606: dryRun: boolean, 3607: ): Promise<RewindFilesResult> { 3608: if (!fileHistoryEnabled()) { 3609: return { canRewind: false, error: 'File rewinding is not enabled.' } 3610: } 3611: if (!fileHistoryCanRestore(appState.fileHistory, userMessageId)) { 3612: return { 3613: canRewind: false, 3614: error: 'No file checkpoint found for this message.', 3615: } 3616: } 3617: if (dryRun) { 3618: const diffStats = await fileHistoryGetDiffStats( 3619: appState.fileHistory, 3620: userMessageId, 3621: ) 3622: return { 3623: canRewind: true, 3624: filesChanged: diffStats?.filesChanged, 3625: insertions: diffStats?.insertions, 3626: deletions: diffStats?.deletions, 3627: } 3628: } 3629: try { 3630: await fileHistoryRewind( 3631: updater => 3632: setAppState(prev => ({ 3633: ...prev, 3634: fileHistory: updater(prev.fileHistory), 3635: })), 3636: userMessageId, 3637: ) 3638: } catch (error) { 3639: return { 3640: canRewind: false, 3641: error: `Failed to rewind: ${errorMessage(error)}`, 3642: } 3643: } 3644: return { canRewind: true } 3645: } 3646: function handleSetPermissionMode( 3647: request: { mode: InternalPermissionMode }, 3648: requestId: string, 3649: toolPermissionContext: ToolPermissionContext, 3650: output: Stream<StdoutMessage>, 3651: ): ToolPermissionContext { 3652: if (request.mode === 'bypassPermissions') { 3653: if (isBypassPermissionsModeDisabled()) { 3654: output.enqueue({ 3655: type: 'control_response', 3656: response: { 3657: subtype: 'error', 3658: request_id: requestId, 3659: error: 3660: 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration', 3661: }, 3662: }) 3663: return toolPermissionContext 3664: } 3665: if (!toolPermissionContext.isBypassPermissionsModeAvailable) { 3666: output.enqueue({ 3667: type: 'control_response', 3668: response: { 3669: subtype: 'error', 3670: request_id: requestId, 3671: error: 3672: 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions', 3673: }, 3674: }) 3675: return toolPermissionContext 3676: } 3677: } 3678: if ( 3679: feature('TRANSCRIPT_CLASSIFIER') && 3680: request.mode === 'auto' && 3681: !isAutoModeGateEnabled() 3682: ) { 3683: const reason = getAutoModeUnavailableReason() 3684: output.enqueue({ 3685: type: 'control_response', 3686: response: { 3687: subtype: 'error', 3688: request_id: requestId, 3689: error: reason 3690: ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` 3691: : 'Cannot set permission mode to auto', 3692: }, 3693: }) 3694: return toolPermissionContext 3695: } 3696: output.enqueue({ 3697: type: 'control_response', 3698: response: { 3699: subtype: 'success', 3700: request_id: requestId, 3701: response: { 3702: mode: request.mode, 3703: }, 3704: }, 3705: }) 3706: return { 3707: ...transitionPermissionMode( 3708: toolPermissionContext.mode, 3709: request.mode, 3710: toolPermissionContext, 3711: ), 3712: mode: request.mode, 3713: } 3714: } 3715: function handleChannelEnable( 3716: requestId: string, 3717: serverName: string, 3718: connectionPool: readonly MCPServerConnection[], 3719: output: Stream<StdoutMessage>, 3720: ): void { 3721: const respondError = (error: string) => 3722: output.enqueue({ 3723: type: 'control_response', 3724: response: { subtype: 'error', request_id: requestId, error }, 3725: }) 3726: if (!(feature('KAIROS') || feature('KAIROS_CHANNELS'))) { 3727: return respondError('channels feature not available in this build') 3728: } 3729: const connection = connectionPool.find( 3730: c => c.name === serverName && c.type === 'connected', 3731: ) 3732: if (!connection || connection.type !== 'connected') { 3733: return respondError(`server ${serverName} is not connected`) 3734: } 3735: const pluginSource = connection.config.pluginSource 3736: const parsed = pluginSource ? parsePluginIdentifier(pluginSource) : undefined 3737: if (!parsed?.marketplace) { 3738: return respondError( 3739: `server ${serverName} is not plugin-sourced; channel_enable requires a marketplace plugin`, 3740: ) 3741: } 3742: const entry: ChannelEntry = { 3743: kind: 'plugin', 3744: name: parsed.name, 3745: marketplace: parsed.marketplace, 3746: } 3747: const prior = getAllowedChannels() 3748: const already = prior.some( 3749: e => 3750: e.kind === 'plugin' && 3751: e.name === entry.name && 3752: e.marketplace === entry.marketplace, 3753: ) 3754: if (!already) setAllowedChannels([...prior, entry]) 3755: const gate = gateChannelServer( 3756: serverName, 3757: connection.capabilities, 3758: pluginSource, 3759: ) 3760: if (gate.action === 'skip') { 3761: if (!already) setAllowedChannels(prior) 3762: return respondError(gate.reason) 3763: } 3764: const pluginId = 3765: `${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 3766: logMCPDebug(serverName, 'Channel notifications registered') 3767: logEvent('tengu_mcp_channel_enable', { plugin: pluginId }) 3768: connection.client.setNotificationHandler( 3769: ChannelMessageNotificationSchema(), 3770: async notification => { 3771: const { content, meta } = notification.params 3772: logMCPDebug( 3773: serverName, 3774: `notifications/claude/channel: ${content.slice(0, 80)}`, 3775: ) 3776: logEvent('tengu_mcp_channel_message', { 3777: content_length: content.length, 3778: meta_key_count: Object.keys(meta ?? {}).length, 3779: entry_kind: 3780: 'plugin' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3781: is_dev: false, 3782: plugin: pluginId, 3783: }) 3784: enqueue({ 3785: mode: 'prompt', 3786: value: wrapChannelMessage(serverName, content, meta), 3787: priority: 'next', 3788: isMeta: true, 3789: origin: { kind: 'channel', server: serverName }, 3790: skipSlashCommands: true, 3791: }) 3792: }, 3793: ) 3794: output.enqueue({ 3795: type: 'control_response', 3796: response: { 3797: subtype: 'success', 3798: request_id: requestId, 3799: response: undefined, 3800: }, 3801: }) 3802: } 3803: function reregisterChannelHandlerAfterReconnect( 3804: connection: MCPServerConnection, 3805: ): void { 3806: if (!(feature('KAIROS') || feature('KAIROS_CHANNELS'))) return 3807: if (connection.type !== 'connected') return 3808: const gate = gateChannelServer( 3809: connection.name, 3810: connection.capabilities, 3811: connection.config.pluginSource, 3812: ) 3813: if (gate.action !== 'register') return 3814: const entry = findChannelEntry(connection.name, getAllowedChannels()) 3815: const pluginId = 3816: entry?.kind === 'plugin' 3817: ? (`${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 3818: : undefined 3819: logMCPDebug( 3820: connection.name, 3821: 'Channel notifications re-registered after reconnect', 3822: ) 3823: connection.client.setNotificationHandler( 3824: ChannelMessageNotificationSchema(), 3825: async notification => { 3826: const { content, meta } = notification.params 3827: logMCPDebug( 3828: connection.name, 3829: `notifications/claude/channel: ${content.slice(0, 80)}`, 3830: ) 3831: logEvent('tengu_mcp_channel_message', { 3832: content_length: content.length, 3833: meta_key_count: Object.keys(meta ?? {}).length, 3834: entry_kind: 3835: entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3836: is_dev: entry?.dev ?? false, 3837: plugin: pluginId, 3838: }) 3839: enqueue({ 3840: mode: 'prompt', 3841: value: wrapChannelMessage(connection.name, content, meta), 3842: priority: 'next', 3843: isMeta: true, 3844: origin: { kind: 'channel', server: connection.name }, 3845: skipSlashCommands: true, 3846: }) 3847: }, 3848: ) 3849: } 3850: function emitLoadError( 3851: message: string, 3852: outputFormat: string | undefined, 3853: ): void { 3854: if (outputFormat === 'stream-json') { 3855: const errorResult = { 3856: type: 'result', 3857: subtype: 'error_during_execution', 3858: duration_ms: 0, 3859: duration_api_ms: 0, 3860: is_error: true, 3861: num_turns: 0, 3862: stop_reason: null, 3863: session_id: getSessionId(), 3864: total_cost_usd: 0, 3865: usage: EMPTY_USAGE, 3866: modelUsage: {}, 3867: permission_denials: [], 3868: uuid: randomUUID(), 3869: errors: [message], 3870: } 3871: process.stdout.write(jsonStringify(errorResult) + '\n') 3872: } else { 3873: process.stderr.write(message + '\n') 3874: } 3875: } 3876: export function removeInterruptedMessage( 3877: messages: Message[], 3878: interruptedUserMessage: NormalizedUserMessage, 3879: ): void { 3880: const idx = messages.findIndex(m => m.uuid === interruptedUserMessage.uuid) 3881: if (idx !== -1) { 3882: messages.splice(idx, 2) 3883: } 3884: } 3885: type LoadInitialMessagesResult = { 3886: messages: Message[] 3887: turnInterruptionState?: TurnInterruptionState 3888: agentSetting?: string 3889: } 3890: async function loadInitialMessages( 3891: setAppState: (f: (prev: AppState) => AppState) => void, 3892: options: { 3893: continue: boolean | undefined 3894: teleport: string | true | null | undefined 3895: resume: string | boolean | undefined 3896: resumeSessionAt: string | undefined 3897: forkSession: boolean | undefined 3898: outputFormat: string | undefined 3899: sessionStartHooksPromise?: ReturnType<typeof processSessionStartHooks> 3900: restoredWorkerState: Promise<SessionExternalMetadata | null> 3901: }, 3902: ): Promise<LoadInitialMessagesResult> { 3903: const persistSession = !isSessionPersistenceDisabled() 3904: if (options.continue) { 3905: try { 3906: logEvent('tengu_continue_print', {}) 3907: const result = await loadConversationForResume( 3908: undefined , 3909: undefined , 3910: ) 3911: if (result) { 3912: if (feature('COORDINATOR_MODE') && coordinatorModeModule) { 3913: const warning = coordinatorModeModule.matchSessionMode(result.mode) 3914: if (warning) { 3915: process.stderr.write(warning + '\n') 3916: const { 3917: getAgentDefinitionsWithOverrides, 3918: getActiveAgentsFromList, 3919: } = 3920: require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js') 3921: getAgentDefinitionsWithOverrides.cache.clear?.() 3922: const freshAgentDefs = await getAgentDefinitionsWithOverrides( 3923: getCwd(), 3924: ) 3925: setAppState(prev => ({ 3926: ...prev, 3927: agentDefinitions: { 3928: ...freshAgentDefs, 3929: allAgents: freshAgentDefs.allAgents, 3930: activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents), 3931: }, 3932: })) 3933: } 3934: } 3935: if (!options.forkSession) { 3936: if (result.sessionId) { 3937: switchSession( 3938: asSessionId(result.sessionId), 3939: result.fullPath ? dirname(result.fullPath) : null, 3940: ) 3941: if (persistSession) { 3942: await resetSessionFilePointer() 3943: } 3944: } 3945: } 3946: restoreSessionStateFromLog(result, setAppState) 3947: restoreSessionMetadata( 3948: options.forkSession 3949: ? { ...result, worktreeSession: undefined } 3950: : result, 3951: ) 3952: if (feature('COORDINATOR_MODE') && coordinatorModeModule) { 3953: saveMode( 3954: coordinatorModeModule.isCoordinatorMode() 3955: ? 'coordinator' 3956: : 'normal', 3957: ) 3958: } 3959: return { 3960: messages: result.messages, 3961: turnInterruptionState: result.turnInterruptionState, 3962: agentSetting: result.agentSetting, 3963: } 3964: } 3965: } catch (error) { 3966: logError(error) 3967: gracefulShutdownSync(1) 3968: return { messages: [] } 3969: } 3970: } 3971: if (options.teleport) { 3972: try { 3973: if (!isPolicyAllowed('allow_remote_sessions')) { 3974: throw new Error( 3975: "Remote sessions are disabled by your organization's policy.", 3976: ) 3977: } 3978: logEvent('tengu_teleport_print', {}) 3979: if (typeof options.teleport !== 'string') { 3980: throw new Error('No session ID provided for teleport') 3981: } 3982: const { 3983: checkOutTeleportedSessionBranch, 3984: processMessagesForTeleportResume, 3985: teleportResumeCodeSession, 3986: validateGitState, 3987: } = await import('src/utils/teleport.js') 3988: await validateGitState() 3989: const teleportResult = await teleportResumeCodeSession(options.teleport) 3990: const { branchError } = await checkOutTeleportedSessionBranch( 3991: teleportResult.branch, 3992: ) 3993: return { 3994: messages: processMessagesForTeleportResume( 3995: teleportResult.log, 3996: branchError, 3997: ), 3998: } 3999: } catch (error) { 4000: logError(error) 4001: gracefulShutdownSync(1) 4002: return { messages: [] } 4003: } 4004: } 4005: if (options.resume) { 4006: try { 4007: logEvent('tengu_resume_print', {}) 4008: const parsedSessionId = parseSessionIdentifier( 4009: typeof options.resume === 'string' ? options.resume : '', 4010: ) 4011: if (!parsedSessionId) { 4012: let errorMessage = 4013: 'Error: --resume requires a valid session ID when used with --print. Usage: claude -p --resume <session-id>' 4014: if (typeof options.resume === 'string') { 4015: errorMessage += `. Session IDs must be in UUID format (e.g., 550e8400-e29b-41d4-a716-446655440000). Provided value "${options.resume}" is not a valid UUID` 4016: } 4017: emitLoadError(errorMessage, options.outputFormat) 4018: gracefulShutdownSync(1) 4019: return { messages: [] } 4020: } 4021: if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CCR_V2)) { 4022: const [, metadata] = await Promise.all([ 4023: hydrateFromCCRv2InternalEvents(parsedSessionId.sessionId), 4024: options.restoredWorkerState, 4025: ]) 4026: if (metadata) { 4027: setAppState(externalMetadataToAppState(metadata)) 4028: if (typeof metadata.model === 'string') { 4029: setMainLoopModelOverride(metadata.model) 4030: } 4031: } 4032: } else if ( 4033: parsedSessionId.isUrl && 4034: parsedSessionId.ingressUrl && 4035: isEnvTruthy(process.env.ENABLE_SESSION_PERSISTENCE) 4036: ) { 4037: await hydrateRemoteSession( 4038: parsedSessionId.sessionId, 4039: parsedSessionId.ingressUrl, 4040: ) 4041: } 4042: const result = await loadConversationForResume( 4043: parsedSessionId.sessionId, 4044: parsedSessionId.jsonlFile || undefined, 4045: ) 4046: if (!result || result.messages.length === 0) { 4047: if ( 4048: parsedSessionId.isUrl || 4049: isEnvTruthy(process.env.CLAUDE_CODE_USE_CCR_V2) 4050: ) { 4051: return { 4052: messages: await (options.sessionStartHooksPromise ?? 4053: processSessionStartHooks('startup')), 4054: } 4055: } else { 4056: emitLoadError( 4057: `No conversation found with session ID: ${parsedSessionId.sessionId}`, 4058: options.outputFormat, 4059: ) 4060: gracefulShutdownSync(1) 4061: return { messages: [] } 4062: } 4063: } 4064: if (options.resumeSessionAt) { 4065: const index = result.messages.findIndex( 4066: m => m.uuid === options.resumeSessionAt, 4067: ) 4068: if (index < 0) { 4069: emitLoadError( 4070: `No message found with message.uuid of: ${options.resumeSessionAt}`, 4071: options.outputFormat, 4072: ) 4073: gracefulShutdownSync(1) 4074: return { messages: [] } 4075: } 4076: result.messages = index >= 0 ? result.messages.slice(0, index + 1) : [] 4077: } 4078: if (feature('COORDINATOR_MODE') && coordinatorModeModule) { 4079: const warning = coordinatorModeModule.matchSessionMode(result.mode) 4080: if (warning) { 4081: process.stderr.write(warning + '\n') 4082: const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } = 4083: require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js') 4084: getAgentDefinitionsWithOverrides.cache.clear?.() 4085: const freshAgentDefs = await getAgentDefinitionsWithOverrides( 4086: getCwd(), 4087: ) 4088: setAppState(prev => ({ 4089: ...prev, 4090: agentDefinitions: { 4091: ...freshAgentDefs, 4092: allAgents: freshAgentDefs.allAgents, 4093: activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents), 4094: }, 4095: })) 4096: } 4097: } 4098: if (!options.forkSession && result.sessionId) { 4099: switchSession( 4100: asSessionId(result.sessionId), 4101: result.fullPath ? dirname(result.fullPath) : null, 4102: ) 4103: if (persistSession) { 4104: await resetSessionFilePointer() 4105: } 4106: } 4107: restoreSessionStateFromLog(result, setAppState) 4108: restoreSessionMetadata( 4109: options.forkSession 4110: ? { ...result, worktreeSession: undefined } 4111: : result, 4112: ) 4113: if (feature('COORDINATOR_MODE') && coordinatorModeModule) { 4114: saveMode( 4115: coordinatorModeModule.isCoordinatorMode() ? 'coordinator' : 'normal', 4116: ) 4117: } 4118: return { 4119: messages: result.messages, 4120: turnInterruptionState: result.turnInterruptionState, 4121: agentSetting: result.agentSetting, 4122: } 4123: } catch (error) { 4124: logError(error) 4125: const errorMessage = 4126: error instanceof Error 4127: ? `Failed to resume session: ${error.message}` 4128: : 'Failed to resume session with --print mode' 4129: emitLoadError(errorMessage, options.outputFormat) 4130: gracefulShutdownSync(1) 4131: return { messages: [] } 4132: } 4133: } 4134: return { 4135: messages: await (options.sessionStartHooksPromise ?? 4136: processSessionStartHooks('startup')), 4137: } 4138: } 4139: function getStructuredIO( 4140: inputPrompt: string | AsyncIterable<string>, 4141: options: { 4142: sdkUrl: string | undefined 4143: replayUserMessages?: boolean 4144: }, 4145: ): StructuredIO { 4146: let inputStream: AsyncIterable<string> 4147: if (typeof inputPrompt === 'string') { 4148: if (inputPrompt.trim() !== '') { 4149: // Normalize to a streaming input. 4150: inputStream = fromArray([ 4151: jsonStringify({ 4152: type: 'user', 4153: session_id: '', 4154: message: { 4155: role: 'user', 4156: content: inputPrompt, 4157: }, 4158: parent_tool_use_id: null, 4159: } satisfies SDKUserMessage), 4160: ]) 4161: } else { 4162: inputStream = fromArray([]) 4163: } 4164: } else { 4165: inputStream = inputPrompt 4166: } 4167: return options.sdkUrl 4168: ? new RemoteIO(options.sdkUrl, inputStream, options.replayUserMessages) 4169: : new StructuredIO(inputStream, options.replayUserMessages) 4170: } 4171: export async function handleOrphanedPermissionResponse({ 4172: message, 4173: setAppState, 4174: onEnqueued, 4175: handledToolUseIds, 4176: }: { 4177: message: SDKControlResponse 4178: setAppState: (f: (prev: AppState) => AppState) => void 4179: onEnqueued?: () => void 4180: handledToolUseIds: Set<string> 4181: }): Promise<boolean> { 4182: if ( 4183: message.response.subtype === 'success' && 4184: message.response.response?.toolUseID && 4185: typeof message.response.response.toolUseID === 'string' 4186: ) { 4187: const permissionResult = message.response.response as PermissionResult 4188: const { toolUseID } = permissionResult 4189: if (!toolUseID) { 4190: return false 4191: } 4192: logForDebugging( 4193: `handleOrphanedPermissionResponse: received orphaned control_response for toolUseID=${toolUseID} request_id=${message.response.request_id}`, 4194: ) 4195: if (handledToolUseIds.has(toolUseID)) { 4196: logForDebugging( 4197: `handleOrphanedPermissionResponse: skipping duplicate orphaned permission for toolUseID=${toolUseID} (already handled)`, 4198: ) 4199: return false 4200: } 4201: const assistantMessage = await findUnresolvedToolUse(toolUseID) 4202: if (!assistantMessage) { 4203: logForDebugging( 4204: `handleOrphanedPermissionResponse: no unresolved tool_use found for toolUseID=${toolUseID} (already resolved in transcript)`, 4205: ) 4206: return false 4207: } 4208: handledToolUseIds.add(toolUseID) 4209: logForDebugging( 4210: `handleOrphanedPermissionResponse: enqueuing orphaned permission for toolUseID=${toolUseID} messageID=${assistantMessage.message.id}`, 4211: ) 4212: enqueue({ 4213: mode: 'orphaned-permission' as const, 4214: value: [], 4215: orphanedPermission: { 4216: permissionResult, 4217: assistantMessage, 4218: }, 4219: }) 4220: onEnqueued?.() 4221: return true 4222: } 4223: return false 4224: } 4225: export type DynamicMcpState = { 4226: clients: MCPServerConnection[] 4227: tools: Tools 4228: configs: Record<string, ScopedMcpServerConfig> 4229: } 4230: function toScopedConfig( 4231: config: McpServerConfigForProcessTransport, 4232: ): ScopedMcpServerConfig { 4233: return { ...config, scope: 'dynamic' } as ScopedMcpServerConfig 4234: } 4235: export type SdkMcpState = { 4236: configs: Record<string, McpSdkServerConfig> 4237: clients: MCPServerConnection[] 4238: tools: Tools 4239: } 4240: export type McpSetServersResult = { 4241: response: SDKControlMcpSetServersResponse 4242: newSdkState: SdkMcpState 4243: newDynamicState: DynamicMcpState 4244: sdkServersChanged: boolean 4245: } 4246: export async function handleMcpSetServers( 4247: servers: Record<string, McpServerConfigForProcessTransport>, 4248: sdkState: SdkMcpState, 4249: dynamicState: DynamicMcpState, 4250: setAppState: (f: (prev: AppState) => AppState) => void, 4251: ): Promise<McpSetServersResult> { 4252: const { allowed: allowedServers, blocked } = filterMcpServersByPolicy(servers) 4253: const policyErrors: Record<string, string> = {} 4254: for (const name of blocked) { 4255: policyErrors[name] = 4256: 'Blocked by enterprise policy (allowedMcpServers/deniedMcpServers)' 4257: } 4258: const sdkServers: Record<string, McpSdkServerConfig> = {} 4259: const processServers: Record<string, McpServerConfigForProcessTransport> = {} 4260: for (const [name, config] of Object.entries(allowedServers)) { 4261: if (config.type === 'sdk') { 4262: sdkServers[name] = config 4263: } else { 4264: processServers[name] = config 4265: } 4266: } 4267: const currentSdkNames = new Set(Object.keys(sdkState.configs)) 4268: const newSdkNames = new Set(Object.keys(sdkServers)) 4269: const sdkAdded: string[] = [] 4270: const sdkRemoved: string[] = [] 4271: const newSdkConfigs = { ...sdkState.configs } 4272: let newSdkClients = [...sdkState.clients] 4273: let newSdkTools = [...sdkState.tools] 4274: for (const name of currentSdkNames) { 4275: if (!newSdkNames.has(name)) { 4276: const client = newSdkClients.find(c => c.name === name) 4277: if (client && client.type === 'connected') { 4278: await client.cleanup() 4279: } 4280: newSdkClients = newSdkClients.filter(c => c.name !== name) 4281: const prefix = `mcp__${name}__` 4282: newSdkTools = newSdkTools.filter(t => !t.name.startsWith(prefix)) 4283: delete newSdkConfigs[name] 4284: sdkRemoved.push(name) 4285: } 4286: } 4287: for (const [name, config] of Object.entries(sdkServers)) { 4288: if (!currentSdkNames.has(name)) { 4289: newSdkConfigs[name] = config 4290: const pendingClient: MCPServerConnection = { 4291: type: 'pending', 4292: name, 4293: config: { ...config, scope: 'dynamic' as const }, 4294: } 4295: newSdkClients = [...newSdkClients, pendingClient] 4296: sdkAdded.push(name) 4297: } 4298: } 4299: const processResult = await reconcileMcpServers( 4300: processServers, 4301: dynamicState, 4302: setAppState, 4303: ) 4304: return { 4305: response: { 4306: added: [...sdkAdded, ...processResult.response.added], 4307: removed: [...sdkRemoved, ...processResult.response.removed], 4308: errors: { ...policyErrors, ...processResult.response.errors }, 4309: }, 4310: newSdkState: { 4311: configs: newSdkConfigs, 4312: clients: newSdkClients, 4313: tools: newSdkTools, 4314: }, 4315: newDynamicState: processResult.newState, 4316: sdkServersChanged: sdkAdded.length > 0 || sdkRemoved.length > 0, 4317: } 4318: } 4319: export async function reconcileMcpServers( 4320: desiredConfigs: Record<string, McpServerConfigForProcessTransport>, 4321: currentState: DynamicMcpState, 4322: setAppState: (f: (prev: AppState) => AppState) => void, 4323: ): Promise<{ 4324: response: SDKControlMcpSetServersResponse 4325: newState: DynamicMcpState 4326: }> { 4327: const currentNames = new Set(Object.keys(currentState.configs)) 4328: const desiredNames = new Set(Object.keys(desiredConfigs)) 4329: const toRemove = [...currentNames].filter(n => !desiredNames.has(n)) 4330: const toAdd = [...desiredNames].filter(n => !currentNames.has(n)) 4331: const toCheck = [...currentNames].filter(n => desiredNames.has(n)) 4332: const toReplace = toCheck.filter(name => { 4333: const currentConfig = currentState.configs[name] 4334: const desiredConfigRaw = desiredConfigs[name] 4335: if (!currentConfig || !desiredConfigRaw) return true 4336: const desiredConfig = toScopedConfig(desiredConfigRaw) 4337: return !areMcpConfigsEqual(currentConfig, desiredConfig) 4338: }) 4339: const removed: string[] = [] 4340: const added: string[] = [] 4341: const errors: Record<string, string> = {} 4342: let newClients = [...currentState.clients] 4343: let newTools = [...currentState.tools] 4344: for (const name of [...toRemove, ...toReplace]) { 4345: const client = newClients.find(c => c.name === name) 4346: const config = currentState.configs[name] 4347: if (client && config) { 4348: if (client.type === 'connected') { 4349: try { 4350: await client.cleanup() 4351: } catch (e) { 4352: logError(e) 4353: } 4354: } 4355: await clearServerCache(name, config) 4356: } 4357: const prefix = `mcp__${name}__` 4358: newTools = newTools.filter(t => !t.name.startsWith(prefix)) 4359: newClients = newClients.filter(c => c.name !== name) 4360: if (toRemove.includes(name)) { 4361: removed.push(name) 4362: } 4363: } 4364: for (const name of [...toAdd, ...toReplace]) { 4365: const config = desiredConfigs[name] 4366: if (!config) continue 4367: const scopedConfig = toScopedConfig(config) 4368: if (config.type === 'sdk') { 4369: added.push(name) 4370: continue 4371: } 4372: try { 4373: const client = await connectToServer(name, scopedConfig) 4374: newClients.push(client) 4375: if (client.type === 'connected') { 4376: const serverTools = await fetchToolsForClient(client) 4377: newTools.push(...serverTools) 4378: } else if (client.type === 'failed') { 4379: errors[name] = client.error || 'Connection failed' 4380: } 4381: added.push(name) 4382: } catch (e) { 4383: const err = toError(e) 4384: errors[name] = err.message 4385: logError(err) 4386: } 4387: } 4388: const newConfigs: Record<string, ScopedMcpServerConfig> = {} 4389: for (const name of desiredNames) { 4390: const config = desiredConfigs[name] 4391: if (config) { 4392: newConfigs[name] = toScopedConfig(config) 4393: } 4394: } 4395: const newState: DynamicMcpState = { 4396: clients: newClients, 4397: tools: newTools, 4398: configs: newConfigs, 4399: } 4400: setAppState(prev => { 4401: const allDynamicServerNames = new Set([ 4402: ...Object.keys(currentState.configs), 4403: ...Object.keys(newConfigs), 4404: ]) 4405: const nonDynamicTools = prev.mcp.tools.filter(t => { 4406: for (const serverName of allDynamicServerNames) { 4407: if (t.name.startsWith(`mcp__${serverName}__`)) { 4408: return false 4409: } 4410: } 4411: return true 4412: }) 4413: const nonDynamicClients = prev.mcp.clients.filter(c => { 4414: return !allDynamicServerNames.has(c.name) 4415: }) 4416: return { 4417: ...prev, 4418: mcp: { 4419: ...prev.mcp, 4420: tools: [...nonDynamicTools, ...newTools], 4421: clients: [...nonDynamicClients, ...newClients], 4422: }, 4423: } 4424: }) 4425: return { 4426: response: { added, removed, errors }, 4427: newState, 4428: } 4429: }

File: src/cli/remoteIO.ts

typescript 1: import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js' 2: import { PassThrough } from 'stream' 3: import { URL } from 'url' 4: import { getSessionId } from '../bootstrap/state.js' 5: import { getPollIntervalConfig } from '../bridge/pollConfig.js' 6: import { registerCleanup } from '../utils/cleanupRegistry.js' 7: import { setCommandLifecycleListener } from '../utils/commandLifecycle.js' 8: import { isDebugMode, logForDebugging } from '../utils/debug.js' 9: import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' 10: import { isEnvTruthy } from '../utils/envUtils.js' 11: import { errorMessage } from '../utils/errors.js' 12: import { gracefulShutdown } from '../utils/gracefulShutdown.js' 13: import { logError } from '../utils/log.js' 14: import { writeToStdout } from '../utils/process.js' 15: import { getSessionIngressAuthToken } from '../utils/sessionIngressAuth.js' 16: import { 17: setSessionMetadataChangedListener, 18: setSessionStateChangedListener, 19: } from '../utils/sessionState.js' 20: import { 21: setInternalEventReader, 22: setInternalEventWriter, 23: } from '../utils/sessionStorage.js' 24: import { ndjsonSafeStringify } from './ndjsonSafeStringify.js' 25: import { StructuredIO } from './structuredIO.js' 26: import { CCRClient, CCRInitError } from './transports/ccrClient.js' 27: import { SSETransport } from './transports/SSETransport.js' 28: import type { Transport } from './transports/Transport.js' 29: import { getTransportForUrl } from './transports/transportUtils.js' 30: export class RemoteIO extends StructuredIO { 31: private url: URL 32: private transport: Transport 33: private inputStream: PassThrough 34: private readonly isBridge: boolean = false 35: private readonly isDebug: boolean = false 36: private ccrClient: CCRClient | null = null 37: private keepAliveTimer: ReturnType<typeof setInterval> | null = null 38: constructor( 39: streamUrl: string, 40: initialPrompt?: AsyncIterable<string>, 41: replayUserMessages?: boolean, 42: ) { 43: const inputStream = new PassThrough({ encoding: 'utf8' }) 44: super(inputStream, replayUserMessages) 45: this.inputStream = inputStream 46: this.url = new URL(streamUrl) 47: const headers: Record<string, string> = {} 48: const sessionToken = getSessionIngressAuthToken() 49: if (sessionToken) { 50: headers['Authorization'] = `Bearer ${sessionToken}` 51: } else { 52: logForDebugging('[remote-io] No session ingress token available', { 53: level: 'error', 54: }) 55: } 56: const erVersion = process.env.CLAUDE_CODE_ENVIRONMENT_RUNNER_VERSION 57: if (erVersion) { 58: headers['x-environment-runner-version'] = erVersion 59: } 60: const refreshHeaders = (): Record<string, string> => { 61: const h: Record<string, string> = {} 62: const freshToken = getSessionIngressAuthToken() 63: if (freshToken) { 64: h['Authorization'] = `Bearer ${freshToken}` 65: } 66: const freshErVersion = process.env.CLAUDE_CODE_ENVIRONMENT_RUNNER_VERSION 67: if (freshErVersion) { 68: h['x-environment-runner-version'] = freshErVersion 69: } 70: return h 71: } 72: this.transport = getTransportForUrl( 73: this.url, 74: headers, 75: getSessionId(), 76: refreshHeaders, 77: ) 78: this.isBridge = process.env.CLAUDE_CODE_ENVIRONMENT_KIND === 'bridge' 79: this.isDebug = isDebugMode() 80: this.transport.setOnData((data: string) => { 81: this.inputStream.write(data) 82: if (this.isBridge && this.isDebug) { 83: writeToStdout(data.endsWith('\n') ? data : data + '\n') 84: } 85: }) 86: this.transport.setOnClose(() => { 87: this.inputStream.end() 88: }) 89: if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CCR_V2)) { 90: if (!(this.transport instanceof SSETransport)) { 91: throw new Error( 92: 'CCR v2 requires SSETransport; check getTransportForUrl', 93: ) 94: } 95: this.ccrClient = new CCRClient(this.transport, this.url) 96: const init = this.ccrClient.initialize() 97: this.restoredWorkerState = init.catch(() => null) 98: init.catch((error: unknown) => { 99: logForDiagnosticsNoPII('error', 'cli_worker_lifecycle_init_failed', { 100: reason: error instanceof CCRInitError ? error.reason : 'unknown', 101: }) 102: logError( 103: new Error(`CCRClient initialization failed: ${errorMessage(error)}`), 104: ) 105: void gracefulShutdown(1, 'other') 106: }) 107: registerCleanup(async () => this.ccrClient?.close()) 108: setInternalEventWriter((eventType, payload, options) => 109: this.ccrClient!.writeInternalEvent(eventType, payload, options), 110: ) 111: setInternalEventReader( 112: () => this.ccrClient!.readInternalEvents(), 113: () => this.ccrClient!.readSubagentInternalEvents(), 114: ) 115: const LIFECYCLE_TO_DELIVERY = { 116: started: 'processing', 117: completed: 'processed', 118: } as const 119: setCommandLifecycleListener((uuid, state) => { 120: this.ccrClient?.reportDelivery(uuid, LIFECYCLE_TO_DELIVERY[state]) 121: }) 122: setSessionStateChangedListener((state, details) => { 123: this.ccrClient?.reportState(state, details) 124: }) 125: setSessionMetadataChangedListener(metadata => { 126: this.ccrClient?.reportMetadata(metadata) 127: }) 128: } 129: void this.transport.connect() 130: const keepAliveIntervalMs = 131: getPollIntervalConfig().session_keepalive_interval_v2_ms 132: if (this.isBridge && keepAliveIntervalMs > 0) { 133: this.keepAliveTimer = setInterval(() => { 134: logForDebugging('[remote-io] keep_alive sent') 135: void this.write({ type: 'keep_alive' }).catch(err => { 136: logForDebugging( 137: `[remote-io] keep_alive write failed: ${errorMessage(err)}`, 138: ) 139: }) 140: }, keepAliveIntervalMs) 141: this.keepAliveTimer.unref?.() 142: } 143: registerCleanup(async () => this.close()) 144: if (initialPrompt) { 145: const stream = this.inputStream 146: void (async () => { 147: for await (const chunk of initialPrompt) { 148: stream.write(String(chunk).replace(/\n$/, '') + '\n') 149: } 150: })() 151: } 152: } 153: override flushInternalEvents(): Promise<void> { 154: return this.ccrClient?.flushInternalEvents() ?? Promise.resolve() 155: } 156: override get internalEventsPending(): number { 157: return this.ccrClient?.internalEventsPending ?? 0 158: } 159: async write(message: StdoutMessage): Promise<void> { 160: if (this.ccrClient) { 161: await this.ccrClient.writeEvent(message) 162: } else { 163: await this.transport.write(message) 164: } 165: if (this.isBridge) { 166: if (message.type === 'control_request' || this.isDebug) { 167: writeToStdout(ndjsonSafeStringify(message) + '\n') 168: } 169: } 170: } 171: close(): void { 172: if (this.keepAliveTimer) { 173: clearInterval(this.keepAliveTimer) 174: this.keepAliveTimer = null 175: } 176: this.transport.close() 177: this.inputStream.end() 178: } 179: }

File: src/cli/structuredIO.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { 3: ElicitResult, 4: JSONRPCMessage, 5: } from '@modelcontextprotocol/sdk/types.js' 6: import { randomUUID } from 'crypto' 7: import type { AssistantMessage } from 'src//types/message.js' 8: import type { 9: HookInput, 10: HookJSONOutput, 11: PermissionUpdate, 12: SDKMessage, 13: SDKUserMessage, 14: } from 'src/entrypoints/agentSdkTypes.js' 15: import { SDKControlElicitationResponseSchema } from 'src/entrypoints/sdk/controlSchemas.js' 16: import type { 17: SDKControlRequest, 18: SDKControlResponse, 19: StdinMessage, 20: StdoutMessage, 21: } from 'src/entrypoints/sdk/controlTypes.js' 22: import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js' 23: import type { Tool, ToolUseContext } from 'src/Tool.js' 24: import { type HookCallback, hookJSONOutputSchema } from 'src/types/hooks.js' 25: import { logForDebugging } from 'src/utils/debug.js' 26: import { logForDiagnosticsNoPII } from 'src/utils/diagLogs.js' 27: import { AbortError } from 'src/utils/errors.js' 28: import { 29: type Output as PermissionToolOutput, 30: permissionPromptToolResultToPermissionDecision, 31: outputSchema as permissionToolOutputSchema, 32: } from 'src/utils/permissions/PermissionPromptToolResultSchema.js' 33: import type { 34: PermissionDecision, 35: PermissionDecisionReason, 36: } from 'src/utils/permissions/PermissionResult.js' 37: import { hasPermissionsToUseTool } from 'src/utils/permissions/permissions.js' 38: import { writeToStdout } from 'src/utils/process.js' 39: import { jsonStringify } from 'src/utils/slowOperations.js' 40: import { z } from 'zod/v4' 41: import { notifyCommandLifecycle } from '../utils/commandLifecycle.js' 42: import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js' 43: import { executePermissionRequestHooks } from '../utils/hooks.js' 44: import { 45: applyPermissionUpdates, 46: persistPermissionUpdates, 47: } from '../utils/permissions/PermissionUpdate.js' 48: import { 49: notifySessionStateChanged, 50: type RequiresActionDetails, 51: type SessionExternalMetadata, 52: } from '../utils/sessionState.js' 53: import { jsonParse } from '../utils/slowOperations.js' 54: import { Stream } from '../utils/stream.js' 55: import { ndjsonSafeStringify } from './ndjsonSafeStringify.js' 56: export const SANDBOX_NETWORK_ACCESS_TOOL_NAME = 'SandboxNetworkAccess' 57: function serializeDecisionReason( 58: reason: PermissionDecisionReason | undefined, 59: ): string | undefined { 60: if (!reason) { 61: return undefined 62: } 63: if ( 64: (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && 65: reason.type === 'classifier' 66: ) { 67: return reason.reason 68: } 69: switch (reason.type) { 70: case 'rule': 71: case 'mode': 72: case 'subcommandResults': 73: case 'permissionPromptTool': 74: return undefined 75: case 'hook': 76: case 'asyncAgent': 77: case 'sandboxOverride': 78: case 'workingDir': 79: case 'safetyCheck': 80: case 'other': 81: return reason.reason 82: } 83: } 84: function buildRequiresActionDetails( 85: tool: Tool, 86: input: Record<string, unknown>, 87: toolUseID: string, 88: requestId: string, 89: ): RequiresActionDetails { 90: let description: string 91: try { 92: description = 93: tool.getActivityDescription?.(input) ?? 94: tool.getToolUseSummary?.(input) ?? 95: tool.userFacingName(input) 96: } catch { 97: description = tool.name 98: } 99: return { 100: tool_name: tool.name, 101: action_description: description, 102: tool_use_id: toolUseID, 103: request_id: requestId, 104: input, 105: } 106: } 107: type PendingRequest<T> = { 108: resolve: (result: T) => void 109: reject: (error: unknown) => void 110: schema?: z.Schema 111: request: SDKControlRequest 112: } 113: const MAX_RESOLVED_TOOL_USE_IDS = 1000 114: export class StructuredIO { 115: readonly structuredInput: AsyncGenerator<StdinMessage | SDKMessage> 116: private readonly pendingRequests = new Map<string, PendingRequest<unknown>>() 117: restoredWorkerState: Promise<SessionExternalMetadata | null> = 118: Promise.resolve(null) 119: private inputClosed = false 120: private unexpectedResponseCallback?: ( 121: response: SDKControlResponse, 122: ) => Promise<void> 123: private readonly resolvedToolUseIds = new Set<string>() 124: private prependedLines: string[] = [] 125: private onControlRequestSent?: (request: SDKControlRequest) => void 126: private onControlRequestResolved?: (requestId: string) => void 127: readonly outbound = new Stream<StdoutMessage>() 128: constructor( 129: private readonly input: AsyncIterable<string>, 130: private readonly replayUserMessages?: boolean, 131: ) { 132: this.input = input 133: this.structuredInput = this.read() 134: } 135: private trackResolvedToolUseId(request: SDKControlRequest): void { 136: if (request.request.subtype === 'can_use_tool') { 137: this.resolvedToolUseIds.add(request.request.tool_use_id) 138: if (this.resolvedToolUseIds.size > MAX_RESOLVED_TOOL_USE_IDS) { 139: const first = this.resolvedToolUseIds.values().next().value 140: if (first !== undefined) { 141: this.resolvedToolUseIds.delete(first) 142: } 143: } 144: } 145: } 146: flushInternalEvents(): Promise<void> { 147: return Promise.resolve() 148: } 149: get internalEventsPending(): number { 150: return 0 151: } 152: prependUserMessage(content: string): void { 153: this.prependedLines.push( 154: jsonStringify({ 155: type: 'user', 156: session_id: '', 157: message: { role: 'user', content }, 158: parent_tool_use_id: null, 159: } satisfies SDKUserMessage) + '\n', 160: ) 161: } 162: private async *read() { 163: let content = '' 164: // Called once before for-await (an empty this.input otherwise skips the 165: // loop body entirely), then again per block. prependedLines re-check is 166: // inside the while so a prepend pushed between two messages in the SAME 167: // block still lands first. 168: const splitAndProcess = async function* (this: StructuredIO) { 169: for (;;) { 170: if (this.prependedLines.length > 0) { 171: content = this.prependedLines.join('') + content 172: this.prependedLines = [] 173: } 174: const newline = content.indexOf('\n') 175: if (newline === -1) break 176: const line = content.slice(0, newline) 177: content = content.slice(newline + 1) 178: const message = await this.processLine(line) 179: if (message) { 180: logForDiagnosticsNoPII('info', 'cli_stdin_message_parsed', { 181: type: message.type, 182: }) 183: yield message 184: } 185: } 186: }.bind(this) 187: yield* splitAndProcess() 188: for await (const block of this.input) { 189: content += block 190: yield* splitAndProcess() 191: } 192: if (content) { 193: const message = await this.processLine(content) 194: if (message) { 195: yield message 196: } 197: } 198: this.inputClosed = true 199: for (const request of this.pendingRequests.values()) { 200: request.reject( 201: new Error('Tool permission stream closed before response received'), 202: ) 203: } 204: } 205: getPendingPermissionRequests() { 206: return Array.from(this.pendingRequests.values()) 207: .map(entry => entry.request) 208: .filter(pr => pr.request.subtype === 'can_use_tool') 209: } 210: setUnexpectedResponseCallback( 211: callback: (response: SDKControlResponse) => Promise<void>, 212: ): void { 213: this.unexpectedResponseCallback = callback 214: } 215: injectControlResponse(response: SDKControlResponse): void { 216: const requestId = response.response?.request_id 217: if (!requestId) return 218: const request = this.pendingRequests.get(requestId) 219: if (!request) return 220: this.trackResolvedToolUseId(request.request) 221: this.pendingRequests.delete(requestId) 222: void this.write({ 223: type: 'control_cancel_request', 224: request_id: requestId, 225: }) 226: if (response.response.subtype === 'error') { 227: request.reject(new Error(response.response.error)) 228: } else { 229: const result = response.response.response 230: if (request.schema) { 231: try { 232: request.resolve(request.schema.parse(result)) 233: } catch (error) { 234: request.reject(error) 235: } 236: } else { 237: request.resolve({}) 238: } 239: } 240: } 241: setOnControlRequestSent( 242: callback: ((request: SDKControlRequest) => void) | undefined, 243: ): void { 244: this.onControlRequestSent = callback 245: } 246: setOnControlRequestResolved( 247: callback: ((requestId: string) => void) | undefined, 248: ): void { 249: this.onControlRequestResolved = callback 250: } 251: private async processLine( 252: line: string, 253: ): Promise<StdinMessage | SDKMessage | undefined> { 254: if (!line) { 255: return undefined 256: } 257: try { 258: const message = normalizeControlMessageKeys(jsonParse(line)) as 259: | StdinMessage 260: | SDKMessage 261: if (message.type === 'keep_alive') { 262: return undefined 263: } 264: if (message.type === 'update_environment_variables') { 265: const keys = Object.keys(message.variables) 266: for (const [key, value] of Object.entries(message.variables)) { 267: process.env[key] = value 268: } 269: logForDebugging( 270: `[structuredIO] applied update_environment_variables: ${keys.join(', ')}`, 271: ) 272: return undefined 273: } 274: if (message.type === 'control_response') { 275: const uuid = 276: 'uuid' in message && typeof message.uuid === 'string' 277: ? message.uuid 278: : undefined 279: if (uuid) { 280: notifyCommandLifecycle(uuid, 'completed') 281: } 282: const request = this.pendingRequests.get(message.response.request_id) 283: if (!request) { 284: const responsePayload = 285: message.response.subtype === 'success' 286: ? message.response.response 287: : undefined 288: const toolUseID = responsePayload?.toolUseID 289: if ( 290: typeof toolUseID === 'string' && 291: this.resolvedToolUseIds.has(toolUseID) 292: ) { 293: logForDebugging( 294: `Ignoring duplicate control_response for already-resolved toolUseID=${toolUseID} request_id=${message.response.request_id}`, 295: ) 296: return undefined 297: } 298: if (this.unexpectedResponseCallback) { 299: await this.unexpectedResponseCallback(message) 300: } 301: return undefined 302: } 303: this.trackResolvedToolUseId(request.request) 304: this.pendingRequests.delete(message.response.request_id) 305: if ( 306: request.request.request.subtype === 'can_use_tool' && 307: this.onControlRequestResolved 308: ) { 309: this.onControlRequestResolved(message.response.request_id) 310: } 311: if (message.response.subtype === 'error') { 312: request.reject(new Error(message.response.error)) 313: return undefined 314: } 315: const result = message.response.response 316: if (request.schema) { 317: try { 318: request.resolve(request.schema.parse(result)) 319: } catch (error) { 320: request.reject(error) 321: } 322: } else { 323: request.resolve({}) 324: } 325: if (this.replayUserMessages) { 326: return message 327: } 328: return undefined 329: } 330: if ( 331: message.type !== 'user' && 332: message.type !== 'control_request' && 333: message.type !== 'assistant' && 334: message.type !== 'system' 335: ) { 336: logForDebugging(`Ignoring unknown message type: ${message.type}`, { 337: level: 'warn', 338: }) 339: return undefined 340: } 341: if (message.type === 'control_request') { 342: if (!message.request) { 343: exitWithMessage(`Error: Missing request on control_request`) 344: } 345: return message 346: } 347: if (message.type === 'assistant' || message.type === 'system') { 348: return message 349: } 350: if (message.message.role !== 'user') { 351: exitWithMessage( 352: `Error: Expected message role 'user', got '${message.message.role}'`, 353: ) 354: } 355: return message 356: } catch (error) { 357: console.error(`Error parsing streaming input line: ${line}: ${error}`) 358: process.exit(1) 359: } 360: } 361: async write(message: StdoutMessage): Promise<void> { 362: writeToStdout(ndjsonSafeStringify(message) + '\n') 363: } 364: private async sendRequest<Response>( 365: request: SDKControlRequest['request'], 366: schema: z.Schema, 367: signal?: AbortSignal, 368: requestId: string = randomUUID(), 369: ): Promise<Response> { 370: const message: SDKControlRequest = { 371: type: 'control_request', 372: request_id: requestId, 373: request, 374: } 375: if (this.inputClosed) { 376: throw new Error('Stream closed') 377: } 378: if (signal?.aborted) { 379: throw new Error('Request aborted') 380: } 381: this.outbound.enqueue(message) 382: if (request.subtype === 'can_use_tool' && this.onControlRequestSent) { 383: this.onControlRequestSent(message) 384: } 385: const aborted = () => { 386: this.outbound.enqueue({ 387: type: 'control_cancel_request', 388: request_id: requestId, 389: }) 390: const request = this.pendingRequests.get(requestId) 391: if (request) { 392: this.trackResolvedToolUseId(request.request) 393: request.reject(new AbortError()) 394: } 395: } 396: if (signal) { 397: signal.addEventListener('abort', aborted, { 398: once: true, 399: }) 400: } 401: try { 402: return await new Promise<Response>((resolve, reject) => { 403: this.pendingRequests.set(requestId, { 404: request: { 405: type: 'control_request', 406: request_id: requestId, 407: request, 408: }, 409: resolve: result => { 410: resolve(result as Response) 411: }, 412: reject, 413: schema, 414: }) 415: }) 416: } finally { 417: if (signal) { 418: signal.removeEventListener('abort', aborted) 419: } 420: this.pendingRequests.delete(requestId) 421: } 422: } 423: createCanUseTool( 424: onPermissionPrompt?: (details: RequiresActionDetails) => void, 425: ): CanUseToolFn { 426: return async ( 427: tool: Tool, 428: input: { [key: string]: unknown }, 429: toolUseContext: ToolUseContext, 430: assistantMessage: AssistantMessage, 431: toolUseID: string, 432: forceDecision?: PermissionDecision, 433: ): Promise<PermissionDecision> => { 434: const mainPermissionResult = 435: forceDecision ?? 436: (await hasPermissionsToUseTool( 437: tool, 438: input, 439: toolUseContext, 440: assistantMessage, 441: toolUseID, 442: )) 443: if ( 444: mainPermissionResult.behavior === 'allow' || 445: mainPermissionResult.behavior === 'deny' 446: ) { 447: return mainPermissionResult 448: } 449: const hookAbortController = new AbortController() 450: const parentSignal = toolUseContext.abortController.signal 451: const onParentAbort = () => hookAbortController.abort() 452: parentSignal.addEventListener('abort', onParentAbort, { once: true }) 453: try { 454: const hookPromise = executePermissionRequestHooksForSDK( 455: tool.name, 456: toolUseID, 457: input, 458: toolUseContext, 459: mainPermissionResult.suggestions, 460: ).then(decision => ({ source: 'hook' as const, decision })) 461: const requestId = randomUUID() 462: onPermissionPrompt?.( 463: buildRequiresActionDetails(tool, input, toolUseID, requestId), 464: ) 465: const sdkPromise = this.sendRequest<PermissionToolOutput>( 466: { 467: subtype: 'can_use_tool', 468: tool_name: tool.name, 469: input, 470: permission_suggestions: mainPermissionResult.suggestions, 471: blocked_path: mainPermissionResult.blockedPath, 472: decision_reason: serializeDecisionReason( 473: mainPermissionResult.decisionReason, 474: ), 475: tool_use_id: toolUseID, 476: agent_id: toolUseContext.agentId, 477: }, 478: permissionToolOutputSchema(), 479: hookAbortController.signal, 480: requestId, 481: ).then(result => ({ source: 'sdk' as const, result })) 482: const winner = await Promise.race([hookPromise, sdkPromise]) 483: if (winner.source === 'hook') { 484: if (winner.decision) { 485: sdkPromise.catch(() => {}) 486: hookAbortController.abort() 487: return winner.decision 488: } 489: const sdkResult = await sdkPromise 490: return permissionPromptToolResultToPermissionDecision( 491: sdkResult.result, 492: tool, 493: input, 494: toolUseContext, 495: ) 496: } 497: return permissionPromptToolResultToPermissionDecision( 498: winner.result, 499: tool, 500: input, 501: toolUseContext, 502: ) 503: } catch (error) { 504: return permissionPromptToolResultToPermissionDecision( 505: { 506: behavior: 'deny', 507: message: `Tool permission request failed: ${error}`, 508: toolUseID, 509: }, 510: tool, 511: input, 512: toolUseContext, 513: ) 514: } finally { 515: if (this.getPendingPermissionRequests().length === 0) { 516: notifySessionStateChanged('running') 517: } 518: parentSignal.removeEventListener('abort', onParentAbort) 519: } 520: } 521: } 522: createHookCallback(callbackId: string, timeout?: number): HookCallback { 523: return { 524: type: 'callback', 525: timeout, 526: callback: async ( 527: input: HookInput, 528: toolUseID: string | null, 529: abort: AbortSignal | undefined, 530: ): Promise<HookJSONOutput> => { 531: try { 532: const result = await this.sendRequest<HookJSONOutput>( 533: { 534: subtype: 'hook_callback', 535: callback_id: callbackId, 536: input, 537: tool_use_id: toolUseID || undefined, 538: }, 539: hookJSONOutputSchema(), 540: abort, 541: ) 542: return result 543: } catch (error) { 544: console.error(`Error in hook callback ${callbackId}:`, error) 545: return {} 546: } 547: }, 548: } 549: } 550: async handleElicitation( 551: serverName: string, 552: message: string, 553: requestedSchema?: Record<string, unknown>, 554: signal?: AbortSignal, 555: mode?: 'form' | 'url', 556: url?: string, 557: elicitationId?: string, 558: ): Promise<ElicitResult> { 559: try { 560: const result = await this.sendRequest<ElicitResult>( 561: { 562: subtype: 'elicitation', 563: mcp_server_name: serverName, 564: message, 565: mode, 566: url, 567: elicitation_id: elicitationId, 568: requested_schema: requestedSchema, 569: }, 570: SDKControlElicitationResponseSchema(), 571: signal, 572: ) 573: return result 574: } catch { 575: return { action: 'cancel' as const } 576: } 577: } 578: createSandboxAskCallback(): (hostPattern: { 579: host: string 580: port?: number 581: }) => Promise<boolean> { 582: return async (hostPattern): Promise<boolean> => { 583: try { 584: const result = await this.sendRequest<PermissionToolOutput>( 585: { 586: subtype: 'can_use_tool', 587: tool_name: SANDBOX_NETWORK_ACCESS_TOOL_NAME, 588: input: { host: hostPattern.host }, 589: tool_use_id: randomUUID(), 590: description: `Allow network connection to ${hostPattern.host}?`, 591: }, 592: permissionToolOutputSchema(), 593: ) 594: return result.behavior === 'allow' 595: } catch { 596: return false 597: } 598: } 599: } 600: async sendMcpMessage( 601: serverName: string, 602: message: JSONRPCMessage, 603: ): Promise<JSONRPCMessage> { 604: const response = await this.sendRequest<{ mcp_response: JSONRPCMessage }>( 605: { 606: subtype: 'mcp_message', 607: server_name: serverName, 608: message, 609: }, 610: z.object({ 611: mcp_response: z.any() as z.Schema<JSONRPCMessage>, 612: }), 613: ) 614: return response.mcp_response 615: } 616: } 617: function exitWithMessage(message: string): never { 618: console.error(message) 619: process.exit(1) 620: } 621: async function executePermissionRequestHooksForSDK( 622: toolName: string, 623: toolUseID: string, 624: input: Record<string, unknown>, 625: toolUseContext: ToolUseContext, 626: suggestions: PermissionUpdate[] | undefined, 627: ): Promise<PermissionDecision | undefined> { 628: const appState = toolUseContext.getAppState() 629: const permissionMode = appState.toolPermissionContext.mode 630: const hookGenerator = executePermissionRequestHooks( 631: toolName, 632: toolUseID, 633: input, 634: toolUseContext, 635: permissionMode, 636: suggestions, 637: toolUseContext.abortController.signal, 638: ) 639: for await (const hookResult of hookGenerator) { 640: if ( 641: hookResult.permissionRequestResult && 642: (hookResult.permissionRequestResult.behavior === 'allow' || 643: hookResult.permissionRequestResult.behavior === 'deny') 644: ) { 645: const decision = hookResult.permissionRequestResult 646: if (decision.behavior === 'allow') { 647: const finalInput = decision.updatedInput || input 648: const permissionUpdates = decision.updatedPermissions ?? [] 649: if (permissionUpdates.length > 0) { 650: persistPermissionUpdates(permissionUpdates) 651: const currentAppState = toolUseContext.getAppState() 652: const updatedContext = applyPermissionUpdates( 653: currentAppState.toolPermissionContext, 654: permissionUpdates, 655: ) 656: toolUseContext.setAppState(prev => { 657: if (prev.toolPermissionContext === updatedContext) return prev 658: return { ...prev, toolPermissionContext: updatedContext } 659: }) 660: } 661: return { 662: behavior: 'allow', 663: updatedInput: finalInput, 664: userModified: false, 665: decisionReason: { 666: type: 'hook', 667: hookName: 'PermissionRequest', 668: }, 669: } 670: } else { 671: return { 672: behavior: 'deny', 673: message: 674: decision.message || 'Permission denied by PermissionRequest hook', 675: decisionReason: { 676: type: 'hook', 677: hookName: 'PermissionRequest', 678: }, 679: } 680: } 681: } 682: } 683: return undefined 684: }

File: src/cli/update.ts

typescript 1: import chalk from 'chalk' 2: import { logEvent } from 'src/services/analytics/index.js' 3: import { 4: getLatestVersion, 5: type InstallStatus, 6: installGlobalPackage, 7: } from 'src/utils/autoUpdater.js' 8: import { regenerateCompletionCache } from 'src/utils/completionCache.js' 9: import { 10: getGlobalConfig, 11: type InstallMethod, 12: saveGlobalConfig, 13: } from 'src/utils/config.js' 14: import { logForDebugging } from 'src/utils/debug.js' 15: import { getDoctorDiagnostic } from 'src/utils/doctorDiagnostic.js' 16: import { gracefulShutdown } from 'src/utils/gracefulShutdown.js' 17: import { 18: installOrUpdateClaudePackage, 19: localInstallationExists, 20: } from 'src/utils/localInstaller.js' 21: import { 22: installLatest as installLatestNative, 23: removeInstalledSymlink, 24: } from 'src/utils/nativeInstaller/index.js' 25: import { getPackageManager } from 'src/utils/nativeInstaller/packageManagers.js' 26: import { writeToStdout } from 'src/utils/process.js' 27: import { gte } from 'src/utils/semver.js' 28: import { getInitialSettings } from 'src/utils/settings/settings.js' 29: export async function update() { 30: logEvent('tengu_update_check', {}) 31: writeToStdout(`Current version: ${MACRO.VERSION}\n`) 32: const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest' 33: writeToStdout(`Checking for updates to ${channel} version...\n`) 34: logForDebugging('update: Starting update check') 35: logForDebugging('update: Running diagnostic') 36: const diagnostic = await getDoctorDiagnostic() 37: logForDebugging(`update: Installation type: ${diagnostic.installationType}`) 38: logForDebugging( 39: `update: Config install method: ${diagnostic.configInstallMethod}`, 40: ) 41: if (diagnostic.multipleInstallations.length > 1) { 42: writeToStdout('\n') 43: writeToStdout(chalk.yellow('Warning: Multiple installations found') + '\n') 44: for (const install of diagnostic.multipleInstallations) { 45: const current = 46: diagnostic.installationType === install.type 47: ? ' (currently running)' 48: : '' 49: writeToStdout(`- ${install.type} at ${install.path}${current}\n`) 50: } 51: } 52: // Display warnings if any exist 53: if (diagnostic.warnings.length > 0) { 54: writeToStdout('\n') 55: for (const warning of diagnostic.warnings) { 56: logForDebugging(`update: Warning detected: ${warning.issue}`) 57: logForDebugging(`update: Showing warning: ${warning.issue}`) 58: writeToStdout(chalk.yellow(`Warning: ${warning.issue}\n`)) 59: writeToStdout(chalk.bold(`Fix: ${warning.fix}\n`)) 60: } 61: } 62: const config = getGlobalConfig() 63: if ( 64: !config.installMethod && 65: diagnostic.installationType !== 'package-manager' 66: ) { 67: writeToStdout('\n') 68: writeToStdout('Updating configuration to track installation method...\n') 69: let detectedMethod: 'local' | 'native' | 'global' | 'unknown' = 'unknown' 70: switch (diagnostic.installationType) { 71: case 'npm-local': 72: detectedMethod = 'local' 73: break 74: case 'native': 75: detectedMethod = 'native' 76: break 77: case 'npm-global': 78: detectedMethod = 'global' 79: break 80: default: 81: detectedMethod = 'unknown' 82: } 83: saveGlobalConfig(current => ({ 84: ...current, 85: installMethod: detectedMethod, 86: })) 87: writeToStdout(`Installation method set to: ${detectedMethod}\n`) 88: } 89: if (diagnostic.installationType === 'development') { 90: writeToStdout('\n') 91: writeToStdout( 92: chalk.yellow('Warning: Cannot update development build') + '\n', 93: ) 94: await gracefulShutdown(1) 95: } 96: if (diagnostic.installationType === 'package-manager') { 97: const packageManager = await getPackageManager() 98: writeToStdout('\n') 99: if (packageManager === 'homebrew') { 100: writeToStdout('Claude is managed by Homebrew.\n') 101: const latest = await getLatestVersion(channel) 102: if (latest && !gte(MACRO.VERSION, latest)) { 103: writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`) 104: writeToStdout('\n') 105: writeToStdout('To update, run:\n') 106: writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n') 107: } else { 108: writeToStdout('Claude is up to date!\n') 109: } 110: } else if (packageManager === 'winget') { 111: writeToStdout('Claude is managed by winget.\n') 112: const latest = await getLatestVersion(channel) 113: if (latest && !gte(MACRO.VERSION, latest)) { 114: writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`) 115: writeToStdout('\n') 116: writeToStdout('To update, run:\n') 117: writeToStdout( 118: chalk.bold(' winget upgrade Anthropic.ClaudeCode') + '\n', 119: ) 120: } else { 121: writeToStdout('Claude is up to date!\n') 122: } 123: } else if (packageManager === 'apk') { 124: writeToStdout('Claude is managed by apk.\n') 125: const latest = await getLatestVersion(channel) 126: if (latest && !gte(MACRO.VERSION, latest)) { 127: writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`) 128: writeToStdout('\n') 129: writeToStdout('To update, run:\n') 130: writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n') 131: } else { 132: writeToStdout('Claude is up to date!\n') 133: } 134: } else { 135: writeToStdout('Claude is managed by a package manager.\n') 136: writeToStdout('Please use your package manager to update.\n') 137: } 138: await gracefulShutdown(0) 139: } 140: if ( 141: config.installMethod && 142: diagnostic.configInstallMethod !== 'not set' && 143: diagnostic.installationType !== 'package-manager' 144: ) { 145: const runningType = diagnostic.installationType 146: const configExpects = diagnostic.configInstallMethod 147: const typeMapping: Record<string, string> = { 148: 'npm-local': 'local', 149: 'npm-global': 'global', 150: native: 'native', 151: development: 'development', 152: unknown: 'unknown', 153: } 154: const normalizedRunningType = typeMapping[runningType] || runningType 155: if ( 156: normalizedRunningType !== configExpects && 157: configExpects !== 'unknown' 158: ) { 159: writeToStdout('\n') 160: writeToStdout(chalk.yellow('Warning: Configuration mismatch') + '\n') 161: writeToStdout(`Config expects: ${configExpects} installation\n`) 162: writeToStdout(`Currently running: ${runningType}\n`) 163: writeToStdout( 164: chalk.yellow( 165: `Updating the ${runningType} installation you are currently using`, 166: ) + '\n', 167: ) 168: saveGlobalConfig(current => ({ 169: ...current, 170: installMethod: normalizedRunningType as InstallMethod, 171: })) 172: writeToStdout( 173: `Config updated to reflect current installation method: ${normalizedRunningType}\n`, 174: ) 175: } 176: } 177: if (diagnostic.installationType === 'native') { 178: logForDebugging( 179: 'update: Detected native installation, using native updater', 180: ) 181: try { 182: const result = await installLatestNative(channel, true) 183: if (result.lockFailed) { 184: const pidInfo = result.lockHolderPid 185: ? ` (PID ${result.lockHolderPid})` 186: : '' 187: writeToStdout( 188: chalk.yellow( 189: `Another Claude process${pidInfo} is currently running. Please try again in a moment.`, 190: ) + '\n', 191: ) 192: await gracefulShutdown(0) 193: } 194: if (!result.latestVersion) { 195: process.stderr.write('Failed to check for updates\n') 196: await gracefulShutdown(1) 197: } 198: if (result.latestVersion === MACRO.VERSION) { 199: writeToStdout( 200: chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n', 201: ) 202: } else { 203: writeToStdout( 204: chalk.green( 205: `Successfully updated from ${MACRO.VERSION} to version ${result.latestVersion}`, 206: ) + '\n', 207: ) 208: await regenerateCompletionCache() 209: } 210: await gracefulShutdown(0) 211: } catch (error) { 212: process.stderr.write('Error: Failed to install native update\n') 213: process.stderr.write(String(error) + '\n') 214: process.stderr.write('Try running "claude doctor" for diagnostics\n') 215: await gracefulShutdown(1) 216: } 217: } 218: if (config.installMethod !== 'native') { 219: await removeInstalledSymlink() 220: } 221: logForDebugging('update: Checking npm registry for latest version') 222: logForDebugging(`update: Package URL: ${MACRO.PACKAGE_URL}`) 223: const npmTag = channel === 'stable' ? 'stable' : 'latest' 224: const npmCommand = `npm view ${MACRO.PACKAGE_URL}@${npmTag} version` 225: logForDebugging(`update: Running: ${npmCommand}`) 226: const latestVersion = await getLatestVersion(channel) 227: logForDebugging( 228: `update: Latest version from npm: ${latestVersion || 'FAILED'}`, 229: ) 230: if (!latestVersion) { 231: logForDebugging('update: Failed to get latest version from npm registry') 232: process.stderr.write(chalk.red('Failed to check for updates') + '\n') 233: process.stderr.write('Unable to fetch latest version from npm registry\n') 234: process.stderr.write('\n') 235: process.stderr.write('Possible causes:\n') 236: process.stderr.write(' • Network connectivity issues\n') 237: process.stderr.write(' • npm registry is unreachable\n') 238: process.stderr.write(' • Corporate proxy/firewall blocking npm\n') 239: if (MACRO.PACKAGE_URL && !MACRO.PACKAGE_URL.startsWith('@anthropic')) { 240: process.stderr.write( 241: ' • Internal/development build not published to npm\n', 242: ) 243: } 244: process.stderr.write('\n') 245: process.stderr.write('Try:\n') 246: process.stderr.write(' • Check your internet connection\n') 247: process.stderr.write(' • Run with --debug flag for more details\n') 248: const packageName = 249: MACRO.PACKAGE_URL || 250: (process.env.USER_TYPE === 'ant' 251: ? '@anthropic-ai/claude-cli' 252: : '@anthropic-ai/claude-code') 253: process.stderr.write( 254: ` • Manually check: npm view ${packageName} version\n`, 255: ) 256: process.stderr.write(' • Check if you need to login: npm whoami\n') 257: await gracefulShutdown(1) 258: } 259: if (latestVersion === MACRO.VERSION) { 260: writeToStdout( 261: chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n', 262: ) 263: await gracefulShutdown(0) 264: } 265: writeToStdout( 266: `New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`, 267: ) 268: writeToStdout('Installing update...\n') 269: let useLocalUpdate = false 270: let updateMethodName = '' 271: switch (diagnostic.installationType) { 272: case 'npm-local': 273: useLocalUpdate = true 274: updateMethodName = 'local' 275: break 276: case 'npm-global': 277: useLocalUpdate = false 278: updateMethodName = 'global' 279: break 280: case 'unknown': { 281: const isLocal = await localInstallationExists() 282: useLocalUpdate = isLocal 283: updateMethodName = isLocal ? 'local' : 'global' 284: writeToStdout( 285: chalk.yellow('Warning: Could not determine installation type') + '\n', 286: ) 287: writeToStdout( 288: `Attempting ${updateMethodName} update based on file detection...\n`, 289: ) 290: break 291: } 292: default: 293: process.stderr.write( 294: `Error: Cannot update ${diagnostic.installationType} installation\n`, 295: ) 296: await gracefulShutdown(1) 297: } 298: writeToStdout(`Using ${updateMethodName} installation update method...\n`) 299: logForDebugging(`update: Update method determined: ${updateMethodName}`) 300: logForDebugging(`update: useLocalUpdate: ${useLocalUpdate}`) 301: let status: InstallStatus 302: if (useLocalUpdate) { 303: logForDebugging( 304: 'update: Calling installOrUpdateClaudePackage() for local update', 305: ) 306: status = await installOrUpdateClaudePackage(channel) 307: } else { 308: logForDebugging('update: Calling installGlobalPackage() for global update') 309: status = await installGlobalPackage() 310: } 311: logForDebugging(`update: Installation status: ${status}`) 312: switch (status) { 313: case 'success': 314: writeToStdout( 315: chalk.green( 316: `Successfully updated from ${MACRO.VERSION} to version ${latestVersion}`, 317: ) + '\n', 318: ) 319: await regenerateCompletionCache() 320: break 321: case 'no_permissions': 322: process.stderr.write( 323: 'Error: Insufficient permissions to install update\n', 324: ) 325: if (useLocalUpdate) { 326: process.stderr.write('Try manually updating with:\n') 327: process.stderr.write( 328: ` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`, 329: ) 330: } else { 331: process.stderr.write('Try running with sudo or fix npm permissions\n') 332: process.stderr.write( 333: 'Or consider using native installation with: claude install\n', 334: ) 335: } 336: await gracefulShutdown(1) 337: break 338: case 'install_failed': 339: process.stderr.write('Error: Failed to install update\n') 340: if (useLocalUpdate) { 341: process.stderr.write('Try manually updating with:\n') 342: process.stderr.write( 343: ` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`, 344: ) 345: } else { 346: process.stderr.write( 347: 'Or consider using native installation with: claude install\n', 348: ) 349: } 350: await gracefulShutdown(1) 351: break 352: case 'in_progress': 353: process.stderr.write( 354: 'Error: Another instance is currently performing an update\n', 355: ) 356: process.stderr.write('Please wait and try again later\n') 357: await gracefulShutdown(1) 358: break 359: } 360: await gracefulShutdown(0) 361: }

File: src/commands/add-dir/add-dir.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import chalk from 'chalk'; 3: import figures from 'figures'; 4: import React, { useEffect } from 'react'; 5: import { getAdditionalDirectoriesForClaudeMd, setAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js'; 6: import type { LocalJSXCommandContext } from '../../commands.js'; 7: import { MessageResponse } from '../../components/MessageResponse.js'; 8: import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js'; 9: import { Box, Text } from '../../ink.js'; 10: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 11: import { applyPermissionUpdate, persistPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js'; 12: import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js'; 13: import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; 14: import { addDirHelpMessage, validateDirectoryForWorkspace } from './validation.js'; 15: function AddDirError(t0) { 16: const $ = _c(10); 17: const { 18: message, 19: args, 20: onDone 21: } = t0; 22: let t1; 23: let t2; 24: if ($[0] !== onDone) { 25: t1 = () => { 26: const timer = setTimeout(onDone, 0); 27: return () => clearTimeout(timer); 28: }; 29: t2 = [onDone]; 30: $[0] = onDone; 31: $[1] = t1; 32: $[2] = t2; 33: } else { 34: t1 = $[1]; 35: t2 = $[2]; 36: } 37: useEffect(t1, t2); 38: let t3; 39: if ($[3] !== args) { 40: t3 = <Text dimColor={true}>{figures.pointer} /add-dir {args}</Text>; 41: $[3] = args; 42: $[4] = t3; 43: } else { 44: t3 = $[4]; 45: } 46: let t4; 47: if ($[5] !== message) { 48: t4 = <MessageResponse><Text>{message}</Text></MessageResponse>; 49: $[5] = message; 50: $[6] = t4; 51: } else { 52: t4 = $[6]; 53: } 54: let t5; 55: if ($[7] !== t3 || $[8] !== t4) { 56: t5 = <Box flexDirection="column">{t3}{t4}</Box>; 57: $[7] = t3; 58: $[8] = t4; 59: $[9] = t5; 60: } else { 61: t5 = $[9]; 62: } 63: return t5; 64: } 65: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext, args?: string): Promise<React.ReactNode> { 66: const directoryPath = (args ?? '').trim(); 67: const appState = context.getAppState(); 68: // Helper to handle adding a directory (shared by both with-path and no-path cases) 69: const handleAddDirectory = async (path: string, remember = false) => { 70: const destination: PermissionUpdateDestination = remember ? 'localSettings' : 'session'; 71: const permissionUpdate = { 72: type: 'addDirectories' as const, 73: directories: [path], 74: destination 75: }; 76: const latestAppState = context.getAppState(); 77: const updatedContext = applyPermissionUpdate(latestAppState.toolPermissionContext, permissionUpdate); 78: context.setAppState(prev => ({ 79: ...prev, 80: toolPermissionContext: updatedContext 81: })); 82: const currentDirs = getAdditionalDirectoriesForClaudeMd(); 83: if (!currentDirs.includes(path)) { 84: setAdditionalDirectoriesForClaudeMd([...currentDirs, path]); 85: } 86: SandboxManager.refreshConfig(); 87: let message: string; 88: if (remember) { 89: try { 90: persistPermissionUpdate(permissionUpdate); 91: message = `Added ${chalk.bold(path)} as a working directory and saved to local settings`; 92: } catch (error) { 93: message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}`; 94: } 95: } else { 96: message = `Added ${chalk.bold(path)} as a working directory for this session`; 97: } 98: const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}`; 99: onDone(messageWithHint); 100: }; 101: if (!directoryPath) { 102: return <AddWorkspaceDirectory permissionContext={appState.toolPermissionContext} onAddDirectory={handleAddDirectory} onCancel={() => { 103: onDone('Did not add a working directory.'); 104: }} />; 105: } 106: const result = await validateDirectoryForWorkspace(directoryPath, appState.toolPermissionContext); 107: if (result.resultType !== 'success') { 108: const message = addDirHelpMessage(result); 109: return <AddDirError message={message} args={args ?? ''} onDone={() => onDone(message)} />; 110: } 111: return <AddWorkspaceDirectory directoryPath={result.absolutePath} permissionContext={appState.toolPermissionContext} onAddDirectory={handleAddDirectory} onCancel={() => { 112: onDone(`Did not add ${chalk.bold(result.absolutePath)} as a working directory.`); 113: }} />; 114: }

File: src/commands/add-dir/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const addDir = { 3: type: 'local-jsx', 4: name: 'add-dir', 5: description: 'Add a new working directory', 6: argumentHint: '<path>', 7: load: () => import('./add-dir.js'), 8: } satisfies Command 9: export default addDir

File: src/commands/add-dir/validation.ts

typescript 1: import chalk from 'chalk' 2: import { stat } from 'fs/promises' 3: import { dirname, resolve } from 'path' 4: import type { ToolPermissionContext } from '../../Tool.js' 5: import { getErrnoCode } from '../../utils/errors.js' 6: import { expandPath } from '../../utils/path.js' 7: import { 8: allWorkingDirectories, 9: pathInWorkingPath, 10: } from '../../utils/permissions/filesystem.js' 11: export type AddDirectoryResult = 12: | { 13: resultType: 'success' 14: absolutePath: string 15: } 16: | { 17: resultType: 'emptyPath' 18: } 19: | { 20: resultType: 'pathNotFound' | 'notADirectory' 21: directoryPath: string 22: absolutePath: string 23: } 24: | { 25: resultType: 'alreadyInWorkingDirectory' 26: directoryPath: string 27: workingDir: string 28: } 29: export async function validateDirectoryForWorkspace( 30: directoryPath: string, 31: permissionContext: ToolPermissionContext, 32: ): Promise<AddDirectoryResult> { 33: if (!directoryPath) { 34: return { 35: resultType: 'emptyPath', 36: } 37: } 38: const absolutePath = resolve(expandPath(directoryPath)) 39: try { 40: const stats = await stat(absolutePath) 41: if (!stats.isDirectory()) { 42: return { 43: resultType: 'notADirectory', 44: directoryPath, 45: absolutePath, 46: } 47: } 48: } catch (e: unknown) { 49: const code = getErrnoCode(e) 50: if ( 51: code === 'ENOENT' || 52: code === 'ENOTDIR' || 53: code === 'EACCES' || 54: code === 'EPERM' 55: ) { 56: return { 57: resultType: 'pathNotFound', 58: directoryPath, 59: absolutePath, 60: } 61: } 62: throw e 63: } 64: const currentWorkingDirs = allWorkingDirectories(permissionContext) 65: for (const workingDir of currentWorkingDirs) { 66: if (pathInWorkingPath(absolutePath, workingDir)) { 67: return { 68: resultType: 'alreadyInWorkingDirectory', 69: directoryPath, 70: workingDir, 71: } 72: } 73: } 74: return { 75: resultType: 'success', 76: absolutePath, 77: } 78: } 79: export function addDirHelpMessage(result: AddDirectoryResult): string { 80: switch (result.resultType) { 81: case 'emptyPath': 82: return 'Please provide a directory path.' 83: case 'pathNotFound': 84: return `Path ${chalk.bold(result.absolutePath)} was not found.` 85: case 'notADirectory': { 86: const parentDir = dirname(result.absolutePath) 87: return `${chalk.bold(result.directoryPath)} is not a directory. Did you mean to add the parent directory ${chalk.bold(parentDir)}?` 88: } 89: case 'alreadyInWorkingDirectory': 90: return `${chalk.bold(result.directoryPath)} is already accessible within the existing working directory ${chalk.bold(result.workingDir)}.` 91: case 'success': 92: return `Added ${chalk.bold(result.absolutePath)} as a working directory.` 93: } 94: }

File: src/commands/agents/agents.tsx

typescript 1: import * as React from 'react'; 2: import { AgentsMenu } from '../../components/agents/AgentsMenu.js'; 3: import type { ToolUseContext } from '../../Tool.js'; 4: import { getTools } from '../../tools.js'; 5: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 6: export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise<React.ReactNode> { 7: const appState = context.getAppState(); 8: const permissionContext = appState.toolPermissionContext; 9: const tools = getTools(permissionContext); 10: return <AgentsMenu tools={tools} onExit={onDone} />; 11: }

File: src/commands/agents/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const agents = { 3: type: 'local-jsx', 4: name: 'agents', 5: description: 'Manage agent configurations', 6: load: () => import('./agents.js'), 7: } satisfies Command 8: export default agents

File: src/commands/ant-trace/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/autofix-pr/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/backfill-sessions/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/branch/branch.ts

typescript 1: import { randomUUID, type UUID } from 'crypto' 2: import { mkdir, readFile, writeFile } from 'fs/promises' 3: import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js' 4: import type { LocalJSXCommandContext } from '../../commands.js' 5: import { logEvent } from '../../services/analytics/index.js' 6: import type { LocalJSXCommandOnDone } from '../../types/command.js' 7: import type { 8: ContentReplacementEntry, 9: Entry, 10: LogOption, 11: SerializedMessage, 12: TranscriptMessage, 13: } from '../../types/logs.js' 14: import { parseJSONL } from '../../utils/json.js' 15: import { 16: getProjectDir, 17: getTranscriptPath, 18: getTranscriptPathForSession, 19: isTranscriptMessage, 20: saveCustomTitle, 21: searchSessionsByCustomTitle, 22: } from '../../utils/sessionStorage.js' 23: import { jsonStringify } from '../../utils/slowOperations.js' 24: import { escapeRegExp } from '../../utils/stringUtils.js' 25: type TranscriptEntry = TranscriptMessage & { 26: forkedFrom?: { 27: sessionId: string 28: messageUuid: UUID 29: } 30: } 31: export function deriveFirstPrompt( 32: firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined, 33: ): string { 34: const content = firstUserMessage?.message?.content 35: if (!content) return 'Branched conversation' 36: const raw = 37: typeof content === 'string' 38: ? content 39: : content.find( 40: (block): block is { type: 'text'; text: string } => 41: block.type === 'text', 42: )?.text 43: if (!raw) return 'Branched conversation' 44: return ( 45: raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation' 46: ) 47: } 48: async function createFork(customTitle?: string): Promise<{ 49: sessionId: UUID 50: title: string | undefined 51: forkPath: string 52: serializedMessages: SerializedMessage[] 53: contentReplacementRecords: ContentReplacementEntry['replacements'] 54: }> { 55: const forkSessionId = randomUUID() as UUID 56: const originalSessionId = getSessionId() 57: const projectDir = getProjectDir(getOriginalCwd()) 58: const forkSessionPath = getTranscriptPathForSession(forkSessionId) 59: const currentTranscriptPath = getTranscriptPath() 60: await mkdir(projectDir, { recursive: true, mode: 0o700 }) 61: let transcriptContent: Buffer 62: try { 63: transcriptContent = await readFile(currentTranscriptPath) 64: } catch { 65: throw new Error('No conversation to branch') 66: } 67: if (transcriptContent.length === 0) { 68: throw new Error('No conversation to branch') 69: } 70: const entries = parseJSONL<Entry>(transcriptContent) 71: const mainConversationEntries = entries.filter( 72: (entry): entry is TranscriptMessage => 73: isTranscriptMessage(entry) && !entry.isSidechain, 74: ) 75: const contentReplacementRecords = entries 76: .filter( 77: (entry): entry is ContentReplacementEntry => 78: entry.type === 'content-replacement' && 79: entry.sessionId === originalSessionId, 80: ) 81: .flatMap(entry => entry.replacements) 82: if (mainConversationEntries.length === 0) { 83: throw new Error('No messages to branch') 84: } 85: let parentUuid: UUID | null = null 86: const lines: string[] = [] 87: const serializedMessages: SerializedMessage[] = [] 88: for (const entry of mainConversationEntries) { 89: const forkedEntry: TranscriptEntry = { 90: ...entry, 91: sessionId: forkSessionId, 92: parentUuid, 93: isSidechain: false, 94: forkedFrom: { 95: sessionId: originalSessionId, 96: messageUuid: entry.uuid, 97: }, 98: } 99: const serialized: SerializedMessage = { 100: ...entry, 101: sessionId: forkSessionId, 102: } 103: serializedMessages.push(serialized) 104: lines.push(jsonStringify(forkedEntry)) 105: if (entry.type !== 'progress') { 106: parentUuid = entry.uuid 107: } 108: } 109: if (contentReplacementRecords.length > 0) { 110: const forkedReplacementEntry: ContentReplacementEntry = { 111: type: 'content-replacement', 112: sessionId: forkSessionId, 113: replacements: contentReplacementRecords, 114: } 115: lines.push(jsonStringify(forkedReplacementEntry)) 116: } 117: await writeFile(forkSessionPath, lines.join('\n') + '\n', { 118: encoding: 'utf8', 119: mode: 0o600, 120: }) 121: return { 122: sessionId: forkSessionId, 123: title: customTitle, 124: forkPath: forkSessionPath, 125: serializedMessages, 126: contentReplacementRecords, 127: } 128: } 129: async function getUniqueForkName(baseName: string): Promise<string> { 130: const candidateName = `${baseName} (Branch)` 131: const existingWithExactName = await searchSessionsByCustomTitle( 132: candidateName, 133: { exact: true }, 134: ) 135: if (existingWithExactName.length === 0) { 136: return candidateName 137: } 138: const existingForks = await searchSessionsByCustomTitle(`${baseName} (Branch`) 139: const usedNumbers = new Set<number>([1]) 140: const forkNumberPattern = new RegExp( 141: `^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`, 142: ) 143: for (const session of existingForks) { 144: const match = session.customTitle?.match(forkNumberPattern) 145: if (match) { 146: if (match[1]) { 147: usedNumbers.add(parseInt(match[1], 10)) 148: } else { 149: usedNumbers.add(1) 150: } 151: } 152: } 153: let nextNumber = 2 154: while (usedNumbers.has(nextNumber)) { 155: nextNumber++ 156: } 157: return `${baseName} (Branch ${nextNumber})` 158: } 159: export async function call( 160: onDone: LocalJSXCommandOnDone, 161: context: LocalJSXCommandContext, 162: args: string, 163: ): Promise<React.ReactNode> { 164: const customTitle = args?.trim() || undefined 165: const originalSessionId = getSessionId() 166: try { 167: const { 168: sessionId, 169: title, 170: forkPath, 171: serializedMessages, 172: contentReplacementRecords, 173: } = await createFork(customTitle) 174: const now = new Date() 175: const firstPrompt = deriveFirstPrompt( 176: serializedMessages.find(m => m.type === 'user'), 177: ) 178: const baseName = title ?? firstPrompt 179: const effectiveTitle = await getUniqueForkName(baseName) 180: await saveCustomTitle(sessionId, effectiveTitle, forkPath) 181: logEvent('tengu_conversation_forked', { 182: message_count: serializedMessages.length, 183: has_custom_title: !!title, 184: }) 185: const forkLog: LogOption = { 186: date: now.toISOString().split('T')[0]!, 187: messages: serializedMessages, 188: fullPath: forkPath, 189: value: now.getTime(), 190: created: now, 191: modified: now, 192: firstPrompt, 193: messageCount: serializedMessages.length, 194: isSidechain: false, 195: sessionId, 196: customTitle: effectiveTitle, 197: contentReplacements: contentReplacementRecords, 198: } 199: const titleInfo = title ? ` "${title}"` : '' 200: const resumeHint = `\nTo resume the original: claude -r ${originalSessionId}` 201: const successMessage = `Branched conversation${titleInfo}. You are now in the branch.${resumeHint}` 202: if (context.resume) { 203: await context.resume(sessionId, forkLog, 'fork') 204: onDone(successMessage, { display: 'system' }) 205: } else { 206: onDone( 207: `Branched conversation${titleInfo}. Resume with: /resume ${sessionId}`, 208: ) 209: } 210: return null 211: } catch (error) { 212: const message = 213: error instanceof Error ? error.message : 'Unknown error occurred' 214: onDone(`Failed to branch conversation: ${message}`) 215: return null 216: } 217: }

File: src/commands/branch/index.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { Command } from '../../commands.js' 3: const branch = { 4: type: 'local-jsx', 5: name: 'branch', 6: aliases: feature('FORK_SUBAGENT') ? [] : ['fork'], 7: description: 'Create a branch of the current conversation at this point', 8: argumentHint: '[name]', 9: load: () => import('./branch.js'), 10: } satisfies Command 11: export default branch

File: src/commands/break-cache/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/bridge/bridge.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { feature } from 'bun:bundle'; 3: import { toString as qrToString } from 'qrcode'; 4: import * as React from 'react'; 5: import { useEffect, useState } from 'react'; 6: import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js'; 7: import { checkBridgeMinVersion, getBridgeDisabledReason, isEnvLessBridgeEnabled } from '../../bridge/bridgeEnabled.js'; 8: import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js'; 9: import { BRIDGE_LOGIN_INSTRUCTION, REMOTE_CONTROL_DISCONNECTED_MSG } from '../../bridge/types.js'; 10: import { Dialog } from '../../components/design-system/Dialog.js'; 11: import { ListItem } from '../../components/design-system/ListItem.js'; 12: import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js'; 13: import { useRegisterOverlay } from '../../context/overlayContext.js'; 14: import { Box, Text } from '../../ink.js'; 15: import { useKeybindings } from '../../keybindings/useKeybinding.js'; 16: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; 17: import { useAppState, useSetAppState } from '../../state/AppState.js'; 18: import type { ToolUseContext } from '../../Tool.js'; 19: import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; 20: import { logForDebugging } from '../../utils/debug.js'; 21: type Props = { 22: onDone: LocalJSXCommandOnDone; 23: name?: string; 24: }; 25: function BridgeToggle(t0) { 26: const $ = _c(10); 27: const { 28: onDone, 29: name 30: } = t0; 31: const setAppState = useSetAppState(); 32: const replBridgeConnected = useAppState(_temp); 33: const replBridgeEnabled = useAppState(_temp2); 34: const replBridgeOutboundOnly = useAppState(_temp3); 35: const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); 36: let t1; 37: if ($[0] !== name || $[1] !== onDone || $[2] !== replBridgeConnected || $[3] !== replBridgeEnabled || $[4] !== replBridgeOutboundOnly || $[5] !== setAppState) { 38: t1 = () => { 39: if ((replBridgeConnected || replBridgeEnabled) && !replBridgeOutboundOnly) { 40: setShowDisconnectDialog(true); 41: return; 42: } 43: let cancelled = false; 44: (async () => { 45: const error = await checkBridgePrerequisites(); 46: if (cancelled) { 47: return; 48: } 49: if (error) { 50: logEvent("tengu_bridge_command", { 51: action: "preflight_failed" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 52: }); 53: onDone(error, { 54: display: "system" 55: }); 56: return; 57: } 58: if (shouldShowRemoteCallout()) { 59: setAppState(prev => { 60: if (prev.showRemoteCallout) { 61: return prev; 62: } 63: return { 64: ...prev, 65: showRemoteCallout: true, 66: replBridgeInitialName: name 67: }; 68: }); 69: onDone("", { 70: display: "system" 71: }); 72: return; 73: } 74: logEvent("tengu_bridge_command", { 75: action: "connect" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 76: }); 77: setAppState(prev_0 => { 78: if (prev_0.replBridgeEnabled && !prev_0.replBridgeOutboundOnly) { 79: return prev_0; 80: } 81: return { 82: ...prev_0, 83: replBridgeEnabled: true, 84: replBridgeExplicit: true, 85: replBridgeOutboundOnly: false, 86: replBridgeInitialName: name 87: }; 88: }); 89: onDone("Remote Control connecting\u2026", { 90: display: "system" 91: }); 92: })(); 93: return () => { 94: cancelled = true; 95: }; 96: }; 97: $[0] = name; 98: $[1] = onDone; 99: $[2] = replBridgeConnected; 100: $[3] = replBridgeEnabled; 101: $[4] = replBridgeOutboundOnly; 102: $[5] = setAppState; 103: $[6] = t1; 104: } else { 105: t1 = $[6]; 106: } 107: let t2; 108: if ($[7] === Symbol.for("react.memo_cache_sentinel")) { 109: t2 = []; 110: $[7] = t2; 111: } else { 112: t2 = $[7]; 113: } 114: useEffect(t1, t2); 115: if (showDisconnectDialog) { 116: let t3; 117: if ($[8] !== onDone) { 118: t3 = <BridgeDisconnectDialog onDone={onDone} />; 119: $[8] = onDone; 120: $[9] = t3; 121: } else { 122: t3 = $[9]; 123: } 124: return t3; 125: } 126: return null; 127: } 128: function _temp3(s_1) { 129: return s_1.replBridgeOutboundOnly; 130: } 131: function _temp2(s_0) { 132: return s_0.replBridgeEnabled; 133: } 134: function _temp(s) { 135: return s.replBridgeConnected; 136: } 137: function BridgeDisconnectDialog(t0) { 138: const $ = _c(61); 139: const { 140: onDone 141: } = t0; 142: useRegisterOverlay("bridge-disconnect-dialog"); 143: const setAppState = useSetAppState(); 144: const sessionUrl = useAppState(_temp4); 145: const connectUrl = useAppState(_temp5); 146: const sessionActive = useAppState(_temp6); 147: const [focusIndex, setFocusIndex] = useState(2); 148: const [showQR, setShowQR] = useState(false); 149: const [qrText, setQrText] = useState(""); 150: const displayUrl = sessionActive ? sessionUrl : connectUrl; 151: let t1; 152: let t2; 153: if ($[0] !== displayUrl || $[1] !== showQR) { 154: t1 = () => { 155: if (!showQR || !displayUrl) { 156: setQrText(""); 157: return; 158: } 159: qrToString(displayUrl, { 160: type: "utf8", 161: errorCorrectionLevel: "L", 162: small: true 163: }).then(setQrText).catch(() => setQrText("")); 164: }; 165: t2 = [showQR, displayUrl]; 166: $[0] = displayUrl; 167: $[1] = showQR; 168: $[2] = t1; 169: $[3] = t2; 170: } else { 171: t1 = $[2]; 172: t2 = $[3]; 173: } 174: useEffect(t1, t2); 175: let t3; 176: if ($[4] !== onDone || $[5] !== setAppState) { 177: t3 = function handleDisconnect() { 178: setAppState(_temp7); 179: logEvent("tengu_bridge_command", { 180: action: "disconnect" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 181: }); 182: onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { 183: display: "system" 184: }); 185: }; 186: $[4] = onDone; 187: $[5] = setAppState; 188: $[6] = t3; 189: } else { 190: t3 = $[6]; 191: } 192: const handleDisconnect = t3; 193: let t4; 194: if ($[7] === Symbol.for("react.memo_cache_sentinel")) { 195: t4 = function handleShowQR() { 196: setShowQR(_temp8); 197: }; 198: $[7] = t4; 199: } else { 200: t4 = $[7]; 201: } 202: const handleShowQR = t4; 203: let t5; 204: if ($[8] !== onDone) { 205: t5 = function handleContinue() { 206: onDone(undefined, { 207: display: "skip" 208: }); 209: }; 210: $[8] = onDone; 211: $[9] = t5; 212: } else { 213: t5 = $[9]; 214: } 215: const handleContinue = t5; 216: let t6; 217: let t7; 218: if ($[10] === Symbol.for("react.memo_cache_sentinel")) { 219: t6 = () => setFocusIndex(_temp9); 220: t7 = () => setFocusIndex(_temp0); 221: $[10] = t6; 222: $[11] = t7; 223: } else { 224: t6 = $[10]; 225: t7 = $[11]; 226: } 227: let t8; 228: if ($[12] !== focusIndex || $[13] !== handleContinue || $[14] !== handleDisconnect) { 229: t8 = { 230: "select:next": t6, 231: "select:previous": t7, 232: "select:accept": () => { 233: if (focusIndex === 0) { 234: handleDisconnect(); 235: } else { 236: if (focusIndex === 1) { 237: handleShowQR(); 238: } else { 239: handleContinue(); 240: } 241: } 242: } 243: }; 244: $[12] = focusIndex; 245: $[13] = handleContinue; 246: $[14] = handleDisconnect; 247: $[15] = t8; 248: } else { 249: t8 = $[15]; 250: } 251: let t9; 252: if ($[16] === Symbol.for("react.memo_cache_sentinel")) { 253: t9 = { 254: context: "Select" 255: }; 256: $[16] = t9; 257: } else { 258: t9 = $[16]; 259: } 260: useKeybindings(t8, t9); 261: let T0; 262: let T1; 263: let t10; 264: let t11; 265: let t12; 266: let t13; 267: let t14; 268: let t15; 269: let t16; 270: if ($[17] !== displayUrl || $[18] !== handleContinue || $[19] !== qrText || $[20] !== showQR) { 271: const qrLines = qrText ? qrText.split("\n").filter(_temp1) : []; 272: T1 = Dialog; 273: t14 = "Remote Control"; 274: t15 = handleContinue; 275: t16 = true; 276: T0 = Box; 277: t10 = "column"; 278: t11 = 1; 279: const t17 = displayUrl ? ` at ${displayUrl}` : ""; 280: if ($[30] !== t17) { 281: t12 = <Text>This session is available via Remote Control{t17}.</Text>; 282: $[30] = t17; 283: $[31] = t12; 284: } else { 285: t12 = $[31]; 286: } 287: t13 = showQR && qrLines.length > 0 && <Box flexDirection="column">{qrLines.map(_temp10)}</Box>; 288: $[17] = displayUrl; 289: $[18] = handleContinue; 290: $[19] = qrText; 291: $[20] = showQR; 292: $[21] = T0; 293: $[22] = T1; 294: $[23] = t10; 295: $[24] = t11; 296: $[25] = t12; 297: $[26] = t13; 298: $[27] = t14; 299: $[28] = t15; 300: $[29] = t16; 301: } else { 302: T0 = $[21]; 303: T1 = $[22]; 304: t10 = $[23]; 305: t11 = $[24]; 306: t12 = $[25]; 307: t13 = $[26]; 308: t14 = $[27]; 309: t15 = $[28]; 310: t16 = $[29]; 311: } 312: const t17 = focusIndex === 0; 313: let t18; 314: if ($[32] === Symbol.for("react.memo_cache_sentinel")) { 315: t18 = <Text>Disconnect this session</Text>; 316: $[32] = t18; 317: } else { 318: t18 = $[32]; 319: } 320: let t19; 321: if ($[33] !== t17) { 322: t19 = <ListItem isFocused={t17}>{t18}</ListItem>; 323: $[33] = t17; 324: $[34] = t19; 325: } else { 326: t19 = $[34]; 327: } 328: const t20 = focusIndex === 1; 329: const t21 = showQR ? "Hide QR code" : "Show QR code"; 330: let t22; 331: if ($[35] !== t21) { 332: t22 = <Text>{t21}</Text>; 333: $[35] = t21; 334: $[36] = t22; 335: } else { 336: t22 = $[36]; 337: } 338: let t23; 339: if ($[37] !== t20 || $[38] !== t22) { 340: t23 = <ListItem isFocused={t20}>{t22}</ListItem>; 341: $[37] = t20; 342: $[38] = t22; 343: $[39] = t23; 344: } else { 345: t23 = $[39]; 346: } 347: const t24 = focusIndex === 2; 348: let t25; 349: if ($[40] === Symbol.for("react.memo_cache_sentinel")) { 350: t25 = <Text>Continue</Text>; 351: $[40] = t25; 352: } else { 353: t25 = $[40]; 354: } 355: let t26; 356: if ($[41] !== t24) { 357: t26 = <ListItem isFocused={t24}>{t25}</ListItem>; 358: $[41] = t24; 359: $[42] = t26; 360: } else { 361: t26 = $[42]; 362: } 363: let t27; 364: if ($[43] !== t19 || $[44] !== t23 || $[45] !== t26) { 365: t27 = <Box flexDirection="column">{t19}{t23}{t26}</Box>; 366: $[43] = t19; 367: $[44] = t23; 368: $[45] = t26; 369: $[46] = t27; 370: } else { 371: t27 = $[46]; 372: } 373: let t28; 374: if ($[47] === Symbol.for("react.memo_cache_sentinel")) { 375: t28 = <Text dimColor={true}>Enter to select · Esc to continue</Text>; 376: $[47] = t28; 377: } else { 378: t28 = $[47]; 379: } 380: let t29; 381: if ($[48] !== T0 || $[49] !== t10 || $[50] !== t11 || $[51] !== t12 || $[52] !== t13 || $[53] !== t27) { 382: t29 = <T0 flexDirection={t10} gap={t11}>{t12}{t13}{t27}{t28}</T0>; 383: $[48] = T0; 384: $[49] = t10; 385: $[50] = t11; 386: $[51] = t12; 387: $[52] = t13; 388: $[53] = t27; 389: $[54] = t29; 390: } else { 391: t29 = $[54]; 392: } 393: let t30; 394: if ($[55] !== T1 || $[56] !== t14 || $[57] !== t15 || $[58] !== t16 || $[59] !== t29) { 395: t30 = <T1 title={t14} onCancel={t15} hideInputGuide={t16}>{t29}</T1>; 396: $[55] = T1; 397: $[56] = t14; 398: $[57] = t15; 399: $[58] = t16; 400: $[59] = t29; 401: $[60] = t30; 402: } else { 403: t30 = $[60]; 404: } 405: return t30; 406: } 407: function _temp10(line, i_1) { 408: return <Text key={i_1}>{line}</Text>; 409: } 410: function _temp1(l) { 411: return l.length > 0; 412: } 413: function _temp0(i_0) { 414: return (i_0 - 1 + 3) % 3; 415: } 416: function _temp9(i) { 417: return (i + 1) % 3; 418: } 419: function _temp8(prev_0) { 420: return !prev_0; 421: } 422: function _temp7(prev) { 423: if (!prev.replBridgeEnabled) { 424: return prev; 425: } 426: return { 427: ...prev, 428: replBridgeEnabled: false, 429: replBridgeExplicit: false, 430: replBridgeOutboundOnly: false 431: }; 432: } 433: function _temp6(s_1) { 434: return s_1.replBridgeSessionActive; 435: } 436: function _temp5(s_0) { 437: return s_0.replBridgeConnectUrl; 438: } 439: function _temp4(s) { 440: return s.replBridgeSessionUrl; 441: } 442: async function checkBridgePrerequisites(): Promise<string | null> { 443: const { 444: waitForPolicyLimitsToLoad, 445: isPolicyAllowed 446: } = await import('../../services/policyLimits/index.js'); 447: await waitForPolicyLimitsToLoad(); 448: if (!isPolicyAllowed('allow_remote_control')) { 449: return "Remote Control is disabled by your organization's policy."; 450: } 451: const disabledReason = await getBridgeDisabledReason(); 452: if (disabledReason) { 453: return disabledReason; 454: } 455: let useV2 = isEnvLessBridgeEnabled(); 456: if (feature('KAIROS') && useV2) { 457: const { 458: isAssistantMode 459: } = await import('../../assistant/index.js'); 460: if (isAssistantMode()) { 461: useV2 = false; 462: } 463: } 464: const versionError = useV2 ? await checkEnvLessBridgeMinVersion() : checkBridgeMinVersion(); 465: if (versionError) { 466: return versionError; 467: } 468: if (!getBridgeAccessToken()) { 469: return BRIDGE_LOGIN_INSTRUCTION; 470: } 471: logForDebugging('[bridge] Prerequisites passed, enabling bridge'); 472: return null; 473: } 474: export async function call(onDone: LocalJSXCommandOnDone, _context: ToolUseContext & LocalJSXCommandContext, args: string): Promise<React.ReactNode> { 475: const name = args.trim() || undefined; 476: return <BridgeToggle onDone={onDone} name={name} />; 477: }

File: src/commands/bridge/index.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' 3: import type { Command } from '../../commands.js' 4: function isEnabled(): boolean { 5: if (!feature('BRIDGE_MODE')) { 6: return false 7: } 8: return isBridgeEnabled() 9: } 10: const bridge = { 11: type: 'local-jsx', 12: name: 'remote-control', 13: aliases: ['rc'], 14: description: 'Connect this terminal for remote-control sessions', 15: argumentHint: '[name]', 16: isEnabled, 17: get isHidden() { 18: return !isEnabled() 19: }, 20: immediate: true, 21: load: () => import('./bridge.js'), 22: } satisfies Command 23: export default bridge

File: src/commands/btw/btw.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import * as React from 'react'; 3: import { useEffect, useRef, useState } from 'react'; 4: import { useInterval } from 'usehooks-ts'; 5: import type { CommandResultDisplay } from '../../commands.js'; 6: import { Markdown } from '../../components/Markdown.js'; 7: import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js'; 8: import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js'; 9: import { getSystemPrompt } from '../../constants/prompts.js'; 10: import { useModalOrTerminalSize } from '../../context/modalContext.js'; 11: import { getSystemContext, getUserContext } from '../../context.js'; 12: import { useTerminalSize } from '../../hooks/useTerminalSize.js'; 13: import ScrollBox, { type ScrollBoxHandle } from '../../ink/components/ScrollBox.js'; 14: import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; 15: import { Box, Text } from '../../ink.js'; 16: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 17: import type { Message } from '../../types/message.js'; 18: import { createAbortController } from '../../utils/abortController.js'; 19: import { saveGlobalConfig } from '../../utils/config.js'; 20: import { errorMessage } from '../../utils/errors.js'; 21: import { type CacheSafeParams, getLastCacheSafeParams } from '../../utils/forkedAgent.js'; 22: import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; 23: import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'; 24: import { runSideQuestion } from '../../utils/sideQuestion.js'; 25: import { asSystemPrompt } from '../../utils/systemPromptType.js'; 26: type BtwComponentProps = { 27: question: string; 28: context: ProcessUserInputContext; 29: onDone: (result?: string, options?: { 30: display?: CommandResultDisplay; 31: }) => void; 32: }; 33: const CHROME_ROWS = 5; 34: const OUTER_CHROME_ROWS = 6; 35: const SCROLL_LINES = 3; 36: function BtwSideQuestion(t0) { 37: const $ = _c(25); 38: const { 39: question, 40: context, 41: onDone 42: } = t0; 43: const [response, setResponse] = useState(null); 44: const [error, setError] = useState(null); 45: const [frame, setFrame] = useState(0); 46: const scrollRef = useRef(null); 47: const { 48: rows 49: } = useModalOrTerminalSize(useTerminalSize()); 50: let t1; 51: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 52: t1 = () => setFrame(_temp); 53: $[0] = t1; 54: } else { 55: t1 = $[0]; 56: } 57: useInterval(t1, response || error ? null : 80); 58: let t2; 59: if ($[1] !== onDone) { 60: t2 = function handleKeyDown(e) { 61: if (e.key === "escape" || e.key === "return" || e.key === " " || e.ctrl && (e.key === "c" || e.key === "d")) { 62: e.preventDefault(); 63: onDone(undefined, { 64: display: "skip" 65: }); 66: return; 67: } 68: if (e.key === "up" || e.ctrl && e.key === "p") { 69: e.preventDefault(); 70: scrollRef.current?.scrollBy(-SCROLL_LINES); 71: } 72: if (e.key === "down" || e.ctrl && e.key === "n") { 73: e.preventDefault(); 74: scrollRef.current?.scrollBy(SCROLL_LINES); 75: } 76: }; 77: $[1] = onDone; 78: $[2] = t2; 79: } else { 80: t2 = $[2]; 81: } 82: const handleKeyDown = t2; 83: let t3; 84: let t4; 85: if ($[3] !== context || $[4] !== question) { 86: t3 = () => { 87: const abortController = createAbortController(); 88: const fetchResponse = async function fetchResponse() { 89: ; 90: try { 91: const cacheSafeParams = await buildCacheSafeParams(context); 92: const result = await runSideQuestion({ 93: question, 94: cacheSafeParams 95: }); 96: if (!abortController.signal.aborted) { 97: if (result.response) { 98: setResponse(result.response); 99: } else { 100: setError("No response received"); 101: } 102: } 103: } catch (t5) { 104: const err = t5; 105: if (!abortController.signal.aborted) { 106: setError(errorMessage(err) || "Failed to get response"); 107: } 108: } 109: }; 110: fetchResponse(); 111: return () => { 112: abortController.abort(); 113: }; 114: }; 115: t4 = [question, context]; 116: $[3] = context; 117: $[4] = question; 118: $[5] = t3; 119: $[6] = t4; 120: } else { 121: t3 = $[5]; 122: t4 = $[6]; 123: } 124: useEffect(t3, t4); 125: const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS); 126: let t5; 127: if ($[7] === Symbol.for("react.memo_cache_sentinel")) { 128: t5 = <Text color="warning" bold={true}>/btw{" "}</Text>; 129: $[7] = t5; 130: } else { 131: t5 = $[7]; 132: } 133: let t6; 134: if ($[8] !== question) { 135: t6 = <Box>{t5}<Text dimColor={true}>{question}</Text></Box>; 136: $[8] = question; 137: $[9] = t6; 138: } else { 139: t6 = $[9]; 140: } 141: let t7; 142: if ($[10] !== error || $[11] !== frame || $[12] !== response) { 143: t7 = <ScrollBox ref={scrollRef} flexDirection="column" flexGrow={1}>{error ? <Text color="error">{error}</Text> : response ? <Markdown>{response}</Markdown> : <Box><SpinnerGlyph frame={frame} messageColor="warning" /><Text color="warning">Answering...</Text></Box>}</ScrollBox>; 144: $[10] = error; 145: $[11] = frame; 146: $[12] = response; 147: $[13] = t7; 148: } else { 149: t7 = $[13]; 150: } 151: let t8; 152: if ($[14] !== maxContentHeight || $[15] !== t7) { 153: t8 = <Box marginTop={1} marginLeft={2} maxHeight={maxContentHeight}>{t7}</Box>; 154: $[14] = maxContentHeight; 155: $[15] = t7; 156: $[16] = t8; 157: } else { 158: t8 = $[16]; 159: } 160: let t9; 161: if ($[17] !== error || $[18] !== response) { 162: t9 = (response || error) && <Box marginTop={1}><Text dimColor={true}>{UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to dismiss</Text></Box>; 163: $[17] = error; 164: $[18] = response; 165: $[19] = t9; 166: } else { 167: t9 = $[19]; 168: } 169: let t10; 170: if ($[20] !== handleKeyDown || $[21] !== t6 || $[22] !== t8 || $[23] !== t9) { 171: t10 = <Box flexDirection="column" paddingLeft={2} marginTop={1} tabIndex={0} autoFocus={true} onKeyDown={handleKeyDown}>{t6}{t8}{t9}</Box>; 172: $[20] = handleKeyDown; 173: $[21] = t6; 174: $[22] = t8; 175: $[23] = t9; 176: $[24] = t10; 177: } else { 178: t10 = $[24]; 179: } 180: return t10; 181: } 182: function _temp(f) { 183: return f + 1; 184: } 185: function stripInProgressAssistantMessage(messages: Message[]): Message[] { 186: const last = messages.at(-1); 187: if (last?.type === 'assistant' && last.message.stop_reason === null) { 188: return messages.slice(0, -1); 189: } 190: return messages; 191: } 192: async function buildCacheSafeParams(context: ProcessUserInputContext): Promise<CacheSafeParams> { 193: const forkContextMessages = getMessagesAfterCompactBoundary(stripInProgressAssistantMessage(context.messages)); 194: const saved = getLastCacheSafeParams(); 195: if (saved) { 196: return { 197: systemPrompt: saved.systemPrompt, 198: userContext: saved.userContext, 199: systemContext: saved.systemContext, 200: toolUseContext: context, 201: forkContextMessages 202: }; 203: } 204: const [rawSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(context.options.tools, context.options.mainLoopModel, [], context.options.mcpClients), getUserContext(), getSystemContext()]); 205: return { 206: systemPrompt: asSystemPrompt(rawSystemPrompt), 207: userContext, 208: systemContext, 209: toolUseContext: context, 210: forkContextMessages 211: }; 212: } 213: export async function call(onDone: LocalJSXCommandOnDone, context: ProcessUserInputContext, args: string): Promise<React.ReactNode> { 214: const question = args?.trim(); 215: if (!question) { 216: onDone('Usage: /btw <your question>', { 217: display: 'system' 218: }); 219: return null; 220: } 221: saveGlobalConfig(current => ({ 222: ...current, 223: btwUseCount: current.btwUseCount + 1 224: })); 225: return <BtwSideQuestion question={question} context={context} onDone={onDone} />; 226: }

File: src/commands/btw/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const btw = { 3: type: 'local-jsx', 4: name: 'btw', 5: description: 6: 'Ask a quick side question without interrupting the main conversation', 7: immediate: true, 8: argumentHint: '<question>', 9: load: () => import('./btw.js'), 10: } satisfies Command 11: export default btw

File: src/commands/bughunter/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/chrome/chrome.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { useState } from 'react'; 3: import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js'; 4: import { Dialog } from '../../components/design-system/Dialog.js'; 5: import { Box, Text } from '../../ink.js'; 6: import { useAppState } from '../../state/AppState.js'; 7: import { isClaudeAISubscriber } from '../../utils/auth.js'; 8: import { openBrowser } from '../../utils/browser.js'; 9: import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, openInChrome } from '../../utils/claudeInChrome/common.js'; 10: import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js'; 11: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; 12: import { env } from '../../utils/env.js'; 13: import { isRunningOnHomespace } from '../../utils/envUtils.js'; 14: const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'; 15: const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'; 16: const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect'; 17: type MenuAction = 'install-extension' | 'reconnect' | 'manage-permissions' | 'toggle-default'; 18: type Props = { 19: onDone: (result?: string) => void; 20: isExtensionInstalled: boolean; 21: configEnabled: boolean | undefined; 22: isClaudeAISubscriber: boolean; 23: isWSL: boolean; 24: }; 25: function ClaudeInChromeMenu(t0) { 26: const $ = _c(41); 27: const { 28: onDone, 29: isExtensionInstalled: installed, 30: configEnabled, 31: isClaudeAISubscriber, 32: isWSL 33: } = t0; 34: const mcpClients = useAppState(_temp); 35: const [selectKey, setSelectKey] = useState(0); 36: const [enabledByDefault, setEnabledByDefault] = useState(configEnabled ?? false); 37: const [showInstallHint, setShowInstallHint] = useState(false); 38: const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed); 39: let t1; 40: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 41: t1 = false && isRunningOnHomespace(); 42: $[0] = t1; 43: } else { 44: t1 = $[0]; 45: } 46: const isHomespace = t1; 47: let t2; 48: if ($[1] !== mcpClients) { 49: t2 = mcpClients.find(_temp2); 50: $[1] = mcpClients; 51: $[2] = t2; 52: } else { 53: t2 = $[2]; 54: } 55: const chromeClient = t2; 56: const isConnected = chromeClient?.type === "connected"; 57: let t3; 58: if ($[3] === Symbol.for("react.memo_cache_sentinel")) { 59: t3 = function openUrl(url) { 60: if (isHomespace) { 61: openBrowser(url); 62: } else { 63: openInChrome(url); 64: } 65: }; 66: $[3] = t3; 67: } else { 68: t3 = $[3]; 69: } 70: const openUrl = t3; 71: let t4; 72: if ($[4] !== enabledByDefault) { 73: t4 = function handleAction(action) { 74: bb22: switch (action) { 75: case "install-extension": 76: { 77: setSelectKey(_temp3); 78: setShowInstallHint(true); 79: openUrl(CHROME_EXTENSION_URL); 80: break bb22; 81: } 82: case "reconnect": 83: { 84: setSelectKey(_temp4); 85: isChromeExtensionInstalled().then(installed_0 => { 86: setIsExtensionInstalled(installed_0); 87: if (installed_0) { 88: setShowInstallHint(false); 89: } 90: }); 91: openUrl(CHROME_RECONNECT_URL); 92: break bb22; 93: } 94: case "manage-permissions": 95: { 96: setSelectKey(_temp5); 97: openUrl(CHROME_PERMISSIONS_URL); 98: break bb22; 99: } 100: case "toggle-default": 101: { 102: const newValue = !enabledByDefault; 103: saveGlobalConfig(current => ({ 104: ...current, 105: claudeInChromeDefaultEnabled: newValue 106: })); 107: setEnabledByDefault(newValue); 108: } 109: } 110: }; 111: $[4] = enabledByDefault; 112: $[5] = t4; 113: } else { 114: t4 = $[5]; 115: } 116: const handleAction = t4; 117: let options; 118: if ($[6] !== enabledByDefault || $[7] !== isExtensionInstalled) { 119: options = []; 120: const requiresExtensionSuffix = isExtensionInstalled ? "" : " (requires extension)"; 121: if (!isExtensionInstalled && !isHomespace) { 122: let t5; 123: if ($[9] === Symbol.for("react.memo_cache_sentinel")) { 124: t5 = { 125: label: "Install Chrome extension", 126: value: "install-extension" 127: }; 128: $[9] = t5; 129: } else { 130: t5 = $[9]; 131: } 132: options.push(t5); 133: } 134: let t5; 135: if ($[10] === Symbol.for("react.memo_cache_sentinel")) { 136: t5 = <Text>Manage permissions</Text>; 137: $[10] = t5; 138: } else { 139: t5 = $[10]; 140: } 141: let t6; 142: if ($[11] !== requiresExtensionSuffix) { 143: t6 = { 144: label: <>{t5}<Text dimColor={true}>{requiresExtensionSuffix}</Text></>, 145: value: "manage-permissions" 146: }; 147: $[11] = requiresExtensionSuffix; 148: $[12] = t6; 149: } else { 150: t6 = $[12]; 151: } 152: let t7; 153: if ($[13] === Symbol.for("react.memo_cache_sentinel")) { 154: t7 = <Text>Reconnect extension</Text>; 155: $[13] = t7; 156: } else { 157: t7 = $[13]; 158: } 159: let t8; 160: if ($[14] !== requiresExtensionSuffix) { 161: t8 = { 162: label: <>{t7}<Text dimColor={true}>{requiresExtensionSuffix}</Text></>, 163: value: "reconnect" 164: }; 165: $[14] = requiresExtensionSuffix; 166: $[15] = t8; 167: } else { 168: t8 = $[15]; 169: } 170: const t9 = `Enabled by default: ${enabledByDefault ? "Yes" : "No"}`; 171: let t10; 172: if ($[16] !== t9) { 173: t10 = { 174: label: t9, 175: value: "toggle-default" 176: }; 177: $[16] = t9; 178: $[17] = t10; 179: } else { 180: t10 = $[17]; 181: } 182: options.push(t6, t8, t10); 183: $[6] = enabledByDefault; 184: $[7] = isExtensionInstalled; 185: $[8] = options; 186: } else { 187: options = $[8]; 188: } 189: const isDisabled = isWSL || true && !isClaudeAISubscriber; 190: let t5; 191: if ($[18] !== onDone) { 192: t5 = () => onDone(); 193: $[18] = onDone; 194: $[19] = t5; 195: } else { 196: t5 = $[19]; 197: } 198: let t6; 199: if ($[20] === Symbol.for("react.memo_cache_sentinel")) { 200: t6 = <Text>Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network requests.</Text>; 201: $[20] = t6; 202: } else { 203: t6 = $[20]; 204: } 205: let t7; 206: if ($[21] !== isWSL) { 207: t7 = isWSL && <Text color="error">Claude in Chrome is not supported in WSL at this time.</Text>; 208: $[21] = isWSL; 209: $[22] = t7; 210: } else { 211: t7 = $[22]; 212: } 213: let t8; 214: if ($[23] !== isClaudeAISubscriber) { 215: t8 = true && !isClaudeAISubscriber && <Text color="error">Claude in Chrome requires a claude.ai subscription.</Text>; 216: $[23] = isClaudeAISubscriber; 217: $[24] = t8; 218: } else { 219: t8 = $[24]; 220: } 221: let t9; 222: if ($[25] !== handleAction || $[26] !== isConnected || $[27] !== isDisabled || $[28] !== isExtensionInstalled || $[29] !== options || $[30] !== selectKey || $[31] !== showInstallHint) { 223: t9 = !isDisabled && <>{!isHomespace && <Box flexDirection="column"><Text>Status:{" "}{isConnected ? <Text color="success">Enabled</Text> : <Text color="inactive">Disabled</Text>}</Text><Text>Extension:{" "}{isExtensionInstalled ? <Text color="success">Installed</Text> : <Text color="warning">Not detected</Text>}</Text></Box>}<Select key={selectKey} options={options} onChange={handleAction} hideIndexes={true} />{showInstallHint && <Text color="warning">Once installed, select {"\"Reconnect extension\""} to connect.</Text>}<Text><Text dimColor={true}>Usage: </Text><Text>claude --chrome</Text><Text dimColor={true}> or </Text><Text>claude --no-chrome</Text></Text><Text dimColor={true}>Site-level permissions are inherited from the Chrome extension. Manage permissions in the Chrome extension settings to control which sites Claude can browse, click, and type on.</Text></>; 224: $[25] = handleAction; 225: $[26] = isConnected; 226: $[27] = isDisabled; 227: $[28] = isExtensionInstalled; 228: $[29] = options; 229: $[30] = selectKey; 230: $[31] = showInstallHint; 231: $[32] = t9; 232: } else { 233: t9 = $[32]; 234: } 235: let t10; 236: if ($[33] === Symbol.for("react.memo_cache_sentinel")) { 237: t10 = <Text dimColor={true}>Learn more: https: 238: $[33] = t10; 239: } else { 240: t10 = $[33]; 241: } 242: let t11; 243: if ($[34] !== t7 || $[35] !== t8 || $[36] !== t9) { 244: t11 = <Box flexDirection="column" gap={1}>{t6}{t7}{t8}{t9}{t10}</Box>; 245: $[34] = t7; 246: $[35] = t8; 247: $[36] = t9; 248: $[37] = t11; 249: } else { 250: t11 = $[37]; 251: } 252: let t12; 253: if ($[38] !== t11 || $[39] !== t5) { 254: t12 = <Dialog title="Claude in Chrome (Beta)" onCancel={t5} color="chromeYellow">{t11}</Dialog>; 255: $[38] = t11; 256: $[39] = t5; 257: $[40] = t12; 258: } else { 259: t12 = $[40]; 260: } 261: return t12; 262: } 263: function _temp5(k) { 264: return k + 1; 265: } 266: function _temp4(k_0) { 267: return k_0 + 1; 268: } 269: function _temp3(k_1) { 270: return k_1 + 1; 271: } 272: function _temp2(c) { 273: return c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME; 274: } 275: function _temp(s) { 276: return s.mcp.clients; 277: } 278: export const call = async function (onDone: (result?: string) => void): Promise<React.ReactNode> { 279: const isExtensionInstalled = await isChromeExtensionInstalled(); 280: const config = getGlobalConfig(); 281: const isSubscriber = isClaudeAISubscriber(); 282: const isWSL = env.isWslEnvironment(); 283: return <ClaudeInChromeMenu onDone={onDone} isExtensionInstalled={isExtensionInstalled} configEnabled={config.claudeInChromeDefaultEnabled} isClaudeAISubscriber={isSubscriber} isWSL={isWSL} />; 284: };

File: src/commands/chrome/index.ts

typescript 1: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 2: import type { Command } from '../../commands.js' 3: const command: Command = { 4: name: 'chrome', 5: description: 'Claude in Chrome (Beta) settings', 6: availability: ['claude-ai'], 7: isEnabled: () => !getIsNonInteractiveSession(), 8: type: 'local-jsx', 9: load: () => import('./chrome.js'), 10: } 11: export default command

File: src/commands/clear/caches.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { 3: clearInvokedSkills, 4: setLastEmittedDate, 5: } from '../../bootstrap/state.js' 6: import { clearCommandsCache } from '../../commands.js' 7: import { getSessionStartDate } from '../../constants/common.js' 8: import { 9: getGitStatus, 10: getSystemContext, 11: getUserContext, 12: setSystemPromptInjection, 13: } from '../../context.js' 14: import { clearFileSuggestionCaches } from '../../hooks/fileSuggestions.js' 15: import { clearAllPendingCallbacks } from '../../hooks/useSwarmPermissionPoller.js' 16: import { clearAllDumpState } from '../../services/api/dumpPrompts.js' 17: import { resetPromptCacheBreakDetection } from '../../services/api/promptCacheBreakDetection.js' 18: import { clearAllSessions } from '../../services/api/sessionIngress.js' 19: import { runPostCompactCleanup } from '../../services/compact/postCompactCleanup.js' 20: import { resetAllLSPDiagnosticState } from '../../services/lsp/LSPDiagnosticRegistry.js' 21: import { clearTrackedMagicDocs } from '../../services/MagicDocs/magicDocs.js' 22: import { clearDynamicSkills } from '../../skills/loadSkillsDir.js' 23: import { resetSentSkillNames } from '../../utils/attachments.js' 24: import { clearCommandPrefixCaches } from '../../utils/bash/commands.js' 25: import { resetGetMemoryFilesCache } from '../../utils/claudemd.js' 26: import { clearRepositoryCaches } from '../../utils/detectRepository.js' 27: import { clearResolveGitDirCache } from '../../utils/git/gitFilesystem.js' 28: import { clearStoredImagePaths } from '../../utils/imageStore.js' 29: import { clearSessionEnvVars } from '../../utils/sessionEnvVars.js' 30: export function clearSessionCaches( 31: preservedAgentIds: ReadonlySet<string> = new Set(), 32: ): void { 33: const hasPreserved = preservedAgentIds.size > 0 34: getUserContext.cache.clear?.() 35: getSystemContext.cache.clear?.() 36: getGitStatus.cache.clear?.() 37: getSessionStartDate.cache.clear?.() 38: clearFileSuggestionCaches() 39: clearCommandsCache() 40: if (!hasPreserved) resetPromptCacheBreakDetection() 41: setSystemPromptInjection(null) 42: setLastEmittedDate(null) 43: runPostCompactCleanup() 44: resetSentSkillNames() 45: resetGetMemoryFilesCache('session_start') 46: clearStoredImagePaths() 47: clearAllSessions() 48: if (!hasPreserved) clearAllPendingCallbacks() 49: if (process.env.USER_TYPE === 'ant') { 50: void import('../../tools/TungstenTool/TungstenTool.js').then( 51: ({ clearSessionsWithTungstenUsage, resetInitializationState }) => { 52: clearSessionsWithTungstenUsage() 53: resetInitializationState() 54: }, 55: ) 56: } 57: if (feature('COMMIT_ATTRIBUTION')) { 58: void import('../../utils/attributionHooks.js').then( 59: ({ clearAttributionCaches }) => clearAttributionCaches(), 60: ) 61: } 62: clearRepositoryCaches() 63: clearCommandPrefixCaches() 64: if (!hasPreserved) clearAllDumpState() 65: clearInvokedSkills(preservedAgentIds) 66: clearResolveGitDirCache() 67: clearDynamicSkills() 68: resetAllLSPDiagnosticState() 69: clearTrackedMagicDocs() 70: clearSessionEnvVars() 71: void import('../../tools/WebFetchTool/utils.js').then( 72: ({ clearWebFetchCache }) => clearWebFetchCache(), 73: ) 74: void import('../../tools/ToolSearchTool/ToolSearchTool.js').then( 75: ({ clearToolSearchDescriptionCache }) => clearToolSearchDescriptionCache(), 76: ) 77: void import('../../tools/AgentTool/loadAgentsDir.js').then( 78: ({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache(), 79: ) 80: void import('../../tools/SkillTool/prompt.js').then(({ clearPromptCache }) => 81: clearPromptCache(), 82: ) 83: }

File: src/commands/clear/clear.ts

typescript 1: import type { LocalCommandCall } from '../../types/command.js' 2: import { clearConversation } from './conversation.js' 3: export const call: LocalCommandCall = async (_, context) => { 4: await clearConversation(context) 5: return { type: 'text', value: '' } 6: }

File: src/commands/clear/conversation.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { randomUUID, type UUID } from 'crypto' 3: import { 4: getLastMainRequestId, 5: getOriginalCwd, 6: getSessionId, 7: regenerateSessionId, 8: } from '../../bootstrap/state.js' 9: import { 10: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 11: logEvent, 12: } from '../../services/analytics/index.js' 13: import type { AppState } from '../../state/AppState.js' 14: import { isInProcessTeammateTask } from '../../tasks/InProcessTeammateTask/types.js' 15: import { 16: isLocalAgentTask, 17: type LocalAgentTaskState, 18: } from '../../tasks/LocalAgentTask/LocalAgentTask.js' 19: import { isLocalShellTask } from '../../tasks/LocalShellTask/guards.js' 20: import { asAgentId } from '../../types/ids.js' 21: import type { Message } from '../../types/message.js' 22: import { createEmptyAttributionState } from '../../utils/commitAttribution.js' 23: import type { FileStateCache } from '../../utils/fileStateCache.js' 24: import { 25: executeSessionEndHooks, 26: getSessionEndHookTimeoutMs, 27: } from '../../utils/hooks.js' 28: import { logError } from '../../utils/log.js' 29: import { clearAllPlanSlugs } from '../../utils/plans.js' 30: import { setCwd } from '../../utils/Shell.js' 31: import { processSessionStartHooks } from '../../utils/sessionStart.js' 32: import { 33: clearSessionMetadata, 34: getAgentTranscriptPath, 35: resetSessionFilePointer, 36: saveWorktreeState, 37: } from '../../utils/sessionStorage.js' 38: import { 39: evictTaskOutput, 40: initTaskOutputAsSymlink, 41: } from '../../utils/task/diskOutput.js' 42: import { getCurrentWorktreeSession } from '../../utils/worktree.js' 43: import { clearSessionCaches } from './caches.js' 44: export async function clearConversation({ 45: setMessages, 46: readFileState, 47: discoveredSkillNames, 48: loadedNestedMemoryPaths, 49: getAppState, 50: setAppState, 51: setConversationId, 52: }: { 53: setMessages: (updater: (prev: Message[]) => Message[]) => void 54: readFileState: FileStateCache 55: discoveredSkillNames?: Set<string> 56: loadedNestedMemoryPaths?: Set<string> 57: getAppState?: () => AppState 58: setAppState?: (f: (prev: AppState) => AppState) => void 59: setConversationId?: (id: UUID) => void 60: }): Promise<void> { 61: const sessionEndTimeoutMs = getSessionEndHookTimeoutMs() 62: await executeSessionEndHooks('clear', { 63: getAppState, 64: setAppState, 65: signal: AbortSignal.timeout(sessionEndTimeoutMs), 66: timeoutMs: sessionEndTimeoutMs, 67: }) 68: const lastRequestId = getLastMainRequestId() 69: if (lastRequestId) { 70: logEvent('tengu_cache_eviction_hint', { 71: scope: 72: 'conversation_clear' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 73: last_request_id: 74: lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 75: }) 76: } 77: const preservedAgentIds = new Set<string>() 78: const preservedLocalAgents: LocalAgentTaskState[] = [] 79: const shouldKillTask = (task: AppState['tasks'][string]): boolean => 80: 'isBackgrounded' in task && task.isBackgrounded === false 81: if (getAppState) { 82: for (const task of Object.values(getAppState().tasks)) { 83: if (shouldKillTask(task)) continue 84: if (isLocalAgentTask(task)) { 85: preservedAgentIds.add(task.agentId) 86: preservedLocalAgents.push(task) 87: } else if (isInProcessTeammateTask(task)) { 88: preservedAgentIds.add(task.identity.agentId) 89: } 90: } 91: } 92: setMessages(() => []) 93: if (feature('PROACTIVE') || feature('KAIROS')) { 94: const { setContextBlocked } = require('../../proactive/index.js') 95: setContextBlocked(false) 96: } 97: if (setConversationId) { 98: setConversationId(randomUUID()) 99: } 100: clearSessionCaches(preservedAgentIds) 101: setCwd(getOriginalCwd()) 102: readFileState.clear() 103: discoveredSkillNames?.clear() 104: loadedNestedMemoryPaths?.clear() 105: if (setAppState) { 106: setAppState(prev => { 107: const nextTasks: AppState['tasks'] = {} 108: for (const [taskId, task] of Object.entries(prev.tasks)) { 109: if (!shouldKillTask(task)) { 110: nextTasks[taskId] = task 111: continue 112: } 113: try { 114: if (task.status === 'running') { 115: if (isLocalShellTask(task)) { 116: task.shellCommand?.kill() 117: task.shellCommand?.cleanup() 118: if (task.cleanupTimeoutId) { 119: clearTimeout(task.cleanupTimeoutId) 120: } 121: } 122: if ('abortController' in task) { 123: task.abortController?.abort() 124: } 125: if ('unregisterCleanup' in task) { 126: task.unregisterCleanup?.() 127: } 128: } 129: } catch (error) { 130: logError(error) 131: } 132: void evictTaskOutput(taskId) 133: } 134: return { 135: ...prev, 136: tasks: nextTasks, 137: attribution: createEmptyAttributionState(), 138: standaloneAgentContext: undefined, 139: fileHistory: { 140: snapshots: [], 141: trackedFiles: new Set(), 142: snapshotSequence: 0, 143: }, 144: mcp: { 145: clients: [], 146: tools: [], 147: commands: [], 148: resources: {}, 149: pluginReconnectKey: prev.mcp.pluginReconnectKey, 150: }, 151: } 152: }) 153: } 154: clearAllPlanSlugs() 155: clearSessionMetadata() 156: regenerateSessionId({ setCurrentAsParent: true }) 157: if (process.env.USER_TYPE === 'ant' && process.env.CLAUDE_CODE_SESSION_ID) { 158: process.env.CLAUDE_CODE_SESSION_ID = getSessionId() 159: } 160: await resetSessionFilePointer() 161: for (const task of preservedLocalAgents) { 162: if (task.status !== 'running') continue 163: void initTaskOutputAsSymlink( 164: task.id, 165: getAgentTranscriptPath(asAgentId(task.agentId)), 166: ) 167: } 168: if (feature('COORDINATOR_MODE')) { 169: const { saveMode } = require('../../utils/sessionStorage.js') 170: const { 171: isCoordinatorMode, 172: } = require('../../coordinator/coordinatorMode.js') 173: saveMode(isCoordinatorMode() ? 'coordinator' : 'normal') 174: } 175: const worktreeSession = getCurrentWorktreeSession() 176: if (worktreeSession) { 177: saveWorktreeState(worktreeSession) 178: } 179: const hookMessages = await processSessionStartHooks('clear') 180: if (hookMessages.length > 0) { 181: setMessages(() => hookMessages) 182: } 183: }

File: src/commands/clear/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const clear = { 3: type: 'local', 4: name: 'clear', 5: description: 'Clear conversation history and free up context', 6: aliases: ['reset', 'new'], 7: supportsNonInteractive: false, 8: load: () => import('./clear.js'), 9: } satisfies Command 10: export default clear

File: src/commands/color/color.ts

typescript 1: import type { UUID } from 'crypto' 2: import { getSessionId } from '../../bootstrap/state.js' 3: import type { ToolUseContext } from '../../Tool.js' 4: import { 5: AGENT_COLORS, 6: type AgentColorName, 7: } from '../../tools/AgentTool/agentColorManager.js' 8: import type { 9: LocalJSXCommandContext, 10: LocalJSXCommandOnDone, 11: } from '../../types/command.js' 12: import { 13: getTranscriptPath, 14: saveAgentColor, 15: } from '../../utils/sessionStorage.js' 16: import { isTeammate } from '../../utils/teammate.js' 17: const RESET_ALIASES = ['default', 'reset', 'none', 'gray', 'grey'] as const 18: export async function call( 19: onDone: LocalJSXCommandOnDone, 20: context: ToolUseContext & LocalJSXCommandContext, 21: args: string, 22: ): Promise<null> { 23: if (isTeammate()) { 24: onDone( 25: 'Cannot set color: This session is a swarm teammate. Teammate colors are assigned by the team leader.', 26: { display: 'system' }, 27: ) 28: return null 29: } 30: if (!args || args.trim() === '') { 31: const colorList = AGENT_COLORS.join(', ') 32: onDone(`Please provide a color. Available colors: ${colorList}, default`, { 33: display: 'system', 34: }) 35: return null 36: } 37: const colorArg = args.trim().toLowerCase() 38: if (RESET_ALIASES.includes(colorArg as (typeof RESET_ALIASES)[number])) { 39: const sessionId = getSessionId() as UUID 40: const fullPath = getTranscriptPath() 41: await saveAgentColor(sessionId, 'default', fullPath) 42: context.setAppState(prev => ({ 43: ...prev, 44: standaloneAgentContext: { 45: ...prev.standaloneAgentContext, 46: name: prev.standaloneAgentContext?.name ?? '', 47: color: undefined, 48: }, 49: })) 50: onDone('Session color reset to default', { display: 'system' }) 51: return null 52: } 53: if (!AGENT_COLORS.includes(colorArg as AgentColorName)) { 54: const colorList = AGENT_COLORS.join(', ') 55: onDone( 56: `Invalid color "${colorArg}". Available colors: ${colorList}, default`, 57: { display: 'system' }, 58: ) 59: return null 60: } 61: const sessionId = getSessionId() as UUID 62: const fullPath = getTranscriptPath() 63: await saveAgentColor(sessionId, colorArg, fullPath) 64: context.setAppState(prev => ({ 65: ...prev, 66: standaloneAgentContext: { 67: ...prev.standaloneAgentContext, 68: name: prev.standaloneAgentContext?.name ?? '', 69: color: colorArg as AgentColorName, 70: }, 71: })) 72: onDone(`Session color set to: ${colorArg}`, { display: 'system' }) 73: return null 74: }

File: src/commands/color/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const color = { 3: type: 'local-jsx', 4: name: 'color', 5: description: 'Set the prompt bar color for this session', 6: immediate: true, 7: argumentHint: '<color|default>', 8: load: () => import('./color.js'), 9: } satisfies Command 10: export default color

File: src/commands/compact/compact.ts

typescript 1: import { feature } from 'bun:bundle' 2: import chalk from 'chalk' 3: import { markPostCompaction } from 'src/bootstrap/state.js' 4: import { getSystemPrompt } from '../../constants/prompts.js' 5: import { getSystemContext, getUserContext } from '../../context.js' 6: import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js' 7: import { notifyCompaction } from '../../services/api/promptCacheBreakDetection.js' 8: import { 9: type CompactionResult, 10: compactConversation, 11: ERROR_MESSAGE_INCOMPLETE_RESPONSE, 12: ERROR_MESSAGE_NOT_ENOUGH_MESSAGES, 13: ERROR_MESSAGE_USER_ABORT, 14: mergeHookInstructions, 15: } from '../../services/compact/compact.js' 16: import { suppressCompactWarning } from '../../services/compact/compactWarningState.js' 17: import { microcompactMessages } from '../../services/compact/microCompact.js' 18: import { runPostCompactCleanup } from '../../services/compact/postCompactCleanup.js' 19: import { trySessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js' 20: import { setLastSummarizedMessageId } from '../../services/SessionMemory/sessionMemoryUtils.js' 21: import type { ToolUseContext } from '../../Tool.js' 22: import type { LocalCommandCall } from '../../types/command.js' 23: import type { Message } from '../../types/message.js' 24: import { hasExactErrorMessage } from '../../utils/errors.js' 25: import { executePreCompactHooks } from '../../utils/hooks.js' 26: import { logError } from '../../utils/log.js' 27: import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' 28: import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js' 29: import { 30: buildEffectiveSystemPrompt, 31: type SystemPrompt, 32: } from '../../utils/systemPrompt.js' 33: const reactiveCompact = feature('REACTIVE_COMPACT') 34: ? (require('../../services/compact/reactiveCompact.js') as typeof import('../../services/compact/reactiveCompact.js')) 35: : null 36: export const call: LocalCommandCall = async (args, context) => { 37: const { abortController } = context 38: let { messages } = context 39: messages = getMessagesAfterCompactBoundary(messages) 40: if (messages.length === 0) { 41: throw new Error('No messages to compact') 42: } 43: const customInstructions = args.trim() 44: try { 45: if (!customInstructions) { 46: const sessionMemoryResult = await trySessionMemoryCompaction( 47: messages, 48: context.agentId, 49: ) 50: if (sessionMemoryResult) { 51: getUserContext.cache.clear?.() 52: runPostCompactCleanup() 53: if (feature('PROMPT_CACHE_BREAK_DETECTION')) { 54: notifyCompaction( 55: context.options.querySource ?? 'compact', 56: context.agentId, 57: ) 58: } 59: markPostCompaction() 60: suppressCompactWarning() 61: return { 62: type: 'compact', 63: compactionResult: sessionMemoryResult, 64: displayText: buildDisplayText(context), 65: } 66: } 67: } 68: if (reactiveCompact?.isReactiveOnlyMode()) { 69: return await compactViaReactive( 70: messages, 71: context, 72: customInstructions, 73: reactiveCompact, 74: ) 75: } 76: const microcompactResult = await microcompactMessages(messages, context) 77: const messagesForCompact = microcompactResult.messages 78: const result = await compactConversation( 79: messagesForCompact, 80: context, 81: await getCacheSharingParams(context, messagesForCompact), 82: false, 83: customInstructions, 84: false, 85: ) 86: setLastSummarizedMessageId(undefined) 87: suppressCompactWarning() 88: getUserContext.cache.clear?.() 89: runPostCompactCleanup() 90: return { 91: type: 'compact', 92: compactionResult: result, 93: displayText: buildDisplayText(context, result.userDisplayMessage), 94: } 95: } catch (error) { 96: if (abortController.signal.aborted) { 97: throw new Error('Compaction canceled.') 98: } else if (hasExactErrorMessage(error, ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)) { 99: throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES) 100: } else if (hasExactErrorMessage(error, ERROR_MESSAGE_INCOMPLETE_RESPONSE)) { 101: throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE) 102: } else { 103: logError(error) 104: throw new Error(`Error during compaction: ${error}`) 105: } 106: } 107: } 108: async function compactViaReactive( 109: messages: Message[], 110: context: ToolUseContext, 111: customInstructions: string, 112: reactive: NonNullable<typeof reactiveCompact>, 113: ): Promise<{ 114: type: 'compact' 115: compactionResult: CompactionResult 116: displayText: string 117: }> { 118: context.onCompactProgress?.({ 119: type: 'hooks_start', 120: hookType: 'pre_compact', 121: }) 122: context.setSDKStatus?.('compacting') 123: try { 124: const [hookResult, cacheSafeParams] = await Promise.all([ 125: executePreCompactHooks( 126: { trigger: 'manual', customInstructions: customInstructions || null }, 127: context.abortController.signal, 128: ), 129: getCacheSharingParams(context, messages), 130: ]) 131: const mergedInstructions = mergeHookInstructions( 132: customInstructions, 133: hookResult.newCustomInstructions, 134: ) 135: context.setStreamMode?.('requesting') 136: context.setResponseLength?.(() => 0) 137: context.onCompactProgress?.({ type: 'compact_start' }) 138: const outcome = await reactive.reactiveCompactOnPromptTooLong( 139: messages, 140: cacheSafeParams, 141: { customInstructions: mergedInstructions, trigger: 'manual' }, 142: ) 143: if (!outcome.ok) { 144: switch (outcome.reason) { 145: case 'too_few_groups': 146: throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES) 147: case 'aborted': 148: throw new Error(ERROR_MESSAGE_USER_ABORT) 149: case 'exhausted': 150: case 'error': 151: case 'media_unstrippable': 152: throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE) 153: } 154: } 155: setLastSummarizedMessageId(undefined) 156: runPostCompactCleanup() 157: suppressCompactWarning() 158: getUserContext.cache.clear?.() 159: const combinedMessage = 160: [hookResult.userDisplayMessage, outcome.result.userDisplayMessage] 161: .filter(Boolean) 162: .join('\n') || undefined 163: return { 164: type: 'compact', 165: compactionResult: { 166: ...outcome.result, 167: userDisplayMessage: combinedMessage, 168: }, 169: displayText: buildDisplayText(context, combinedMessage), 170: } 171: } finally { 172: context.setStreamMode?.('requesting') 173: context.setResponseLength?.(() => 0) 174: context.onCompactProgress?.({ type: 'compact_end' }) 175: context.setSDKStatus?.(null) 176: } 177: } 178: function buildDisplayText( 179: context: ToolUseContext, 180: userDisplayMessage?: string, 181: ): string { 182: const upgradeMessage = getUpgradeMessage('tip') 183: const expandShortcut = getShortcutDisplay( 184: 'app:toggleTranscript', 185: 'Global', 186: 'ctrl+o', 187: ) 188: const dimmed = [ 189: ...(context.options.verbose 190: ? [] 191: : [`(${expandShortcut} to see full summary)`]), 192: ...(userDisplayMessage ? [userDisplayMessage] : []), 193: ...(upgradeMessage ? [upgradeMessage] : []), 194: ] 195: return chalk.dim('Compacted ' + dimmed.join('\n')) 196: } 197: async function getCacheSharingParams( 198: context: ToolUseContext, 199: forkContextMessages: Message[], 200: ): Promise<{ 201: systemPrompt: SystemPrompt 202: userContext: { [k: string]: string } 203: systemContext: { [k: string]: string } 204: toolUseContext: ToolUseContext 205: forkContextMessages: Message[] 206: }> { 207: const appState = context.getAppState() 208: const defaultSysPrompt = await getSystemPrompt( 209: context.options.tools, 210: context.options.mainLoopModel, 211: Array.from( 212: appState.toolPermissionContext.additionalWorkingDirectories.keys(), 213: ), 214: context.options.mcpClients, 215: ) 216: const systemPrompt = buildEffectiveSystemPrompt({ 217: mainThreadAgentDefinition: undefined, 218: toolUseContext: context, 219: customSystemPrompt: context.options.customSystemPrompt, 220: defaultSystemPrompt: defaultSysPrompt, 221: appendSystemPrompt: context.options.appendSystemPrompt, 222: }) 223: const [userContext, systemContext] = await Promise.all([ 224: getUserContext(), 225: getSystemContext(), 226: ]) 227: return { 228: systemPrompt, 229: userContext, 230: systemContext, 231: toolUseContext: context, 232: forkContextMessages, 233: } 234: }

File: src/commands/compact/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { isEnvTruthy } from '../../utils/envUtils.js' 3: const compact = { 4: type: 'local', 5: name: 'compact', 6: description: 7: 'Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]', 8: isEnabled: () => !isEnvTruthy(process.env.DISABLE_COMPACT), 9: supportsNonInteractive: true, 10: argumentHint: '<optional custom summarization instructions>', 11: load: () => import('./compact.js'), 12: } satisfies Command 13: export default compact

File: src/commands/config/config.tsx

typescript 1: import * as React from 'react'; 2: import { Settings } from '../../components/Settings/Settings.js'; 3: import type { LocalJSXCommandCall } from '../../types/command.js'; 4: export const call: LocalJSXCommandCall = async (onDone, context) => { 5: return <Settings onClose={onDone} context={context} defaultTab="Config" />; 6: };

File: src/commands/config/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const config = { 3: aliases: ['settings'], 4: type: 'local-jsx', 5: name: 'config', 6: description: 'Open config panel', 7: load: () => import('./config.js'), 8: } satisfies Command 9: export default config

File: src/commands/context/context-noninteractive.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { microcompactMessages } from '../../services/compact/microCompact.js' 3: import type { AppState } from '../../state/AppStateStore.js' 4: import type { Tools, ToolUseContext } from '../../Tool.js' 5: import type { AgentDefinitionsResult } from '../../tools/AgentTool/loadAgentsDir.js' 6: import type { Message } from '../../types/message.js' 7: import { 8: analyzeContextUsage, 9: type ContextData, 10: } from '../../utils/analyzeContext.js' 11: import { formatTokens } from '../../utils/format.js' 12: import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' 13: import { getSourceDisplayName } from '../../utils/settings/constants.js' 14: import { plural } from '../../utils/stringUtils.js' 15: type CollectContextDataInput = { 16: messages: Message[] 17: getAppState: () => AppState 18: options: { 19: mainLoopModel: string 20: tools: Tools 21: agentDefinitions: AgentDefinitionsResult 22: customSystemPrompt?: string 23: appendSystemPrompt?: string 24: } 25: } 26: export async function collectContextData( 27: context: CollectContextDataInput, 28: ): Promise<ContextData> { 29: const { 30: messages, 31: getAppState, 32: options: { 33: mainLoopModel, 34: tools, 35: agentDefinitions, 36: customSystemPrompt, 37: appendSystemPrompt, 38: }, 39: } = context 40: let apiView = getMessagesAfterCompactBoundary(messages) 41: if (feature('CONTEXT_COLLAPSE')) { 42: const { projectView } = 43: require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js') 44: apiView = projectView(apiView) 45: } 46: const { messages: compactedMessages } = await microcompactMessages(apiView) 47: const appState = getAppState() 48: return analyzeContextUsage( 49: compactedMessages, 50: mainLoopModel, 51: async () => appState.toolPermissionContext, 52: tools, 53: agentDefinitions, 54: undefined, 55: { options: { customSystemPrompt, appendSystemPrompt } } as Pick< 56: ToolUseContext, 57: 'options' 58: >, 59: undefined, 60: apiView, 61: ) 62: } 63: export async function call( 64: _args: string, 65: context: ToolUseContext, 66: ): Promise<{ type: 'text'; value: string }> { 67: const data = await collectContextData(context) 68: return { 69: type: 'text' as const, 70: value: formatContextAsMarkdownTable(data), 71: } 72: } 73: function formatContextAsMarkdownTable(data: ContextData): string { 74: const { 75: categories, 76: totalTokens, 77: rawMaxTokens, 78: percentage, 79: model, 80: memoryFiles, 81: mcpTools, 82: agents, 83: skills, 84: messageBreakdown, 85: systemTools, 86: systemPromptSections, 87: } = data 88: let output = `## Context Usage\n\n` 89: output += `**Model:** ${model} \n` 90: output += `**Tokens:** ${formatTokens(totalTokens)} / ${formatTokens(rawMaxTokens)} (${percentage}%)\n` 91: if (feature('CONTEXT_COLLAPSE')) { 92: const { getStats, isContextCollapseEnabled } = 93: require('../../services/contextCollapse/index.js') as typeof import('../../services/contextCollapse/index.js') 94: if (isContextCollapseEnabled()) { 95: const s = getStats() 96: const { health: h } = s 97: const parts = [] 98: if (s.collapsedSpans > 0) { 99: parts.push( 100: `${s.collapsedSpans} ${plural(s.collapsedSpans, 'span')} summarized (${s.collapsedMessages} messages)`, 101: ) 102: } 103: if (s.stagedSpans > 0) parts.push(`${s.stagedSpans} staged`) 104: const summary = 105: parts.length > 0 106: ? parts.join(', ') 107: : h.totalSpawns > 0 108: ? `${h.totalSpawns} ${plural(h.totalSpawns, 'spawn')}, nothing staged yet` 109: : 'waiting for first trigger' 110: output += `**Context strategy:** collapse (${summary})\n` 111: if (h.totalErrors > 0) { 112: output += `**Collapse errors:** ${h.totalErrors}/${h.totalSpawns} spawns failed` 113: if (h.lastError) { 114: output += ` (last: ${h.lastError.slice(0, 80)})` 115: } 116: output += '\n' 117: } else if (h.emptySpawnWarningEmitted) { 118: output += `**Collapse idle:** ${h.totalEmptySpawns} consecutive empty runs\n` 119: } 120: } 121: } 122: output += '\n' 123: const visibleCategories = categories.filter( 124: cat => 125: cat.tokens > 0 && 126: cat.name !== 'Free space' && 127: cat.name !== 'Autocompact buffer', 128: ) 129: if (visibleCategories.length > 0) { 130: output += `### Estimated usage by category\n\n` 131: output += `| Category | Tokens | Percentage |\n` 132: output += `|----------|--------|------------|\n` 133: for (const cat of visibleCategories) { 134: const percentDisplay = ((cat.tokens / rawMaxTokens) * 100).toFixed(1) 135: output += `| ${cat.name} | ${formatTokens(cat.tokens)} | ${percentDisplay}% |\n` 136: } 137: const freeSpaceCategory = categories.find(c => c.name === 'Free space') 138: if (freeSpaceCategory && freeSpaceCategory.tokens > 0) { 139: const percentDisplay = ( 140: (freeSpaceCategory.tokens / rawMaxTokens) * 141: 100 142: ).toFixed(1) 143: output += `| Free space | ${formatTokens(freeSpaceCategory.tokens)} | ${percentDisplay}% |\n` 144: } 145: const autocompactCategory = categories.find( 146: c => c.name === 'Autocompact buffer', 147: ) 148: if (autocompactCategory && autocompactCategory.tokens > 0) { 149: const percentDisplay = ( 150: (autocompactCategory.tokens / rawMaxTokens) * 151: 100 152: ).toFixed(1) 153: output += `| Autocompact buffer | ${formatTokens(autocompactCategory.tokens)} | ${percentDisplay}% |\n` 154: } 155: output += `\n` 156: } 157: if (mcpTools.length > 0) { 158: output += `### MCP Tools\n\n` 159: output += `| Tool | Server | Tokens |\n` 160: output += `|------|--------|--------|\n` 161: for (const tool of mcpTools) { 162: output += `| ${tool.name} | ${tool.serverName} | ${formatTokens(tool.tokens)} |\n` 163: } 164: output += `\n` 165: } 166: if ( 167: systemTools && 168: systemTools.length > 0 && 169: process.env.USER_TYPE === 'ant' 170: ) { 171: output += `### [ANT-ONLY] System Tools\n\n` 172: output += `| Tool | Tokens |\n` 173: output += `|------|--------|\n` 174: for (const tool of systemTools) { 175: output += `| ${tool.name} | ${formatTokens(tool.tokens)} |\n` 176: } 177: output += `\n` 178: } 179: if ( 180: systemPromptSections && 181: systemPromptSections.length > 0 && 182: process.env.USER_TYPE === 'ant' 183: ) { 184: output += `### [ANT-ONLY] System Prompt Sections\n\n` 185: output += `| Section | Tokens |\n` 186: output += `|---------|--------|\n` 187: for (const section of systemPromptSections) { 188: output += `| ${section.name} | ${formatTokens(section.tokens)} |\n` 189: } 190: output += `\n` 191: } 192: if (agents.length > 0) { 193: output += `### Custom Agents\n\n` 194: output += `| Agent Type | Source | Tokens |\n` 195: output += `|------------|--------|--------|\n` 196: for (const agent of agents) { 197: let sourceDisplay: string 198: switch (agent.source) { 199: case 'projectSettings': 200: sourceDisplay = 'Project' 201: break 202: case 'userSettings': 203: sourceDisplay = 'User' 204: break 205: case 'localSettings': 206: sourceDisplay = 'Local' 207: break 208: case 'flagSettings': 209: sourceDisplay = 'Flag' 210: break 211: case 'policySettings': 212: sourceDisplay = 'Policy' 213: break 214: case 'plugin': 215: sourceDisplay = 'Plugin' 216: break 217: case 'built-in': 218: sourceDisplay = 'Built-in' 219: break 220: default: 221: sourceDisplay = String(agent.source) 222: } 223: output += `| ${agent.agentType} | ${sourceDisplay} | ${formatTokens(agent.tokens)} |\n` 224: } 225: output += `\n` 226: } 227: if (memoryFiles.length > 0) { 228: output += `### Memory Files\n\n` 229: output += `| Type | Path | Tokens |\n` 230: output += `|------|------|--------|\n` 231: for (const file of memoryFiles) { 232: output += `| ${file.type} | ${file.path} | ${formatTokens(file.tokens)} |\n` 233: } 234: output += `\n` 235: } 236: if (skills && skills.tokens > 0 && skills.skillFrontmatter.length > 0) { 237: output += `### Skills\n\n` 238: output += `| Skill | Source | Tokens |\n` 239: output += `|-------|--------|--------|\n` 240: for (const skill of skills.skillFrontmatter) { 241: output += `| ${skill.name} | ${getSourceDisplayName(skill.source)} | ${formatTokens(skill.tokens)} |\n` 242: } 243: output += `\n` 244: } 245: if (messageBreakdown && process.env.USER_TYPE === 'ant') { 246: output += `### [ANT-ONLY] Message Breakdown\n\n` 247: output += `| Category | Tokens |\n` 248: output += `|----------|--------|\n` 249: output += `| Tool calls | ${formatTokens(messageBreakdown.toolCallTokens)} |\n` 250: output += `| Tool results | ${formatTokens(messageBreakdown.toolResultTokens)} |\n` 251: output += `| Attachments | ${formatTokens(messageBreakdown.attachmentTokens)} |\n` 252: output += `| Assistant messages (non-tool) | ${formatTokens(messageBreakdown.assistantMessageTokens)} |\n` 253: output += `| User messages (non-tool-result) | ${formatTokens(messageBreakdown.userMessageTokens)} |\n` 254: output += `\n` 255: if (messageBreakdown.toolCallsByType.length > 0) { 256: output += `#### Top Tools\n\n` 257: output += `| Tool | Call Tokens | Result Tokens |\n` 258: output += `|------|-------------|---------------|\n` 259: for (const tool of messageBreakdown.toolCallsByType) { 260: output += `| ${tool.name} | ${formatTokens(tool.callTokens)} | ${formatTokens(tool.resultTokens)} |\n` 261: } 262: output += `\n` 263: } 264: if (messageBreakdown.attachmentsByType.length > 0) { 265: output += `#### Top Attachments\n\n` 266: output += `| Attachment | Tokens |\n` 267: output += `|------------|--------|\n` 268: for (const attachment of messageBreakdown.attachmentsByType) { 269: output += `| ${attachment.name} | ${formatTokens(attachment.tokens)} |\n` 270: } 271: output += `\n` 272: } 273: } 274: return output 275: }

File: src/commands/context/context.tsx

typescript 1: import { feature } from 'bun:bundle'; 2: import * as React from 'react'; 3: import type { LocalJSXCommandContext } from '../../commands.js'; 4: import { ContextVisualization } from '../../components/ContextVisualization.js'; 5: import { microcompactMessages } from '../../services/compact/microCompact.js'; 6: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 7: import type { Message } from '../../types/message.js'; 8: import { analyzeContextUsage } from '../../utils/analyzeContext.js'; 9: import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; 10: import { renderToAnsiString } from '../../utils/staticRender.js'; 11: function toApiView(messages: Message[]): Message[] { 12: let view = getMessagesAfterCompactBoundary(messages); 13: if (feature('CONTEXT_COLLAPSE')) { 14: const { 15: projectView 16: } = require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js'); 17: view = projectView(view); 18: } 19: return view; 20: } 21: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> { 22: const { 23: messages, 24: getAppState, 25: options: { 26: mainLoopModel, 27: tools 28: } 29: } = context; 30: const apiView = toApiView(messages); 31: const { 32: messages: compactedMessages 33: } = await microcompactMessages(apiView); 34: const terminalWidth = process.stdout.columns || 80; 35: const appState = getAppState(); 36: const data = await analyzeContextUsage(compactedMessages, mainLoopModel, async () => appState.toolPermissionContext, tools, appState.agentDefinitions, terminalWidth, context, 37: undefined, 38: apiView 39: ); 40: const output = await renderToAnsiString(<ContextVisualization data={data} />); 41: onDone(output); 42: return null; 43: }

File: src/commands/context/index.ts

typescript 1: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 2: import type { Command } from '../../commands.js' 3: export const context: Command = { 4: name: 'context', 5: description: 'Visualize current context usage as a colored grid', 6: isEnabled: () => !getIsNonInteractiveSession(), 7: type: 'local-jsx', 8: load: () => import('./context.js'), 9: } 10: export const contextNonInteractive: Command = { 11: type: 'local', 12: name: 'context', 13: supportsNonInteractive: true, 14: description: 'Show current context usage', 15: get isHidden() { 16: return !getIsNonInteractiveSession() 17: }, 18: isEnabled() { 19: return getIsNonInteractiveSession() 20: }, 21: load: () => import('./context-noninteractive.js'), 22: }

File: src/commands/copy/copy.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { mkdir, writeFile } from 'fs/promises'; 3: import { marked, type Tokens } from 'marked'; 4: import { tmpdir } from 'os'; 5: import { join } from 'path'; 6: import React, { useRef } from 'react'; 7: import type { CommandResultDisplay } from '../../commands.js'; 8: import type { OptionWithDescription } from '../../components/CustomSelect/select.js'; 9: import { Select } from '../../components/CustomSelect/select.js'; 10: import { Byline } from '../../components/design-system/Byline.js'; 11: import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; 12: import { Pane } from '../../components/design-system/Pane.js'; 13: import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; 14: import { stringWidth } from '../../ink/stringWidth.js'; 15: import { setClipboard } from '../../ink/termio/osc.js'; 16: import { Box, Text } from '../../ink.js'; 17: import { logEvent } from '../../services/analytics/index.js'; 18: import type { LocalJSXCommandCall } from '../../types/command.js'; 19: import type { AssistantMessage, Message } from '../../types/message.js'; 20: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; 21: import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js'; 22: import { countCharInString } from '../../utils/stringUtils.js'; 23: const COPY_DIR = join(tmpdir(), 'claude'); 24: const RESPONSE_FILENAME = 'response.md'; 25: const MAX_LOOKBACK = 20; 26: type CodeBlock = { 27: code: string; 28: lang: string | undefined; 29: }; 30: function extractCodeBlocks(markdown: string): CodeBlock[] { 31: const tokens = marked.lexer(stripPromptXMLTags(markdown)); 32: const blocks: CodeBlock[] = []; 33: for (const token of tokens) { 34: if (token.type === 'code') { 35: const codeToken = token as Tokens.Code; 36: blocks.push({ 37: code: codeToken.text, 38: lang: codeToken.lang 39: }); 40: } 41: } 42: return blocks; 43: } 44: export function collectRecentAssistantTexts(messages: Message[]): string[] { 45: const texts: string[] = []; 46: for (let i = messages.length - 1; i >= 0 && texts.length < MAX_LOOKBACK; i--) { 47: const msg = messages[i]; 48: if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue; 49: const content = (msg as AssistantMessage).message.content; 50: if (!Array.isArray(content)) continue; 51: const text = extractTextContent(content, '\n\n'); 52: if (text) texts.push(text); 53: } 54: return texts; 55: } 56: export function fileExtension(lang: string | undefined): string { 57: if (lang) { 58: const sanitized = lang.replace(/[^a-zA-Z0-9]/g, ''); 59: if (sanitized && sanitized !== 'plaintext') { 60: return `.${sanitized}`; 61: } 62: } 63: return '.txt'; 64: } 65: async function writeToFile(text: string, filename: string): Promise<string> { 66: const filePath = join(COPY_DIR, filename); 67: await mkdir(COPY_DIR, { 68: recursive: true 69: }); 70: await writeFile(filePath, text, 'utf-8'); 71: return filePath; 72: } 73: async function copyOrWriteToFile(text: string, filename: string): Promise<string> { 74: const raw = await setClipboard(text); 75: if (raw) process.stdout.write(raw); 76: const lineCount = countCharInString(text, '\n') + 1; 77: const charCount = text.length; 78: try { 79: const filePath = await writeToFile(text, filename); 80: return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}`; 81: } catch { 82: return `Copied to clipboard (${charCount} characters, ${lineCount} lines)`; 83: } 84: } 85: function truncateLine(text: string, maxLen: number): string { 86: const firstLine = text.split('\n')[0] ?? ''; 87: if (stringWidth(firstLine) <= maxLen) { 88: return firstLine; 89: } 90: let result = ''; 91: let width = 0; 92: const targetWidth = maxLen - 1; 93: for (const char of firstLine) { 94: const charWidth = stringWidth(char); 95: if (width + charWidth > targetWidth) break; 96: result += char; 97: width += charWidth; 98: } 99: return result + '\u2026'; 100: } 101: type PickerProps = { 102: fullText: string; 103: codeBlocks: CodeBlock[]; 104: messageAge: number; 105: onDone: (result?: string, options?: { 106: display?: CommandResultDisplay; 107: }) => void; 108: }; 109: type PickerSelection = number | 'full' | 'always'; 110: function CopyPicker(t0) { 111: const $ = _c(33); 112: const { 113: fullText, 114: codeBlocks, 115: messageAge, 116: onDone 117: } = t0; 118: const focusedRef = useRef("full"); 119: const t1 = `${fullText.length} chars, ${countCharInString(fullText, "\n") + 1} lines`; 120: let t2; 121: if ($[0] !== t1) { 122: t2 = { 123: label: "Full response", 124: value: "full" as const, 125: description: t1 126: }; 127: $[0] = t1; 128: $[1] = t2; 129: } else { 130: t2 = $[1]; 131: } 132: let t3; 133: if ($[2] !== codeBlocks || $[3] !== t2) { 134: let t4; 135: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 136: t4 = { 137: label: "Always copy full response", 138: value: "always" as const, 139: description: "Skip this picker in the future (revert via /config)" 140: }; 141: $[5] = t4; 142: } else { 143: t4 = $[5]; 144: } 145: t3 = [t2, ...codeBlocks.map(_temp), t4]; 146: $[2] = codeBlocks; 147: $[3] = t2; 148: $[4] = t3; 149: } else { 150: t3 = $[4]; 151: } 152: const options = t3; 153: let t4; 154: if ($[6] !== codeBlocks || $[7] !== fullText) { 155: t4 = function getSelectionContent(selected) { 156: if (selected === "full" || selected === "always") { 157: return { 158: text: fullText, 159: filename: RESPONSE_FILENAME 160: }; 161: } 162: const block_0 = codeBlocks[selected]; 163: return { 164: text: block_0.code, 165: filename: `copy${fileExtension(block_0.lang)}`, 166: blockIndex: selected 167: }; 168: }; 169: $[6] = codeBlocks; 170: $[7] = fullText; 171: $[8] = t4; 172: } else { 173: t4 = $[8]; 174: } 175: const getSelectionContent = t4; 176: let t5; 177: if ($[9] !== codeBlocks.length || $[10] !== getSelectionContent || $[11] !== messageAge || $[12] !== onDone) { 178: t5 = async function handleSelect(selected_0) { 179: const content = getSelectionContent(selected_0); 180: if (selected_0 === "always") { 181: if (!getGlobalConfig().copyFullResponse) { 182: saveGlobalConfig(_temp2); 183: } 184: logEvent("tengu_copy", { 185: block_count: codeBlocks.length, 186: always: true, 187: message_age: messageAge 188: }); 189: const result = await copyOrWriteToFile(content.text, content.filename); 190: onDone(`${result}\nPreference saved. Use /config to change copyFullResponse`); 191: return; 192: } 193: logEvent("tengu_copy", { 194: selected_block: content.blockIndex, 195: block_count: codeBlocks.length, 196: message_age: messageAge 197: }); 198: const result_0 = await copyOrWriteToFile(content.text, content.filename); 199: onDone(result_0); 200: }; 201: $[9] = codeBlocks.length; 202: $[10] = getSelectionContent; 203: $[11] = messageAge; 204: $[12] = onDone; 205: $[13] = t5; 206: } else { 207: t5 = $[13]; 208: } 209: const handleSelect = t5; 210: let t6; 211: if ($[14] !== codeBlocks.length || $[15] !== getSelectionContent || $[16] !== messageAge || $[17] !== onDone) { 212: const handleWrite = async function handleWrite(selected_1) { 213: const content_0 = getSelectionContent(selected_1); 214: logEvent("tengu_copy", { 215: selected_block: content_0.blockIndex, 216: block_count: codeBlocks.length, 217: message_age: messageAge, 218: write_shortcut: true 219: }); 220: ; 221: try { 222: const filePath = await writeToFile(content_0.text, content_0.filename); 223: onDone(`Written to ${filePath}`); 224: } catch (t7) { 225: const e = t7; 226: onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`); 227: } 228: }; 229: t6 = function handleKeyDown(e_0) { 230: if (e_0.key === "w") { 231: e_0.preventDefault(); 232: handleWrite(focusedRef.current); 233: } 234: }; 235: $[14] = codeBlocks.length; 236: $[15] = getSelectionContent; 237: $[16] = messageAge; 238: $[17] = onDone; 239: $[18] = t6; 240: } else { 241: t6 = $[18]; 242: } 243: const handleKeyDown = t6; 244: let t7; 245: if ($[19] === Symbol.for("react.memo_cache_sentinel")) { 246: t7 = <Text dimColor={true}>Select content to copy:</Text>; 247: $[19] = t7; 248: } else { 249: t7 = $[19]; 250: } 251: let t8; 252: if ($[20] === Symbol.for("react.memo_cache_sentinel")) { 253: t8 = value => { 254: focusedRef.current = value; 255: }; 256: $[20] = t8; 257: } else { 258: t8 = $[20]; 259: } 260: let t9; 261: if ($[21] !== handleSelect) { 262: t9 = selected_2 => { 263: handleSelect(selected_2); 264: }; 265: $[21] = handleSelect; 266: $[22] = t9; 267: } else { 268: t9 = $[22]; 269: } 270: let t10; 271: if ($[23] !== onDone) { 272: t10 = () => { 273: onDone("Copy cancelled", { 274: display: "system" 275: }); 276: }; 277: $[23] = onDone; 278: $[24] = t10; 279: } else { 280: t10 = $[24]; 281: } 282: let t11; 283: if ($[25] !== options || $[26] !== t10 || $[27] !== t9) { 284: t11 = <Select options={options} hideIndexes={false} onFocus={t8} onChange={t9} onCancel={t10} />; 285: $[25] = options; 286: $[26] = t10; 287: $[27] = t9; 288: $[28] = t11; 289: } else { 290: t11 = $[28]; 291: } 292: let t12; 293: if ($[29] === Symbol.for("react.memo_cache_sentinel")) { 294: t12 = <Text dimColor={true}><Byline><KeyboardShortcutHint shortcut="enter" action="copy" /><KeyboardShortcutHint shortcut="w" action="write to file" /><KeyboardShortcutHint shortcut="esc" action="cancel" /></Byline></Text>; 295: $[29] = t12; 296: } else { 297: t12 = $[29]; 298: } 299: let t13; 300: if ($[30] !== handleKeyDown || $[31] !== t11) { 301: t13 = <Pane><Box flexDirection="column" gap={1} tabIndex={0} autoFocus={true} onKeyDown={handleKeyDown}>{t7}{t11}{t12}</Box></Pane>; 302: $[30] = handleKeyDown; 303: $[31] = t11; 304: $[32] = t13; 305: } else { 306: t13 = $[32]; 307: } 308: return t13; 309: } 310: function _temp2(c) { 311: return { 312: ...c, 313: copyFullResponse: true 314: }; 315: } 316: function _temp(block, index) { 317: const blockLines = countCharInString(block.code, "\n") + 1; 318: return { 319: label: truncateLine(block.code, 60), 320: value: index, 321: description: [block.lang, blockLines > 1 ? `${blockLines} lines` : undefined].filter(Boolean).join(", ") || undefined 322: }; 323: } 324: export const call: LocalJSXCommandCall = async (onDone, context, args) => { 325: const texts = collectRecentAssistantTexts(context.messages); 326: if (texts.length === 0) { 327: onDone('No assistant message to copy'); 328: return null; 329: } 330: let age = 0; 331: const arg = args?.trim(); 332: if (arg) { 333: const n = Number(arg); 334: if (!Number.isInteger(n) || n < 1) { 335: onDone(`Usage: /copy [N] where N is 1 (latest), 2, 3, \u2026 Got: ${arg}`); 336: return null; 337: } 338: if (n > texts.length) { 339: onDone(`Only ${texts.length} assistant ${texts.length === 1 ? 'message' : 'messages'} available to copy`); 340: return null; 341: } 342: age = n - 1; 343: } 344: const text = texts[age]!; 345: const codeBlocks = extractCodeBlocks(text); 346: const config = getGlobalConfig(); 347: if (codeBlocks.length === 0 || config.copyFullResponse) { 348: logEvent('tengu_copy', { 349: always: config.copyFullResponse, 350: block_count: codeBlocks.length, 351: message_age: age 352: }); 353: const result = await copyOrWriteToFile(text, RESPONSE_FILENAME); 354: onDone(result); 355: return null; 356: } 357: return <CopyPicker fullText={text} codeBlocks={codeBlocks} messageAge={age} onDone={onDone} />; 358: };

File: src/commands/copy/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const copy = { 3: type: 'local-jsx', 4: name: 'copy', 5: description: 6: "Copy Claude's last response to clipboard (or /copy N for the Nth-latest)", 7: load: () => import('./copy.js'), 8: } satisfies Command 9: export default copy

File: src/commands/cost/cost.ts

typescript 1: import { formatTotalCost } from '../../cost-tracker.js' 2: import { currentLimits } from '../../services/claudeAiLimits.js' 3: import type { LocalCommandCall } from '../../types/command.js' 4: import { isClaudeAISubscriber } from '../../utils/auth.js' 5: export const call: LocalCommandCall = async () => { 6: if (isClaudeAISubscriber()) { 7: let value: string 8: if (currentLimits.isUsingOverage) { 9: value = 10: 'You are currently using your overages to power your Claude Code usage. We will automatically switch you back to your subscription rate limits when they reset' 11: } else { 12: value = 13: 'You are currently using your subscription to power your Claude Code usage' 14: } 15: if (process.env.USER_TYPE === 'ant') { 16: value += `\n\n[ANT-ONLY] Showing cost anyway:\n ${formatTotalCost()}` 17: } 18: return { type: 'text', value } 19: } 20: return { type: 'text', value: formatTotalCost() } 21: }

File: src/commands/cost/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { isClaudeAISubscriber } from '../../utils/auth.js' 3: const cost = { 4: type: 'local', 5: name: 'cost', 6: description: 'Show the total cost and duration of the current session', 7: get isHidden() { 8: if (process.env.USER_TYPE === 'ant') { 9: return false 10: } 11: return isClaudeAISubscriber() 12: }, 13: supportsNonInteractive: true, 14: load: () => import('./cost.js'), 15: } satisfies Command 16: export default cost

File: src/commands/ctx_viz/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/debug-tool-call/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/desktop/desktop.tsx

typescript 1: import React from 'react'; 2: import type { CommandResultDisplay } from '../../commands.js'; 3: import { DesktopHandoff } from '../../components/DesktopHandoff.js'; 4: export async function call(onDone: (result?: string, options?: { 5: display?: CommandResultDisplay; 6: }) => void): Promise<React.ReactNode> { 7: return <DesktopHandoff onDone={onDone} />; 8: }

File: src/commands/desktop/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: function isSupportedPlatform(): boolean { 3: if (process.platform === 'darwin') { 4: return true 5: } 6: if (process.platform === 'win32' && process.arch === 'x64') { 7: return true 8: } 9: return false 10: } 11: const desktop = { 12: type: 'local-jsx', 13: name: 'desktop', 14: aliases: ['app'], 15: description: 'Continue the current session in Claude Desktop', 16: availability: ['claude-ai'], 17: isEnabled: isSupportedPlatform, 18: get isHidden() { 19: return !isSupportedPlatform() 20: }, 21: load: () => import('./desktop.js'), 22: } satisfies Command 23: export default desktop

File: src/commands/diff/diff.tsx

typescript 1: import * as React from 'react'; 2: import type { LocalJSXCommandCall } from '../../types/command.js'; 3: export const call: LocalJSXCommandCall = async (onDone, context) => { 4: const { 5: DiffDialog 6: } = await import('../../components/diff/DiffDialog.js'); 7: return <DiffDialog messages={context.messages} onDone={onDone} />; 8: };

File: src/commands/diff/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: export default { 3: type: 'local-jsx', 4: name: 'diff', 5: description: 'View uncommitted changes and per-turn diffs', 6: load: () => import('./diff.js'), 7: } satisfies Command

File: src/commands/doctor/doctor.tsx

typescript 1: import React from 'react'; 2: import { Doctor } from '../../screens/Doctor.js'; 3: import type { LocalJSXCommandCall } from '../../types/command.js'; 4: export const call: LocalJSXCommandCall = (onDone, _context, _args) => { 5: return Promise.resolve(<Doctor onDone={onDone} />); 6: };

File: src/commands/doctor/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { isEnvTruthy } from '../../utils/envUtils.js' 3: const doctor: Command = { 4: name: 'doctor', 5: description: 'Diagnose and verify your Claude Code installation and settings', 6: isEnabled: () => !isEnvTruthy(process.env.DISABLE_DOCTOR_COMMAND), 7: type: 'local-jsx', 8: load: () => import('./doctor.js'), 9: } 10: export default doctor

File: src/commands/effort/effort.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import * as React from 'react'; 3: import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; 4: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; 5: import { useAppState, useSetAppState } from '../../state/AppState.js'; 6: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 7: import { type EffortValue, getDisplayedEffortLevel, getEffortEnvOverride, getEffortValueDescription, isEffortLevel, toPersistableEffort } from '../../utils/effort.js'; 8: import { updateSettingsForSource } from '../../utils/settings/settings.js'; 9: const COMMON_HELP_ARGS = ['help', '-h', '--help']; 10: type EffortCommandResult = { 11: message: string; 12: effortUpdate?: { 13: value: EffortValue | undefined; 14: }; 15: }; 16: function setEffortValue(effortValue: EffortValue): EffortCommandResult { 17: const persistable = toPersistableEffort(effortValue); 18: if (persistable !== undefined) { 19: const result = updateSettingsForSource('userSettings', { 20: effortLevel: persistable 21: }); 22: if (result.error) { 23: return { 24: message: `Failed to set effort level: ${result.error.message}` 25: }; 26: } 27: } 28: logEvent('tengu_effort_command', { 29: effort: effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 30: }); 31: const envOverride = getEffortEnvOverride(); 32: if (envOverride !== undefined && envOverride !== effortValue) { 33: const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL; 34: if (persistable === undefined) { 35: return { 36: message: `Not applied: CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides effort this session, and ${effortValue} is session-only (nothing saved)`, 37: effortUpdate: { 38: value: effortValue 39: } 40: }; 41: } 42: return { 43: message: `CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session — clear it and ${effortValue} takes over`, 44: effortUpdate: { 45: value: effortValue 46: } 47: }; 48: } 49: const description = getEffortValueDescription(effortValue); 50: const suffix = persistable !== undefined ? '' : ' (this session only)'; 51: return { 52: message: `Set effort level to ${effortValue}${suffix}: ${description}`, 53: effortUpdate: { 54: value: effortValue 55: } 56: }; 57: } 58: export function showCurrentEffort(appStateEffort: EffortValue | undefined, model: string): EffortCommandResult { 59: const envOverride = getEffortEnvOverride(); 60: const effectiveValue = envOverride === null ? undefined : envOverride ?? appStateEffort; 61: if (effectiveValue === undefined) { 62: const level = getDisplayedEffortLevel(model, appStateEffort); 63: return { 64: message: `Effort level: auto (currently ${level})` 65: }; 66: } 67: const description = getEffortValueDescription(effectiveValue); 68: return { 69: message: `Current effort level: ${effectiveValue} (${description})` 70: }; 71: } 72: function unsetEffortLevel(): EffortCommandResult { 73: const result = updateSettingsForSource('userSettings', { 74: effortLevel: undefined 75: }); 76: if (result.error) { 77: return { 78: message: `Failed to set effort level: ${result.error.message}` 79: }; 80: } 81: logEvent('tengu_effort_command', { 82: effort: 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 83: }); 84: const envOverride = getEffortEnvOverride(); 85: if (envOverride !== undefined && envOverride !== null) { 86: const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL; 87: return { 88: message: `Cleared effort from settings, but CLAUDE_CODE_EFFORT_LEVEL=${envRaw} still controls this session`, 89: effortUpdate: { 90: value: undefined 91: } 92: }; 93: } 94: return { 95: message: 'Effort level set to auto', 96: effortUpdate: { 97: value: undefined 98: } 99: }; 100: } 101: export function executeEffort(args: string): EffortCommandResult { 102: const normalized = args.toLowerCase(); 103: if (normalized === 'auto' || normalized === 'unset') { 104: return unsetEffortLevel(); 105: } 106: if (!isEffortLevel(normalized)) { 107: return { 108: message: `Invalid argument: ${args}. Valid options are: low, medium, high, max, auto` 109: }; 110: } 111: return setEffortValue(normalized); 112: } 113: function ShowCurrentEffort(t0) { 114: const { 115: onDone 116: } = t0; 117: const effortValue = useAppState(_temp); 118: const model = useMainLoopModel(); 119: const { 120: message 121: } = showCurrentEffort(effortValue, model); 122: onDone(message); 123: return null; 124: } 125: function _temp(s) { 126: return s.effortValue; 127: } 128: function ApplyEffortAndClose(t0) { 129: const $ = _c(6); 130: const { 131: result, 132: onDone 133: } = t0; 134: const setAppState = useSetAppState(); 135: const { 136: effortUpdate, 137: message 138: } = result; 139: let t1; 140: let t2; 141: if ($[0] !== effortUpdate || $[1] !== message || $[2] !== onDone || $[3] !== setAppState) { 142: t1 = () => { 143: if (effortUpdate) { 144: setAppState(prev => ({ 145: ...prev, 146: effortValue: effortUpdate.value 147: })); 148: } 149: onDone(message); 150: }; 151: t2 = [setAppState, effortUpdate, message, onDone]; 152: $[0] = effortUpdate; 153: $[1] = message; 154: $[2] = onDone; 155: $[3] = setAppState; 156: $[4] = t1; 157: $[5] = t2; 158: } else { 159: t1 = $[4]; 160: t2 = $[5]; 161: } 162: React.useEffect(t1, t2); 163: return null; 164: } 165: export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> { 166: args = args?.trim() || ''; 167: if (COMMON_HELP_ARGS.includes(args)) { 168: onDone('Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6 only)\n- auto: Use the default effort level for your model'); 169: return; 170: } 171: if (!args || args === 'current' || args === 'status') { 172: return <ShowCurrentEffort onDone={onDone} />; 173: } 174: const result = executeEffort(args); 175: return <ApplyEffortAndClose result={result} onDone={onDone} />; 176: }

File: src/commands/effort/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js' 3: export default { 4: type: 'local-jsx', 5: name: 'effort', 6: description: 'Set effort level for model usage', 7: argumentHint: '[low|medium|high|max|auto]', 8: get immediate() { 9: return shouldInferenceConfigCommandBeImmediate() 10: }, 11: load: () => import('./effort.js'), 12: } satisfies Command

File: src/commands/env/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/exit/exit.tsx

typescript 1: import { feature } from 'bun:bundle'; 2: import { spawnSync } from 'child_process'; 3: import sample from 'lodash-es/sample.js'; 4: import * as React from 'react'; 5: import { ExitFlow } from '../../components/ExitFlow.js'; 6: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 7: import { isBgSession } from '../../utils/concurrentSessions.js'; 8: import { gracefulShutdown } from '../../utils/gracefulShutdown.js'; 9: import { getCurrentWorktreeSession } from '../../utils/worktree.js'; 10: const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!']; 11: function getRandomGoodbyeMessage(): string { 12: return sample(GOODBYE_MESSAGES) ?? 'Goodbye!'; 13: } 14: export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> { 15: if (feature('BG_SESSIONS') && isBgSession()) { 16: onDone(); 17: spawnSync('tmux', ['detach-client'], { 18: stdio: 'ignore' 19: }); 20: return null; 21: } 22: const showWorktree = getCurrentWorktreeSession() !== null; 23: if (showWorktree) { 24: return <ExitFlow showWorktree={showWorktree} onDone={onDone} onCancel={() => onDone()} />; 25: } 26: onDone(getRandomGoodbyeMessage()); 27: await gracefulShutdown(0, 'prompt_input_exit'); 28: return null; 29: }

File: src/commands/exit/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const exit = { 3: type: 'local-jsx', 4: name: 'exit', 5: aliases: ['quit'], 6: description: 'Exit the REPL', 7: immediate: true, 8: load: () => import('./exit.js'), 9: } satisfies Command 10: export default exit

File: src/commands/export/export.tsx

typescript 1: import { join } from 'path'; 2: import React from 'react'; 3: import { ExportDialog } from '../../components/ExportDialog.js'; 4: import type { ToolUseContext } from '../../Tool.js'; 5: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 6: import type { Message } from '../../types/message.js'; 7: import { getCwd } from '../../utils/cwd.js'; 8: import { renderMessagesToPlainText } from '../../utils/exportRenderer.js'; 9: import { writeFileSync_DEPRECATED } from '../../utils/slowOperations.js'; 10: function formatTimestamp(date: Date): string { 11: const year = date.getFullYear(); 12: const month = String(date.getMonth() + 1).padStart(2, '0'); 13: const day = String(date.getDate()).padStart(2, '0'); 14: const hours = String(date.getHours()).padStart(2, '0'); 15: const minutes = String(date.getMinutes()).padStart(2, '0'); 16: const seconds = String(date.getSeconds()).padStart(2, '0'); 17: return `${year}-${month}-${day}-${hours}${minutes}${seconds}`; 18: } 19: export function extractFirstPrompt(messages: Message[]): string { 20: const firstUserMessage = messages.find(msg => msg.type === 'user'); 21: if (!firstUserMessage || firstUserMessage.type !== 'user') { 22: return ''; 23: } 24: const content = firstUserMessage.message?.content; 25: let result = ''; 26: if (typeof content === 'string') { 27: result = content.trim(); 28: } else if (Array.isArray(content)) { 29: const textContent = content.find(item => item.type === 'text'); 30: if (textContent && 'text' in textContent) { 31: result = textContent.text.trim(); 32: } 33: } 34: result = result.split('\n')[0] || ''; 35: if (result.length > 50) { 36: result = result.substring(0, 49) + '…'; 37: } 38: return result; 39: } 40: export function sanitizeFilename(text: string): string { 41: // Replace special characters with hyphens 42: return text.toLowerCase().replace(/[^a-z0-9\s-]/g, '') // Remove special chars 43: .replace(/\s+/g, '-') // Replace spaces with hyphens 44: .replace(/-+/g, '-') // Replace multiple hyphens with single 45: .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens 46: } 47: async function exportWithReactRenderer(context: ToolUseContext): Promise<string> { 48: const tools = context.options.tools || []; 49: return renderMessagesToPlainText(context.messages, tools); 50: } 51: export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext, args: string): Promise<React.ReactNode> { 52: // Render the conversation content 53: const content = await exportWithReactRenderer(context); 54: // If args are provided, write directly to file and skip dialog 55: const filename = args.trim(); 56: if (filename) { 57: const finalFilename = filename.endsWith('.txt') ? filename : filename.replace(/\.[^.]+$/, '') + '.txt'; 58: const filepath = join(getCwd(), finalFilename); 59: try { 60: writeFileSync_DEPRECATED(filepath, content, { 61: encoding: 'utf-8', 62: flush: true 63: }); 64: onDone(`Conversation exported to: ${filepath}`); 65: return null; 66: } catch (error) { 67: onDone(`Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`); 68: return null; 69: } 70: } 71: const firstPrompt = extractFirstPrompt(context.messages); 72: const timestamp = formatTimestamp(new Date()); 73: let defaultFilename: string; 74: if (firstPrompt) { 75: const sanitized = sanitizeFilename(firstPrompt); 76: defaultFilename = sanitized ? `${timestamp}-${sanitized}.txt` : `conversation-${timestamp}.txt`; 77: } else { 78: defaultFilename = `conversation-${timestamp}.txt`; 79: } 80: return <ExportDialog content={content} defaultFilename={defaultFilename} onDone={result => { 81: onDone(result.message); 82: }} />; 83: }

File: src/commands/export/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const exportCommand = { 3: type: 'local-jsx', 4: name: 'export', 5: description: 'Export the current conversation to a file or clipboard', 6: argumentHint: '[filename]', 7: load: () => import('./export.js'), 8: } satisfies Command 9: export default exportCommand

File: src/commands/extra-usage/extra-usage-core.ts

typescript 1: import { 2: checkAdminRequestEligibility, 3: createAdminRequest, 4: getMyAdminRequests, 5: } from '../../services/api/adminRequests.js' 6: import { invalidateOverageCreditGrantCache } from '../../services/api/overageCreditGrant.js' 7: import { type ExtraUsage, fetchUtilization } from '../../services/api/usage.js' 8: import { getSubscriptionType } from '../../utils/auth.js' 9: import { hasClaudeAiBillingAccess } from '../../utils/billing.js' 10: import { openBrowser } from '../../utils/browser.js' 11: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 12: import { logError } from '../../utils/log.js' 13: type ExtraUsageResult = 14: | { type: 'message'; value: string } 15: | { type: 'browser-opened'; url: string; opened: boolean } 16: export async function runExtraUsage(): Promise<ExtraUsageResult> { 17: if (!getGlobalConfig().hasVisitedExtraUsage) { 18: saveGlobalConfig(prev => ({ ...prev, hasVisitedExtraUsage: true })) 19: } 20: invalidateOverageCreditGrantCache() 21: const subscriptionType = getSubscriptionType() 22: const isTeamOrEnterprise = 23: subscriptionType === 'team' || subscriptionType === 'enterprise' 24: const hasBillingAccess = hasClaudeAiBillingAccess() 25: if (!hasBillingAccess && isTeamOrEnterprise) { 26: let extraUsage: ExtraUsage | null | undefined 27: try { 28: const utilization = await fetchUtilization() 29: extraUsage = utilization?.extra_usage 30: } catch (error) { 31: logError(error as Error) 32: } 33: if (extraUsage?.is_enabled && extraUsage.monthly_limit === null) { 34: return { 35: type: 'message', 36: value: 37: 'Your organization already has unlimited extra usage. No request needed.', 38: } 39: } 40: try { 41: const eligibility = await checkAdminRequestEligibility('limit_increase') 42: if (eligibility?.is_allowed === false) { 43: return { 44: type: 'message', 45: value: 'Please contact your admin to manage extra usage settings.', 46: } 47: } 48: } catch (error) { 49: logError(error as Error) 50: } 51: try { 52: const pendingOrDismissedRequests = await getMyAdminRequests( 53: 'limit_increase', 54: ['pending', 'dismissed'], 55: ) 56: if (pendingOrDismissedRequests && pendingOrDismissedRequests.length > 0) { 57: return { 58: type: 'message', 59: value: 60: 'You have already submitted a request for extra usage to your admin.', 61: } 62: } 63: } catch (error) { 64: logError(error as Error) 65: } 66: try { 67: await createAdminRequest({ 68: request_type: 'limit_increase', 69: details: null, 70: }) 71: return { 72: type: 'message', 73: value: extraUsage?.is_enabled 74: ? 'Request sent to your admin to increase extra usage.' 75: : 'Request sent to your admin to enable extra usage.', 76: } 77: } catch (error) { 78: logError(error as Error) 79: } 80: return { 81: type: 'message', 82: value: 'Please contact your admin to manage extra usage settings.', 83: } 84: } 85: const url = isTeamOrEnterprise 86: ? 'https://claude.ai/admin-settings/usage' 87: : 'https://claude.ai/settings/usage' 88: try { 89: const opened = await openBrowser(url) 90: return { type: 'browser-opened', url, opened } 91: } catch (error) { 92: logError(error as Error) 93: return { 94: type: 'message', 95: value: `Failed to open browser. Please visit ${url} to manage extra usage.`, 96: } 97: } 98: }

File: src/commands/extra-usage/extra-usage-noninteractive.ts

typescript 1: import { runExtraUsage } from './extra-usage-core.js' 2: export async function call(): Promise<{ type: 'text'; value: string }> { 3: const result = await runExtraUsage() 4: if (result.type === 'message') { 5: return { type: 'text', value: result.value } 6: } 7: return { 8: type: 'text', 9: value: result.opened 10: ? `Browser opened to manage extra usage. If it didn't open, visit: ${result.url}` 11: : `Please visit ${result.url} to manage extra usage.`, 12: } 13: }

File: src/commands/extra-usage/extra-usage.tsx

typescript 1: import React from 'react'; 2: import type { LocalJSXCommandContext } from '../../commands.js'; 3: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 4: import { Login } from '../login/login.js'; 5: import { runExtraUsage } from './extra-usage-core.js'; 6: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode | null> { 7: const result = await runExtraUsage(); 8: if (result.type === 'message') { 9: onDone(result.value); 10: return null; 11: } 12: return <Login startingMessage={'Starting new login following /extra-usage. Exit with Ctrl-C to use existing account.'} onDone={success => { 13: context.onChangeAPIKey(); 14: onDone(success ? 'Login successful' : 'Login interrupted'); 15: }} />; 16: }

File: src/commands/extra-usage/index.ts

typescript 1: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 2: import type { Command } from '../../commands.js' 3: import { isOverageProvisioningAllowed } from '../../utils/auth.js' 4: import { isEnvTruthy } from '../../utils/envUtils.js' 5: function isExtraUsageAllowed(): boolean { 6: if (isEnvTruthy(process.env.DISABLE_EXTRA_USAGE_COMMAND)) { 7: return false 8: } 9: return isOverageProvisioningAllowed() 10: } 11: export const extraUsage = { 12: type: 'local-jsx', 13: name: 'extra-usage', 14: description: 'Configure extra usage to keep working when limits are hit', 15: isEnabled: () => isExtraUsageAllowed() && !getIsNonInteractiveSession(), 16: load: () => import('./extra-usage.js'), 17: } satisfies Command 18: export const extraUsageNonInteractive = { 19: type: 'local', 20: name: 'extra-usage', 21: supportsNonInteractive: true, 22: description: 'Configure extra usage to keep working when limits are hit', 23: isEnabled: () => isExtraUsageAllowed() && getIsNonInteractiveSession(), 24: get isHidden() { 25: return !getIsNonInteractiveSession() 26: }, 27: load: () => import('./extra-usage-noninteractive.js'), 28: } satisfies Command

File: src/commands/fast/fast.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import * as React from 'react'; 3: import { useState } from 'react'; 4: import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js'; 5: import { Dialog } from '../../components/design-system/Dialog.js'; 6: import { FastIcon, getFastIconString } from '../../components/FastIcon.js'; 7: import { Box, Link, Text } from '../../ink.js'; 8: import { useKeybindings } from '../../keybindings/useKeybinding.js'; 9: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; 10: import { type AppState, useAppState, useSetAppState } from '../../state/AppState.js'; 11: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 12: import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, getFastModeModel, getFastModeRuntimeState, getFastModeUnavailableReason, isFastModeEnabled, isFastModeSupportedByModel, prefetchFastModeStatus } from '../../utils/fastMode.js'; 13: import { formatDuration } from '../../utils/format.js'; 14: import { formatModelPricing, getOpus46CostTier } from '../../utils/modelCost.js'; 15: import { updateSettingsForSource } from '../../utils/settings/settings.js'; 16: function applyFastMode(enable: boolean, setAppState: (f: (prev: AppState) => AppState) => void): void { 17: clearFastModeCooldown(); 18: updateSettingsForSource('userSettings', { 19: fastMode: enable ? true : undefined 20: }); 21: if (enable) { 22: setAppState(prev => { 23: const needsModelSwitch = !isFastModeSupportedByModel(prev.mainLoopModel); 24: return { 25: ...prev, 26: ...(needsModelSwitch ? { 27: mainLoopModel: getFastModeModel(), 28: mainLoopModelForSession: null 29: } : {}), 30: fastMode: true 31: }; 32: }); 33: } else { 34: setAppState(prev => ({ 35: ...prev, 36: fastMode: false 37: })); 38: } 39: } 40: export function FastModePicker(t0) { 41: const $ = _c(30); 42: const { 43: onDone, 44: unavailableReason 45: } = t0; 46: const model = useAppState(_temp); 47: const initialFastMode = useAppState(_temp2); 48: const setAppState = useSetAppState(); 49: const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false); 50: let t1; 51: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 52: t1 = getFastModeRuntimeState(); 53: $[0] = t1; 54: } else { 55: t1 = $[0]; 56: } 57: const runtimeState = t1; 58: const isCooldown = runtimeState.status === "cooldown"; 59: const isUnavailable = unavailableReason !== null; 60: let t2; 61: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 62: t2 = formatModelPricing(getOpus46CostTier(true)); 63: $[1] = t2; 64: } else { 65: t2 = $[1]; 66: } 67: const pricing = t2; 68: let t3; 69: if ($[2] !== enableFastMode || $[3] !== isUnavailable || $[4] !== model || $[5] !== onDone || $[6] !== setAppState) { 70: t3 = function handleConfirm() { 71: if (isUnavailable) { 72: return; 73: } 74: applyFastMode(enableFastMode, setAppState); 75: logEvent("tengu_fast_mode_toggled", { 76: enabled: enableFastMode, 77: source: "picker" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 78: }); 79: if (enableFastMode) { 80: const fastIcon = getFastIconString(enableFastMode); 81: const modelUpdated = !isFastModeSupportedByModel(model) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : ""; 82: onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`); 83: } else { 84: setAppState(_temp3); 85: onDone("Fast mode OFF"); 86: } 87: }; 88: $[2] = enableFastMode; 89: $[3] = isUnavailable; 90: $[4] = model; 91: $[5] = onDone; 92: $[6] = setAppState; 93: $[7] = t3; 94: } else { 95: t3 = $[7]; 96: } 97: const handleConfirm = t3; 98: let t4; 99: if ($[8] !== initialFastMode || $[9] !== isUnavailable || $[10] !== onDone || $[11] !== setAppState) { 100: t4 = function handleCancel() { 101: if (isUnavailable) { 102: if (initialFastMode) { 103: applyFastMode(false, setAppState); 104: } 105: onDone("Fast mode OFF", { 106: display: "system" 107: }); 108: return; 109: } 110: const message = initialFastMode ? `${getFastIconString()} Kept Fast mode ON` : "Kept Fast mode OFF"; 111: onDone(message, { 112: display: "system" 113: }); 114: }; 115: $[8] = initialFastMode; 116: $[9] = isUnavailable; 117: $[10] = onDone; 118: $[11] = setAppState; 119: $[12] = t4; 120: } else { 121: t4 = $[12]; 122: } 123: const handleCancel = t4; 124: let t5; 125: if ($[13] !== isUnavailable) { 126: t5 = function handleToggle() { 127: if (isUnavailable) { 128: return; 129: } 130: setEnableFastMode(_temp4); 131: }; 132: $[13] = isUnavailable; 133: $[14] = t5; 134: } else { 135: t5 = $[14]; 136: } 137: const handleToggle = t5; 138: let t6; 139: if ($[15] !== handleConfirm || $[16] !== handleToggle) { 140: t6 = { 141: "confirm:yes": handleConfirm, 142: "confirm:nextField": handleToggle, 143: "confirm:next": handleToggle, 144: "confirm:previous": handleToggle, 145: "confirm:cycleMode": handleToggle, 146: "confirm:toggle": handleToggle 147: }; 148: $[15] = handleConfirm; 149: $[16] = handleToggle; 150: $[17] = t6; 151: } else { 152: t6 = $[17]; 153: } 154: let t7; 155: if ($[18] === Symbol.for("react.memo_cache_sentinel")) { 156: t7 = { 157: context: "Confirmation" 158: }; 159: $[18] = t7; 160: } else { 161: t7 = $[18]; 162: } 163: useKeybindings(t6, t7); 164: let t8; 165: if ($[19] === Symbol.for("react.memo_cache_sentinel")) { 166: t8 = <Text><FastIcon cooldown={isCooldown} /> Fast mode (research preview)</Text>; 167: $[19] = t8; 168: } else { 169: t8 = $[19]; 170: } 171: const title = t8; 172: let t9; 173: if ($[20] !== isUnavailable) { 174: t9 = exitState => exitState.pending ? <Text>Press {exitState.keyName} again to exit</Text> : isUnavailable ? <Text>Esc to cancel</Text> : <Text>Tab to toggle · Enter to confirm · Esc to cancel</Text>; 175: $[20] = isUnavailable; 176: $[21] = t9; 177: } else { 178: t9 = $[21]; 179: } 180: let t10; 181: if ($[22] !== enableFastMode || $[23] !== unavailableReason) { 182: t10 = unavailableReason ? <Box marginLeft={2}><Text color="error">{unavailableReason}</Text></Box> : <><Box flexDirection="column" gap={0} marginLeft={2}><Box flexDirection="row" gap={2}><Text bold={true}>Fast mode</Text><Text color={enableFastMode ? "fastMode" : undefined} bold={enableFastMode}>{enableFastMode ? "ON " : "OFF"}</Text><Text dimColor={true}>{pricing}</Text></Box></Box>{isCooldown && runtimeState.status === "cooldown" && <Box marginLeft={2}><Text color="warning">{runtimeState.reason === "overloaded" ? "Fast mode overloaded and is temporarily unavailable" : "You've hit your fast limit"}{" \xB7 resets in "}{formatDuration(runtimeState.resetAt - Date.now(), { 183: hideTrailingZeros: true 184: })}</Text></Box>}</>; 185: $[22] = enableFastMode; 186: $[23] = unavailableReason; 187: $[24] = t10; 188: } else { 189: t10 = $[24]; 190: } 191: let t11; 192: if ($[25] === Symbol.for("react.memo_cache_sentinel")) { 193: t11 = <Text dimColor={true}>Learn more:{" "}<Link url="https://code.claude.com/docs/en/fast-mode">https: 194: $[25] = t11; 195: } else { 196: t11 = $[25]; 197: } 198: let t12; 199: if ($[26] !== handleCancel || $[27] !== t10 || $[28] !== t9) { 200: t12 = <Dialog title={title} subtitle={`High-speed mode for ${FAST_MODE_MODEL_DISPLAY}. Billed as extra usage at a premium rate. Separate rate limits apply.`} onCancel={handleCancel} color="fastMode" inputGuide={t9}>{t10}{t11}</Dialog>; 201: $[26] = handleCancel; 202: $[27] = t10; 203: $[28] = t9; 204: $[29] = t12; 205: } else { 206: t12 = $[29]; 207: } 208: return t12; 209: } 210: function _temp4(prev_0) { 211: return !prev_0; 212: } 213: function _temp3(prev) { 214: return { 215: ...prev, 216: fastMode: false 217: }; 218: } 219: function _temp2(s_0) { 220: return s_0.fastMode; 221: } 222: function _temp(s) { 223: return s.mainLoopModel; 224: } 225: async function handleFastModeShortcut(enable: boolean, getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void): Promise<string> { 226: const unavailableReason = getFastModeUnavailableReason(); 227: if (unavailableReason) { 228: return `Fast mode unavailable: ${unavailableReason}`; 229: } 230: const { 231: mainLoopModel 232: } = getAppState(); 233: applyFastMode(enable, setAppState); 234: logEvent('tengu_fast_mode_toggled', { 235: enabled: enable, 236: source: 'shortcut' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 237: }); 238: if (enable) { 239: const fastIcon = getFastIconString(true); 240: const modelUpdated = !isFastModeSupportedByModel(mainLoopModel) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : ''; 241: const pricing = formatModelPricing(getOpus46CostTier(true)); 242: return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`; 243: } else { 244: return `Fast mode OFF`; 245: } 246: } 247: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext, args?: string): Promise<React.ReactNode | null> { 248: if (!isFastModeEnabled()) { 249: return null; 250: } 251: // Fetch org fast mode status before showing the picker. We must know 252: // whether the org has disabled fast mode before allowing any toggle. 253: // If a startup prefetch is already in flight, this awaits it. 254: await prefetchFastModeStatus(); 255: const arg = args?.trim().toLowerCase(); 256: if (arg === 'on' || arg === 'off') { 257: const result = await handleFastModeShortcut(arg === 'on', context.getAppState, context.setAppState); 258: onDone(result); 259: return null; 260: } 261: const unavailableReason = getFastModeUnavailableReason(); 262: logEvent('tengu_fast_mode_picker_shown', { 263: unavailable_reason: (unavailableReason ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 264: }); 265: return <FastModePicker onDone={onDone} unavailableReason={unavailableReason} />; 266: }

File: src/commands/fast/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { 3: FAST_MODE_MODEL_DISPLAY, 4: isFastModeEnabled, 5: } from '../../utils/fastMode.js' 6: import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js' 7: const fast = { 8: type: 'local-jsx', 9: name: 'fast', 10: get description() { 11: return `Toggle fast mode (${FAST_MODE_MODEL_DISPLAY} only)` 12: }, 13: availability: ['claude-ai', 'console'], 14: isEnabled: () => isFastModeEnabled(), 15: get isHidden() { 16: return !isFastModeEnabled() 17: }, 18: argumentHint: '[on|off]', 19: get immediate() { 20: return shouldInferenceConfigCommandBeImmediate() 21: }, 22: load: () => import('./fast.js'), 23: } satisfies Command 24: export default fast

File: src/commands/feedback/feedback.tsx

typescript 1: import * as React from 'react'; 2: import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js'; 3: import { Feedback } from '../../components/Feedback.js'; 4: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 5: import type { Message } from '../../types/message.js'; 6: export function renderFeedbackComponent(onDone: (result?: string, options?: { 7: display?: CommandResultDisplay; 8: }) => void, abortSignal: AbortSignal, messages: Message[], initialDescription: string = '', backgroundTasks: { 9: [taskId: string]: { 10: type: string; 11: identity?: { 12: agentId: string; 13: }; 14: messages?: Message[]; 15: }; 16: } = {}): React.ReactNode { 17: return <Feedback abortSignal={abortSignal} messages={messages} initialDescription={initialDescription} onDone={onDone} backgroundTasks={backgroundTasks} />; 18: } 19: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext, args?: string): Promise<React.ReactNode> { 20: const initialDescription = args || ''; 21: return renderFeedbackComponent(onDone, context.abortController.signal, context.messages, initialDescription); 22: }

File: src/commands/feedback/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { isPolicyAllowed } from '../../services/policyLimits/index.js' 3: import { isEnvTruthy } from '../../utils/envUtils.js' 4: import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' 5: const feedback = { 6: aliases: ['bug'], 7: type: 'local-jsx', 8: name: 'feedback', 9: description: `Submit feedback about Claude Code`, 10: argumentHint: '[report]', 11: isEnabled: () => 12: !( 13: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || 14: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || 15: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || 16: isEnvTruthy(process.env.DISABLE_FEEDBACK_COMMAND) || 17: isEnvTruthy(process.env.DISABLE_BUG_COMMAND) || 18: isEssentialTrafficOnly() || 19: process.env.USER_TYPE === 'ant' || 20: !isPolicyAllowed('allow_product_feedback') 21: ), 22: load: () => import('./feedback.js'), 23: } satisfies Command 24: export default feedback

File: src/commands/files/files.ts

typescript 1: import { relative } from 'path' 2: import type { ToolUseContext } from '../../Tool.js' 3: import type { LocalCommandResult } from '../../types/command.js' 4: import { getCwd } from '../../utils/cwd.js' 5: import { cacheKeys } from '../../utils/fileStateCache.js' 6: export async function call( 7: _args: string, 8: context: ToolUseContext, 9: ): Promise<LocalCommandResult> { 10: const files = context.readFileState ? cacheKeys(context.readFileState) : [] 11: if (files.length === 0) { 12: return { type: 'text' as const, value: 'No files in context' } 13: } 14: const fileList = files.map(file => relative(getCwd(), file)).join('\n') 15: return { type: 'text' as const, value: `Files in context:\n${fileList}` } 16: }

File: src/commands/files/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const files = { 3: type: 'local', 4: name: 'files', 5: description: 'List all files currently in context', 6: isEnabled: () => process.env.USER_TYPE === 'ant', 7: supportsNonInteractive: true, 8: load: () => import('./files.js'), 9: } satisfies Command 10: export default files

File: src/commands/good-claude/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/heapdump/heapdump.ts

typescript 1: import { performHeapDump } from '../../utils/heapDumpService.js' 2: export async function call(): Promise<{ type: 'text'; value: string }> { 3: const result = await performHeapDump() 4: if (!result.success) { 5: return { 6: type: 'text', 7: value: `Failed to create heap dump: ${result.error}`, 8: } 9: } 10: return { 11: type: 'text', 12: value: `${result.heapPath}\n${result.diagPath}`, 13: } 14: }

File: src/commands/heapdump/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const heapDump = { 3: type: 'local', 4: name: 'heapdump', 5: description: 'Dump the JS heap to ~/Desktop', 6: isHidden: true, 7: supportsNonInteractive: true, 8: load: () => import('./heapdump.js'), 9: } satisfies Command 10: export default heapDump

File: src/commands/help/help.tsx

typescript 1: import * as React from 'react'; 2: import { HelpV2 } from '../../components/HelpV2/HelpV2.js'; 3: import type { LocalJSXCommandCall } from '../../types/command.js'; 4: export const call: LocalJSXCommandCall = async (onDone, { 5: options: { 6: commands 7: } 8: }) => { 9: return <HelpV2 commands={commands} onClose={onDone} />; 10: };

File: src/commands/help/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const help = { 3: type: 'local-jsx', 4: name: 'help', 5: description: 'Show help and available commands', 6: load: () => import('./help.js'), 7: } satisfies Command 8: export default help

File: src/commands/hooks/hooks.tsx

typescript 1: import * as React from 'react'; 2: import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js'; 3: import { logEvent } from '../../services/analytics/index.js'; 4: import { getTools } from '../../tools.js'; 5: import type { LocalJSXCommandCall } from '../../types/command.js'; 6: export const call: LocalJSXCommandCall = async (onDone, context) => { 7: logEvent('tengu_hooks_command', {}); 8: const appState = context.getAppState(); 9: const permissionContext = appState.toolPermissionContext; 10: const toolNames = getTools(permissionContext).map(tool => tool.name); 11: return <HooksConfigMenu toolNames={toolNames} onExit={onDone} />; 12: };

File: src/commands/hooks/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const hooks = { 3: type: 'local-jsx', 4: name: 'hooks', 5: description: 'View hook configurations for tool events', 6: immediate: true, 7: load: () => import('./hooks.js'), 8: } satisfies Command 9: export default hooks

File: src/commands/ide/ide.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import chalk from 'chalk'; 3: import * as path from 'path'; 4: import React, { useCallback, useEffect, useRef, useState } from 'react'; 5: import { logEvent } from 'src/services/analytics/index.js'; 6: import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js'; 7: import { Select } from '../../components/CustomSelect/index.js'; 8: import { Dialog } from '../../components/design-system/Dialog.js'; 9: import { IdeAutoConnectDialog, IdeDisableAutoConnectDialog, shouldShowAutoConnectDialog, shouldShowDisableAutoConnectDialog } from '../../components/IdeAutoConnectDialog.js'; 10: import { Box, Text } from '../../ink.js'; 11: import { clearServerCache } from '../../services/mcp/client.js'; 12: import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'; 13: import { useAppState, useSetAppState } from '../../state/AppState.js'; 14: import { getCwd } from '../../utils/cwd.js'; 15: import { execFileNoThrow } from '../../utils/execFileNoThrow.js'; 16: import { type DetectedIDEInfo, detectIDEs, detectRunningIDEs, type IdeType, isJetBrainsIde, isSupportedJetBrainsTerminal, isSupportedTerminal, toIDEDisplayName } from '../../utils/ide.js'; 17: import { getCurrentWorktreeSession } from '../../utils/worktree.js'; 18: type IDEScreenProps = { 19: availableIDEs: DetectedIDEInfo[]; 20: unavailableIDEs: DetectedIDEInfo[]; 21: selectedIDE?: DetectedIDEInfo | null; 22: onClose: () => void; 23: onSelect: (ide?: DetectedIDEInfo) => void; 24: }; 25: function IDEScreen(t0) { 26: const $ = _c(39); 27: const { 28: availableIDEs, 29: unavailableIDEs, 30: selectedIDE, 31: onClose, 32: onSelect 33: } = t0; 34: let t1; 35: if ($[0] !== selectedIDE?.port) { 36: t1 = selectedIDE?.port?.toString() ?? "None"; 37: $[0] = selectedIDE?.port; 38: $[1] = t1; 39: } else { 40: t1 = $[1]; 41: } 42: const [selectedValue, setSelectedValue] = useState(t1); 43: const [showAutoConnectDialog, setShowAutoConnectDialog] = useState(false); 44: const [showDisableAutoConnectDialog, setShowDisableAutoConnectDialog] = useState(false); 45: let t2; 46: if ($[2] !== availableIDEs || $[3] !== onSelect) { 47: t2 = value => { 48: if (value !== "None" && shouldShowAutoConnectDialog()) { 49: setShowAutoConnectDialog(true); 50: } else { 51: if (value === "None" && shouldShowDisableAutoConnectDialog()) { 52: setShowDisableAutoConnectDialog(true); 53: } else { 54: onSelect(availableIDEs.find(ide => ide.port === parseInt(value))); 55: } 56: } 57: }; 58: $[2] = availableIDEs; 59: $[3] = onSelect; 60: $[4] = t2; 61: } else { 62: t2 = $[4]; 63: } 64: const handleSelectIDE = t2; 65: let t3; 66: if ($[5] !== availableIDEs) { 67: t3 = availableIDEs.reduce(_temp, {}); 68: $[5] = availableIDEs; 69: $[6] = t3; 70: } else { 71: t3 = $[6]; 72: } 73: const ideCounts = t3; 74: let t4; 75: if ($[7] !== availableIDEs || $[8] !== ideCounts) { 76: let t5; 77: if ($[10] !== ideCounts) { 78: t5 = ide_1 => { 79: const hasMultipleInstances = (ideCounts[ide_1.name] || 0) > 1; 80: const showWorkspace = hasMultipleInstances && ide_1.workspaceFolders.length > 0; 81: return { 82: label: ide_1.name, 83: value: ide_1.port.toString(), 84: description: showWorkspace ? formatWorkspaceFolders(ide_1.workspaceFolders) : undefined 85: }; 86: }; 87: $[10] = ideCounts; 88: $[11] = t5; 89: } else { 90: t5 = $[11]; 91: } 92: t4 = availableIDEs.map(t5).concat([{ 93: label: "None", 94: value: "None", 95: description: undefined 96: }]); 97: $[7] = availableIDEs; 98: $[8] = ideCounts; 99: $[9] = t4; 100: } else { 101: t4 = $[9]; 102: } 103: const options = t4; 104: if (showAutoConnectDialog) { 105: let t5; 106: if ($[12] !== handleSelectIDE || $[13] !== selectedValue) { 107: t5 = <IdeAutoConnectDialog onComplete={() => handleSelectIDE(selectedValue)} />; 108: $[12] = handleSelectIDE; 109: $[13] = selectedValue; 110: $[14] = t5; 111: } else { 112: t5 = $[14]; 113: } 114: return t5; 115: } 116: if (showDisableAutoConnectDialog) { 117: let t5; 118: if ($[15] !== onSelect) { 119: t5 = <IdeDisableAutoConnectDialog onComplete={() => { 120: onSelect(undefined); 121: }} />; 122: $[15] = onSelect; 123: $[16] = t5; 124: } else { 125: t5 = $[16]; 126: } 127: return t5; 128: } 129: let t5; 130: if ($[17] !== availableIDEs.length) { 131: t5 = availableIDEs.length === 0 && <Text dimColor={true}>{isSupportedJetBrainsTerminal() ? "No available IDEs detected. Please install the plugin and restart your IDE:\nhttps://docs.claude.com/s/claude-code-jetbrains" : "No available IDEs detected. Make sure your IDE has the Claude Code extension or plugin installed and is running."}</Text>; 132: $[17] = availableIDEs.length; 133: $[18] = t5; 134: } else { 135: t5 = $[18]; 136: } 137: let t6; 138: if ($[19] !== availableIDEs.length || $[20] !== handleSelectIDE || $[21] !== options || $[22] !== selectedValue) { 139: t6 = availableIDEs.length !== 0 && <Select defaultValue={selectedValue} defaultFocusValue={selectedValue} options={options} onChange={value_0 => { 140: setSelectedValue(value_0); 141: handleSelectIDE(value_0); 142: }} />; 143: $[19] = availableIDEs.length; 144: $[20] = handleSelectIDE; 145: $[21] = options; 146: $[22] = selectedValue; 147: $[23] = t6; 148: } else { 149: t6 = $[23]; 150: } 151: let t7; 152: if ($[24] !== availableIDEs) { 153: t7 = availableIDEs.length !== 0 && availableIDEs.some(_temp2) && <Box marginTop={1}><Text color="warning">Note: Only one Claude Code instance can be connected to VS Code at a time.</Text></Box>; 154: $[24] = availableIDEs; 155: $[25] = t7; 156: } else { 157: t7 = $[25]; 158: } 159: let t8; 160: if ($[26] !== availableIDEs.length) { 161: t8 = availableIDEs.length !== 0 && !isSupportedTerminal() && <Box marginTop={1}><Text dimColor={true}>Tip: You can enable auto-connect to IDE in /config or with the --ide flag</Text></Box>; 162: $[26] = availableIDEs.length; 163: $[27] = t8; 164: } else { 165: t8 = $[27]; 166: } 167: let t9; 168: if ($[28] !== unavailableIDEs) { 169: t9 = unavailableIDEs.length > 0 && <Box marginTop={1} flexDirection="column"><Text dimColor={true}>Found {unavailableIDEs.length} other running IDE(s). However, their workspace/project directories do not match the current cwd.</Text><Box marginTop={1} flexDirection="column">{unavailableIDEs.map(_temp3)}</Box></Box>; 170: $[28] = unavailableIDEs; 171: $[29] = t9; 172: } else { 173: t9 = $[29]; 174: } 175: let t10; 176: if ($[30] !== t5 || $[31] !== t6 || $[32] !== t7 || $[33] !== t8 || $[34] !== t9) { 177: t10 = <Box flexDirection="column">{t5}{t6}{t7}{t8}{t9}</Box>; 178: $[30] = t5; 179: $[31] = t6; 180: $[32] = t7; 181: $[33] = t8; 182: $[34] = t9; 183: $[35] = t10; 184: } else { 185: t10 = $[35]; 186: } 187: let t11; 188: if ($[36] !== onClose || $[37] !== t10) { 189: t11 = <Dialog title="Select IDE" subtitle="Connect to an IDE for integrated development features." onCancel={onClose} color="ide">{t10}</Dialog>; 190: $[36] = onClose; 191: $[37] = t10; 192: $[38] = t11; 193: } else { 194: t11 = $[38]; 195: } 196: return t11; 197: } 198: function _temp3(ide_3, index) { 199: return <Box key={index} paddingLeft={3}><Text dimColor={true}>• {ide_3.name}: {formatWorkspaceFolders(ide_3.workspaceFolders)}</Text></Box>; 200: } 201: function _temp2(ide_2) { 202: return ide_2.name === "VS Code" || ide_2.name === "Visual Studio Code"; 203: } 204: function _temp(acc, ide_0) { 205: acc[ide_0.name] = (acc[ide_0.name] || 0) + 1; 206: return acc; 207: } 208: async function findCurrentIDE(availableIDEs: DetectedIDEInfo[], dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>): Promise<DetectedIDEInfo | null> { 209: const currentConfig = dynamicMcpConfig?.ide; 210: if (!currentConfig || currentConfig.type !== 'sse-ide' && currentConfig.type !== 'ws-ide') { 211: return null; 212: } 213: for (const ide of availableIDEs) { 214: if (ide.url === currentConfig.url) { 215: return ide; 216: } 217: } 218: return null; 219: } 220: type IDEOpenSelectionProps = { 221: availableIDEs: DetectedIDEInfo[]; 222: onSelectIDE: (ide?: DetectedIDEInfo) => void; 223: onDone: (result?: string, options?: { 224: display?: CommandResultDisplay; 225: }) => void; 226: }; 227: function IDEOpenSelection(t0) { 228: const $ = _c(18); 229: const { 230: availableIDEs, 231: onSelectIDE, 232: onDone 233: } = t0; 234: let t1; 235: if ($[0] !== availableIDEs[0]?.port) { 236: t1 = availableIDEs[0]?.port?.toString() ?? ""; 237: $[0] = availableIDEs[0]?.port; 238: $[1] = t1; 239: } else { 240: t1 = $[1]; 241: } 242: const [selectedValue, setSelectedValue] = useState(t1); 243: let t2; 244: if ($[2] !== availableIDEs || $[3] !== onSelectIDE) { 245: t2 = value => { 246: const selectedIDE = availableIDEs.find(ide => ide.port === parseInt(value)); 247: onSelectIDE(selectedIDE); 248: }; 249: $[2] = availableIDEs; 250: $[3] = onSelectIDE; 251: $[4] = t2; 252: } else { 253: t2 = $[4]; 254: } 255: const handleSelectIDE = t2; 256: let t3; 257: if ($[5] !== availableIDEs) { 258: t3 = availableIDEs.map(_temp4); 259: $[5] = availableIDEs; 260: $[6] = t3; 261: } else { 262: t3 = $[6]; 263: } 264: const options = t3; 265: let t4; 266: if ($[7] !== onDone) { 267: t4 = function handleCancel() { 268: onDone("IDE selection cancelled", { 269: display: "system" 270: }); 271: }; 272: $[7] = onDone; 273: $[8] = t4; 274: } else { 275: t4 = $[8]; 276: } 277: const handleCancel = t4; 278: let t5; 279: if ($[9] !== handleSelectIDE) { 280: t5 = value_0 => { 281: setSelectedValue(value_0); 282: handleSelectIDE(value_0); 283: }; 284: $[9] = handleSelectIDE; 285: $[10] = t5; 286: } else { 287: t5 = $[10]; 288: } 289: let t6; 290: if ($[11] !== options || $[12] !== selectedValue || $[13] !== t5) { 291: t6 = <Select defaultValue={selectedValue} defaultFocusValue={selectedValue} options={options} onChange={t5} />; 292: $[11] = options; 293: $[12] = selectedValue; 294: $[13] = t5; 295: $[14] = t6; 296: } else { 297: t6 = $[14]; 298: } 299: let t7; 300: if ($[15] !== handleCancel || $[16] !== t6) { 301: t7 = <Dialog title="Select an IDE to open the project" onCancel={handleCancel} color="ide">{t6}</Dialog>; 302: $[15] = handleCancel; 303: $[16] = t6; 304: $[17] = t7; 305: } else { 306: t7 = $[17]; 307: } 308: return t7; 309: } 310: function _temp4(ide_0) { 311: return { 312: label: ide_0.name, 313: value: ide_0.port.toString() 314: }; 315: } 316: function RunningIDESelector(t0) { 317: const $ = _c(15); 318: const { 319: runningIDEs, 320: onSelectIDE, 321: onDone 322: } = t0; 323: const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? ""); 324: let t1; 325: if ($[0] !== onSelectIDE) { 326: t1 = value => { 327: onSelectIDE(value as IdeType); 328: }; 329: $[0] = onSelectIDE; 330: $[1] = t1; 331: } else { 332: t1 = $[1]; 333: } 334: const handleSelectIDE = t1; 335: let t2; 336: if ($[2] !== runningIDEs) { 337: t2 = runningIDEs.map(_temp5); 338: $[2] = runningIDEs; 339: $[3] = t2; 340: } else { 341: t2 = $[3]; 342: } 343: const options = t2; 344: let t3; 345: if ($[4] !== onDone) { 346: t3 = function handleCancel() { 347: onDone("IDE selection cancelled", { 348: display: "system" 349: }); 350: }; 351: $[4] = onDone; 352: $[5] = t3; 353: } else { 354: t3 = $[5]; 355: } 356: const handleCancel = t3; 357: let t4; 358: if ($[6] !== handleSelectIDE) { 359: t4 = value_0 => { 360: setSelectedValue(value_0); 361: handleSelectIDE(value_0); 362: }; 363: $[6] = handleSelectIDE; 364: $[7] = t4; 365: } else { 366: t4 = $[7]; 367: } 368: let t5; 369: if ($[8] !== options || $[9] !== selectedValue || $[10] !== t4) { 370: t5 = <Select defaultFocusValue={selectedValue} options={options} onChange={t4} />; 371: $[8] = options; 372: $[9] = selectedValue; 373: $[10] = t4; 374: $[11] = t5; 375: } else { 376: t5 = $[11]; 377: } 378: let t6; 379: if ($[12] !== handleCancel || $[13] !== t5) { 380: t6 = <Dialog title="Select IDE to install extension" onCancel={handleCancel} color="ide">{t5}</Dialog>; 381: $[12] = handleCancel; 382: $[13] = t5; 383: $[14] = t6; 384: } else { 385: t6 = $[14]; 386: } 387: return t6; 388: } 389: function _temp5(ide) { 390: return { 391: label: toIDEDisplayName(ide), 392: value: ide 393: }; 394: } 395: function InstallOnMount(t0) { 396: const $ = _c(4); 397: const { 398: ide, 399: onInstall 400: } = t0; 401: let t1; 402: let t2; 403: if ($[0] !== ide || $[1] !== onInstall) { 404: t1 = () => { 405: onInstall(ide); 406: }; 407: t2 = [ide, onInstall]; 408: $[0] = ide; 409: $[1] = onInstall; 410: $[2] = t1; 411: $[3] = t2; 412: } else { 413: t1 = $[2]; 414: t2 = $[3]; 415: } 416: useEffect(t1, t2); 417: return null; 418: } 419: export async function call(onDone: (result?: string, options?: { 420: display?: CommandResultDisplay; 421: }) => void, context: LocalJSXCommandContext, args: string): Promise<React.ReactNode | null> { 422: logEvent('tengu_ext_ide_command', {}); 423: const { 424: options: { 425: dynamicMcpConfig 426: }, 427: onChangeDynamicMcpConfig 428: } = context; 429: if (args?.trim() === 'open') { 430: const worktreeSession = getCurrentWorktreeSession(); 431: const targetPath = worktreeSession ? worktreeSession.worktreePath : getCwd(); 432: const detectedIDEs = await detectIDEs(true); 433: const availableIDEs = detectedIDEs.filter(ide => ide.isValid); 434: if (availableIDEs.length === 0) { 435: onDone('No IDEs with Claude Code extension detected.'); 436: return null; 437: } 438: return <IDEOpenSelection availableIDEs={availableIDEs} onSelectIDE={async (selectedIDE?: DetectedIDEInfo) => { 439: if (!selectedIDE) { 440: onDone('No IDE selected.'); 441: return; 442: } 443: if (selectedIDE.name.toLowerCase().includes('vscode') || selectedIDE.name.toLowerCase().includes('cursor') || selectedIDE.name.toLowerCase().includes('windsurf')) { 444: const { 445: code 446: } = await execFileNoThrow('code', [targetPath]); 447: if (code === 0) { 448: onDone(`Opened ${worktreeSession ? 'worktree' : 'project'} in ${chalk.bold(selectedIDE.name)}`); 449: } else { 450: onDone(`Failed to open in ${selectedIDE.name}. Try opening manually: ${targetPath}`); 451: } 452: } else if (isSupportedJetBrainsTerminal()) { 453: onDone(`Please open the ${worktreeSession ? 'worktree' : 'project'} manually in ${chalk.bold(selectedIDE.name)}: ${targetPath}`); 454: } else { 455: onDone(`Please open the ${worktreeSession ? 'worktree' : 'project'} manually in ${chalk.bold(selectedIDE.name)}: ${targetPath}`); 456: } 457: }} onDone={() => { 458: onDone('Exited without opening IDE', { 459: display: 'system' 460: }); 461: }} />; 462: } 463: const detectedIDEs = await detectIDEs(true); 464: if (detectedIDEs.length === 0 && context.onInstallIDEExtension && !isSupportedTerminal()) { 465: const runningIDEs = await detectRunningIDEs(); 466: const onInstall = (ide: IdeType) => { 467: if (context.onInstallIDEExtension) { 468: context.onInstallIDEExtension(ide); 469: if (isJetBrainsIde(ide)) { 470: onDone(`Installed plugin to ${chalk.bold(toIDEDisplayName(ide))}\n` + `Please ${chalk.bold('restart your IDE')} completely for it to take effect`); 471: } else { 472: onDone(`Installed extension to ${chalk.bold(toIDEDisplayName(ide))}`); 473: } 474: } 475: }; 476: if (runningIDEs.length > 1) { 477: return <RunningIDESelector runningIDEs={runningIDEs} onSelectIDE={onInstall} onDone={() => { 478: onDone('No IDE selected.', { 479: display: 'system' 480: }); 481: }} />; 482: } else if (runningIDEs.length === 1) { 483: return <InstallOnMount ide={runningIDEs[0]!} onInstall={onInstall} />; 484: } 485: } 486: const availableIDEs = detectedIDEs.filter(ide => ide.isValid); 487: const unavailableIDEs = detectedIDEs.filter(ide => !ide.isValid); 488: const currentIDE = await findCurrentIDE(availableIDEs, dynamicMcpConfig); 489: return <IDECommandFlow availableIDEs={availableIDEs} unavailableIDEs={unavailableIDEs} currentIDE={currentIDE} dynamicMcpConfig={dynamicMcpConfig} onChangeDynamicMcpConfig={onChangeDynamicMcpConfig} onDone={onDone} />; 490: } 491: const IDE_CONNECTION_TIMEOUT_MS = 35000; 492: type IDECommandFlowProps = { 493: availableIDEs: DetectedIDEInfo[]; 494: unavailableIDEs: DetectedIDEInfo[]; 495: currentIDE: DetectedIDEInfo | null; 496: dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>; 497: onChangeDynamicMcpConfig?: (config: Record<string, ScopedMcpServerConfig>) => void; 498: onDone: (result?: string, options?: { 499: display?: CommandResultDisplay; 500: }) => void; 501: }; 502: function IDECommandFlow({ 503: availableIDEs, 504: unavailableIDEs, 505: currentIDE, 506: dynamicMcpConfig, 507: onChangeDynamicMcpConfig, 508: onDone 509: }: IDECommandFlowProps): React.ReactNode { 510: const [connectingIDE, setConnectingIDE] = useState<DetectedIDEInfo | null>(null); 511: const ideClient = useAppState(s => s.mcp.clients.find(c => c.name === 'ide')); 512: const setAppState = useSetAppState(); 513: const isFirstCheckRef = useRef(true); 514: useEffect(() => { 515: if (!connectingIDE) return; 516: if (isFirstCheckRef.current) { 517: isFirstCheckRef.current = false; 518: return; 519: } 520: if (!ideClient || ideClient.type === 'pending') return; 521: if (ideClient.type === 'connected') { 522: onDone(`Connected to ${connectingIDE.name}.`); 523: } else if (ideClient.type === 'failed') { 524: onDone(`Failed to connect to ${connectingIDE.name}.`); 525: } 526: }, [ideClient, connectingIDE, onDone]); 527: useEffect(() => { 528: if (!connectingIDE) return; 529: const timer = setTimeout(onDone, IDE_CONNECTION_TIMEOUT_MS, `Connection to ${connectingIDE.name} timed out.`); 530: return () => clearTimeout(timer); 531: }, [connectingIDE, onDone]); 532: const handleSelectIDE = useCallback((selectedIDE?: DetectedIDEInfo) => { 533: if (!onChangeDynamicMcpConfig) { 534: onDone('Error connecting to IDE.'); 535: return; 536: } 537: const newConfig = { 538: ...(dynamicMcpConfig || {}) 539: }; 540: if (currentIDE) { 541: delete newConfig.ide; 542: } 543: if (!selectedIDE) { 544: if (ideClient && ideClient.type === 'connected' && currentIDE) { 545: ideClient.client.onclose = () => {}; 546: void clearServerCache('ide', ideClient.config); 547: setAppState(prev => ({ 548: ...prev, 549: mcp: { 550: ...prev.mcp, 551: clients: prev.mcp.clients.filter(c_0 => c_0.name !== 'ide'), 552: tools: prev.mcp.tools.filter(t => !t.name?.startsWith('mcp__ide__')), 553: commands: prev.mcp.commands.filter(c_1 => !c_1.name?.startsWith('mcp__ide__')) 554: } 555: })); 556: } 557: onChangeDynamicMcpConfig(newConfig); 558: onDone(currentIDE ? `Disconnected from ${currentIDE.name}.` : 'No IDE selected.'); 559: return; 560: } 561: const url = selectedIDE.url; 562: newConfig.ide = { 563: type: url.startsWith('ws:') ? 'ws-ide' : 'sse-ide', 564: url: url, 565: ideName: selectedIDE.name, 566: authToken: selectedIDE.authToken, 567: ideRunningInWindows: selectedIDE.ideRunningInWindows, 568: scope: 'dynamic' as const 569: } as ScopedMcpServerConfig; 570: isFirstCheckRef.current = true; 571: setConnectingIDE(selectedIDE); 572: onChangeDynamicMcpConfig(newConfig); 573: }, [dynamicMcpConfig, currentIDE, ideClient, setAppState, onChangeDynamicMcpConfig, onDone]); 574: if (connectingIDE) { 575: return <Text dimColor>Connecting to {connectingIDE.name}…</Text>; 576: } 577: return <IDEScreen availableIDEs={availableIDEs} unavailableIDEs={unavailableIDEs} selectedIDE={currentIDE} onClose={() => onDone('IDE selection cancelled', { 578: display: 'system' 579: })} onSelect={handleSelectIDE} />; 580: } 581: export function formatWorkspaceFolders(folders: string[], maxLength: number = 100): string { 582: if (folders.length === 0) return ''; 583: const cwd = getCwd(); 584: // Only show first 2 workspaces 585: const foldersToShow = folders.slice(0, 2); 586: const hasMore = folders.length > 2; 587: // Account for ", …" if there are more folders 588: const ellipsisOverhead = hasMore ? 3 : 0; // ", …" 589: // Account for commas and spaces between paths (", " = 2 chars per separator) 590: const separatorOverhead = (foldersToShow.length - 1) * 2; 591: const availableLength = maxLength - separatorOverhead - ellipsisOverhead; 592: const maxLengthPerPath = Math.floor(availableLength / foldersToShow.length); 593: const cwdNFC = cwd.normalize('NFC'); 594: const formattedFolders = foldersToShow.map(folder => { 595: const folderNFC = folder.normalize('NFC'); 596: if (folderNFC.startsWith(cwdNFC + path.sep)) { 597: folder = folderNFC.slice(cwdNFC.length + 1); 598: } 599: if (folder.length <= maxLengthPerPath) { 600: return folder; 601: } 602: return '…' + folder.slice(-(maxLengthPerPath - 1)); 603: }); 604: let result = formattedFolders.join(', '); 605: if (hasMore) { 606: result += ', …'; 607: } 608: return result; 609: }

File: src/commands/ide/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const ide = { 3: type: 'local-jsx', 4: name: 'ide', 5: description: 'Manage IDE integrations and show status', 6: argumentHint: '[open]', 7: load: () => import('./ide.js'), 8: } satisfies Command 9: export default ide

File: src/commands/install-github-app/ApiKeyStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { useCallback, useState } from 'react'; 3: import TextInput from '../../components/TextInput.js'; 4: import { useTerminalSize } from '../../hooks/useTerminalSize.js'; 5: import { Box, color, Text, useTheme } from '../../ink.js'; 6: import { useKeybindings } from '../../keybindings/useKeybinding.js'; 7: interface ApiKeyStepProps { 8: existingApiKey: string | null; 9: useExistingKey: boolean; 10: apiKeyOrOAuthToken: string; 11: onApiKeyChange: (value: string) => void; 12: onToggleUseExistingKey: (useExisting: boolean) => void; 13: onSubmit: () => void; 14: onCreateOAuthToken?: () => void; 15: selectedOption?: 'existing' | 'new' | 'oauth'; 16: onSelectOption?: (option: 'existing' | 'new' | 'oauth') => void; 17: } 18: export function ApiKeyStep(t0) { 19: const $ = _c(55); 20: const { 21: existingApiKey, 22: apiKeyOrOAuthToken, 23: onApiKeyChange, 24: onSubmit, 25: onToggleUseExistingKey, 26: onCreateOAuthToken, 27: selectedOption: t1, 28: onSelectOption 29: } = t0; 30: const selectedOption = t1 === undefined ? existingApiKey ? "existing" : onCreateOAuthToken ? "oauth" : "new" : t1; 31: const [cursorOffset, setCursorOffset] = useState(0); 32: const terminalSize = useTerminalSize(); 33: const [theme] = useTheme(); 34: let t2; 35: if ($[0] !== existingApiKey || $[1] !== onCreateOAuthToken || $[2] !== onSelectOption || $[3] !== onToggleUseExistingKey || $[4] !== selectedOption) { 36: t2 = () => { 37: if (selectedOption === "new" && onCreateOAuthToken) { 38: onSelectOption?.("oauth"); 39: } else { 40: if (selectedOption === "oauth" && existingApiKey) { 41: onSelectOption?.("existing"); 42: onToggleUseExistingKey(true); 43: } 44: } 45: }; 46: $[0] = existingApiKey; 47: $[1] = onCreateOAuthToken; 48: $[2] = onSelectOption; 49: $[3] = onToggleUseExistingKey; 50: $[4] = selectedOption; 51: $[5] = t2; 52: } else { 53: t2 = $[5]; 54: } 55: const handlePrevious = t2; 56: let t3; 57: if ($[6] !== onCreateOAuthToken || $[7] !== onSelectOption || $[8] !== onToggleUseExistingKey || $[9] !== selectedOption) { 58: t3 = () => { 59: if (selectedOption === "existing") { 60: onSelectOption?.(onCreateOAuthToken ? "oauth" : "new"); 61: onToggleUseExistingKey(false); 62: } else { 63: if (selectedOption === "oauth") { 64: onSelectOption?.("new"); 65: } 66: } 67: }; 68: $[6] = onCreateOAuthToken; 69: $[7] = onSelectOption; 70: $[8] = onToggleUseExistingKey; 71: $[9] = selectedOption; 72: $[10] = t3; 73: } else { 74: t3 = $[10]; 75: } 76: const handleNext = t3; 77: let t4; 78: if ($[11] !== onCreateOAuthToken || $[12] !== onSubmit || $[13] !== selectedOption) { 79: t4 = () => { 80: if (selectedOption === "oauth" && onCreateOAuthToken) { 81: onCreateOAuthToken(); 82: } else { 83: onSubmit(); 84: } 85: }; 86: $[11] = onCreateOAuthToken; 87: $[12] = onSubmit; 88: $[13] = selectedOption; 89: $[14] = t4; 90: } else { 91: t4 = $[14]; 92: } 93: const handleConfirm = t4; 94: const isTextInputVisible = selectedOption === "new"; 95: let t5; 96: if ($[15] !== handleConfirm || $[16] !== handleNext || $[17] !== handlePrevious) { 97: t5 = { 98: "confirm:previous": handlePrevious, 99: "confirm:next": handleNext, 100: "confirm:yes": handleConfirm 101: }; 102: $[15] = handleConfirm; 103: $[16] = handleNext; 104: $[17] = handlePrevious; 105: $[18] = t5; 106: } else { 107: t5 = $[18]; 108: } 109: const t6 = !isTextInputVisible; 110: let t7; 111: if ($[19] !== t6) { 112: t7 = { 113: context: "Confirmation", 114: isActive: t6 115: }; 116: $[19] = t6; 117: $[20] = t7; 118: } else { 119: t7 = $[20]; 120: } 121: useKeybindings(t5, t7); 122: let t8; 123: if ($[21] !== handleNext || $[22] !== handlePrevious) { 124: t8 = { 125: "confirm:previous": handlePrevious, 126: "confirm:next": handleNext 127: }; 128: $[21] = handleNext; 129: $[22] = handlePrevious; 130: $[23] = t8; 131: } else { 132: t8 = $[23]; 133: } 134: let t9; 135: if ($[24] !== isTextInputVisible) { 136: t9 = { 137: context: "Confirmation", 138: isActive: isTextInputVisible 139: }; 140: $[24] = isTextInputVisible; 141: $[25] = t9; 142: } else { 143: t9 = $[25]; 144: } 145: useKeybindings(t8, t9); 146: let t10; 147: if ($[26] === Symbol.for("react.memo_cache_sentinel")) { 148: t10 = <Box flexDirection="column" marginBottom={1}><Text bold={true}>Install GitHub App</Text><Text dimColor={true}>Choose API key</Text></Box>; 149: $[26] = t10; 150: } else { 151: t10 = $[26]; 152: } 153: let t11; 154: if ($[27] !== existingApiKey || $[28] !== selectedOption || $[29] !== theme) { 155: t11 = existingApiKey && <Box marginBottom={1}><Text>{selectedOption === "existing" ? color("success", theme)("> ") : " "}Use your existing Claude Code API key</Text></Box>; 156: $[27] = existingApiKey; 157: $[28] = selectedOption; 158: $[29] = theme; 159: $[30] = t11; 160: } else { 161: t11 = $[30]; 162: } 163: let t12; 164: if ($[31] !== onCreateOAuthToken || $[32] !== selectedOption || $[33] !== theme) { 165: t12 = onCreateOAuthToken && <Box marginBottom={1}><Text>{selectedOption === "oauth" ? color("success", theme)("> ") : " "}Create a long-lived token with your Claude subscription</Text></Box>; 166: $[31] = onCreateOAuthToken; 167: $[32] = selectedOption; 168: $[33] = theme; 169: $[34] = t12; 170: } else { 171: t12 = $[34]; 172: } 173: let t13; 174: if ($[35] !== selectedOption || $[36] !== theme) { 175: t13 = selectedOption === "new" ? color("success", theme)("> ") : " "; 176: $[35] = selectedOption; 177: $[36] = theme; 178: $[37] = t13; 179: } else { 180: t13 = $[37]; 181: } 182: let t14; 183: if ($[38] !== t13) { 184: t14 = <Box marginBottom={1}><Text>{t13}Enter a new API key</Text></Box>; 185: $[38] = t13; 186: $[39] = t14; 187: } else { 188: t14 = $[39]; 189: } 190: let t15; 191: if ($[40] !== apiKeyOrOAuthToken || $[41] !== cursorOffset || $[42] !== onApiKeyChange || $[43] !== onSubmit || $[44] !== selectedOption || $[45] !== terminalSize) { 192: t15 = selectedOption === "new" && <TextInput value={apiKeyOrOAuthToken} onChange={onApiKeyChange} onSubmit={onSubmit} onPaste={onApiKeyChange} focus={true} placeholder={"sk-ant\u2026 (Create a new key at https://platform.claude.com/settings/keys)"} mask="*" columns={terminalSize.columns} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} showCursor={true} />; 193: $[40] = apiKeyOrOAuthToken; 194: $[41] = cursorOffset; 195: $[42] = onApiKeyChange; 196: $[43] = onSubmit; 197: $[44] = selectedOption; 198: $[45] = terminalSize; 199: $[46] = t15; 200: } else { 201: t15 = $[46]; 202: } 203: let t16; 204: if ($[47] !== t11 || $[48] !== t12 || $[49] !== t14 || $[50] !== t15) { 205: t16 = <Box flexDirection="column" borderStyle="round" paddingX={1}>{t10}{t11}{t12}{t14}{t15}</Box>; 206: $[47] = t11; 207: $[48] = t12; 208: $[49] = t14; 209: $[50] = t15; 210: $[51] = t16; 211: } else { 212: t16 = $[51]; 213: } 214: let t17; 215: if ($[52] === Symbol.for("react.memo_cache_sentinel")) { 216: t17 = <Box marginLeft={3}><Text dimColor={true}>↑/↓ to select · Enter to continue</Text></Box>; 217: $[52] = t17; 218: } else { 219: t17 = $[52]; 220: } 221: let t18; 222: if ($[53] !== t16) { 223: t18 = <>{t16}{t17}</>; 224: $[53] = t16; 225: $[54] = t18; 226: } else { 227: t18 = $[54]; 228: } 229: return t18; 230: }

File: src/commands/install-github-app/CheckExistingSecretStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { useCallback, useState } from 'react'; 3: import TextInput from '../../components/TextInput.js'; 4: import { useTerminalSize } from '../../hooks/useTerminalSize.js'; 5: import { Box, color, Text, useTheme } from '../../ink.js'; 6: import { useKeybindings } from '../../keybindings/useKeybinding.js'; 7: interface CheckExistingSecretStepProps { 8: useExistingSecret: boolean; 9: secretName: string; 10: onToggleUseExistingSecret: (useExisting: boolean) => void; 11: onSecretNameChange: (value: string) => void; 12: onSubmit: () => void; 13: } 14: export function CheckExistingSecretStep(t0) { 15: const $ = _c(42); 16: const { 17: useExistingSecret, 18: secretName, 19: onToggleUseExistingSecret, 20: onSecretNameChange, 21: onSubmit 22: } = t0; 23: const [cursorOffset, setCursorOffset] = useState(0); 24: const terminalSize = useTerminalSize(); 25: const [theme] = useTheme(); 26: let t1; 27: if ($[0] !== onToggleUseExistingSecret) { 28: t1 = () => onToggleUseExistingSecret(true); 29: $[0] = onToggleUseExistingSecret; 30: $[1] = t1; 31: } else { 32: t1 = $[1]; 33: } 34: const handlePrevious = t1; 35: let t2; 36: if ($[2] !== onToggleUseExistingSecret) { 37: t2 = () => onToggleUseExistingSecret(false); 38: $[2] = onToggleUseExistingSecret; 39: $[3] = t2; 40: } else { 41: t2 = $[3]; 42: } 43: const handleNext = t2; 44: let t3; 45: if ($[4] !== handleNext || $[5] !== handlePrevious || $[6] !== onSubmit) { 46: t3 = { 47: "confirm:previous": handlePrevious, 48: "confirm:next": handleNext, 49: "confirm:yes": onSubmit 50: }; 51: $[4] = handleNext; 52: $[5] = handlePrevious; 53: $[6] = onSubmit; 54: $[7] = t3; 55: } else { 56: t3 = $[7]; 57: } 58: let t4; 59: if ($[8] !== useExistingSecret) { 60: t4 = { 61: context: "Confirmation", 62: isActive: useExistingSecret 63: }; 64: $[8] = useExistingSecret; 65: $[9] = t4; 66: } else { 67: t4 = $[9]; 68: } 69: useKeybindings(t3, t4); 70: let t5; 71: if ($[10] !== handleNext || $[11] !== handlePrevious) { 72: t5 = { 73: "confirm:previous": handlePrevious, 74: "confirm:next": handleNext 75: }; 76: $[10] = handleNext; 77: $[11] = handlePrevious; 78: $[12] = t5; 79: } else { 80: t5 = $[12]; 81: } 82: const t6 = !useExistingSecret; 83: let t7; 84: if ($[13] !== t6) { 85: t7 = { 86: context: "Confirmation", 87: isActive: t6 88: }; 89: $[13] = t6; 90: $[14] = t7; 91: } else { 92: t7 = $[14]; 93: } 94: useKeybindings(t5, t7); 95: let t8; 96: if ($[15] === Symbol.for("react.memo_cache_sentinel")) { 97: t8 = <Box flexDirection="column" marginBottom={1}><Text bold={true}>Install GitHub App</Text><Text dimColor={true}>Setup API key secret</Text></Box>; 98: $[15] = t8; 99: } else { 100: t8 = $[15]; 101: } 102: let t9; 103: if ($[16] === Symbol.for("react.memo_cache_sentinel")) { 104: t9 = <Box marginBottom={1}><Text color="warning">ANTHROPIC_API_KEY already exists in repository secrets!</Text></Box>; 105: $[16] = t9; 106: } else { 107: t9 = $[16]; 108: } 109: let t10; 110: if ($[17] === Symbol.for("react.memo_cache_sentinel")) { 111: t10 = <Box marginBottom={1}><Text>Would you like to:</Text></Box>; 112: $[17] = t10; 113: } else { 114: t10 = $[17]; 115: } 116: let t11; 117: if ($[18] !== theme || $[19] !== useExistingSecret) { 118: t11 = useExistingSecret ? color("success", theme)("> ") : " "; 119: $[18] = theme; 120: $[19] = useExistingSecret; 121: $[20] = t11; 122: } else { 123: t11 = $[20]; 124: } 125: let t12; 126: if ($[21] !== t11) { 127: t12 = <Box marginBottom={1}><Text>{t11}Use the existing API key</Text></Box>; 128: $[21] = t11; 129: $[22] = t12; 130: } else { 131: t12 = $[22]; 132: } 133: let t13; 134: if ($[23] !== theme || $[24] !== useExistingSecret) { 135: t13 = !useExistingSecret ? color("success", theme)("> ") : " "; 136: $[23] = theme; 137: $[24] = useExistingSecret; 138: $[25] = t13; 139: } else { 140: t13 = $[25]; 141: } 142: let t14; 143: if ($[26] !== t13) { 144: t14 = <Box marginBottom={1}><Text>{t13}Create a new secret with a different name</Text></Box>; 145: $[26] = t13; 146: $[27] = t14; 147: } else { 148: t14 = $[27]; 149: } 150: let t15; 151: if ($[28] !== cursorOffset || $[29] !== onSecretNameChange || $[30] !== onSubmit || $[31] !== secretName || $[32] !== terminalSize || $[33] !== useExistingSecret) { 152: t15 = !useExistingSecret && <><Box marginBottom={1}><Text>Enter new secret name (alphanumeric with underscores):</Text></Box><TextInput value={secretName} onChange={onSecretNameChange} onSubmit={onSubmit} focus={true} placeholder="e.g., CLAUDE_API_KEY" columns={terminalSize.columns} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} showCursor={true} /></>; 153: $[28] = cursorOffset; 154: $[29] = onSecretNameChange; 155: $[30] = onSubmit; 156: $[31] = secretName; 157: $[32] = terminalSize; 158: $[33] = useExistingSecret; 159: $[34] = t15; 160: } else { 161: t15 = $[34]; 162: } 163: let t16; 164: if ($[35] !== t12 || $[36] !== t14 || $[37] !== t15) { 165: t16 = <Box flexDirection="column" borderStyle="round" paddingX={1}>{t8}{t9}{t10}{t12}{t14}{t15}</Box>; 166: $[35] = t12; 167: $[36] = t14; 168: $[37] = t15; 169: $[38] = t16; 170: } else { 171: t16 = $[38]; 172: } 173: let t17; 174: if ($[39] === Symbol.for("react.memo_cache_sentinel")) { 175: t17 = <Box marginLeft={3}><Text dimColor={true}>↑/↓ to select · Enter to continue</Text></Box>; 176: $[39] = t17; 177: } else { 178: t17 = $[39]; 179: } 180: let t18; 181: if ($[40] !== t16) { 182: t18 = <>{t16}{t17}</>; 183: $[40] = t16; 184: $[41] = t18; 185: } else { 186: t18 = $[41]; 187: } 188: return t18; 189: }

File: src/commands/install-github-app/CheckGitHubStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React from 'react'; 3: import { Text } from '../../ink.js'; 4: export function CheckGitHubStep() { 5: const $ = _c(1); 6: let t0; 7: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 8: t0 = <Text>Checking GitHub CLI installation…</Text>; 9: $[0] = t0; 10: } else { 11: t0 = $[0]; 12: } 13: return t0; 14: }

File: src/commands/install-github-app/ChooseRepoStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { useCallback, useState } from 'react'; 3: import TextInput from '../../components/TextInput.js'; 4: import { useTerminalSize } from '../../hooks/useTerminalSize.js'; 5: import { Box, Text } from '../../ink.js'; 6: import { useKeybindings } from '../../keybindings/useKeybinding.js'; 7: interface ChooseRepoStepProps { 8: currentRepo: string | null; 9: useCurrentRepo: boolean; 10: repoUrl: string; 11: onRepoUrlChange: (value: string) => void; 12: onToggleUseCurrentRepo: (useCurrentRepo: boolean) => void; 13: onSubmit: () => void; 14: } 15: export function ChooseRepoStep(t0) { 16: const $ = _c(49); 17: const { 18: currentRepo, 19: useCurrentRepo, 20: repoUrl, 21: onRepoUrlChange, 22: onSubmit, 23: onToggleUseCurrentRepo 24: } = t0; 25: const [cursorOffset, setCursorOffset] = useState(0); 26: const [showEmptyError, setShowEmptyError] = useState(false); 27: const terminalSize = useTerminalSize(); 28: const textInputColumns = terminalSize.columns; 29: let t1; 30: if ($[0] !== currentRepo || $[1] !== onSubmit || $[2] !== repoUrl || $[3] !== useCurrentRepo) { 31: t1 = () => { 32: const repoName = useCurrentRepo ? currentRepo : repoUrl; 33: if (!repoName?.trim()) { 34: setShowEmptyError(true); 35: return; 36: } 37: onSubmit(); 38: }; 39: $[0] = currentRepo; 40: $[1] = onSubmit; 41: $[2] = repoUrl; 42: $[3] = useCurrentRepo; 43: $[4] = t1; 44: } else { 45: t1 = $[4]; 46: } 47: const handleSubmit = t1; 48: const isTextInputVisible = !useCurrentRepo || !currentRepo; 49: let t2; 50: if ($[5] !== onToggleUseCurrentRepo) { 51: t2 = () => { 52: onToggleUseCurrentRepo(true); 53: setShowEmptyError(false); 54: }; 55: $[5] = onToggleUseCurrentRepo; 56: $[6] = t2; 57: } else { 58: t2 = $[6]; 59: } 60: const handlePrevious = t2; 61: let t3; 62: if ($[7] !== onToggleUseCurrentRepo) { 63: t3 = () => { 64: onToggleUseCurrentRepo(false); 65: setShowEmptyError(false); 66: }; 67: $[7] = onToggleUseCurrentRepo; 68: $[8] = t3; 69: } else { 70: t3 = $[8]; 71: } 72: const handleNext = t3; 73: let t4; 74: if ($[9] !== handleNext || $[10] !== handlePrevious || $[11] !== handleSubmit) { 75: t4 = { 76: "confirm:previous": handlePrevious, 77: "confirm:next": handleNext, 78: "confirm:yes": handleSubmit 79: }; 80: $[9] = handleNext; 81: $[10] = handlePrevious; 82: $[11] = handleSubmit; 83: $[12] = t4; 84: } else { 85: t4 = $[12]; 86: } 87: const t5 = !isTextInputVisible; 88: let t6; 89: if ($[13] !== t5) { 90: t6 = { 91: context: "Confirmation", 92: isActive: t5 93: }; 94: $[13] = t5; 95: $[14] = t6; 96: } else { 97: t6 = $[14]; 98: } 99: useKeybindings(t4, t6); 100: let t7; 101: if ($[15] !== handleNext || $[16] !== handlePrevious) { 102: t7 = { 103: "confirm:previous": handlePrevious, 104: "confirm:next": handleNext 105: }; 106: $[15] = handleNext; 107: $[16] = handlePrevious; 108: $[17] = t7; 109: } else { 110: t7 = $[17]; 111: } 112: let t8; 113: if ($[18] !== isTextInputVisible) { 114: t8 = { 115: context: "Confirmation", 116: isActive: isTextInputVisible 117: }; 118: $[18] = isTextInputVisible; 119: $[19] = t8; 120: } else { 121: t8 = $[19]; 122: } 123: useKeybindings(t7, t8); 124: let t9; 125: if ($[20] === Symbol.for("react.memo_cache_sentinel")) { 126: t9 = <Box flexDirection="column" marginBottom={1}><Text bold={true}>Install GitHub App</Text><Text dimColor={true}>Select GitHub repository</Text></Box>; 127: $[20] = t9; 128: } else { 129: t9 = $[20]; 130: } 131: let t10; 132: if ($[21] !== currentRepo || $[22] !== useCurrentRepo) { 133: t10 = currentRepo && <Box marginBottom={1}><Text bold={useCurrentRepo} color={useCurrentRepo ? "permission" : undefined}>{useCurrentRepo ? "> " : " "}Use current repository: {currentRepo}</Text></Box>; 134: $[21] = currentRepo; 135: $[22] = useCurrentRepo; 136: $[23] = t10; 137: } else { 138: t10 = $[23]; 139: } 140: const t11 = !useCurrentRepo || !currentRepo; 141: const t12 = !useCurrentRepo || !currentRepo ? "permission" : undefined; 142: const t13 = !useCurrentRepo || !currentRepo ? "> " : " "; 143: const t14 = currentRepo ? "Enter a different repository" : "Enter repository"; 144: let t15; 145: if ($[24] !== t11 || $[25] !== t12 || $[26] !== t13 || $[27] !== t14) { 146: t15 = <Box marginBottom={1}><Text bold={t11} color={t12}>{t13}{t14}</Text></Box>; 147: $[24] = t11; 148: $[25] = t12; 149: $[26] = t13; 150: $[27] = t14; 151: $[28] = t15; 152: } else { 153: t15 = $[28]; 154: } 155: let t16; 156: if ($[29] !== currentRepo || $[30] !== cursorOffset || $[31] !== handleSubmit || $[32] !== onRepoUrlChange || $[33] !== repoUrl || $[34] !== textInputColumns || $[35] !== useCurrentRepo) { 157: t16 = (!useCurrentRepo || !currentRepo) && <Box marginLeft={2} marginBottom={1}><TextInput value={repoUrl} onChange={value => { 158: onRepoUrlChange(value); 159: setShowEmptyError(false); 160: }} onSubmit={handleSubmit} focus={true} placeholder={"Enter a repo as owner/repo or https://github.com/owner/repo\u2026"} columns={textInputColumns} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} showCursor={true} /></Box>; 161: $[29] = currentRepo; 162: $[30] = cursorOffset; 163: $[31] = handleSubmit; 164: $[32] = onRepoUrlChange; 165: $[33] = repoUrl; 166: $[34] = textInputColumns; 167: $[35] = useCurrentRepo; 168: $[36] = t16; 169: } else { 170: t16 = $[36]; 171: } 172: let t17; 173: if ($[37] !== t10 || $[38] !== t15 || $[39] !== t16) { 174: t17 = <Box flexDirection="column" borderStyle="round" paddingX={1}>{t9}{t10}{t15}{t16}</Box>; 175: $[37] = t10; 176: $[38] = t15; 177: $[39] = t16; 178: $[40] = t17; 179: } else { 180: t17 = $[40]; 181: } 182: let t18; 183: if ($[41] !== showEmptyError) { 184: t18 = showEmptyError && <Box marginLeft={3} marginBottom={1}><Text color="error">Please enter a repository name to continue</Text></Box>; 185: $[41] = showEmptyError; 186: $[42] = t18; 187: } else { 188: t18 = $[42]; 189: } 190: const t19 = currentRepo ? "\u2191/\u2193 to select \xB7 " : ""; 191: let t20; 192: if ($[43] !== t19) { 193: t20 = <Box marginLeft={3}><Text dimColor={true}>{t19}Enter to continue</Text></Box>; 194: $[43] = t19; 195: $[44] = t20; 196: } else { 197: t20 = $[44]; 198: } 199: let t21; 200: if ($[45] !== t17 || $[46] !== t18 || $[47] !== t20) { 201: t21 = <>{t17}{t18}{t20}</>; 202: $[45] = t17; 203: $[46] = t18; 204: $[47] = t20; 205: $[48] = t21; 206: } else { 207: t21 = $[48]; 208: } 209: return t21; 210: }

File: src/commands/install-github-app/CreatingStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React from 'react'; 3: import { Box, Text } from '../../ink.js'; 4: import type { Workflow } from './types.js'; 5: interface CreatingStepProps { 6: currentWorkflowInstallStep: number; 7: secretExists: boolean; 8: useExistingSecret: boolean; 9: secretName: string; 10: skipWorkflow?: boolean; 11: selectedWorkflows: Workflow[]; 12: } 13: export function CreatingStep(t0) { 14: const $ = _c(10); 15: const { 16: currentWorkflowInstallStep, 17: secretExists, 18: useExistingSecret, 19: secretName, 20: skipWorkflow: t1, 21: selectedWorkflows 22: } = t0; 23: const skipWorkflow = t1 === undefined ? false : t1; 24: let t2; 25: if ($[0] !== secretExists || $[1] !== secretName || $[2] !== selectedWorkflows || $[3] !== skipWorkflow || $[4] !== useExistingSecret) { 26: t2 = skipWorkflow ? ["Getting repository information", secretExists && useExistingSecret ? "Using existing API key secret" : `Setting up ${secretName} secret`] : ["Getting repository information", "Creating branch", selectedWorkflows.length > 1 ? "Creating workflow files" : "Creating workflow file", secretExists && useExistingSecret ? "Using existing API key secret" : `Setting up ${secretName} secret`, "Opening pull request page"]; 27: $[0] = secretExists; 28: $[1] = secretName; 29: $[2] = selectedWorkflows; 30: $[3] = skipWorkflow; 31: $[4] = useExistingSecret; 32: $[5] = t2; 33: } else { 34: t2 = $[5]; 35: } 36: const progressSteps = t2; 37: let t3; 38: if ($[6] === Symbol.for("react.memo_cache_sentinel")) { 39: t3 = <Box flexDirection="column" marginBottom={1}><Text bold={true}>Install GitHub App</Text><Text dimColor={true}>Create GitHub Actions workflow</Text></Box>; 40: $[6] = t3; 41: } else { 42: t3 = $[6]; 43: } 44: let t4; 45: if ($[7] !== currentWorkflowInstallStep || $[8] !== progressSteps) { 46: t4 = <><Box flexDirection="column" borderStyle="round" paddingX={1}>{t3}{progressSteps.map((stepText, index) => { 47: let status = "pending"; 48: if (index < currentWorkflowInstallStep) { 49: status = "completed"; 50: } else { 51: if (index === currentWorkflowInstallStep) { 52: status = "in-progress"; 53: } 54: } 55: return <Box key={index}><Text color={status === "completed" ? "success" : status === "in-progress" ? "warning" : undefined}>{status === "completed" ? "\u2713 " : ""}{stepText}{status === "in-progress" ? "\u2026" : ""}</Text></Box>; 56: })}</Box></>; 57: $[7] = currentWorkflowInstallStep; 58: $[8] = progressSteps; 59: $[9] = t4; 60: } else { 61: t4 = $[9]; 62: } 63: return t4; 64: }

File: src/commands/install-github-app/ErrorStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React from 'react'; 3: import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'; 4: import { Box, Text } from '../../ink.js'; 5: interface ErrorStepProps { 6: error: string | undefined; 7: errorReason?: string; 8: errorInstructions?: string[]; 9: } 10: export function ErrorStep(t0) { 11: const $ = _c(15); 12: const { 13: error, 14: errorReason, 15: errorInstructions 16: } = t0; 17: let t1; 18: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 19: t1 = <Box flexDirection="column" marginBottom={1}><Text bold={true}>Install GitHub App</Text></Box>; 20: $[0] = t1; 21: } else { 22: t1 = $[0]; 23: } 24: let t2; 25: if ($[1] !== error) { 26: t2 = <Text color="error">Error: {error}</Text>; 27: $[1] = error; 28: $[2] = t2; 29: } else { 30: t2 = $[2]; 31: } 32: let t3; 33: if ($[3] !== errorReason) { 34: t3 = errorReason && <Box marginTop={1}><Text dimColor={true}>Reason: {errorReason}</Text></Box>; 35: $[3] = errorReason; 36: $[4] = t3; 37: } else { 38: t3 = $[4]; 39: } 40: let t4; 41: if ($[5] !== errorInstructions) { 42: t4 = errorInstructions && errorInstructions.length > 0 && <Box flexDirection="column" marginTop={1}><Text dimColor={true}>How to fix:</Text>{errorInstructions.map(_temp)}</Box>; 43: $[5] = errorInstructions; 44: $[6] = t4; 45: } else { 46: t4 = $[6]; 47: } 48: let t5; 49: if ($[7] === Symbol.for("react.memo_cache_sentinel")) { 50: t5 = <Box marginTop={1}><Text dimColor={true}>For manual setup instructions, see:{" "}<Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text></Text></Box>; 51: $[7] = t5; 52: } else { 53: t5 = $[7]; 54: } 55: let t6; 56: if ($[8] !== t2 || $[9] !== t3 || $[10] !== t4) { 57: t6 = <Box flexDirection="column" borderStyle="round" paddingX={1}>{t1}{t2}{t3}{t4}{t5}</Box>; 58: $[8] = t2; 59: $[9] = t3; 60: $[10] = t4; 61: $[11] = t6; 62: } else { 63: t6 = $[11]; 64: } 65: let t7; 66: if ($[12] === Symbol.for("react.memo_cache_sentinel")) { 67: t7 = <Box marginLeft={3}><Text dimColor={true}>Press any key to exit</Text></Box>; 68: $[12] = t7; 69: } else { 70: t7 = $[12]; 71: } 72: let t8; 73: if ($[13] !== t6) { 74: t8 = <>{t6}{t7}</>; 75: $[13] = t6; 76: $[14] = t8; 77: } else { 78: t8 = $[14]; 79: } 80: return t8; 81: } 82: function _temp(instruction, index) { 83: return <Box key={index} marginLeft={2}><Text dimColor={true}>• </Text><Text>{instruction}</Text></Box>; 84: }

File: src/commands/install-github-app/ExistingWorkflowStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React from 'react'; 3: import { Select } from 'src/components/CustomSelect/index.js'; 4: import { Box, Text } from '../../ink.js'; 5: interface ExistingWorkflowStepProps { 6: repoName: string; 7: onSelectAction: (action: 'update' | 'skip' | 'exit') => void; 8: } 9: export function ExistingWorkflowStep(t0) { 10: const $ = _c(16); 11: const { 12: repoName, 13: onSelectAction 14: } = t0; 15: let t1; 16: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 17: t1 = [{ 18: label: "Update workflow file with latest version", 19: value: "update" 20: }, { 21: label: "Skip workflow update (configure secrets only)", 22: value: "skip" 23: }, { 24: label: "Exit without making changes", 25: value: "exit" 26: }]; 27: $[0] = t1; 28: } else { 29: t1 = $[0]; 30: } 31: const options = t1; 32: let t2; 33: if ($[1] !== onSelectAction) { 34: t2 = value => { 35: onSelectAction(value as 'update' | 'skip' | 'exit'); 36: }; 37: $[1] = onSelectAction; 38: $[2] = t2; 39: } else { 40: t2 = $[2]; 41: } 42: const handleSelect = t2; 43: let t3; 44: if ($[3] !== onSelectAction) { 45: t3 = () => { 46: onSelectAction("exit"); 47: }; 48: $[3] = onSelectAction; 49: $[4] = t3; 50: } else { 51: t3 = $[4]; 52: } 53: const handleCancel = t3; 54: let t4; 55: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 56: t4 = <Text bold={true}>Existing Workflow Found</Text>; 57: $[5] = t4; 58: } else { 59: t4 = $[5]; 60: } 61: let t5; 62: if ($[6] !== repoName) { 63: t5 = <Box flexDirection="column" marginBottom={1}>{t4}<Text dimColor={true}>Repository: {repoName}</Text></Box>; 64: $[6] = repoName; 65: $[7] = t5; 66: } else { 67: t5 = $[7]; 68: } 69: let t6; 70: if ($[8] === Symbol.for("react.memo_cache_sentinel")) { 71: t6 = <Box flexDirection="column" marginBottom={1}><Text>A Claude workflow file already exists at{" "}<Text color="claude">.github/workflows/claude.yml</Text></Text><Text dimColor={true}>What would you like to do?</Text></Box>; 72: $[8] = t6; 73: } else { 74: t6 = $[8]; 75: } 76: let t7; 77: if ($[9] !== handleCancel || $[10] !== handleSelect) { 78: t7 = <Box flexDirection="column"><Select options={options} onChange={handleSelect} onCancel={handleCancel} /></Box>; 79: $[9] = handleCancel; 80: $[10] = handleSelect; 81: $[11] = t7; 82: } else { 83: t7 = $[11]; 84: } 85: let t8; 86: if ($[12] === Symbol.for("react.memo_cache_sentinel")) { 87: t8 = <Box marginTop={1}><Text dimColor={true}>View the latest workflow template at:{" "}<Text color="claude">https: 88: $[12] = t8; 89: } else { 90: t8 = $[12]; 91: } 92: let t9; 93: if ($[13] !== t5 || $[14] !== t7) { 94: t9 = <Box flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1}>{t5}{t6}{t7}{t8}</Box>; 95: $[13] = t5; 96: $[14] = t7; 97: $[15] = t9; 98: } else { 99: t9 = $[15]; 100: } 101: return t9; 102: }

File: src/commands/install-github-app/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { isEnvTruthy } from '../../utils/envUtils.js' 3: const installGitHubApp = { 4: type: 'local-jsx', 5: name: 'install-github-app', 6: description: 'Set up Claude GitHub Actions for a repository', 7: availability: ['claude-ai', 'console'], 8: isEnabled: () => !isEnvTruthy(process.env.DISABLE_INSTALL_GITHUB_APP_COMMAND), 9: load: () => import('./install-github-app.js'), 10: } satisfies Command 11: export default installGitHubApp

File: src/commands/install-github-app/install-github-app.tsx

typescript 1: import { execa } from 'execa'; 2: import React, { useCallback, useState } from 'react'; 3: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; 4: import { WorkflowMultiselectDialog } from '../../components/WorkflowMultiselectDialog.js'; 5: import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'; 6: import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; 7: import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; 8: import { Box } from '../../ink.js'; 9: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 10: import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js'; 11: import { openBrowser } from '../../utils/browser.js'; 12: import { execFileNoThrow } from '../../utils/execFileNoThrow.js'; 13: import { getGithubRepo } from '../../utils/git.js'; 14: import { plural } from '../../utils/stringUtils.js'; 15: import { ApiKeyStep } from './ApiKeyStep.js'; 16: import { CheckExistingSecretStep } from './CheckExistingSecretStep.js'; 17: import { CheckGitHubStep } from './CheckGitHubStep.js'; 18: import { ChooseRepoStep } from './ChooseRepoStep.js'; 19: import { CreatingStep } from './CreatingStep.js'; 20: import { ErrorStep } from './ErrorStep.js'; 21: import { ExistingWorkflowStep } from './ExistingWorkflowStep.js'; 22: import { InstallAppStep } from './InstallAppStep.js'; 23: import { OAuthFlowStep } from './OAuthFlowStep.js'; 24: import { SuccessStep } from './SuccessStep.js'; 25: import { setupGitHubActions } from './setupGitHubActions.js'; 26: import type { State, Warning, Workflow } from './types.js'; 27: import { WarningsStep } from './WarningsStep.js'; 28: const INITIAL_STATE: State = { 29: step: 'check-gh', 30: selectedRepoName: '', 31: currentRepo: '', 32: useCurrentRepo: false, 33: // Default to false, will be set to true if repo detected 34: apiKeyOrOAuthToken: '', 35: useExistingKey: true, 36: currentWorkflowInstallStep: 0, 37: warnings: [], 38: secretExists: false, 39: secretName: 'ANTHROPIC_API_KEY', 40: useExistingSecret: true, 41: workflowExists: false, 42: selectedWorkflows: ['claude', 'claude-review'] as Workflow[], 43: selectedApiKeyOption: 'new' as 'existing' | 'new' | 'oauth', 44: authType: 'api_key' 45: }; 46: function InstallGitHubApp(props: { 47: onDone: (message: string) => void; 48: }): React.ReactNode { 49: const [existingApiKey] = useState(() => getAnthropicApiKey()); 50: const [state, setState] = useState({ 51: ...INITIAL_STATE, 52: useExistingKey: !!existingApiKey, 53: selectedApiKeyOption: (existingApiKey ? 'existing' : isAnthropicAuthEnabled() ? 'oauth' : 'new') as 'existing' | 'new' | 'oauth' 54: }); 55: useExitOnCtrlCDWithKeybindings(); 56: React.useEffect(() => { 57: logEvent('tengu_install_github_app_started', {}); 58: }, []); 59: const checkGitHubCLI = useCallback(async () => { 60: const warnings: Warning[] = []; 61: const ghVersionResult = await execa('gh --version', { 62: shell: true, 63: reject: false 64: }); 65: if (ghVersionResult.exitCode !== 0) { 66: warnings.push({ 67: title: 'GitHub CLI not found', 68: message: 'GitHub CLI (gh) does not appear to be installed or accessible.', 69: instructions: ['Install GitHub CLI from https://cli.github.com/', 'macOS: brew install gh', 'Windows: winget install --id GitHub.cli', 'Linux: See installation instructions at https://github.com/cli/cli#installation'] 70: }); 71: } 72: const authResult = await execa('gh auth status -a', { 73: shell: true, 74: reject: false 75: }); 76: if (authResult.exitCode !== 0) { 77: warnings.push({ 78: title: 'GitHub CLI not authenticated', 79: message: 'GitHub CLI does not appear to be authenticated.', 80: instructions: ['Run: gh auth login', 'Follow the prompts to authenticate with GitHub', 'Or set up authentication using environment variables or other methods'] 81: }); 82: } else { 83: const tokenScopesMatch = authResult.stdout.match(/Token scopes:.*$/m); 84: if (tokenScopesMatch) { 85: const scopes = tokenScopesMatch[0]; 86: const missingScopes: string[] = []; 87: if (!scopes.includes('repo')) { 88: missingScopes.push('repo'); 89: } 90: if (!scopes.includes('workflow')) { 91: missingScopes.push('workflow'); 92: } 93: if (missingScopes.length > 0) { 94: setState(prev => ({ 95: ...prev, 96: step: 'error', 97: error: `GitHub CLI is missing required permissions: ${missingScopes.join(', ')}.`, 98: errorReason: 'Missing required scopes', 99: errorInstructions: [`Your GitHub CLI authentication is missing the "${missingScopes.join('" and "')}" ${plural(missingScopes.length, 'scope')} needed to manage GitHub Actions and secrets.`, '', 'To fix this, run:', ' gh auth refresh -h github.com -s repo,workflow', '', 'This will add the necessary permissions to manage workflows and secrets.'] 100: })); 101: return; 102: } 103: } 104: } 105: // Check if in a git repo and get remote URL 106: const currentRepo = (await getGithubRepo()) ?? ''; 107: logEvent('tengu_install_github_app_step_completed', { 108: step: 'check-gh' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 109: }); 110: setState(prev_0 => ({ 111: ...prev_0, 112: warnings, 113: currentRepo, 114: selectedRepoName: currentRepo, 115: useCurrentRepo: !!currentRepo, 116: step: warnings.length > 0 ? 'warnings' : 'choose-repo' 117: })); 118: }, []); 119: React.useEffect(() => { 120: if (state.step === 'check-gh') { 121: void checkGitHubCLI(); 122: } 123: }, [state.step, checkGitHubCLI]); 124: const runSetupGitHubActions = useCallback(async (apiKeyOrOAuthToken: string | null, secretName: string) => { 125: setState(prev_1 => ({ 126: ...prev_1, 127: step: 'creating', 128: currentWorkflowInstallStep: 0 129: })); 130: try { 131: await setupGitHubActions(state.selectedRepoName, apiKeyOrOAuthToken, secretName, () => { 132: setState(prev_4 => ({ 133: ...prev_4, 134: currentWorkflowInstallStep: prev_4.currentWorkflowInstallStep + 1 135: })); 136: }, state.workflowAction === 'skip', state.selectedWorkflows, state.authType, { 137: useCurrentRepo: state.useCurrentRepo, 138: workflowExists: state.workflowExists, 139: secretExists: state.secretExists 140: }); 141: logEvent('tengu_install_github_app_step_completed', { 142: step: 'creating' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 143: }); 144: setState(prev_5 => ({ 145: ...prev_5, 146: step: 'success' 147: })); 148: } catch (error) { 149: const errorMessage = error instanceof Error ? error.message : 'Failed to set up GitHub Actions'; 150: if (errorMessage.includes('workflow file already exists')) { 151: logEvent('tengu_install_github_app_error', { 152: reason: 'workflow_file_exists' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 153: }); 154: setState(prev_2 => ({ 155: ...prev_2, 156: step: 'error', 157: error: 'A Claude workflow file already exists in this repository.', 158: errorReason: 'Workflow file conflict', 159: errorInstructions: ['The file .github/workflows/claude.yml already exists', 'You can either:', ' 1. Delete the existing file and run this command again', ' 2. Update the existing file manually using the template from:', ` ${GITHUB_ACTION_SETUP_DOCS_URL}`] 160: })); 161: } else { 162: logEvent('tengu_install_github_app_error', { 163: reason: 'setup_github_actions_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 164: }); 165: setState(prev_3 => ({ 166: ...prev_3, 167: step: 'error', 168: error: errorMessage, 169: errorReason: 'GitHub Actions setup failed', 170: errorInstructions: [] 171: })); 172: } 173: } 174: }, [state.selectedRepoName, state.workflowAction, state.selectedWorkflows, state.useCurrentRepo, state.workflowExists, state.secretExists, state.authType]); 175: async function openGitHubAppInstallation() { 176: const installUrl = 'https://github.com/apps/claude'; 177: await openBrowser(installUrl); 178: } 179: async function checkRepositoryPermissions(repoName: string): Promise<{ 180: hasAccess: boolean; 181: error?: string; 182: }> { 183: try { 184: const result = await execFileNoThrow('gh', ['api', `repos/${repoName}`, '--jq', '.permissions.admin']); 185: if (result.code === 0) { 186: const hasAdmin = result.stdout.trim() === 'true'; 187: return { 188: hasAccess: hasAdmin 189: }; 190: } 191: if (result.stderr.includes('404') || result.stderr.includes('Not Found')) { 192: return { 193: hasAccess: false, 194: error: 'repository_not_found' 195: }; 196: } 197: return { 198: hasAccess: false 199: }; 200: } catch { 201: return { 202: hasAccess: false 203: }; 204: } 205: } 206: async function checkExistingWorkflowFile(repoName_0: string): Promise<boolean> { 207: const checkFileResult = await execFileNoThrow('gh', ['api', `repos/${repoName_0}/contents/.github/workflows/claude.yml`, '--jq', '.sha']); 208: return checkFileResult.code === 0; 209: } 210: async function checkExistingSecret() { 211: const checkSecretsResult = await execFileNoThrow('gh', ['secret', 'list', '--app', 'actions', '--repo', state.selectedRepoName]); 212: if (checkSecretsResult.code === 0) { 213: const lines = checkSecretsResult.stdout.split('\n'); 214: const hasAnthropicKey = lines.some((line: string) => { 215: return /^ANTHROPIC_API_KEY\s+/.test(line); 216: }); 217: if (hasAnthropicKey) { 218: setState(prev_6 => ({ 219: ...prev_6, 220: secretExists: true, 221: step: 'check-existing-secret' 222: })); 223: } else { 224: if (existingApiKey) { 225: setState(prev_7 => ({ 226: ...prev_7, 227: apiKeyOrOAuthToken: existingApiKey, 228: useExistingKey: true 229: })); 230: await runSetupGitHubActions(existingApiKey, state.secretName); 231: } else { 232: setState(prev_8 => ({ 233: ...prev_8, 234: step: 'api-key' 235: })); 236: } 237: } 238: } else { 239: if (existingApiKey) { 240: setState(prev_9 => ({ 241: ...prev_9, 242: apiKeyOrOAuthToken: existingApiKey, 243: useExistingKey: true 244: })); 245: await runSetupGitHubActions(existingApiKey, state.secretName); 246: } else { 247: setState(prev_10 => ({ 248: ...prev_10, 249: step: 'api-key' 250: })); 251: } 252: } 253: } 254: const handleSubmit = async () => { 255: if (state.step === 'warnings') { 256: logEvent('tengu_install_github_app_step_completed', { 257: step: 'warnings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 258: }); 259: setState(prev_11 => ({ 260: ...prev_11, 261: step: 'install-app' 262: })); 263: setTimeout(openGitHubAppInstallation, 0); 264: } else if (state.step === 'choose-repo') { 265: let repoName_1 = state.useCurrentRepo ? state.currentRepo : state.selectedRepoName; 266: if (!repoName_1.trim()) { 267: return; 268: } 269: const repoWarnings: Warning[] = []; 270: if (repoName_1.includes('github.com')) { 271: const match = repoName_1.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/); 272: if (!match) { 273: repoWarnings.push({ 274: title: 'Invalid GitHub URL format', 275: message: 'The repository URL format appears to be invalid.', 276: instructions: ['Use format: owner/repo or https://github.com/owner/repo', 'Example: anthropics/claude-cli'] 277: }); 278: } else { 279: repoName_1 = match[1]?.replace(/\.git$/, '') || ''; 280: } 281: } 282: if (!repoName_1.includes('/')) { 283: repoWarnings.push({ 284: title: 'Repository format warning', 285: message: 'Repository should be in format "owner/repo"', 286: instructions: ['Use format: owner/repo', 'Example: anthropics/claude-cli'] 287: }); 288: } 289: const permissionCheck = await checkRepositoryPermissions(repoName_1); 290: if (permissionCheck.error === 'repository_not_found') { 291: repoWarnings.push({ 292: title: 'Repository not found', 293: message: `Repository ${repoName_1} was not found or you don't have access.`, 294: instructions: [`Check that the repository name is correct: ${repoName_1}`, 'Ensure you have access to this repository', 'For private repositories, make sure your GitHub token has the "repo" scope', 'You can add the repo scope with: gh auth refresh -h github.com -s repo,workflow'] 295: }); 296: } else if (!permissionCheck.hasAccess) { 297: repoWarnings.push({ 298: title: 'Admin permissions required', 299: message: `You might need admin permissions on ${repoName_1} to set up GitHub Actions.`, 300: instructions: ['Repository admins can install GitHub Apps and set secrets', 'Ask a repository admin to run this command if setup fails', 'Alternatively, you can use the manual setup instructions'] 301: }); 302: } 303: const workflowExists = await checkExistingWorkflowFile(repoName_1); 304: if (repoWarnings.length > 0) { 305: const allWarnings = [...state.warnings, ...repoWarnings]; 306: setState(prev_12 => ({ 307: ...prev_12, 308: selectedRepoName: repoName_1, 309: workflowExists, 310: warnings: allWarnings, 311: step: 'warnings' 312: })); 313: } else { 314: logEvent('tengu_install_github_app_step_completed', { 315: step: 'choose-repo' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 316: }); 317: setState(prev_13 => ({ 318: ...prev_13, 319: selectedRepoName: repoName_1, 320: workflowExists, 321: step: 'install-app' 322: })); 323: setTimeout(openGitHubAppInstallation, 0); 324: } 325: } else if (state.step === 'install-app') { 326: logEvent('tengu_install_github_app_step_completed', { 327: step: 'install-app' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 328: }); 329: if (state.workflowExists) { 330: setState(prev_14 => ({ 331: ...prev_14, 332: step: 'check-existing-workflow' 333: })); 334: } else { 335: setState(prev_15 => ({ 336: ...prev_15, 337: step: 'select-workflows' 338: })); 339: } 340: } else if (state.step === 'check-existing-workflow') { 341: return; 342: } else if (state.step === 'select-workflows') { 343: return; 344: } else if (state.step === 'check-existing-secret') { 345: logEvent('tengu_install_github_app_step_completed', { 346: step: 'check-existing-secret' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 347: }); 348: if (state.useExistingSecret) { 349: await runSetupGitHubActions(null, state.secretName); 350: } else { 351: await runSetupGitHubActions(state.apiKeyOrOAuthToken, state.secretName); 352: } 353: } else if (state.step === 'api-key') { 354: if (state.selectedApiKeyOption === 'oauth') { 355: return; 356: } 357: const apiKeyToUse = state.selectedApiKeyOption === 'existing' ? existingApiKey : state.apiKeyOrOAuthToken; 358: if (!apiKeyToUse) { 359: logEvent('tengu_install_github_app_error', { 360: reason: 'api_key_missing' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 361: }); 362: setState(prev_16 => ({ 363: ...prev_16, 364: step: 'error', 365: error: 'API key is required' 366: })); 367: return; 368: } 369: setState(prev_17 => ({ 370: ...prev_17, 371: apiKeyOrOAuthToken: apiKeyToUse, 372: useExistingKey: state.selectedApiKeyOption === 'existing' 373: })); 374: const checkSecretsResult_0 = await execFileNoThrow('gh', ['secret', 'list', '--app', 'actions', '--repo', state.selectedRepoName]); 375: if (checkSecretsResult_0.code === 0) { 376: const lines_0 = checkSecretsResult_0.stdout.split('\n'); 377: const hasAnthropicKey_0 = lines_0.some((line_0: string) => { 378: return /^ANTHROPIC_API_KEY\s+/.test(line_0); 379: }); 380: if (hasAnthropicKey_0) { 381: logEvent('tengu_install_github_app_step_completed', { 382: step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 383: }); 384: setState(prev_18 => ({ 385: ...prev_18, 386: secretExists: true, 387: step: 'check-existing-secret' 388: })); 389: } else { 390: logEvent('tengu_install_github_app_step_completed', { 391: step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 392: }); 393: await runSetupGitHubActions(apiKeyToUse, state.secretName); 394: } 395: } else { 396: logEvent('tengu_install_github_app_step_completed', { 397: step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 398: }); 399: await runSetupGitHubActions(apiKeyToUse, state.secretName); 400: } 401: } 402: }; 403: const handleRepoUrlChange = (value: string) => { 404: setState(prev_19 => ({ 405: ...prev_19, 406: selectedRepoName: value 407: })); 408: }; 409: const handleApiKeyChange = (value_0: string) => { 410: setState(prev_20 => ({ 411: ...prev_20, 412: apiKeyOrOAuthToken: value_0 413: })); 414: }; 415: const handleApiKeyOptionChange = (option: 'existing' | 'new' | 'oauth') => { 416: setState(prev_21 => ({ 417: ...prev_21, 418: selectedApiKeyOption: option 419: })); 420: }; 421: const handleCreateOAuthToken = useCallback(() => { 422: logEvent('tengu_install_github_app_step_completed', { 423: step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 424: }); 425: setState(prev_22 => ({ 426: ...prev_22, 427: step: 'oauth-flow' 428: })); 429: }, []); 430: const handleOAuthSuccess = useCallback((token: string) => { 431: logEvent('tengu_install_github_app_step_completed', { 432: step: 'oauth-flow' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 433: }); 434: setState(prev_23 => ({ 435: ...prev_23, 436: apiKeyOrOAuthToken: token, 437: useExistingKey: false, 438: secretName: 'CLAUDE_CODE_OAUTH_TOKEN', 439: authType: 'oauth_token' 440: })); 441: void runSetupGitHubActions(token, 'CLAUDE_CODE_OAUTH_TOKEN'); 442: }, [runSetupGitHubActions]); 443: const handleOAuthCancel = useCallback(() => { 444: setState(prev_24 => ({ 445: ...prev_24, 446: step: 'api-key' 447: })); 448: }, []); 449: const handleSecretNameChange = (value_1: string) => { 450: if (value_1 && !/^[a-zA-Z0-9_]+$/.test(value_1)) return; 451: setState(prev_25 => ({ 452: ...prev_25, 453: secretName: value_1 454: })); 455: }; 456: const handleToggleUseCurrentRepo = (useCurrentRepo: boolean) => { 457: setState(prev_26 => ({ 458: ...prev_26, 459: useCurrentRepo, 460: selectedRepoName: useCurrentRepo ? prev_26.currentRepo : '' 461: })); 462: }; 463: const handleToggleUseExistingKey = (useExistingKey: boolean) => { 464: setState(prev_27 => ({ 465: ...prev_27, 466: useExistingKey 467: })); 468: }; 469: const handleToggleUseExistingSecret = (useExistingSecret: boolean) => { 470: setState(prev_28 => ({ 471: ...prev_28, 472: useExistingSecret, 473: secretName: useExistingSecret ? 'ANTHROPIC_API_KEY' : '' 474: })); 475: }; 476: const handleWorkflowAction = async (action: 'update' | 'skip' | 'exit') => { 477: if (action === 'exit') { 478: props.onDone('Installation cancelled by user'); 479: return; 480: } 481: logEvent('tengu_install_github_app_step_completed', { 482: step: 'check-existing-workflow' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 483: }); 484: setState(prev_29 => ({ 485: ...prev_29, 486: workflowAction: action 487: })); 488: if (action === 'skip' || action === 'update') { 489: if (existingApiKey) { 490: await checkExistingSecret(); 491: } else { 492: setState(prev_30 => ({ 493: ...prev_30, 494: step: 'api-key' 495: })); 496: } 497: } 498: }; 499: function handleDismissKeyDown(e: KeyboardEvent): void { 500: e.preventDefault(); 501: if (state.step === 'success') { 502: logEvent('tengu_install_github_app_completed', {}); 503: } 504: props.onDone(state.step === 'success' ? 'GitHub Actions setup complete!' : state.error ? `Couldn't install GitHub App: ${state.error}\nFor manual setup instructions, see: ${GITHUB_ACTION_SETUP_DOCS_URL}` : `GitHub App installation failed\nFor manual setup instructions, see: ${GITHUB_ACTION_SETUP_DOCS_URL}`); 505: } 506: switch (state.step) { 507: case 'check-gh': 508: return <CheckGitHubStep />; 509: case 'warnings': 510: return <WarningsStep warnings={state.warnings} onContinue={handleSubmit} />; 511: case 'choose-repo': 512: return <ChooseRepoStep currentRepo={state.currentRepo} useCurrentRepo={state.useCurrentRepo} repoUrl={state.selectedRepoName} onRepoUrlChange={handleRepoUrlChange} onToggleUseCurrentRepo={handleToggleUseCurrentRepo} onSubmit={handleSubmit} />; 513: case 'install-app': 514: return <InstallAppStep repoUrl={state.selectedRepoName} onSubmit={handleSubmit} />; 515: case 'check-existing-workflow': 516: return <ExistingWorkflowStep repoName={state.selectedRepoName} onSelectAction={handleWorkflowAction} />; 517: case 'check-existing-secret': 518: return <CheckExistingSecretStep useExistingSecret={state.useExistingSecret} secretName={state.secretName} onToggleUseExistingSecret={handleToggleUseExistingSecret} onSecretNameChange={handleSecretNameChange} onSubmit={handleSubmit} />; 519: case 'api-key': 520: return <ApiKeyStep existingApiKey={existingApiKey} useExistingKey={state.useExistingKey} apiKeyOrOAuthToken={state.apiKeyOrOAuthToken} onApiKeyChange={handleApiKeyChange} onToggleUseExistingKey={handleToggleUseExistingKey} onSubmit={handleSubmit} onCreateOAuthToken={isAnthropicAuthEnabled() ? handleCreateOAuthToken : undefined} selectedOption={state.selectedApiKeyOption} onSelectOption={handleApiKeyOptionChange} />; 521: case 'creating': 522: return <CreatingStep currentWorkflowInstallStep={state.currentWorkflowInstallStep} secretExists={state.secretExists} useExistingSecret={state.useExistingSecret} secretName={state.secretName} skipWorkflow={state.workflowAction === 'skip'} selectedWorkflows={state.selectedWorkflows} />; 523: case 'success': 524: return <Box tabIndex={0} autoFocus onKeyDown={handleDismissKeyDown}> 525: <SuccessStep secretExists={state.secretExists} useExistingSecret={state.useExistingSecret} secretName={state.secretName} skipWorkflow={state.workflowAction === 'skip'} /> 526: </Box>; 527: case 'error': 528: return <Box tabIndex={0} autoFocus onKeyDown={handleDismissKeyDown}> 529: <ErrorStep error={state.error} errorReason={state.errorReason} errorInstructions={state.errorInstructions} /> 530: </Box>; 531: case 'select-workflows': 532: return <WorkflowMultiselectDialog defaultSelections={state.selectedWorkflows} onSubmit={selectedWorkflows => { 533: logEvent('tengu_install_github_app_step_completed', { 534: step: 'select-workflows' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 535: }); 536: setState(prev_31 => ({ 537: ...prev_31, 538: selectedWorkflows 539: })); 540: if (existingApiKey) { 541: void checkExistingSecret(); 542: } else { 543: setState(prev_32 => ({ 544: ...prev_32, 545: step: 'api-key' 546: })); 547: } 548: }} />; 549: case 'oauth-flow': 550: return <OAuthFlowStep onSuccess={handleOAuthSuccess} onCancel={handleOAuthCancel} />; 551: } 552: } 553: export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> { 554: return <InstallGitHubApp onDone={onDone} />; 555: }

File: src/commands/install-github-app/InstallAppStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import React from 'react'; 4: import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'; 5: import { Box, Text } from '../../ink.js'; 6: import { useKeybinding } from '../../keybindings/useKeybinding.js'; 7: interface InstallAppStepProps { 8: repoUrl: string; 9: onSubmit: () => void; 10: } 11: export function InstallAppStep(t0) { 12: const $ = _c(12); 13: const { 14: repoUrl, 15: onSubmit 16: } = t0; 17: let t1; 18: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 19: t1 = { 20: context: "Confirmation" 21: }; 22: $[0] = t1; 23: } else { 24: t1 = $[0]; 25: } 26: useKeybinding("confirm:yes", onSubmit, t1); 27: let t2; 28: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 29: t2 = <Box flexDirection="column" marginBottom={1}><Text bold={true}>Install the Claude GitHub App</Text></Box>; 30: $[1] = t2; 31: } else { 32: t2 = $[1]; 33: } 34: let t3; 35: if ($[2] === Symbol.for("react.memo_cache_sentinel")) { 36: t3 = <Box marginBottom={1}><Text>Opening browser to install the Claude GitHub App…</Text></Box>; 37: $[2] = t3; 38: } else { 39: t3 = $[2]; 40: } 41: let t4; 42: if ($[3] === Symbol.for("react.memo_cache_sentinel")) { 43: t4 = <Box marginBottom={1}><Text>If your browser doesn't open automatically, visit:</Text></Box>; 44: $[3] = t4; 45: } else { 46: t4 = $[3]; 47: } 48: let t5; 49: if ($[4] === Symbol.for("react.memo_cache_sentinel")) { 50: t5 = <Box marginBottom={1}><Text underline={true}>https: 51: $[4] = t5; 52: } else { 53: t5 = $[4]; 54: } 55: let t6; 56: if ($[5] !== repoUrl) { 57: t6 = <Box marginBottom={1}><Text>Please install the app for repository: <Text bold={true}>{repoUrl}</Text></Text></Box>; 58: $[5] = repoUrl; 59: $[6] = t6; 60: } else { 61: t6 = $[6]; 62: } 63: let t7; 64: if ($[7] === Symbol.for("react.memo_cache_sentinel")) { 65: t7 = <Box marginBottom={1}><Text dimColor={true}>Important: Make sure to grant access to this specific repository</Text></Box>; 66: $[7] = t7; 67: } else { 68: t7 = $[7]; 69: } 70: let t8; 71: if ($[8] === Symbol.for("react.memo_cache_sentinel")) { 72: t8 = <Box><Text bold={true} color="permission">Press Enter once you've installed the app{figures.ellipsis}</Text></Box>; 73: $[8] = t8; 74: } else { 75: t8 = $[8]; 76: } 77: let t9; 78: if ($[9] === Symbol.for("react.memo_cache_sentinel")) { 79: t9 = <Box marginTop={1}><Text dimColor={true}>Having trouble? See manual setup instructions at:{" "}<Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text></Text></Box>; 80: $[9] = t9; 81: } else { 82: t9 = $[9]; 83: } 84: let t10; 85: if ($[10] !== t6) { 86: t10 = <Box flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1}>{t2}{t3}{t4}{t5}{t6}{t7}{t8}{t9}</Box>; 87: $[10] = t6; 88: $[11] = t10; 89: } else { 90: t10 = $[11]; 91: } 92: return t10; 93: }

File: src/commands/install-github-app/OAuthFlowStep.tsx

typescript 1: import React, { useCallback, useEffect, useRef, useState } from 'react'; 2: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; 3: import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; 4: import { Spinner } from '../../components/Spinner.js'; 5: import TextInput from '../../components/TextInput.js'; 6: import { useTerminalSize } from '../../hooks/useTerminalSize.js'; 7: import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; 8: import { setClipboard } from '../../ink/termio/osc.js'; 9: import { Box, Link, Text } from '../../ink.js'; 10: import { OAuthService } from '../../services/oauth/index.js'; 11: import { saveOAuthTokensIfNeeded } from '../../utils/auth.js'; 12: import { logError } from '../../utils/log.js'; 13: interface OAuthFlowStepProps { 14: onSuccess: (token: string) => void; 15: onCancel: () => void; 16: } 17: type OAuthStatus = { 18: state: 'starting'; 19: } | { 20: state: 'waiting_for_login'; 21: url: string; 22: } | { 23: state: 'processing'; 24: } | { 25: state: 'success'; 26: token: string; 27: } | { 28: state: 'error'; 29: message: string; 30: toRetry?: OAuthStatus; 31: } | { 32: state: 'about_to_retry'; 33: nextState: OAuthStatus; 34: }; 35: const PASTE_HERE_MSG = 'Paste code here if prompted > '; 36: export function OAuthFlowStep({ 37: onSuccess, 38: onCancel 39: }: OAuthFlowStepProps): React.ReactNode { 40: const [oauthStatus, setOAuthStatus] = useState<OAuthStatus>({ 41: state: 'starting' 42: }); 43: const [oauthService] = useState(() => new OAuthService()); 44: const [pastedCode, setPastedCode] = useState(''); 45: const [cursorOffset, setCursorOffset] = useState(0); 46: const [showPastePrompt, setShowPastePrompt] = useState(false); 47: const [urlCopied, setUrlCopied] = useState(false); 48: const timersRef = useRef<Set<NodeJS.Timeout>>(new Set()); 49: // Separate ref so startOAuth's timer clear doesn't cancel the urlCopied reset 50: const urlCopiedTimerRef = useRef<NodeJS.Timeout | undefined>(undefined); 51: const terminalSize = useTerminalSize(); 52: const textInputColumns = Math.max(50, terminalSize.columns - PASTE_HERE_MSG.length - 4); 53: function handleKeyDown(e: KeyboardEvent): void { 54: if (oauthStatus.state !== 'error') return; 55: e.preventDefault(); 56: if (e.key === 'return' && oauthStatus.toRetry) { 57: setPastedCode(''); 58: setCursorOffset(0); 59: setOAuthStatus({ 60: state: 'about_to_retry', 61: nextState: oauthStatus.toRetry 62: }); 63: } else { 64: onCancel(); 65: } 66: } 67: async function handleSubmitCode(value: string, url: string) { 68: try { 69: const [authorizationCode, state] = value.split('#'); 70: if (!authorizationCode || !state) { 71: setOAuthStatus({ 72: state: 'error', 73: message: 'Invalid code. Please make sure the full code was copied', 74: toRetry: { 75: state: 'waiting_for_login', 76: url 77: } 78: }); 79: return; 80: } 81: logEvent('tengu_oauth_manual_entry', {}); 82: oauthService.handleManualAuthCodeInput({ 83: authorizationCode, 84: state 85: }); 86: } catch (err: unknown) { 87: logError(err); 88: setOAuthStatus({ 89: state: 'error', 90: message: (err as Error).message, 91: toRetry: { 92: state: 'waiting_for_login', 93: url 94: } 95: }); 96: } 97: } 98: const startOAuth = useCallback(async () => { 99: timersRef.current.forEach(timer => clearTimeout(timer)); 100: timersRef.current.clear(); 101: try { 102: const result = await oauthService.startOAuthFlow(async url_0 => { 103: setOAuthStatus({ 104: state: 'waiting_for_login', 105: url: url_0 106: }); 107: const timer_0 = setTimeout(setShowPastePrompt, 3000, true); 108: timersRef.current.add(timer_0); 109: }, { 110: loginWithClaudeAi: true, 111: inferenceOnly: true, 112: expiresIn: 365 * 24 * 60 * 60 113: }); 114: setOAuthStatus({ 115: state: 'processing' 116: }); 117: saveOAuthTokensIfNeeded(result); 118: const timer1 = setTimeout((setOAuthStatus_0, accessToken, onSuccess_0, timersRef_0) => { 119: setOAuthStatus_0({ 120: state: 'success', 121: token: accessToken 122: }); 123: const timer2 = setTimeout(onSuccess_0, 1000, accessToken); 124: timersRef_0.current.add(timer2); 125: }, 100, setOAuthStatus, result.accessToken, onSuccess, timersRef); 126: timersRef.current.add(timer1); 127: } catch (err_0) { 128: const errorMessage = (err_0 as Error).message; 129: setOAuthStatus({ 130: state: 'error', 131: message: errorMessage, 132: toRetry: { 133: state: 'starting' 134: } 135: }); 136: logError(err_0); 137: logEvent('tengu_oauth_error', { 138: error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 139: }); 140: } 141: }, [oauthService, onSuccess]); 142: useEffect(() => { 143: if (oauthStatus.state === 'starting') { 144: void startOAuth(); 145: } 146: }, [oauthStatus.state, startOAuth]); 147: useEffect(() => { 148: if (oauthStatus.state === 'about_to_retry') { 149: const timer_1 = setTimeout((nextState, setShowPastePrompt_0, setOAuthStatus_1) => { 150: setShowPastePrompt_0(nextState.state === 'waiting_for_login'); 151: setOAuthStatus_1(nextState); 152: }, 500, oauthStatus.nextState, setShowPastePrompt, setOAuthStatus); 153: timersRef.current.add(timer_1); 154: } 155: }, [oauthStatus]); 156: useEffect(() => { 157: if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) { 158: void setClipboard(oauthStatus.url).then(raw => { 159: if (raw) process.stdout.write(raw); 160: setUrlCopied(true); 161: clearTimeout(urlCopiedTimerRef.current); 162: urlCopiedTimerRef.current = setTimeout(setUrlCopied, 2000, false); 163: }); 164: setPastedCode(''); 165: } 166: }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]); 167: // Cleanup OAuth service and timers when component unmounts 168: useEffect(() => { 169: const timers = timersRef.current; 170: return () => { 171: oauthService.cleanup(); 172: // Clear all timers 173: timers.forEach(timer_2 => clearTimeout(timer_2)); 174: timers.clear(); 175: clearTimeout(urlCopiedTimerRef.current); 176: }; 177: }, [oauthService]); 178: // Helper function to render the appropriate status message 179: function renderStatusMessage(): React.ReactNode { 180: switch (oauthStatus.state) { 181: case 'starting': 182: return <Box> 183: <Spinner /> 184: <Text>Starting authentication…</Text> 185: </Box>; 186: case 'waiting_for_login': 187: return <Box flexDirection="column" gap={1}> 188: {!showPastePrompt && <Box> 189: <Spinner /> 190: <Text> 191: Opening browser to sign in with your Claude account… 192: </Text> 193: </Box>} 194: {showPastePrompt && <Box> 195: <Text>{PASTE_HERE_MSG}</Text> 196: <TextInput value={pastedCode} onChange={setPastedCode} onSubmit={(value_0: string) => handleSubmitCode(value_0, oauthStatus.url)} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} /> 197: </Box>} 198: </Box>; 199: case 'processing': 200: return <Box> 201: <Spinner /> 202: <Text>Processing authentication…</Text> 203: </Box>; 204: case 'success': 205: return <Box flexDirection="column" gap={1}> 206: <Text color="success"> 207: ✓ Authentication token created successfully! 208: </Text> 209: <Text dimColor>Using token for GitHub Actions setup…</Text> 210: </Box>; 211: case 'error': 212: return <Box flexDirection="column" gap={1}> 213: <Text color="error">OAuth error: {oauthStatus.message}</Text> 214: {oauthStatus.toRetry ? <Text dimColor> 215: Press Enter to try again, or any other key to cancel 216: </Text> : <Text dimColor>Press any key to return to API key selection</Text>} 217: </Box>; 218: case 'about_to_retry': 219: return <Box flexDirection="column" gap={1}> 220: <Text color="permission">Retrying…</Text> 221: </Box>; 222: default: 223: return null; 224: } 225: } 226: return <Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}> 227: {} 228: {oauthStatus.state === 'starting' && <Box flexDirection="column" gap={1} paddingBottom={1}> 229: <Text bold>Create Authentication Token</Text> 230: <Text dimColor>Creating a long-lived token for GitHub Actions</Text> 231: </Box>} 232: {} 233: {oauthStatus.state !== 'success' && oauthStatus.state !== 'starting' && oauthStatus.state !== 'processing' && <Box key="header" flexDirection="column" gap={1} paddingBottom={1}> 234: <Text bold>Create Authentication Token</Text> 235: <Text dimColor>Creating a long-lived token for GitHub Actions</Text> 236: </Box>} 237: {} 238: {oauthStatus.state === 'waiting_for_login' && showPastePrompt && <Box flexDirection="column" key="urlToCopy" gap={1} paddingBottom={1}> 239: <Box paddingX={1}> 240: <Text dimColor> 241: Browser didn&apos;t open? Use the url below to sign in{' '} 242: </Text> 243: {urlCopied ? <Text color="success">(Copied!)</Text> : <Text dimColor> 244: <KeyboardShortcutHint shortcut="c" action="copy" parens /> 245: </Text>} 246: </Box> 247: <Link url={oauthStatus.url}> 248: <Text dimColor>{oauthStatus.url}</Text> 249: </Link> 250: </Box>} 251: <Box paddingLeft={1} flexDirection="column" gap={1}> 252: {renderStatusMessage()} 253: </Box> 254: </Box>; 255: }

File: src/commands/install-github-app/setupGitHubActions.ts

typescript 1: import { 2: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3: logEvent, 4: } from 'src/services/analytics/index.js' 5: import { saveGlobalConfig } from 'src/utils/config.js' 6: import { 7: CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT, 8: PR_BODY, 9: PR_TITLE, 10: WORKFLOW_CONTENT, 11: } from '../../constants/github-app.js' 12: import { openBrowser } from '../../utils/browser.js' 13: import { execFileNoThrow } from '../../utils/execFileNoThrow.js' 14: import { logError } from '../../utils/log.js' 15: import type { Workflow } from './types.js' 16: async function createWorkflowFile( 17: repoName: string, 18: branchName: string, 19: workflowPath: string, 20: workflowContent: string, 21: secretName: string, 22: message: string, 23: context?: { 24: useCurrentRepo?: boolean 25: workflowExists?: boolean 26: secretExists?: boolean 27: }, 28: ): Promise<void> { 29: const checkFileResult = await execFileNoThrow('gh', [ 30: 'api', 31: `repos/${repoName}/contents/${workflowPath}`, 32: '--jq', 33: '.sha', 34: ]) 35: let fileSha: string | null = null 36: if (checkFileResult.code === 0) { 37: fileSha = checkFileResult.stdout.trim() 38: } 39: let content = workflowContent 40: if (secretName === 'CLAUDE_CODE_OAUTH_TOKEN') { 41: content = workflowContent.replace( 42: /anthropic_api_key: \$\{\{ secrets\.ANTHROPIC_API_KEY \}\}/g, 43: `claude_code_oauth_token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}`, 44: ) 45: } else if (secretName !== 'ANTHROPIC_API_KEY') { 46: content = workflowContent.replace( 47: /anthropic_api_key: \$\{\{ secrets\.ANTHROPIC_API_KEY \}\}/g, 48: `anthropic_api_key: \${{ secrets.${secretName} }}`, 49: ) 50: } 51: const base64Content = Buffer.from(content).toString('base64') 52: const apiParams = [ 53: 'api', 54: '--method', 55: 'PUT', 56: `repos/${repoName}/contents/${workflowPath}`, 57: '-f', 58: `message=${fileSha ? `"Update ${message}"` : `"${message}"`}`, 59: '-f', 60: `content=${base64Content}`, 61: '-f', 62: `branch=${branchName}`, 63: ] 64: if (fileSha) { 65: apiParams.push('-f', `sha=${fileSha}`) 66: } 67: const createFileResult = await execFileNoThrow('gh', apiParams) 68: if (createFileResult.code !== 0) { 69: if ( 70: createFileResult.stderr.includes('422') && 71: createFileResult.stderr.includes('sha') 72: ) { 73: logEvent('tengu_setup_github_actions_failed', { 74: reason: 75: 'failed_to_create_workflow_file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 76: exit_code: createFileResult.code, 77: ...context, 78: }) 79: throw new Error( 80: `Failed to create workflow file ${workflowPath}: A Claude workflow file already exists in this repository. Please remove it first or update it manually.`, 81: ) 82: } 83: logEvent('tengu_setup_github_actions_failed', { 84: reason: 85: 'failed_to_create_workflow_file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 86: exit_code: createFileResult.code, 87: ...context, 88: }) 89: const helpText = 90: '\n\nNeed help? Common issues:\n' + 91: '· Permission denied → Run: gh auth refresh -h github.com -s repo,workflow\n' + 92: '· Not authorized → Ensure you have admin access to the repository\n' + 93: '· For manual setup → Visit: https://github.com/anthropics/claude-code-action' 94: throw new Error( 95: `Failed to create workflow file ${workflowPath}: ${createFileResult.stderr}${helpText}`, 96: ) 97: } 98: } 99: export async function setupGitHubActions( 100: repoName: string, 101: apiKeyOrOAuthToken: string | null, 102: secretName: string, 103: updateProgress: () => void, 104: skipWorkflow = false, 105: selectedWorkflows: Workflow[], 106: authType: 'api_key' | 'oauth_token', 107: context?: { 108: useCurrentRepo?: boolean 109: workflowExists?: boolean 110: secretExists?: boolean 111: }, 112: ) { 113: try { 114: logEvent('tengu_setup_github_actions_started', { 115: skip_workflow: skipWorkflow, 116: has_api_key: !!apiKeyOrOAuthToken, 117: using_default_secret_name: secretName === 'ANTHROPIC_API_KEY', 118: selected_claude_workflow: selectedWorkflows.includes('claude'), 119: selected_claude_review_workflow: 120: selectedWorkflows.includes('claude-review'), 121: ...context, 122: }) 123: const repoCheckResult = await execFileNoThrow('gh', [ 124: 'api', 125: `repos/${repoName}`, 126: '--jq', 127: '.id', 128: ]) 129: if (repoCheckResult.code !== 0) { 130: logEvent('tengu_setup_github_actions_failed', { 131: reason: 132: 'repo_not_found' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 133: exit_code: repoCheckResult.code, 134: ...context, 135: }) 136: throw new Error( 137: `Failed to access repository ${repoName}: ${repoCheckResult.stderr}`, 138: ) 139: } 140: const defaultBranchResult = await execFileNoThrow('gh', [ 141: 'api', 142: `repos/${repoName}`, 143: '--jq', 144: '.default_branch', 145: ]) 146: if (defaultBranchResult.code !== 0) { 147: logEvent('tengu_setup_github_actions_failed', { 148: reason: 149: 'failed_to_get_default_branch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 150: exit_code: defaultBranchResult.code, 151: ...context, 152: }) 153: throw new Error( 154: `Failed to get default branch: ${defaultBranchResult.stderr}`, 155: ) 156: } 157: const defaultBranch = defaultBranchResult.stdout.trim() 158: const shaResult = await execFileNoThrow('gh', [ 159: 'api', 160: `repos/${repoName}/git/ref/heads/${defaultBranch}`, 161: '--jq', 162: '.object.sha', 163: ]) 164: if (shaResult.code !== 0) { 165: logEvent('tengu_setup_github_actions_failed', { 166: reason: 167: 'failed_to_get_branch_sha' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 168: exit_code: shaResult.code, 169: ...context, 170: }) 171: throw new Error(`Failed to get branch SHA: ${shaResult.stderr}`) 172: } 173: const sha = shaResult.stdout.trim() 174: let branchName: string | null = null 175: if (!skipWorkflow) { 176: updateProgress() 177: branchName = `add-claude-github-actions-${Date.now()}` 178: const createBranchResult = await execFileNoThrow('gh', [ 179: 'api', 180: '--method', 181: 'POST', 182: `repos/${repoName}/git/refs`, 183: '-f', 184: `ref=refs/heads/${branchName}`, 185: '-f', 186: `sha=${sha}`, 187: ]) 188: if (createBranchResult.code !== 0) { 189: logEvent('tengu_setup_github_actions_failed', { 190: reason: 191: 'failed_to_create_branch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 192: exit_code: createBranchResult.code, 193: ...context, 194: }) 195: throw new Error(`Failed to create branch: ${createBranchResult.stderr}`) 196: } 197: updateProgress() 198: const workflows = [] 199: if (selectedWorkflows.includes('claude')) { 200: workflows.push({ 201: path: '.github/workflows/claude.yml', 202: content: WORKFLOW_CONTENT, 203: message: 'Claude PR Assistant workflow', 204: }) 205: } 206: if (selectedWorkflows.includes('claude-review')) { 207: workflows.push({ 208: path: '.github/workflows/claude-code-review.yml', 209: content: CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT, 210: message: 'Claude Code Review workflow', 211: }) 212: } 213: for (const workflow of workflows) { 214: await createWorkflowFile( 215: repoName, 216: branchName, 217: workflow.path, 218: workflow.content, 219: secretName, 220: workflow.message, 221: context, 222: ) 223: } 224: } 225: updateProgress() 226: if (apiKeyOrOAuthToken) { 227: const setSecretResult = await execFileNoThrow('gh', [ 228: 'secret', 229: 'set', 230: secretName, 231: '--body', 232: apiKeyOrOAuthToken, 233: '--repo', 234: repoName, 235: ]) 236: if (setSecretResult.code !== 0) { 237: logEvent('tengu_setup_github_actions_failed', { 238: reason: 239: 'failed_to_set_api_key_secret' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 240: exit_code: setSecretResult.code, 241: ...context, 242: }) 243: const helpText = 244: '\n\nNeed help? Common issues:\n' + 245: '· Permission denied → Run: gh auth refresh -h github.com -s repo\n' + 246: '· Not authorized → Ensure you have admin access to the repository\n' + 247: '· For manual setup → Visit: https://github.com/anthropics/claude-code-action' 248: throw new Error( 249: `Failed to set API key secret: ${setSecretResult.stderr || 'Unknown error'}${helpText}`, 250: ) 251: } 252: } 253: if (!skipWorkflow && branchName) { 254: updateProgress() 255: const compareUrl = `https://github.com/${repoName}/compare/${defaultBranch}...${branchName}?quick_pull=1&title=${encodeURIComponent(PR_TITLE)}&body=${encodeURIComponent(PR_BODY)}` 256: await openBrowser(compareUrl) 257: } 258: logEvent('tengu_setup_github_actions_completed', { 259: skip_workflow: skipWorkflow, 260: has_api_key: !!apiKeyOrOAuthToken, 261: auth_type: 262: authType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 263: using_default_secret_name: secretName === 'ANTHROPIC_API_KEY', 264: selected_claude_workflow: selectedWorkflows.includes('claude'), 265: selected_claude_review_workflow: 266: selectedWorkflows.includes('claude-review'), 267: ...context, 268: }) 269: saveGlobalConfig(current => ({ 270: ...current, 271: githubActionSetupCount: (current.githubActionSetupCount ?? 0) + 1, 272: })) 273: } catch (error) { 274: if ( 275: !error || 276: !(error instanceof Error) || 277: !error.message.includes('Failed to') 278: ) { 279: logEvent('tengu_setup_github_actions_failed', { 280: reason: 281: 'unexpected_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 282: ...context, 283: }) 284: } 285: if (error instanceof Error) { 286: logError(error) 287: } 288: throw error 289: } 290: }

File: src/commands/install-github-app/SuccessStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React from 'react'; 3: import { Box, Text } from '../../ink.js'; 4: type SuccessStepProps = { 5: secretExists: boolean; 6: useExistingSecret: boolean; 7: secretName: string; 8: skipWorkflow?: boolean; 9: }; 10: export function SuccessStep(t0) { 11: const $ = _c(21); 12: const { 13: secretExists, 14: useExistingSecret, 15: secretName, 16: skipWorkflow: t1 17: } = t0; 18: const skipWorkflow = t1 === undefined ? false : t1; 19: let t2; 20: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 21: t2 = <Box flexDirection="column" marginBottom={1}><Text bold={true}>Install GitHub App</Text><Text dimColor={true}>Success</Text></Box>; 22: $[0] = t2; 23: } else { 24: t2 = $[0]; 25: } 26: let t3; 27: if ($[1] !== skipWorkflow) { 28: t3 = !skipWorkflow && <Text color="success">✓ GitHub Actions workflow created!</Text>; 29: $[1] = skipWorkflow; 30: $[2] = t3; 31: } else { 32: t3 = $[2]; 33: } 34: let t4; 35: if ($[3] !== secretExists || $[4] !== useExistingSecret) { 36: t4 = secretExists && useExistingSecret && <Box marginTop={1}><Text color="success">✓ Using existing ANTHROPIC_API_KEY secret</Text></Box>; 37: $[3] = secretExists; 38: $[4] = useExistingSecret; 39: $[5] = t4; 40: } else { 41: t4 = $[5]; 42: } 43: let t5; 44: if ($[6] !== secretExists || $[7] !== secretName || $[8] !== useExistingSecret) { 45: t5 = (!secretExists || !useExistingSecret) && <Box marginTop={1}><Text color="success">✓ API key saved as {secretName} secret</Text></Box>; 46: $[6] = secretExists; 47: $[7] = secretName; 48: $[8] = useExistingSecret; 49: $[9] = t5; 50: } else { 51: t5 = $[9]; 52: } 53: let t6; 54: if ($[10] === Symbol.for("react.memo_cache_sentinel")) { 55: t6 = <Box marginTop={1}><Text>Next steps:</Text></Box>; 56: $[10] = t6; 57: } else { 58: t6 = $[10]; 59: } 60: let t7; 61: if ($[11] !== skipWorkflow) { 62: t7 = skipWorkflow ? <><Text>1. Install the Claude GitHub App if you haven't already</Text><Text>2. Your workflow file was kept unchanged</Text><Text>3. API key is configured and ready to use</Text></> : <><Text>1. A pre-filled PR page has been created</Text><Text>2. Install the Claude GitHub App if you haven't already</Text><Text>3. Merge the PR to enable Claude PR assistance</Text></>; 63: $[11] = skipWorkflow; 64: $[12] = t7; 65: } else { 66: t7 = $[12]; 67: } 68: let t8; 69: if ($[13] !== t3 || $[14] !== t4 || $[15] !== t5 || $[16] !== t7) { 70: t8 = <Box flexDirection="column" borderStyle="round" paddingX={1}>{t2}{t3}{t4}{t5}{t6}{t7}</Box>; 71: $[13] = t3; 72: $[14] = t4; 73: $[15] = t5; 74: $[16] = t7; 75: $[17] = t8; 76: } else { 77: t8 = $[17]; 78: } 79: let t9; 80: if ($[18] === Symbol.for("react.memo_cache_sentinel")) { 81: t9 = <Box marginLeft={3}><Text dimColor={true}>Press any key to exit</Text></Box>; 82: $[18] = t9; 83: } else { 84: t9 = $[18]; 85: } 86: let t10; 87: if ($[19] !== t8) { 88: t10 = <>{t8}{t9}</>; 89: $[19] = t8; 90: $[20] = t10; 91: } else { 92: t10 = $[20]; 93: } 94: return t10; 95: }

File: src/commands/install-github-app/WarningsStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import React from 'react'; 4: import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'; 5: import { Box, Text } from '../../ink.js'; 6: import { useKeybinding } from '../../keybindings/useKeybinding.js'; 7: import type { Warning } from './types.js'; 8: interface WarningsStepProps { 9: warnings: Warning[]; 10: onContinue: () => void; 11: } 12: export function WarningsStep(t0) { 13: const $ = _c(8); 14: const { 15: warnings, 16: onContinue 17: } = t0; 18: let t1; 19: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 20: t1 = { 21: context: "Confirmation" 22: }; 23: $[0] = t1; 24: } else { 25: t1 = $[0]; 26: } 27: useKeybinding("confirm:yes", onContinue, t1); 28: let t2; 29: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 30: t2 = <Box flexDirection="column" marginBottom={1}><Text bold={true}>{figures.warning} Setup Warnings</Text><Text dimColor={true}>We found some potential issues, but you can continue anyway</Text></Box>; 31: $[1] = t2; 32: } else { 33: t2 = $[1]; 34: } 35: let t3; 36: if ($[2] !== warnings) { 37: t3 = warnings.map(_temp2); 38: $[2] = warnings; 39: $[3] = t3; 40: } else { 41: t3 = $[3]; 42: } 43: let t4; 44: if ($[4] === Symbol.for("react.memo_cache_sentinel")) { 45: t4 = <Box marginTop={1}><Text bold={true} color="permission">Press Enter to continue anyway, or Ctrl+C to exit and fix issues</Text></Box>; 46: $[4] = t4; 47: } else { 48: t4 = $[4]; 49: } 50: let t5; 51: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 52: t5 = <Box marginTop={1}><Text dimColor={true}>You can also try the manual setup steps if needed:{" "}<Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text></Text></Box>; 53: $[5] = t5; 54: } else { 55: t5 = $[5]; 56: } 57: let t6; 58: if ($[6] !== t3) { 59: t6 = <><Box flexDirection="column" borderStyle="round" paddingX={1}>{t2}{t3}{t4}{t5}</Box></>; 60: $[6] = t3; 61: $[7] = t6; 62: } else { 63: t6 = $[7]; 64: } 65: return t6; 66: } 67: function _temp2(warning, index) { 68: return <Box key={index} flexDirection="column" marginBottom={1}><Text color="warning" bold={true}>{warning.title}</Text><Text>{warning.message}</Text>{warning.instructions.length > 0 && <Box flexDirection="column" marginLeft={2} marginTop={1}>{warning.instructions.map(_temp)}</Box>}</Box>; 69: } 70: function _temp(instruction, i) { 71: return <Text key={i} dimColor={true}>• {instruction}</Text>; 72: }

File: src/commands/install-slack-app/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const installSlackApp = { 3: type: 'local', 4: name: 'install-slack-app', 5: description: 'Install the Claude Slack app', 6: availability: ['claude-ai'], 7: supportsNonInteractive: false, 8: load: () => import('./install-slack-app.js'), 9: } satisfies Command 10: export default installSlackApp

File: src/commands/install-slack-app/install-slack-app.ts

typescript 1: import type { LocalCommandResult } from '../../commands.js' 2: import { logEvent } from '../../services/analytics/index.js' 3: import { openBrowser } from '../../utils/browser.js' 4: import { saveGlobalConfig } from '../../utils/config.js' 5: const SLACK_APP_URL = 'https://slack.com/marketplace/A08SF47R6P4-claude' 6: export async function call(): Promise<LocalCommandResult> { 7: logEvent('tengu_install_slack_app_clicked', {}) 8: saveGlobalConfig(current => ({ 9: ...current, 10: slackAppInstallCount: (current.slackAppInstallCount ?? 0) + 1, 11: })) 12: const success = await openBrowser(SLACK_APP_URL) 13: if (success) { 14: return { 15: type: 'text', 16: value: 'Opening Slack app installation page in browser…', 17: } 18: } else { 19: return { 20: type: 'text', 21: value: `Couldn't open browser. Visit: ${SLACK_APP_URL}`, 22: } 23: } 24: }

File: src/commands/issue/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/keybindings/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js' 3: const keybindings = { 4: name: 'keybindings', 5: description: 'Open or create your keybindings configuration file', 6: isEnabled: () => isKeybindingCustomizationEnabled(), 7: supportsNonInteractive: false, 8: type: 'local', 9: load: () => import('./keybindings.js'), 10: } satisfies Command 11: export default keybindings

File: src/commands/keybindings/keybindings.ts

typescript 1: import { mkdir, writeFile } from 'fs/promises' 2: import { dirname } from 'path' 3: import { 4: getKeybindingsPath, 5: isKeybindingCustomizationEnabled, 6: } from '../../keybindings/loadUserBindings.js' 7: import { generateKeybindingsTemplate } from '../../keybindings/template.js' 8: import { getErrnoCode } from '../../utils/errors.js' 9: import { editFileInEditor } from '../../utils/promptEditor.js' 10: export async function call(): Promise<{ type: 'text'; value: string }> { 11: if (!isKeybindingCustomizationEnabled()) { 12: return { 13: type: 'text', 14: value: 15: 'Keybinding customization is not enabled. This feature is currently in preview.', 16: } 17: } 18: const keybindingsPath = getKeybindingsPath() 19: let fileExists = false 20: await mkdir(dirname(keybindingsPath), { recursive: true }) 21: try { 22: await writeFile(keybindingsPath, generateKeybindingsTemplate(), { 23: encoding: 'utf-8', 24: flag: 'wx', 25: }) 26: } catch (e: unknown) { 27: if (getErrnoCode(e) === 'EEXIST') { 28: fileExists = true 29: } else { 30: throw e 31: } 32: } 33: const result = await editFileInEditor(keybindingsPath) 34: if (result.error) { 35: return { 36: type: 'text', 37: value: `${fileExists ? 'Opened' : 'Created'} ${keybindingsPath}. Could not open in editor: ${result.error}`, 38: } 39: } 40: return { 41: type: 'text', 42: value: fileExists 43: ? `Opened ${keybindingsPath} in your editor.` 44: : `Created ${keybindingsPath} with template. Opened in your editor.`, 45: } 46: }

File: src/commands/login/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { hasAnthropicApiKeyAuth } from '../../utils/auth.js' 3: import { isEnvTruthy } from '../../utils/envUtils.js' 4: export default () => 5: ({ 6: type: 'local-jsx', 7: name: 'login', 8: description: hasAnthropicApiKeyAuth() 9: ? 'Switch Anthropic accounts' 10: : 'Sign in with your Anthropic account', 11: isEnabled: () => !isEnvTruthy(process.env.DISABLE_LOGIN_COMMAND), 12: load: () => import('./login.js'), 13: }) satisfies Command

File: src/commands/login/login.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { feature } from 'bun:bundle'; 3: import * as React from 'react'; 4: import { resetCostState } from '../../bootstrap/state.js'; 5: import { clearTrustedDeviceToken, enrollTrustedDevice } from '../../bridge/trustedDevice.js'; 6: import type { LocalJSXCommandContext } from '../../commands.js'; 7: import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; 8: import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js'; 9: import { Dialog } from '../../components/design-system/Dialog.js'; 10: import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; 11: import { Text } from '../../ink.js'; 12: import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'; 13: import { refreshPolicyLimits } from '../../services/policyLimits/index.js'; 14: import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js'; 15: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 16: import { stripSignatureBlocks } from '../../utils/messages.js'; 17: import { checkAndDisableAutoModeIfNeeded, checkAndDisableBypassPermissionsIfNeeded, resetAutoModeGateCheck, resetBypassPermissionsCheck } from '../../utils/permissions/bypassPermissionsKillswitch.js'; 18: import { resetUserCache } from '../../utils/user.js'; 19: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> { 20: return <Login onDone={async success => { 21: context.onChangeAPIKey(); 22: context.setMessages(stripSignatureBlocks); 23: if (success) { 24: resetCostState(); 25: void refreshRemoteManagedSettings(); 26: void refreshPolicyLimits(); 27: resetUserCache(); 28: refreshGrowthBookAfterAuthChange(); 29: clearTrustedDeviceToken(); 30: void enrollTrustedDevice(); 31: resetBypassPermissionsCheck(); 32: const appState = context.getAppState(); 33: void checkAndDisableBypassPermissionsIfNeeded(appState.toolPermissionContext, context.setAppState); 34: if (feature('TRANSCRIPT_CLASSIFIER')) { 35: resetAutoModeGateCheck(); 36: void checkAndDisableAutoModeIfNeeded(appState.toolPermissionContext, context.setAppState, appState.fastMode); 37: } 38: context.setAppState(prev => ({ 39: ...prev, 40: authVersion: prev.authVersion + 1 41: })); 42: } 43: onDone(success ? 'Login successful' : 'Login interrupted'); 44: }} />; 45: } 46: export function Login(props) { 47: const $ = _c(12); 48: const mainLoopModel = useMainLoopModel(); 49: let t0; 50: if ($[0] !== mainLoopModel || $[1] !== props) { 51: t0 = () => props.onDone(false, mainLoopModel); 52: $[0] = mainLoopModel; 53: $[1] = props; 54: $[2] = t0; 55: } else { 56: t0 = $[2]; 57: } 58: let t1; 59: if ($[3] !== mainLoopModel || $[4] !== props) { 60: t1 = () => props.onDone(true, mainLoopModel); 61: $[3] = mainLoopModel; 62: $[4] = props; 63: $[5] = t1; 64: } else { 65: t1 = $[5]; 66: } 67: let t2; 68: if ($[6] !== props.startingMessage || $[7] !== t1) { 69: t2 = <ConsoleOAuthFlow onDone={t1} startingMessage={props.startingMessage} />; 70: $[6] = props.startingMessage; 71: $[7] = t1; 72: $[8] = t2; 73: } else { 74: t2 = $[8]; 75: } 76: let t3; 77: if ($[9] !== t0 || $[10] !== t2) { 78: t3 = <Dialog title="Login" onCancel={t0} color="permission" inputGuide={_temp}>{t2}</Dialog>; 79: $[9] = t0; 80: $[10] = t2; 81: $[11] = t3; 82: } else { 83: t3 = $[11]; 84: } 85: return t3; 86: } 87: function _temp(exitState) { 88: return exitState.pending ? <Text>Press {exitState.keyName} again to exit</Text> : <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />; 89: }

File: src/commands/logout/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { isEnvTruthy } from '../../utils/envUtils.js' 3: export default { 4: type: 'local-jsx', 5: name: 'logout', 6: description: 'Sign out from your Anthropic account', 7: isEnabled: () => !isEnvTruthy(process.env.DISABLE_LOGOUT_COMMAND), 8: load: () => import('./logout.js'), 9: } satisfies Command

File: src/commands/logout/logout.tsx

typescript 1: import * as React from 'react'; 2: import { clearTrustedDeviceTokenCache } from '../../bridge/trustedDevice.js'; 3: import { Text } from '../../ink.js'; 4: import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'; 5: import { getGroveNoticeConfig, getGroveSettings } from '../../services/api/grove.js'; 6: import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js'; 7: import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js'; 8: import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js'; 9: import { clearBetasCaches } from '../../utils/betas.js'; 10: import { saveGlobalConfig } from '../../utils/config.js'; 11: import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; 12: import { getSecureStorage } from '../../utils/secureStorage/index.js'; 13: import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js'; 14: import { resetUserCache } from '../../utils/user.js'; 15: export async function performLogout({ 16: clearOnboarding = false 17: }): Promise<void> { 18: const { 19: flushTelemetry 20: } = await import('../../utils/telemetry/instrumentation.js'); 21: await flushTelemetry(); 22: await removeApiKey(); 23: const secureStorage = getSecureStorage(); 24: secureStorage.delete(); 25: await clearAuthRelatedCaches(); 26: saveGlobalConfig(current => { 27: const updated = { 28: ...current 29: }; 30: if (clearOnboarding) { 31: updated.hasCompletedOnboarding = false; 32: updated.subscriptionNoticeCount = 0; 33: updated.hasAvailableSubscription = false; 34: if (updated.customApiKeyResponses?.approved) { 35: updated.customApiKeyResponses = { 36: ...updated.customApiKeyResponses, 37: approved: [] 38: }; 39: } 40: } 41: updated.oauthAccount = undefined; 42: return updated; 43: }); 44: } 45: export async function clearAuthRelatedCaches(): Promise<void> { 46: getClaudeAIOAuthTokens.cache?.clear?.(); 47: clearTrustedDeviceTokenCache(); 48: clearBetasCaches(); 49: clearToolSchemaCache(); 50: resetUserCache(); 51: refreshGrowthBookAfterAuthChange(); 52: getGroveNoticeConfig.cache?.clear?.(); 53: getGroveSettings.cache?.clear?.(); 54: await clearRemoteManagedSettingsCache(); 55: await clearPolicyLimitsCache(); 56: } 57: export async function call(): Promise<React.ReactNode> { 58: await performLogout({ 59: clearOnboarding: true 60: }); 61: const message = <Text>Successfully logged out from your Anthropic account.</Text>; 62: setTimeout(() => { 63: gracefulShutdownSync(0, 'logout'); 64: }, 200); 65: return message; 66: }

File: src/commands/mcp/addCommand.ts

typescript 1: import { type Command, Option } from '@commander-js/extra-typings' 2: import { cliError, cliOk } from '../../cli/exit.js' 3: import { 4: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 5: logEvent, 6: } from '../../services/analytics/index.js' 7: import { 8: readClientSecret, 9: saveMcpClientSecret, 10: } from '../../services/mcp/auth.js' 11: import { addMcpConfig } from '../../services/mcp/config.js' 12: import { 13: describeMcpConfigFilePath, 14: ensureConfigScope, 15: ensureTransport, 16: parseHeaders, 17: } from '../../services/mcp/utils.js' 18: import { 19: getXaaIdpSettings, 20: isXaaEnabled, 21: } from '../../services/mcp/xaaIdpLogin.js' 22: import { parseEnvVars } from '../../utils/envUtils.js' 23: import { jsonStringify } from '../../utils/slowOperations.js' 24: export function registerMcpAddCommand(mcp: Command): void { 25: mcp 26: .command('add <name> <commandOrUrl> [args...]') 27: .description( 28: 'Add an MCP server to Claude Code.\n\n' + 29: 'Examples:\n' + 30: ' # Add HTTP server:\n' + 31: ' claude mcp add --transport http sentry https://mcp.sentry.dev/mcp\n\n' + 32: ' # Add HTTP server with headers:\n' + 33: ' claude mcp add --transport http corridor https://app.corridor.dev/api/mcp --header "Authorization: Bearer ..."\n\n' + 34: ' # Add stdio server with environment variables:\n' + 35: ' claude mcp add -e API_KEY=xxx my-server -- npx my-mcp-server\n\n' + 36: ' # Add stdio server with subprocess flags:\n' + 37: ' claude mcp add my-server -- my-command --some-flag arg1', 38: ) 39: .option( 40: '-s, --scope <scope>', 41: 'Configuration scope (local, user, or project)', 42: 'local', 43: ) 44: .option( 45: '-t, --transport <transport>', 46: 'Transport type (stdio, sse, http). Defaults to stdio if not specified.', 47: ) 48: .option( 49: '-e, --env <env...>', 50: 'Set environment variables (e.g. -e KEY=value)', 51: ) 52: .option( 53: '-H, --header <header...>', 54: 'Set WebSocket headers (e.g. -H "X-Api-Key: abc123" -H "X-Custom: value")', 55: ) 56: .option('--client-id <clientId>', 'OAuth client ID for HTTP/SSE servers') 57: .option( 58: '--client-secret', 59: 'Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)', 60: ) 61: .option( 62: '--callback-port <port>', 63: 'Fixed port for OAuth callback (for servers requiring pre-registered redirect URIs)', 64: ) 65: .helpOption('-h, --help', 'Display help for command') 66: .addOption( 67: new Option( 68: '--xaa', 69: "Enable XAA (SEP-990) for this server. Requires 'claude mcp xaa setup' first. Also requires --client-id and --client-secret (for the MCP server's AS).", 70: ).hideHelp(!isXaaEnabled()), 71: ) 72: .action(async (name, commandOrUrl, args, options) => { 73: const actualCommand = commandOrUrl 74: const actualArgs = args 75: if (!name) { 76: cliError( 77: 'Error: Server name is required.\n' + 78: 'Usage: claude mcp add <name> <command> [args...]', 79: ) 80: } else if (!actualCommand) { 81: cliError( 82: 'Error: Command is required when server name is provided.\n' + 83: 'Usage: claude mcp add <name> <command> [args...]', 84: ) 85: } 86: try { 87: const scope = ensureConfigScope(options.scope) 88: const transport = ensureTransport(options.transport) 89: if (options.xaa && !isXaaEnabled()) { 90: cliError( 91: 'Error: --xaa requires CLAUDE_CODE_ENABLE_XAA=1 in your environment', 92: ) 93: } 94: const xaa = Boolean(options.xaa) 95: if (xaa) { 96: const missing: string[] = [] 97: if (!options.clientId) missing.push('--client-id') 98: if (!options.clientSecret) missing.push('--client-secret') 99: if (!getXaaIdpSettings()) { 100: missing.push( 101: "'claude mcp xaa setup' (settings.xaaIdp not configured)", 102: ) 103: } 104: if (missing.length) { 105: cliError(`Error: --xaa requires: ${missing.join(', ')}`) 106: } 107: } 108: const transportExplicit = options.transport !== undefined 109: const looksLikeUrl = 110: actualCommand.startsWith('http://') || 111: actualCommand.startsWith('https://') || 112: actualCommand.startsWith('localhost') || 113: actualCommand.endsWith('/sse') || 114: actualCommand.endsWith('/mcp') 115: logEvent('tengu_mcp_add', { 116: type: transport as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 117: scope: 118: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 119: source: 120: 'command' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 121: transport: 122: transport as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 123: transportExplicit: transportExplicit, 124: looksLikeUrl: looksLikeUrl, 125: }) 126: if (transport === 'sse') { 127: if (!actualCommand) { 128: cliError('Error: URL is required for SSE transport.') 129: } 130: const headers = options.header 131: ? parseHeaders(options.header) 132: : undefined 133: const callbackPort = options.callbackPort 134: ? parseInt(options.callbackPort, 10) 135: : undefined 136: const oauth = 137: options.clientId || callbackPort || xaa 138: ? { 139: ...(options.clientId ? { clientId: options.clientId } : {}), 140: ...(callbackPort ? { callbackPort } : {}), 141: ...(xaa ? { xaa: true } : {}), 142: } 143: : undefined 144: const clientSecret = 145: options.clientSecret && options.clientId 146: ? await readClientSecret() 147: : undefined 148: const serverConfig = { 149: type: 'sse' as const, 150: url: actualCommand, 151: headers, 152: oauth, 153: } 154: await addMcpConfig(name, serverConfig, scope) 155: if (clientSecret) { 156: saveMcpClientSecret(name, serverConfig, clientSecret) 157: } 158: process.stdout.write( 159: `Added SSE MCP server ${name} with URL: ${actualCommand} to ${scope} config\n`, 160: ) 161: if (headers) { 162: process.stdout.write( 163: `Headers: ${jsonStringify(headers, null, 2)}\n`, 164: ) 165: } 166: } else if (transport === 'http') { 167: if (!actualCommand) { 168: cliError('Error: URL is required for HTTP transport.') 169: } 170: const headers = options.header 171: ? parseHeaders(options.header) 172: : undefined 173: const callbackPort = options.callbackPort 174: ? parseInt(options.callbackPort, 10) 175: : undefined 176: const oauth = 177: options.clientId || callbackPort || xaa 178: ? { 179: ...(options.clientId ? { clientId: options.clientId } : {}), 180: ...(callbackPort ? { callbackPort } : {}), 181: ...(xaa ? { xaa: true } : {}), 182: } 183: : undefined 184: const clientSecret = 185: options.clientSecret && options.clientId 186: ? await readClientSecret() 187: : undefined 188: const serverConfig = { 189: type: 'http' as const, 190: url: actualCommand, 191: headers, 192: oauth, 193: } 194: await addMcpConfig(name, serverConfig, scope) 195: if (clientSecret) { 196: saveMcpClientSecret(name, serverConfig, clientSecret) 197: } 198: process.stdout.write( 199: `Added HTTP MCP server ${name} with URL: ${actualCommand} to ${scope} config\n`, 200: ) 201: if (headers) { 202: process.stdout.write( 203: `Headers: ${jsonStringify(headers, null, 2)}\n`, 204: ) 205: } 206: } else { 207: if ( 208: options.clientId || 209: options.clientSecret || 210: options.callbackPort || 211: options.xaa 212: ) { 213: process.stderr.write( 214: `Warning: --client-id, --client-secret, --callback-port, and --xaa are only supported for HTTP/SSE transports and will be ignored for stdio.\n`, 215: ) 216: } 217: if (!transportExplicit && looksLikeUrl) { 218: process.stderr.write( 219: `\nWarning: The command "${actualCommand}" looks like a URL, but is being interpreted as a stdio server as --transport was not specified.\n`, 220: ) 221: process.stderr.write( 222: `If this is an HTTP server, use: claude mcp add --transport http ${name} ${actualCommand}\n`, 223: ) 224: process.stderr.write( 225: `If this is an SSE server, use: claude mcp add --transport sse ${name} ${actualCommand}\n`, 226: ) 227: } 228: const env = parseEnvVars(options.env) 229: await addMcpConfig( 230: name, 231: { type: 'stdio', command: actualCommand, args: actualArgs, env }, 232: scope, 233: ) 234: process.stdout.write( 235: `Added stdio MCP server ${name} with command: ${actualCommand} ${actualArgs.join(' ')} to ${scope} config\n`, 236: ) 237: } 238: cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`) 239: } catch (error) { 240: cliError((error as Error).message) 241: } 242: }) 243: }

File: src/commands/mcp/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const mcp = { 3: type: 'local-jsx', 4: name: 'mcp', 5: description: 'Manage MCP servers', 6: immediate: true, 7: argumentHint: '[enable|disable [server-name]]', 8: load: () => import('./mcp.js'), 9: } satisfies Command 10: export default mcp

File: src/commands/mcp/mcp.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { useEffect, useRef } from 'react'; 3: import { MCPSettings } from '../../components/mcp/index.js'; 4: import { MCPReconnect } from '../../components/mcp/MCPReconnect.js'; 5: import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'; 6: import { useAppState } from '../../state/AppState.js'; 7: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 8: import { PluginSettings } from '../plugin/PluginSettings.js'; 9: function MCPToggle(t0) { 10: const $ = _c(7); 11: const { 12: action, 13: target, 14: onComplete 15: } = t0; 16: const mcpClients = useAppState(_temp); 17: const toggleMcpServer = useMcpToggleEnabled(); 18: const didRun = useRef(false); 19: let t1; 20: let t2; 21: if ($[0] !== action || $[1] !== mcpClients || $[2] !== onComplete || $[3] !== target || $[4] !== toggleMcpServer) { 22: t1 = () => { 23: if (didRun.current) { 24: return; 25: } 26: didRun.current = true; 27: const isEnabling = action === "enable"; 28: const clients = mcpClients.filter(_temp2); 29: const toToggle = target === "all" ? clients.filter(c_0 => isEnabling ? c_0.type === "disabled" : c_0.type !== "disabled") : clients.filter(c_1 => c_1.name === target); 30: if (toToggle.length === 0) { 31: onComplete(target === "all" ? `All MCP servers are already ${isEnabling ? "enabled" : "disabled"}` : `MCP server "${target}" not found`); 32: return; 33: } 34: for (const s_0 of toToggle) { 35: toggleMcpServer(s_0.name); 36: } 37: onComplete(target === "all" ? `${isEnabling ? "Enabled" : "Disabled"} ${toToggle.length} MCP server(s)` : `MCP server "${target}" ${isEnabling ? "enabled" : "disabled"}`); 38: }; 39: t2 = [action, target, mcpClients, toggleMcpServer, onComplete]; 40: $[0] = action; 41: $[1] = mcpClients; 42: $[2] = onComplete; 43: $[3] = target; 44: $[4] = toggleMcpServer; 45: $[5] = t1; 46: $[6] = t2; 47: } else { 48: t1 = $[5]; 49: t2 = $[6]; 50: } 51: useEffect(t1, t2); 52: return null; 53: } 54: function _temp2(c) { 55: return c.name !== "ide"; 56: } 57: function _temp(s) { 58: return s.mcp.clients; 59: } 60: export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> { 61: if (args) { 62: const parts = args.trim().split(/\s+/); 63: if (parts[0] === 'no-redirect') { 64: return <MCPSettings onComplete={onDone} />; 65: } 66: if (parts[0] === 'reconnect' && parts[1]) { 67: return <MCPReconnect serverName={parts.slice(1).join(' ')} onComplete={onDone} />; 68: } 69: if (parts[0] === 'enable' || parts[0] === 'disable') { 70: return <MCPToggle action={parts[0]} target={parts.length > 1 ? parts.slice(1).join(' ') : 'all'} onComplete={onDone} />; 71: } 72: } 73: if ("external" === 'ant') { 74: return <PluginSettings onComplete={onDone} args="manage" showMcpRedirectMessage />; 75: } 76: return <MCPSettings onComplete={onDone} />; 77: }

File: src/commands/mcp/xaaIdpCommand.ts

typescript 1: import type { Command } from '@commander-js/extra-typings' 2: import { cliError, cliOk } from '../../cli/exit.js' 3: import { 4: acquireIdpIdToken, 5: clearIdpClientSecret, 6: clearIdpIdToken, 7: getCachedIdpIdToken, 8: getIdpClientSecret, 9: getXaaIdpSettings, 10: issuerKey, 11: saveIdpClientSecret, 12: saveIdpIdTokenFromJwt, 13: } from '../../services/mcp/xaaIdpLogin.js' 14: import { errorMessage } from '../../utils/errors.js' 15: import { updateSettingsForSource } from '../../utils/settings/settings.js' 16: export function registerMcpXaaIdpCommand(mcp: Command): void { 17: const xaaIdp = mcp 18: .command('xaa') 19: .description('Manage the XAA (SEP-990) IdP connection') 20: xaaIdp 21: .command('setup') 22: .description( 23: 'Configure the IdP connection (one-time setup for all XAA-enabled servers)', 24: ) 25: .requiredOption('--issuer <url>', 'IdP issuer URL (OIDC discovery)') 26: .requiredOption('--client-id <id>', "Claude Code's client_id at the IdP") 27: .option( 28: '--client-secret', 29: 'Read IdP client secret from MCP_XAA_IDP_CLIENT_SECRET env var', 30: ) 31: .option( 32: '--callback-port <port>', 33: 'Fixed loopback callback port (only if IdP does not honor RFC 8252 port-any matching)', 34: ) 35: .action(options => { 36: let issuerUrl: URL 37: try { 38: issuerUrl = new URL(options.issuer) 39: } catch { 40: return cliError( 41: `Error: --issuer must be a valid URL (got "${options.issuer}")`, 42: ) 43: } 44: if ( 45: issuerUrl.protocol !== 'https:' && 46: !( 47: issuerUrl.protocol === 'http:' && 48: (issuerUrl.hostname === 'localhost' || 49: issuerUrl.hostname === '127.0.0.1' || 50: issuerUrl.hostname === '[::1]') 51: ) 52: ) { 53: return cliError( 54: `Error: --issuer must use https:// (got "${issuerUrl.protocol}//${issuerUrl.host}")`, 55: ) 56: } 57: const callbackPort = options.callbackPort 58: ? parseInt(options.callbackPort, 10) 59: : undefined 60: if ( 61: callbackPort !== undefined && 62: (!Number.isInteger(callbackPort) || callbackPort <= 0) 63: ) { 64: return cliError('Error: --callback-port must be a positive integer') 65: } 66: const secret = options.clientSecret 67: ? process.env.MCP_XAA_IDP_CLIENT_SECRET 68: : undefined 69: if (options.clientSecret && !secret) { 70: return cliError( 71: 'Error: --client-secret requires MCP_XAA_IDP_CLIENT_SECRET env var', 72: ) 73: } 74: const old = getXaaIdpSettings() 75: const oldIssuer = old?.issuer 76: const oldClientId = old?.clientId 77: const { error } = updateSettingsForSource('userSettings', { 78: xaaIdp: { 79: issuer: options.issuer, 80: clientId: options.clientId, 81: callbackPort, 82: }, 83: }) 84: if (error) { 85: return cliError(`Error writing settings: ${error.message}`) 86: } 87: if (oldIssuer) { 88: if (issuerKey(oldIssuer) !== issuerKey(options.issuer)) { 89: clearIdpIdToken(oldIssuer) 90: clearIdpClientSecret(oldIssuer) 91: } else if (oldClientId !== options.clientId) { 92: clearIdpIdToken(oldIssuer) 93: clearIdpClientSecret(oldIssuer) 94: } 95: } 96: if (secret) { 97: const { success, warning } = saveIdpClientSecret(options.issuer, secret) 98: if (!success) { 99: return cliError( 100: `Error: settings written but keychain save failed${warning ? ` — ${warning}` : ''}. ` + 101: `Re-run with --client-secret once keychain is available.`, 102: ) 103: } 104: } 105: cliOk(`XAA IdP connection configured for ${options.issuer}`) 106: }) 107: xaaIdp 108: .command('login') 109: .description( 110: 'Cache an IdP id_token so XAA-enabled MCP servers authenticate ' + 111: 'silently. Default: run the OIDC browser login. With --id-token: ' + 112: 'write a pre-obtained JWT directly (used by conformance/e2e tests ' + 113: 'where the mock IdP does not serve /authorize).', 114: ) 115: .option( 116: '--force', 117: 'Ignore any cached id_token and re-login (useful after IdP-side revocation)', 118: ) 119: .option( 120: '--id-token <jwt>', 121: 'Write this pre-obtained id_token directly to cache, skipping the OIDC browser login', 122: ) 123: .action(async options => { 124: const idp = getXaaIdpSettings() 125: if (!idp) { 126: return cliError( 127: "Error: no XAA IdP connection. Run 'claude mcp xaa setup' first.", 128: ) 129: } 130: if (options.idToken) { 131: const expiresAt = saveIdpIdTokenFromJwt(idp.issuer, options.idToken) 132: return cliOk( 133: `id_token cached for ${idp.issuer} (expires ${new Date(expiresAt).toISOString()})`, 134: ) 135: } 136: if (options.force) { 137: clearIdpIdToken(idp.issuer) 138: } 139: const wasCached = getCachedIdpIdToken(idp.issuer) !== undefined 140: if (wasCached) { 141: return cliOk( 142: `Already logged in to ${idp.issuer} (cached id_token still valid). Use --force to re-login.`, 143: ) 144: } 145: process.stdout.write(`Opening browser for IdP login at ${idp.issuer}…\n`) 146: try { 147: await acquireIdpIdToken({ 148: idpIssuer: idp.issuer, 149: idpClientId: idp.clientId, 150: idpClientSecret: getIdpClientSecret(idp.issuer), 151: callbackPort: idp.callbackPort, 152: onAuthorizationUrl: url => { 153: process.stdout.write( 154: `If the browser did not open, visit:\n ${url}\n`, 155: ) 156: }, 157: }) 158: cliOk( 159: `Logged in. MCP servers with --xaa will now authenticate silently.`, 160: ) 161: } catch (e) { 162: cliError(`IdP login failed: ${errorMessage(e)}`) 163: } 164: }) 165: xaaIdp 166: .command('show') 167: .description('Show the current IdP connection config') 168: .action(() => { 169: const idp = getXaaIdpSettings() 170: if (!idp) { 171: return cliOk('No XAA IdP connection configured.') 172: } 173: const hasSecret = getIdpClientSecret(idp.issuer) !== undefined 174: const hasIdToken = getCachedIdpIdToken(idp.issuer) !== undefined 175: process.stdout.write(`Issuer: ${idp.issuer}\n`) 176: process.stdout.write(`Client ID: ${idp.clientId}\n`) 177: if (idp.callbackPort !== undefined) { 178: process.stdout.write(`Callback port: ${idp.callbackPort}\n`) 179: } 180: process.stdout.write( 181: `Client secret: ${hasSecret ? '(stored in keychain)' : '(not set — PKCE-only)'}\n`, 182: ) 183: process.stdout.write( 184: `Logged in: ${hasIdToken ? 'yes (id_token cached)' : "no — run 'claude mcp xaa login'"}\n`, 185: ) 186: cliOk() 187: }) 188: xaaIdp 189: .command('clear') 190: .description('Clear the IdP connection config and cached id_token') 191: .action(() => { 192: const idp = getXaaIdpSettings() 193: const { error } = updateSettingsForSource('userSettings', { 194: xaaIdp: undefined, 195: }) 196: if (error) { 197: return cliError(`Error writing settings: ${error.message}`) 198: } 199: if (idp) { 200: clearIdpIdToken(idp.issuer) 201: clearIdpClientSecret(idp.issuer) 202: } 203: cliOk('XAA IdP connection cleared') 204: }) 205: }

File: src/commands/memory/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const memory: Command = { 3: type: 'local-jsx', 4: name: 'memory', 5: description: 'Edit Claude memory files', 6: load: () => import('./memory.js'), 7: } 8: export default memory

File: src/commands/memory/memory.tsx

typescript 1: import { mkdir, writeFile } from 'fs/promises'; 2: import * as React from 'react'; 3: import type { CommandResultDisplay } from '../../commands.js'; 4: import { Dialog } from '../../components/design-system/Dialog.js'; 5: import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js'; 6: import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js'; 7: import { Box, Link, Text } from '../../ink.js'; 8: import type { LocalJSXCommandCall } from '../../types/command.js'; 9: import { clearMemoryFileCaches, getMemoryFiles } from '../../utils/claudemd.js'; 10: import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'; 11: import { getErrnoCode } from '../../utils/errors.js'; 12: import { logError } from '../../utils/log.js'; 13: import { editFileInEditor } from '../../utils/promptEditor.js'; 14: function MemoryCommand({ 15: onDone 16: }: { 17: onDone: (result?: string, options?: { 18: display?: CommandResultDisplay; 19: }) => void; 20: }): React.ReactNode { 21: const handleSelectMemoryFile = async (memoryPath: string) => { 22: try { 23: if (memoryPath.includes(getClaudeConfigHomeDir())) { 24: await mkdir(getClaudeConfigHomeDir(), { 25: recursive: true 26: }); 27: } 28: try { 29: await writeFile(memoryPath, '', { 30: encoding: 'utf8', 31: flag: 'wx' 32: }); 33: } catch (e: unknown) { 34: if (getErrnoCode(e) !== 'EEXIST') { 35: throw e; 36: } 37: } 38: await editFileInEditor(memoryPath); 39: let editorSource = 'default'; 40: let editorValue = ''; 41: if (process.env.VISUAL) { 42: editorSource = '$VISUAL'; 43: editorValue = process.env.VISUAL; 44: } else if (process.env.EDITOR) { 45: editorSource = '$EDITOR'; 46: editorValue = process.env.EDITOR; 47: } 48: const editorInfo = editorSource !== 'default' ? `Using ${editorSource}="${editorValue}".` : ''; 49: const editorHint = editorInfo ? `> ${editorInfo} To change editor, set $EDITOR or $VISUAL environment variable.` : `> To use a different editor, set the $EDITOR or $VISUAL environment variable.`; 50: onDone(`Opened memory file at ${getRelativeMemoryPath(memoryPath)}\n\n${editorHint}`, { 51: display: 'system' 52: }); 53: } catch (error) { 54: logError(error); 55: onDone(`Error opening memory file: ${error}`); 56: } 57: }; 58: const handleCancel = () => { 59: onDone('Cancelled memory editing', { 60: display: 'system' 61: }); 62: }; 63: return <Dialog title="Memory" onCancel={handleCancel} color="remember"> 64: <Box flexDirection="column"> 65: <React.Suspense fallback={null}> 66: <MemoryFileSelector onSelect={handleSelectMemoryFile} onCancel={handleCancel} /> 67: </React.Suspense> 68: <Box marginTop={1}> 69: <Text dimColor> 70: Learn more: <Link url="https://code.claude.com/docs/en/memory" /> 71: </Text> 72: </Box> 73: </Box> 74: </Dialog>; 75: } 76: export const call: LocalJSXCommandCall = async onDone => { 77: clearMemoryFileCaches(); 78: await getMemoryFiles(); 79: return <MemoryCommand onDone={onDone} />; 80: };

File: src/commands/mobile/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const mobile = { 3: type: 'local-jsx', 4: name: 'mobile', 5: aliases: ['ios', 'android'], 6: description: 'Show QR code to download the Claude mobile app', 7: load: () => import('./mobile.js'), 8: } satisfies Command 9: export default mobile

File: src/commands/mobile/mobile.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { toString as qrToString } from 'qrcode'; 3: import * as React from 'react'; 4: import { useCallback, useEffect, useState } from 'react'; 5: import { Pane } from '../../components/design-system/Pane.js'; 6: import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; 7: import { Box, Text } from '../../ink.js'; 8: import { useKeybinding } from '../../keybindings/useKeybinding.js'; 9: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 10: type Platform = 'ios' | 'android'; 11: type Props = { 12: onDone: () => void; 13: }; 14: const PLATFORMS: Record<Platform, { 15: url: string; 16: }> = { 17: ios: { 18: url: 'https://apps.apple.com/app/claude-by-anthropic/id6473753684' 19: }, 20: android: { 21: url: 'https://play.google.com/store/apps/details?id=com.anthropic.claude' 22: } 23: }; 24: function MobileQRCode(t0) { 25: const $ = _c(52); 26: const { 27: onDone 28: } = t0; 29: const [platform, setPlatform] = useState("ios"); 30: let t1; 31: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 32: t1 = { 33: ios: "", 34: android: "" 35: }; 36: $[0] = t1; 37: } else { 38: t1 = $[0]; 39: } 40: const [qrCodes, setQrCodes] = useState(t1); 41: const { 42: url 43: } = PLATFORMS[platform]; 44: const qrCode = qrCodes[platform]; 45: let t2; 46: let t3; 47: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 48: t2 = () => { 49: const generateQRCodes = async function generateQRCodes() { 50: const [ios, android] = await Promise.all([qrToString(PLATFORMS.ios.url, { 51: type: "utf8", 52: errorCorrectionLevel: "L" 53: }), qrToString(PLATFORMS.android.url, { 54: type: "utf8", 55: errorCorrectionLevel: "L" 56: })]); 57: setQrCodes({ 58: ios, 59: android 60: }); 61: }; 62: generateQRCodes().catch(_temp); 63: }; 64: t3 = []; 65: $[1] = t2; 66: $[2] = t3; 67: } else { 68: t2 = $[1]; 69: t3 = $[2]; 70: } 71: useEffect(t2, t3); 72: let t4; 73: if ($[3] !== onDone) { 74: t4 = () => { 75: onDone(); 76: }; 77: $[3] = onDone; 78: $[4] = t4; 79: } else { 80: t4 = $[4]; 81: } 82: const handleClose = t4; 83: let t5; 84: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 85: t5 = { 86: context: "Confirmation" 87: }; 88: $[5] = t5; 89: } else { 90: t5 = $[5]; 91: } 92: useKeybinding("confirm:no", handleClose, t5); 93: let t6; 94: if ($[6] !== onDone) { 95: t6 = function handleKeyDown(e) { 96: if (e.key === "q" || e.ctrl && e.key === "c") { 97: e.preventDefault(); 98: onDone(); 99: return; 100: } 101: if (e.key === "tab" || e.key === "left" || e.key === "right") { 102: e.preventDefault(); 103: setPlatform(_temp2); 104: } 105: }; 106: $[6] = onDone; 107: $[7] = t6; 108: } else { 109: t6 = $[7]; 110: } 111: const handleKeyDown = t6; 112: let T0; 113: let T1; 114: let t10; 115: let t11; 116: let t12; 117: let t13; 118: let t7; 119: let t8; 120: let t9; 121: if ($[8] !== handleKeyDown || $[9] !== qrCode) { 122: const lines = qrCode.split("\n").filter(_temp3); 123: T1 = Pane; 124: T0 = Box; 125: t7 = "column"; 126: t8 = 0; 127: t9 = true; 128: t10 = handleKeyDown; 129: if ($[19] === Symbol.for("react.memo_cache_sentinel")) { 130: t11 = <Text> </Text>; 131: t12 = <Text> </Text>; 132: $[19] = t11; 133: $[20] = t12; 134: } else { 135: t11 = $[19]; 136: t12 = $[20]; 137: } 138: t13 = lines.map(_temp4); 139: $[8] = handleKeyDown; 140: $[9] = qrCode; 141: $[10] = T0; 142: $[11] = T1; 143: $[12] = t10; 144: $[13] = t11; 145: $[14] = t12; 146: $[15] = t13; 147: $[16] = t7; 148: $[17] = t8; 149: $[18] = t9; 150: } else { 151: T0 = $[10]; 152: T1 = $[11]; 153: t10 = $[12]; 154: t11 = $[13]; 155: t12 = $[14]; 156: t13 = $[15]; 157: t7 = $[16]; 158: t8 = $[17]; 159: t9 = $[18]; 160: } 161: let t14; 162: let t15; 163: if ($[21] === Symbol.for("react.memo_cache_sentinel")) { 164: t14 = <Text> </Text>; 165: t15 = <Text> </Text>; 166: $[21] = t14; 167: $[22] = t15; 168: } else { 169: t14 = $[21]; 170: t15 = $[22]; 171: } 172: const t16 = platform === "ios"; 173: const t17 = platform === "ios"; 174: let t18; 175: if ($[23] !== t16 || $[24] !== t17) { 176: t18 = <Text bold={t16} underline={t17}>iOS</Text>; 177: $[23] = t16; 178: $[24] = t17; 179: $[25] = t18; 180: } else { 181: t18 = $[25]; 182: } 183: let t19; 184: if ($[26] === Symbol.for("react.memo_cache_sentinel")) { 185: t19 = <Text dimColor={true}>{" / "}</Text>; 186: $[26] = t19; 187: } else { 188: t19 = $[26]; 189: } 190: const t20 = platform === "android"; 191: const t21 = platform === "android"; 192: let t22; 193: if ($[27] !== t20 || $[28] !== t21) { 194: t22 = <Text bold={t20} underline={t21}>Android</Text>; 195: $[27] = t20; 196: $[28] = t21; 197: $[29] = t22; 198: } else { 199: t22 = $[29]; 200: } 201: let t23; 202: if ($[30] !== t18 || $[31] !== t22) { 203: t23 = <Text>{t18}{t19}{t22}</Text>; 204: $[30] = t18; 205: $[31] = t22; 206: $[32] = t23; 207: } else { 208: t23 = $[32]; 209: } 210: let t24; 211: if ($[33] === Symbol.for("react.memo_cache_sentinel")) { 212: t24 = <Text dimColor={true}>(tab to switch, esc to close)</Text>; 213: $[33] = t24; 214: } else { 215: t24 = $[33]; 216: } 217: let t25; 218: if ($[34] !== t23) { 219: t25 = <Box flexDirection="row" gap={2}>{t23}{t24}</Box>; 220: $[34] = t23; 221: $[35] = t25; 222: } else { 223: t25 = $[35]; 224: } 225: let t26; 226: if ($[36] !== url) { 227: t26 = <Text dimColor={true}>{url}</Text>; 228: $[36] = url; 229: $[37] = t26; 230: } else { 231: t26 = $[37]; 232: } 233: let t27; 234: if ($[38] !== T0 || $[39] !== t10 || $[40] !== t11 || $[41] !== t12 || $[42] !== t13 || $[43] !== t25 || $[44] !== t26 || $[45] !== t7 || $[46] !== t8 || $[47] !== t9) { 235: t27 = <T0 flexDirection={t7} tabIndex={t8} autoFocus={t9} onKeyDown={t10}>{t11}{t12}{t13}{t14}{t15}{t25}{t26}</T0>; 236: $[38] = T0; 237: $[39] = t10; 238: $[40] = t11; 239: $[41] = t12; 240: $[42] = t13; 241: $[43] = t25; 242: $[44] = t26; 243: $[45] = t7; 244: $[46] = t8; 245: $[47] = t9; 246: $[48] = t27; 247: } else { 248: t27 = $[48]; 249: } 250: let t28; 251: if ($[49] !== T1 || $[50] !== t27) { 252: t28 = <T1>{t27}</T1>; 253: $[49] = T1; 254: $[50] = t27; 255: $[51] = t28; 256: } else { 257: t28 = $[51]; 258: } 259: return t28; 260: } 261: function _temp4(line_0, i) { 262: return <Text key={i}>{line_0}</Text>; 263: } 264: function _temp3(line) { 265: return line.length > 0; 266: } 267: function _temp2(prev) { 268: return prev === "ios" ? "android" : "ios"; 269: } 270: function _temp() {} 271: export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> { 272: return <MobileQRCode onDone={onDone} />; 273: }

File: src/commands/mock-limits/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/model/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js' 3: import { getMainLoopModel, renderModelName } from '../../utils/model/model.js' 4: export default { 5: type: 'local-jsx', 6: name: 'model', 7: get description() { 8: return `Set the AI model for Claude Code (currently ${renderModelName(getMainLoopModel())})` 9: }, 10: argumentHint: '[model]', 11: get immediate() { 12: return shouldInferenceConfigCommandBeImmediate() 13: }, 14: load: () => import('./model.js'), 15: } satisfies Command

File: src/commands/model/model.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import chalk from 'chalk'; 3: import * as React from 'react'; 4: import type { CommandResultDisplay } from '../../commands.js'; 5: import { ModelPicker } from '../../components/ModelPicker.js'; 6: import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'; 7: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; 8: import { useAppState, useSetAppState } from '../../state/AppState.js'; 9: import type { LocalJSXCommandCall } from '../../types/command.js'; 10: import type { EffortLevel } from '../../utils/effort.js'; 11: import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'; 12: import { clearFastModeCooldown, isFastModeAvailable, isFastModeEnabled, isFastModeSupportedByModel } from '../../utils/fastMode.js'; 13: import { MODEL_ALIASES } from '../../utils/model/aliases.js'; 14: import { checkOpus1mAccess, checkSonnet1mAccess } from '../../utils/model/check1mAccess.js'; 15: import { getDefaultMainLoopModelSetting, isOpus1mMergeEnabled, renderDefaultModelSetting } from '../../utils/model/model.js'; 16: import { isModelAllowed } from '../../utils/model/modelAllowlist.js'; 17: import { validateModel } from '../../utils/model/validateModel.js'; 18: function ModelPickerWrapper(t0) { 19: const $ = _c(17); 20: const { 21: onDone 22: } = t0; 23: const mainLoopModel = useAppState(_temp); 24: const mainLoopModelForSession = useAppState(_temp2); 25: const isFastMode = useAppState(_temp3); 26: const setAppState = useSetAppState(); 27: let t1; 28: if ($[0] !== mainLoopModel || $[1] !== onDone) { 29: t1 = function handleCancel() { 30: logEvent("tengu_model_command_menu", { 31: action: "cancel" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 32: }); 33: const displayModel = renderModelLabel(mainLoopModel); 34: onDone(`Kept model as ${chalk.bold(displayModel)}`, { 35: display: "system" 36: }); 37: }; 38: $[0] = mainLoopModel; 39: $[1] = onDone; 40: $[2] = t1; 41: } else { 42: t1 = $[2]; 43: } 44: const handleCancel = t1; 45: let t2; 46: if ($[3] !== isFastMode || $[4] !== mainLoopModel || $[5] !== onDone || $[6] !== setAppState) { 47: t2 = function handleSelect(model, effort) { 48: logEvent("tengu_model_command_menu", { 49: action: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 50: from_model: mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 51: to_model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 52: }); 53: setAppState(prev => ({ 54: ...prev, 55: mainLoopModel: model, 56: mainLoopModelForSession: null 57: })); 58: let message = `Set model to ${chalk.bold(renderModelLabel(model))}`; 59: if (effort !== undefined) { 60: message = message + ` with ${chalk.bold(effort)} effort`; 61: } 62: let wasFastModeToggledOn = undefined; 63: if (isFastModeEnabled()) { 64: clearFastModeCooldown(); 65: if (!isFastModeSupportedByModel(model) && isFastMode) { 66: setAppState(_temp4); 67: wasFastModeToggledOn = false; 68: } else { 69: if (isFastModeSupportedByModel(model) && isFastModeAvailable() && isFastMode) { 70: message = message + " \xB7 Fast mode ON"; 71: wasFastModeToggledOn = true; 72: } 73: } 74: } 75: if (isBilledAsExtraUsage(model, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) { 76: message = message + " \xB7 Billed as extra usage"; 77: } 78: if (wasFastModeToggledOn === false) { 79: message = message + " \xB7 Fast mode OFF"; 80: } 81: onDone(message); 82: }; 83: $[3] = isFastMode; 84: $[4] = mainLoopModel; 85: $[5] = onDone; 86: $[6] = setAppState; 87: $[7] = t2; 88: } else { 89: t2 = $[7]; 90: } 91: const handleSelect = t2; 92: let t3; 93: if ($[8] !== isFastMode || $[9] !== mainLoopModel) { 94: t3 = isFastModeEnabled() && isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable(); 95: $[8] = isFastMode; 96: $[9] = mainLoopModel; 97: $[10] = t3; 98: } else { 99: t3 = $[10]; 100: } 101: let t4; 102: if ($[11] !== handleCancel || $[12] !== handleSelect || $[13] !== mainLoopModel || $[14] !== mainLoopModelForSession || $[15] !== t3) { 103: t4 = <ModelPicker initial={mainLoopModel} sessionModel={mainLoopModelForSession} onSelect={handleSelect} onCancel={handleCancel} isStandaloneCommand={true} showFastModeNotice={t3} />; 104: $[11] = handleCancel; 105: $[12] = handleSelect; 106: $[13] = mainLoopModel; 107: $[14] = mainLoopModelForSession; 108: $[15] = t3; 109: $[16] = t4; 110: } else { 111: t4 = $[16]; 112: } 113: return t4; 114: } 115: function _temp4(prev_0) { 116: return { 117: ...prev_0, 118: fastMode: false 119: }; 120: } 121: function _temp3(s_1) { 122: return s_1.fastMode; 123: } 124: function _temp2(s_0) { 125: return s_0.mainLoopModelForSession; 126: } 127: function _temp(s) { 128: return s.mainLoopModel; 129: } 130: function SetModelAndClose({ 131: args, 132: onDone 133: }: { 134: args: string; 135: onDone: (result?: string, options?: { 136: display?: CommandResultDisplay; 137: }) => void; 138: }): React.ReactNode { 139: const isFastMode = useAppState(s => s.fastMode); 140: const setAppState = useSetAppState(); 141: const model = args === 'default' ? null : args; 142: React.useEffect(() => { 143: async function handleModelChange(): Promise<void> { 144: if (model && !isModelAllowed(model)) { 145: onDone(`Model '${model}' is not available. Your organization restricts model selection.`, { 146: display: 'system' 147: }); 148: return; 149: } 150: if (model && isOpus1mUnavailable(model)) { 151: onDone(`Opus 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`, { 152: display: 'system' 153: }); 154: return; 155: } 156: if (model && isSonnet1mUnavailable(model)) { 157: onDone(`Sonnet 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`, { 158: display: 'system' 159: }); 160: return; 161: } 162: if (!model) { 163: setModel(null); 164: return; 165: } 166: if (isKnownAlias(model)) { 167: setModel(model); 168: return; 169: } 170: try { 171: const { 172: valid, 173: error: error_0 174: } = await validateModel(model); 175: if (valid) { 176: setModel(model); 177: } else { 178: onDone(error_0 || `Model '${model}' not found`, { 179: display: 'system' 180: }); 181: } 182: } catch (error) { 183: onDone(`Failed to validate model: ${(error as Error).message}`, { 184: display: 'system' 185: }); 186: } 187: } 188: function setModel(modelValue: string | null): void { 189: setAppState(prev => ({ 190: ...prev, 191: mainLoopModel: modelValue, 192: mainLoopModelForSession: null 193: })); 194: let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`; 195: let wasFastModeToggledOn = undefined; 196: if (isFastModeEnabled()) { 197: clearFastModeCooldown(); 198: if (!isFastModeSupportedByModel(modelValue) && isFastMode) { 199: setAppState(prev_0 => ({ 200: ...prev_0, 201: fastMode: false 202: })); 203: wasFastModeToggledOn = false; 204: } else if (isFastModeSupportedByModel(modelValue) && isFastMode) { 205: message += ` · Fast mode ON`; 206: wasFastModeToggledOn = true; 207: } 208: } 209: if (isBilledAsExtraUsage(modelValue, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) { 210: message += ` · Billed as extra usage`; 211: } 212: if (wasFastModeToggledOn === false) { 213: message += ` · Fast mode OFF`; 214: } 215: onDone(message); 216: } 217: void handleModelChange(); 218: }, [model, onDone, setAppState]); 219: return null; 220: } 221: function isKnownAlias(model: string): boolean { 222: return (MODEL_ALIASES as readonly string[]).includes(model.toLowerCase().trim()); 223: } 224: function isOpus1mUnavailable(model: string): boolean { 225: const m = model.toLowerCase(); 226: return !checkOpus1mAccess() && !isOpus1mMergeEnabled() && m.includes('opus') && m.includes('[1m]'); 227: } 228: function isSonnet1mUnavailable(model: string): boolean { 229: const m = model.toLowerCase(); 230: return !checkSonnet1mAccess() && (m.includes('sonnet[1m]') || m.includes('sonnet-4-6[1m]')); 231: } 232: function ShowModelAndClose(t0) { 233: const { 234: onDone 235: } = t0; 236: const mainLoopModel = useAppState(_temp7); 237: const mainLoopModelForSession = useAppState(_temp8); 238: const effortValue = useAppState(_temp9); 239: const displayModel = renderModelLabel(mainLoopModel); 240: const effortInfo = effortValue !== undefined ? ` (effort: ${effortValue})` : ""; 241: if (mainLoopModelForSession) { 242: onDone(`Current model: ${chalk.bold(renderModelLabel(mainLoopModelForSession))} (session override from plan mode)\nBase model: ${displayModel}${effortInfo}`); 243: } else { 244: onDone(`Current model: ${displayModel}${effortInfo}`); 245: } 246: return null; 247: } 248: function _temp9(s_1) { 249: return s_1.effortValue; 250: } 251: function _temp8(s_0) { 252: return s_0.mainLoopModelForSession; 253: } 254: function _temp7(s) { 255: return s.mainLoopModel; 256: } 257: export const call: LocalJSXCommandCall = async (onDone, _context, args) => { 258: args = args?.trim() || ''; 259: if (COMMON_INFO_ARGS.includes(args)) { 260: logEvent('tengu_model_command_inline_help', { 261: args: args as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 262: }); 263: return <ShowModelAndClose onDone={onDone} />; 264: } 265: if (COMMON_HELP_ARGS.includes(args)) { 266: onDone('Run /model to open the model selection menu, or /model [modelName] to set the model.', { 267: display: 'system' 268: }); 269: return; 270: } 271: if (args) { 272: logEvent('tengu_model_command_inline', { 273: args: args as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 274: }); 275: return <SetModelAndClose args={args} onDone={onDone} />; 276: } 277: return <ModelPickerWrapper onDone={onDone} />; 278: }; 279: function renderModelLabel(model: string | null): string { 280: const rendered = renderDefaultModelSetting(model ?? getDefaultMainLoopModelSetting()); 281: return model === null ? `${rendered} (default)` : rendered; 282: }

File: src/commands/oauth-refresh/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/onboarding/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/output-style/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const outputStyle = { 3: type: 'local-jsx', 4: name: 'output-style', 5: description: 'Deprecated: use /config to change output style', 6: isHidden: true, 7: load: () => import('./output-style.js'), 8: } satisfies Command 9: export default outputStyle

File: src/commands/output-style/output-style.tsx

typescript 1: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 2: export async function call(onDone: LocalJSXCommandOnDone): Promise<undefined> { 3: onDone('/output-style has been deprecated. Use /config to change your output style, or set it in your settings file. Changes take effect on the next session.', { 4: display: 'system' 5: }); 6: }

File: src/commands/passes/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { 3: checkCachedPassesEligibility, 4: getCachedReferrerReward, 5: } from '../../services/api/referral.js' 6: export default { 7: type: 'local-jsx', 8: name: 'passes', 9: get description() { 10: const reward = getCachedReferrerReward() 11: if (reward) { 12: return 'Share a free week of Claude Code with friends and earn extra usage' 13: } 14: return 'Share a free week of Claude Code with friends' 15: }, 16: get isHidden() { 17: const { eligible, hasCache } = checkCachedPassesEligibility() 18: return !eligible || !hasCache 19: }, 20: load: () => import('./passes.js'), 21: } satisfies Command

File: src/commands/passes/passes.tsx

typescript 1: import * as React from 'react'; 2: import { Passes } from '../../components/Passes/Passes.js'; 3: import { logEvent } from '../../services/analytics/index.js'; 4: import { getCachedRemainingPasses } from '../../services/api/referral.js'; 5: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 6: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; 7: export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> { 8: const config = getGlobalConfig(); 9: const isFirstVisit = !config.hasVisitedPasses; 10: if (isFirstVisit) { 11: const remaining = getCachedRemainingPasses(); 12: saveGlobalConfig(current => ({ 13: ...current, 14: hasVisitedPasses: true, 15: passesLastSeenRemaining: remaining ?? current.passesLastSeenRemaining 16: })); 17: } 18: logEvent('tengu_guest_passes_visited', { 19: is_first_visit: isFirstVisit 20: }); 21: return <Passes onDone={onDone} />; 22: }

File: src/commands/perf-issue/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/permissions/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const permissions = { 3: type: 'local-jsx', 4: name: 'permissions', 5: aliases: ['allowed-tools'], 6: description: 'Manage allow & deny tool permission rules', 7: load: () => import('./permissions.js'), 8: } satisfies Command 9: export default permissions

File: src/commands/permissions/permissions.tsx

typescript 1: import * as React from 'react'; 2: import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js'; 3: import type { LocalJSXCommandCall } from '../../types/command.js'; 4: import { createPermissionRetryMessage } from '../../utils/messages.js'; 5: export const call: LocalJSXCommandCall = async (onDone, context) => { 6: return <PermissionRuleList onExit={onDone} onRetryDenials={commands => { 7: context.setMessages(prev => [...prev, createPermissionRetryMessage(commands)]); 8: }} />; 9: };

File: src/commands/plan/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const plan = { 3: type: 'local-jsx', 4: name: 'plan', 5: description: 'Enable plan mode or view the current session plan', 6: argumentHint: '[open|<description>]', 7: load: () => import('./plan.js'), 8: } satisfies Command 9: export default plan

File: src/commands/plan/plan.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import * as React from 'react'; 3: import { handlePlanModeTransition } from '../../bootstrap/state.js'; 4: import type { LocalJSXCommandContext } from '../../commands.js'; 5: import { Box, Text } from '../../ink.js'; 6: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 7: import { getExternalEditor } from '../../utils/editor.js'; 8: import { toIDEDisplayName } from '../../utils/ide.js'; 9: import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js'; 10: import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js'; 11: import { getPlan, getPlanFilePath } from '../../utils/plans.js'; 12: import { editFileInEditor } from '../../utils/promptEditor.js'; 13: import { renderToString } from '../../utils/staticRender.js'; 14: function PlanDisplay(t0) { 15: const $ = _c(11); 16: const { 17: planContent, 18: planPath, 19: editorName 20: } = t0; 21: let t1; 22: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 23: t1 = <Text bold={true}>Current Plan</Text>; 24: $[0] = t1; 25: } else { 26: t1 = $[0]; 27: } 28: let t2; 29: if ($[1] !== planPath) { 30: t2 = <Text dimColor={true}>{planPath}</Text>; 31: $[1] = planPath; 32: $[2] = t2; 33: } else { 34: t2 = $[2]; 35: } 36: let t3; 37: if ($[3] !== planContent) { 38: t3 = <Box marginTop={1}><Text>{planContent}</Text></Box>; 39: $[3] = planContent; 40: $[4] = t3; 41: } else { 42: t3 = $[4]; 43: } 44: let t4; 45: if ($[5] !== editorName) { 46: t4 = editorName && <Box marginTop={1}><Text dimColor={true}>"/plan open"</Text><Text dimColor={true}> to edit this plan in </Text><Text bold={true} dimColor={true}>{editorName}</Text></Box>; 47: $[5] = editorName; 48: $[6] = t4; 49: } else { 50: t4 = $[6]; 51: } 52: let t5; 53: if ($[7] !== t2 || $[8] !== t3 || $[9] !== t4) { 54: t5 = <Box flexDirection="column">{t1}{t2}{t3}{t4}</Box>; 55: $[7] = t2; 56: $[8] = t3; 57: $[9] = t4; 58: $[10] = t5; 59: } else { 60: t5 = $[10]; 61: } 62: return t5; 63: } 64: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext, args: string): Promise<React.ReactNode> { 65: const { 66: getAppState, 67: setAppState 68: } = context; 69: const appState = getAppState(); 70: const currentMode = appState.toolPermissionContext.mode; 71: if (currentMode !== 'plan') { 72: handlePlanModeTransition(currentMode, 'plan'); 73: setAppState(prev => ({ 74: ...prev, 75: toolPermissionContext: applyPermissionUpdate(prepareContextForPlanMode(prev.toolPermissionContext), { 76: type: 'setMode', 77: mode: 'plan', 78: destination: 'session' 79: }) 80: })); 81: const description = args.trim(); 82: if (description && description !== 'open') { 83: onDone('Enabled plan mode', { 84: shouldQuery: true 85: }); 86: } else { 87: onDone('Enabled plan mode'); 88: } 89: return null; 90: } 91: const planContent = getPlan(); 92: const planPath = getPlanFilePath(); 93: if (!planContent) { 94: onDone('Already in plan mode. No plan written yet.'); 95: return null; 96: } 97: const argList = args.trim().split(/\s+/); 98: if (argList[0] === 'open') { 99: const result = await editFileInEditor(planPath); 100: if (result.error) { 101: onDone(`Failed to open plan in editor: ${result.error}`); 102: } else { 103: onDone(`Opened plan in editor: ${planPath}`); 104: } 105: return null; 106: } 107: const editor = getExternalEditor(); 108: const editorName = editor ? toIDEDisplayName(editor) : undefined; 109: const display = <PlanDisplay planContent={planContent} planPath={planPath} editorName={editorName} />; 110: const output = await renderToString(display); 111: onDone(output); 112: return null; 113: }

File: src/commands/plugin/AddMarketplace.tsx

typescript 1: import * as React from 'react'; 2: import { useEffect, useRef, useState } from 'react'; 3: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; 4: import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; 5: import { Byline } from '../../components/design-system/Byline.js'; 6: import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; 7: import { Spinner } from '../../components/Spinner.js'; 8: import TextInput from '../../components/TextInput.js'; 9: import { Box, Text } from '../../ink.js'; 10: import { toError } from '../../utils/errors.js'; 11: import { logError } from '../../utils/log.js'; 12: import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; 13: import { addMarketplaceSource, saveMarketplaceToSettings } from '../../utils/plugins/marketplaceManager.js'; 14: import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js'; 15: import type { ViewState } from './types.js'; 16: type Props = { 17: inputValue: string; 18: setInputValue: (value: string) => void; 19: cursorOffset: number; 20: setCursorOffset: (offset: number) => void; 21: error: string | null; 22: setError: (error: string | null) => void; 23: result: string | null; 24: setResult: (result: string | null) => void; 25: setViewState: (state: ViewState) => void; 26: onAddComplete?: () => void | Promise<void>; 27: cliMode?: boolean; 28: }; 29: export function AddMarketplace({ 30: inputValue, 31: setInputValue, 32: cursorOffset, 33: setCursorOffset, 34: error, 35: setError, 36: result, 37: setResult, 38: setViewState, 39: onAddComplete, 40: cliMode = false 41: }: Props): React.ReactNode { 42: const hasAttemptedAutoAdd = useRef(false); 43: const [isLoading, setLoading] = useState(false); 44: const [progressMessage, setProgressMessage] = useState<string>(''); 45: const handleAdd = async () => { 46: const input = inputValue.trim(); 47: if (!input) { 48: setError('Please enter a marketplace source'); 49: return; 50: } 51: const parsed = await parseMarketplaceInput(input); 52: if (!parsed) { 53: setError('Invalid marketplace source format. Try: owner/repo, https://..., or ./path'); 54: return; 55: } 56: if ('error' in parsed) { 57: setError(parsed.error); 58: return; 59: } 60: setError(null); 61: try { 62: setLoading(true); 63: setProgressMessage(''); 64: const { 65: name, 66: resolvedSource 67: } = await addMarketplaceSource(parsed, message => { 68: setProgressMessage(message); 69: }); 70: saveMarketplaceToSettings(name, { 71: source: resolvedSource 72: }); 73: clearAllCaches(); 74: let sourceType = parsed.source; 75: if (parsed.source === 'github') { 76: sourceType = parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; 77: } 78: logEvent('tengu_marketplace_added', { 79: source_type: sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 80: }); 81: if (onAddComplete) { 82: await onAddComplete(); 83: } 84: setProgressMessage(''); 85: setLoading(false); 86: if (cliMode) { 87: // In CLI mode, set result to trigger completion 88: setResult(`Successfully added marketplace: ${name}`); 89: } else { 90: // In interactive mode, switch to browse view 91: setViewState({ 92: type: 'browse-marketplace', 93: targetMarketplace: name 94: }); 95: } 96: } catch (err) { 97: const error = toError(err); 98: logError(error); 99: setError(error.message); 100: setProgressMessage(''); 101: setLoading(false); 102: if (cliMode) { 103: // In CLI mode, set result with error to trigger completion 104: setResult(`Error: ${error.message}`); 105: } else { 106: setResult(null); 107: } 108: } 109: }; 110: // Auto-add if inputValue is provided 111: useEffect(() => { 112: if (inputValue && !hasAttemptedAutoAdd.current && !error && !result) { 113: hasAttemptedAutoAdd.current = true; 114: void handleAdd(); 115: } 116: // eslint-disable-next-line react-hooks/exhaustive-deps 117: // biome-ignore lint/correctness/useExhaustiveDependencies: intentional 118: }, []); // Only run once on mount 119: return <Box flexDirection="column"> 120: <Box flexDirection="column" paddingX={1} borderStyle="round"> 121: <Box marginBottom={1}> 122: <Text bold>Add Marketplace</Text> 123: </Box> 124: <Box flexDirection="column"> 125: <Text>Enter marketplace source:</Text> 126: <Text dimColor>Examples:</Text> 127: <Text dimColor> · owner/repo (GitHub)</Text> 128: <Text dimColor> · git@github.com:owner/repo.git (SSH)</Text> 129: <Text dimColor> · https://example.com/marketplace.json</Text> 130: <Text dimColor> · ./path/to/marketplace</Text> 131: <Box marginTop={1}> 132: <TextInput value={inputValue} onChange={setInputValue} onSubmit={handleAdd} columns={80} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} focus showCursor /> 133: </Box> 134: </Box> 135: {isLoading && <Box marginTop={1}> 136: <Spinner /> 137: <Text> 138: {progressMessage || 'Adding marketplace to configuration…'} 139: </Text> 140: </Box>} 141: {error && <Box marginTop={1}> 142: <Text color="error">{error}</Text> 143: </Box>} 144: {result && <Box marginTop={1}> 145: <Text>{result}</Text> 146: </Box>} 147: </Box> 148: <Box marginLeft={3}> 149: <Text dimColor italic> 150: <Byline> 151: <KeyboardShortcutHint shortcut="Enter" action="add" /> 152: <ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="cancel" /> 153: </Byline> 154: </Text> 155: </Box> 156: </Box>; 157: }

File: src/commands/plugin/BrowseMarketplace.tsx

typescript 1: import figures from 'figures'; 2: import * as React from 'react'; 3: import { useEffect, useState } from 'react'; 4: import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; 5: import { Byline } from '../../components/design-system/Byline.js'; 6: import { Box, Text } from '../../ink.js'; 7: import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; 8: import type { LoadedPlugin } from '../../types/plugin.js'; 9: import { count } from '../../utils/array.js'; 10: import { openBrowser } from '../../utils/browser.js'; 11: import { logForDebugging } from '../../utils/debug.js'; 12: import { errorMessage } from '../../utils/errors.js'; 13: import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; 14: import { formatInstallCount, getInstallCounts } from '../../utils/plugins/installCounts.js'; 15: import { isPluginGloballyInstalled, isPluginInstalled } from '../../utils/plugins/installedPluginsManager.js'; 16: import { createPluginId, formatFailureDetails, formatMarketplaceLoadingErrors, getMarketplaceSourceDisplay, loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js'; 17: import { getMarketplace, loadKnownMarketplacesConfig } from '../../utils/plugins/marketplaceManager.js'; 18: import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js'; 19: import { installPluginFromMarketplace } from '../../utils/plugins/pluginInstallationHelpers.js'; 20: import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js'; 21: import { plural } from '../../utils/stringUtils.js'; 22: import { truncateToWidth } from '../../utils/truncate.js'; 23: import { findPluginOptionsTarget, PluginOptionsFlow } from './PluginOptionsFlow.js'; 24: import { PluginTrustWarning } from './PluginTrustWarning.js'; 25: import { buildPluginDetailsMenuOptions, extractGitHubRepo, type InstallablePlugin, PluginSelectionKeyHint } from './pluginDetailsHelpers.js'; 26: import type { ViewState as ParentViewState } from './types.js'; 27: import { usePagination } from './usePagination.js'; 28: type Props = { 29: error: string | null; 30: setError: (error: string | null) => void; 31: result: string | null; 32: setResult: (result: string | null) => void; 33: setViewState: (state: ParentViewState) => void; 34: onInstallComplete?: () => void | Promise<void>; 35: targetMarketplace?: string; 36: targetPlugin?: string; 37: }; 38: type ViewState = 'marketplace-list' | 'plugin-list' | 'plugin-details' | { 39: type: 'plugin-options'; 40: plugin: LoadedPlugin; 41: pluginId: string; 42: }; 43: type MarketplaceInfo = { 44: name: string; 45: totalPlugins: number; 46: installedCount: number; 47: source?: string; 48: }; 49: export function BrowseMarketplace({ 50: error, 51: setError, 52: result: _result, 53: setResult, 54: setViewState: setParentViewState, 55: onInstallComplete, 56: targetMarketplace, 57: targetPlugin 58: }: Props): React.ReactNode { 59: const [viewState, setViewState] = useState<ViewState>('marketplace-list'); 60: const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null); 61: const [selectedPlugin, setSelectedPlugin] = useState<InstallablePlugin | null>(null); 62: const [marketplaces, setMarketplaces] = useState<MarketplaceInfo[]>([]); 63: const [availablePlugins, setAvailablePlugins] = useState<InstallablePlugin[]>([]); 64: const [loading, setLoading] = useState(true); 65: const [installCounts, setInstallCounts] = useState<Map<string, number> | null>(null); 66: const [selectedIndex, setSelectedIndex] = useState(0); 67: const [selectedForInstall, setSelectedForInstall] = useState<Set<string>>(new Set()); 68: const [installingPlugins, setInstallingPlugins] = useState<Set<string>>(new Set()); 69: const pagination = usePagination<InstallablePlugin>({ 70: totalItems: availablePlugins.length, 71: selectedIndex 72: }); 73: const [detailsMenuIndex, setDetailsMenuIndex] = useState(0); 74: const [isInstalling, setIsInstalling] = useState(false); 75: const [installError, setInstallError] = useState<string | null>(null); 76: const [warning, setWarning] = useState<string | null>(null); 77: const handleBack = React.useCallback(() => { 78: if (viewState === 'plugin-list') { 79: if (targetMarketplace) { 80: setParentViewState({ 81: type: 'manage-marketplaces', 82: targetMarketplace 83: }); 84: } else if (marketplaces.length === 1) { 85: setParentViewState({ 86: type: 'menu' 87: }); 88: } else { 89: setViewState('marketplace-list'); 90: setSelectedMarketplace(null); 91: setSelectedForInstall(new Set()); 92: } 93: } else if (viewState === 'plugin-details') { 94: setViewState('plugin-list'); 95: setSelectedPlugin(null); 96: } else { 97: setParentViewState({ 98: type: 'menu' 99: }); 100: } 101: }, [viewState, targetMarketplace, setParentViewState, marketplaces.length]); 102: useKeybinding('confirm:no', handleBack, { 103: context: 'Confirmation' 104: }); 105: useEffect(() => { 106: async function loadMarketplaceData() { 107: try { 108: const config = await loadKnownMarketplacesConfig(); 109: const { 110: marketplaces: marketplaces_0, 111: failures 112: } = await loadMarketplacesWithGracefulDegradation(config); 113: const marketplaceInfos: MarketplaceInfo[] = []; 114: for (const { 115: name, 116: config: marketplaceConfig, 117: data: marketplace 118: } of marketplaces_0) { 119: if (marketplace) { 120: const installedFromThisMarketplace = count(marketplace.plugins, plugin => isPluginInstalled(createPluginId(plugin.name, name))); 121: marketplaceInfos.push({ 122: name, 123: totalPlugins: marketplace.plugins.length, 124: installedCount: installedFromThisMarketplace, 125: source: getMarketplaceSourceDisplay(marketplaceConfig.source) 126: }); 127: } 128: } 129: marketplaceInfos.sort((a, b) => { 130: if (a.name === 'claude-plugin-directory') return -1; 131: if (b.name === 'claude-plugin-directory') return 1; 132: return 0; 133: }); 134: setMarketplaces(marketplaceInfos); 135: const successCount = count(marketplaces_0, m => m.data !== null); 136: const errorResult = formatMarketplaceLoadingErrors(failures, successCount); 137: if (errorResult) { 138: if (errorResult.type === 'warning') { 139: setWarning(errorResult.message + '. Showing available marketplaces.'); 140: } else { 141: throw new Error(errorResult.message); 142: } 143: } 144: if (marketplaceInfos.length === 1 && !targetMarketplace && !targetPlugin) { 145: const singleMarketplace = marketplaceInfos[0]; 146: if (singleMarketplace) { 147: setSelectedMarketplace(singleMarketplace.name); 148: setViewState('plugin-list'); 149: } 150: } 151: if (targetPlugin) { 152: let foundPlugin: InstallablePlugin | null = null; 153: let foundMarketplace: string | null = null; 154: for (const [name_0] of Object.entries(config)) { 155: const marketplace_0 = await getMarketplace(name_0); 156: if (marketplace_0) { 157: const plugin_0 = marketplace_0.plugins.find(p => p.name === targetPlugin); 158: if (plugin_0) { 159: const pluginId = createPluginId(plugin_0.name, name_0); 160: foundPlugin = { 161: entry: plugin_0, 162: marketplaceName: name_0, 163: pluginId, 164: isInstalled: isPluginGloballyInstalled(pluginId) 165: }; 166: foundMarketplace = name_0; 167: break; 168: } 169: } 170: } 171: if (foundPlugin && foundMarketplace) { 172: const pluginId_0 = foundPlugin.pluginId; 173: const globallyInstalled = isPluginGloballyInstalled(pluginId_0); 174: if (globallyInstalled) { 175: setError(`Plugin '${pluginId_0}' is already installed globally. Use '/plugin' to manage existing plugins.`); 176: } else { 177: setSelectedMarketplace(foundMarketplace); 178: setSelectedPlugin(foundPlugin); 179: setViewState('plugin-details'); 180: } 181: } else { 182: setError(`Plugin "${targetPlugin}" not found in any marketplace`); 183: } 184: } else if (targetMarketplace) { 185: const marketplaceExists = marketplaceInfos.some(m_0 => m_0.name === targetMarketplace); 186: if (marketplaceExists) { 187: setSelectedMarketplace(targetMarketplace); 188: setViewState('plugin-list'); 189: } else { 190: setError(`Marketplace "${targetMarketplace}" not found`); 191: } 192: } 193: } catch (err) { 194: setError(err instanceof Error ? err.message : 'Failed to load marketplaces'); 195: } finally { 196: setLoading(false); 197: } 198: } 199: void loadMarketplaceData(); 200: }, [setError, targetMarketplace, targetPlugin]); 201: useEffect(() => { 202: if (!selectedMarketplace) return; 203: let cancelled = false; 204: async function loadPluginsForMarketplace(marketplaceName: string) { 205: setLoading(true); 206: try { 207: const marketplace_1 = await getMarketplace(marketplaceName); 208: if (cancelled) return; 209: if (!marketplace_1) { 210: throw new Error(`Failed to load marketplace: ${marketplaceName}`); 211: } 212: const installablePlugins: InstallablePlugin[] = []; 213: for (const entry of marketplace_1.plugins) { 214: const pluginId_1 = createPluginId(entry.name, marketplaceName); 215: if (isPluginBlockedByPolicy(pluginId_1)) continue; 216: installablePlugins.push({ 217: entry, 218: marketplaceName: marketplaceName, 219: pluginId: pluginId_1, 220: isInstalled: isPluginGloballyInstalled(pluginId_1) 221: }); 222: } 223: try { 224: const counts = await getInstallCounts(); 225: if (cancelled) return; 226: setInstallCounts(counts); 227: if (counts) { 228: installablePlugins.sort((a_1, b_1) => { 229: const countA = counts.get(a_1.pluginId) ?? 0; 230: const countB = counts.get(b_1.pluginId) ?? 0; 231: if (countA !== countB) return countB - countA; 232: return a_1.entry.name.localeCompare(b_1.entry.name); 233: }); 234: } else { 235: installablePlugins.sort((a_2, b_2) => a_2.entry.name.localeCompare(b_2.entry.name)); 236: } 237: } catch (error_0) { 238: if (cancelled) return; 239: logForDebugging(`Failed to fetch install counts: ${errorMessage(error_0)}`); 240: installablePlugins.sort((a_0, b_0) => a_0.entry.name.localeCompare(b_0.entry.name)); 241: } 242: setAvailablePlugins(installablePlugins); 243: setSelectedIndex(0); 244: setSelectedForInstall(new Set()); 245: } catch (err_0) { 246: if (cancelled) return; 247: setError(err_0 instanceof Error ? err_0.message : 'Failed to load plugins'); 248: } finally { 249: setLoading(false); 250: } 251: } 252: void loadPluginsForMarketplace(selectedMarketplace); 253: return () => { 254: cancelled = true; 255: }; 256: }, [selectedMarketplace, setError]); 257: const installSelectedPlugins = async () => { 258: if (selectedForInstall.size === 0) return; 259: const pluginsToInstall = availablePlugins.filter(p_0 => selectedForInstall.has(p_0.pluginId)); 260: setInstallingPlugins(new Set(pluginsToInstall.map(p_1 => p_1.pluginId))); 261: let successCount_0 = 0; 262: let failureCount = 0; 263: const newFailedPlugins: Array<{ 264: name: string; 265: reason: string; 266: }> = []; 267: for (const plugin_1 of pluginsToInstall) { 268: const result = await installPluginFromMarketplace({ 269: pluginId: plugin_1.pluginId, 270: entry: plugin_1.entry, 271: marketplaceName: plugin_1.marketplaceName, 272: scope: 'user' 273: }); 274: if (result.success) { 275: successCount_0++; 276: } else { 277: failureCount++; 278: newFailedPlugins.push({ 279: name: plugin_1.entry.name, 280: reason: result.error 281: }); 282: } 283: } 284: setInstallingPlugins(new Set()); 285: setSelectedForInstall(new Set()); 286: clearAllCaches(); 287: if (failureCount === 0) { 288: const message = `✓ Installed ${successCount_0} ${plural(successCount_0, 'plugin')}. ` + `Run /reload-plugins to activate.`; 289: setResult(message); 290: } else if (successCount_0 === 0) { 291: setError(`Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`); 292: } else { 293: const message_0 = `✓ Installed ${successCount_0} of ${successCount_0 + failureCount} plugins. ` + `Failed: ${formatFailureDetails(newFailedPlugins, false)}. ` + `Run /reload-plugins to activate successfully installed plugins.`; 294: setResult(message_0); 295: } 296: if (successCount_0 > 0) { 297: if (onInstallComplete) { 298: await onInstallComplete(); 299: } 300: } 301: setParentViewState({ 302: type: 'menu' 303: }); 304: }; 305: const handleSinglePluginInstall = async (plugin_2: InstallablePlugin, scope: 'user' | 'project' | 'local' = 'user') => { 306: setIsInstalling(true); 307: setInstallError(null); 308: const result_0 = await installPluginFromMarketplace({ 309: pluginId: plugin_2.pluginId, 310: entry: plugin_2.entry, 311: marketplaceName: plugin_2.marketplaceName, 312: scope 313: }); 314: if (result_0.success) { 315: const loaded = await findPluginOptionsTarget(plugin_2.pluginId); 316: if (loaded) { 317: setIsInstalling(false); 318: setViewState({ 319: type: 'plugin-options', 320: plugin: loaded, 321: pluginId: plugin_2.pluginId 322: }); 323: return; 324: } 325: setResult(result_0.message); 326: if (onInstallComplete) { 327: await onInstallComplete(); 328: } 329: setParentViewState({ 330: type: 'menu' 331: }); 332: } else { 333: setIsInstalling(false); 334: setInstallError(result_0.error); 335: } 336: }; 337: useEffect(() => { 338: if (error) { 339: setResult(error); 340: } 341: }, [error, setResult]); 342: useKeybindings({ 343: 'select:previous': () => { 344: if (selectedIndex > 0) { 345: setSelectedIndex(selectedIndex - 1); 346: } 347: }, 348: 'select:next': () => { 349: if (selectedIndex < marketplaces.length - 1) { 350: setSelectedIndex(selectedIndex + 1); 351: } 352: }, 353: 'select:accept': () => { 354: const marketplace_2 = marketplaces[selectedIndex]; 355: if (marketplace_2) { 356: setSelectedMarketplace(marketplace_2.name); 357: setViewState('plugin-list'); 358: } 359: } 360: }, { 361: context: 'Select', 362: isActive: viewState === 'marketplace-list' 363: }); 364: useKeybindings({ 365: 'select:previous': () => { 366: if (selectedIndex > 0) { 367: pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex); 368: } 369: }, 370: 'select:next': () => { 371: if (selectedIndex < availablePlugins.length - 1) { 372: pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex); 373: } 374: }, 375: 'select:accept': () => { 376: if (selectedIndex === availablePlugins.length && selectedForInstall.size > 0) { 377: void installSelectedPlugins(); 378: } else if (selectedIndex < availablePlugins.length) { 379: const plugin_3 = availablePlugins[selectedIndex]; 380: if (plugin_3) { 381: if (plugin_3.isInstalled) { 382: setParentViewState({ 383: type: 'manage-plugins', 384: targetPlugin: plugin_3.entry.name, 385: targetMarketplace: plugin_3.marketplaceName 386: }); 387: } else { 388: setSelectedPlugin(plugin_3); 389: setViewState('plugin-details'); 390: setDetailsMenuIndex(0); 391: setInstallError(null); 392: } 393: } 394: } 395: } 396: }, { 397: context: 'Select', 398: isActive: viewState === 'plugin-list' 399: }); 400: useKeybindings({ 401: 'plugin:toggle': () => { 402: if (selectedIndex < availablePlugins.length) { 403: const plugin_4 = availablePlugins[selectedIndex]; 404: if (plugin_4 && !plugin_4.isInstalled) { 405: const newSelection = new Set(selectedForInstall); 406: if (newSelection.has(plugin_4.pluginId)) { 407: newSelection.delete(plugin_4.pluginId); 408: } else { 409: newSelection.add(plugin_4.pluginId); 410: } 411: setSelectedForInstall(newSelection); 412: } 413: } 414: }, 415: 'plugin:install': () => { 416: if (selectedForInstall.size > 0) { 417: void installSelectedPlugins(); 418: } 419: } 420: }, { 421: context: 'Plugin', 422: isActive: viewState === 'plugin-list' 423: }); 424: const detailsMenuOptions = React.useMemo(() => { 425: if (!selectedPlugin) return []; 426: const hasHomepage = selectedPlugin.entry.homepage; 427: const githubRepo = extractGitHubRepo(selectedPlugin); 428: return buildPluginDetailsMenuOptions(hasHomepage, githubRepo); 429: }, [selectedPlugin]); 430: useKeybindings({ 431: 'select:previous': () => { 432: if (detailsMenuIndex > 0) { 433: setDetailsMenuIndex(detailsMenuIndex - 1); 434: } 435: }, 436: 'select:next': () => { 437: if (detailsMenuIndex < detailsMenuOptions.length - 1) { 438: setDetailsMenuIndex(detailsMenuIndex + 1); 439: } 440: }, 441: 'select:accept': () => { 442: if (!selectedPlugin) return; 443: const action = detailsMenuOptions[detailsMenuIndex]?.action; 444: const hasHomepage_0 = selectedPlugin.entry.homepage; 445: const githubRepo_0 = extractGitHubRepo(selectedPlugin); 446: if (action === 'install-user') { 447: void handleSinglePluginInstall(selectedPlugin, 'user'); 448: } else if (action === 'install-project') { 449: void handleSinglePluginInstall(selectedPlugin, 'project'); 450: } else if (action === 'install-local') { 451: void handleSinglePluginInstall(selectedPlugin, 'local'); 452: } else if (action === 'homepage' && hasHomepage_0) { 453: void openBrowser(hasHomepage_0); 454: } else if (action === 'github' && githubRepo_0) { 455: void openBrowser(`https://github.com/${githubRepo_0}`); 456: } else if (action === 'back') { 457: setViewState('plugin-list'); 458: setSelectedPlugin(null); 459: } 460: } 461: }, { 462: context: 'Select', 463: isActive: viewState === 'plugin-details' && !!selectedPlugin 464: }); 465: if (typeof viewState === 'object' && viewState.type === 'plugin-options') { 466: const { 467: plugin: plugin_5, 468: pluginId: pluginId_2 469: } = viewState; 470: function finish(msg: string): void { 471: setResult(msg); 472: if (onInstallComplete) { 473: void onInstallComplete(); 474: } 475: setParentViewState({ 476: type: 'menu' 477: }); 478: } 479: return <PluginOptionsFlow plugin={plugin_5} pluginId={pluginId_2} onDone={(outcome, detail) => { 480: switch (outcome) { 481: case 'configured': 482: finish(`✓ Installed and configured ${plugin_5.name}. Run /reload-plugins to apply.`); 483: break; 484: case 'skipped': 485: finish(`✓ Installed ${plugin_5.name}. Run /reload-plugins to apply.`); 486: break; 487: case 'error': 488: finish(`Installed but failed to save config: ${detail}`); 489: break; 490: } 491: }} />; 492: } 493: if (loading) { 494: return <Text>Loading…</Text>; 495: } 496: if (error) { 497: return <Text color="error">{error}</Text>; 498: } 499: if (viewState === 'marketplace-list') { 500: if (marketplaces.length === 0) { 501: return <Box flexDirection="column"> 502: <Box marginBottom={1}> 503: <Text bold>Select marketplace</Text> 504: </Box> 505: <Text>No marketplaces configured.</Text> 506: <Text dimColor> 507: Add a marketplace first using {"'Add marketplace'"}. 508: </Text> 509: <Box marginTop={1} paddingLeft={1}> 510: <Text dimColor> 511: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" /> 512: </Text> 513: </Box> 514: </Box>; 515: } 516: return <Box flexDirection="column"> 517: <Box marginBottom={1}> 518: <Text bold>Select marketplace</Text> 519: </Box> 520: {} 521: {warning && <Box marginBottom={1} flexDirection="column"> 522: <Text color="warning"> 523: {figures.warning} {warning} 524: </Text> 525: </Box>} 526: {marketplaces.map((marketplace_3, index) => <Box key={marketplace_3.name} flexDirection="column" marginBottom={index < marketplaces.length - 1 ? 1 : 0}> 527: <Box> 528: <Text color={selectedIndex === index ? 'suggestion' : undefined}> 529: {selectedIndex === index ? figures.pointer : ' '}{' '} 530: {marketplace_3.name} 531: </Text> 532: </Box> 533: <Box marginLeft={2}> 534: <Text dimColor> 535: {marketplace_3.totalPlugins}{' '} 536: {plural(marketplace_3.totalPlugins, 'plugin')} available 537: {marketplace_3.installedCount > 0 && ` · ${marketplace_3.installedCount} already installed`} 538: {marketplace_3.source && ` · ${marketplace_3.source}`} 539: </Text> 540: </Box> 541: </Box>)} 542: <Box marginTop={1}> 543: <Text dimColor italic> 544: <Byline> 545: <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" /> 546: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" /> 547: </Byline> 548: </Text> 549: </Box> 550: </Box>; 551: } 552: if (viewState === 'plugin-details' && selectedPlugin) { 553: const hasHomepage_1 = selectedPlugin.entry.homepage; 554: const githubRepo_1 = extractGitHubRepo(selectedPlugin); 555: const menuOptions = buildPluginDetailsMenuOptions(hasHomepage_1, githubRepo_1); 556: return <Box flexDirection="column"> 557: <Box marginBottom={1}> 558: <Text bold>Plugin Details</Text> 559: </Box> 560: {} 561: <Box flexDirection="column" marginBottom={1}> 562: <Text bold>{selectedPlugin.entry.name}</Text> 563: {selectedPlugin.entry.version && <Text dimColor>Version: {selectedPlugin.entry.version}</Text>} 564: {selectedPlugin.entry.description && <Box marginTop={1}> 565: <Text>{selectedPlugin.entry.description}</Text> 566: </Box>} 567: {selectedPlugin.entry.author && <Box marginTop={1}> 568: <Text dimColor> 569: By:{' '} 570: {typeof selectedPlugin.entry.author === 'string' ? selectedPlugin.entry.author : selectedPlugin.entry.author.name} 571: </Text> 572: </Box>} 573: </Box> 574: {} 575: <Box flexDirection="column" marginBottom={1}> 576: <Text bold>Will install:</Text> 577: {selectedPlugin.entry.commands && <Text dimColor> 578: · Commands:{' '} 579: {Array.isArray(selectedPlugin.entry.commands) ? selectedPlugin.entry.commands.join(', ') : Object.keys(selectedPlugin.entry.commands).join(', ')} 580: </Text>} 581: {selectedPlugin.entry.agents && <Text dimColor> 582: · Agents:{' '} 583: {Array.isArray(selectedPlugin.entry.agents) ? selectedPlugin.entry.agents.join(', ') : Object.keys(selectedPlugin.entry.agents).join(', ')} 584: </Text>} 585: {selectedPlugin.entry.hooks && <Text dimColor> 586: · Hooks: {Object.keys(selectedPlugin.entry.hooks).join(', ')} 587: </Text>} 588: {selectedPlugin.entry.mcpServers && <Text dimColor> 589: · MCP Servers:{' '} 590: {Array.isArray(selectedPlugin.entry.mcpServers) ? selectedPlugin.entry.mcpServers.join(', ') : typeof selectedPlugin.entry.mcpServers === 'object' ? Object.keys(selectedPlugin.entry.mcpServers).join(', ') : 'configured'} 591: </Text>} 592: {!selectedPlugin.entry.commands && !selectedPlugin.entry.agents && !selectedPlugin.entry.hooks && !selectedPlugin.entry.mcpServers && <> 593: {typeof selectedPlugin.entry.source === 'object' && 'source' in selectedPlugin.entry.source && (selectedPlugin.entry.source.source === 'github' || selectedPlugin.entry.source.source === 'url' || selectedPlugin.entry.source.source === 'npm' || selectedPlugin.entry.source.source === 'pip') ? <Text dimColor> 594: · Component summary not available for remote plugin 595: </Text> : 596: <Text dimColor> 597: · Components will be discovered at installation 598: </Text>} 599: </>} 600: </Box> 601: <PluginTrustWarning /> 602: {} 603: {installError && <Box marginBottom={1}> 604: <Text color="error">Error: {installError}</Text> 605: </Box>} 606: {} 607: <Box flexDirection="column"> 608: {menuOptions.map((option, index_0) => <Box key={option.action}> 609: {detailsMenuIndex === index_0 && <Text>{'> '}</Text>} 610: {detailsMenuIndex !== index_0 && <Text>{' '}</Text>} 611: <Text bold={detailsMenuIndex === index_0}> 612: {isInstalling && option.action === 'install' ? 'Installing…' : option.label} 613: </Text> 614: </Box>)} 615: </Box> 616: <Box marginTop={1} paddingLeft={1}> 617: <Text dimColor> 618: <Byline> 619: <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" /> 620: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" /> 621: </Byline> 622: </Text> 623: </Box> 624: </Box>; 625: } 626: if (availablePlugins.length === 0) { 627: return <Box flexDirection="column"> 628: <Box marginBottom={1}> 629: <Text bold>Install plugins</Text> 630: </Box> 631: <Text dimColor>No new plugins available to install.</Text> 632: <Text dimColor> 633: All plugins from this marketplace are already installed. 634: </Text> 635: <Box marginLeft={3}> 636: <Text dimColor italic> 637: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" /> 638: </Text> 639: </Box> 640: </Box>; 641: } 642: const visiblePlugins = pagination.getVisibleItems(availablePlugins); 643: return <Box flexDirection="column"> 644: <Box marginBottom={1}> 645: <Text bold>Install Plugins</Text> 646: </Box> 647: {} 648: {pagination.scrollPosition.canScrollUp && <Box> 649: <Text dimColor> {figures.arrowUp} more above</Text> 650: </Box>} 651: {} 652: {visiblePlugins.map((plugin_6, visibleIndex) => { 653: const actualIndex = pagination.toActualIndex(visibleIndex); 654: const isSelected = selectedIndex === actualIndex; 655: const isSelectedForInstall = selectedForInstall.has(plugin_6.pluginId); 656: const isInstalling_0 = installingPlugins.has(plugin_6.pluginId); 657: const isLast = visibleIndex === visiblePlugins.length - 1; 658: return <Box key={plugin_6.pluginId} flexDirection="column" marginBottom={isLast && !error ? 0 : 1}> 659: <Box> 660: <Text color={isSelected ? 'suggestion' : undefined}> 661: {isSelected ? figures.pointer : ' '}{' '} 662: </Text> 663: <Text color={plugin_6.isInstalled ? 'success' : undefined}> 664: {plugin_6.isInstalled ? figures.tick : isInstalling_0 ? figures.ellipsis : isSelectedForInstall ? figures.radioOn : figures.radioOff}{' '} 665: {plugin_6.entry.name} 666: {plugin_6.entry.category && <Text dimColor> [{plugin_6.entry.category}]</Text>} 667: {plugin_6.entry.tags?.includes('community-managed') && <Text dimColor> [Community Managed]</Text>} 668: {plugin_6.isInstalled && <Text dimColor> (installed)</Text>} 669: {installCounts && selectedMarketplace === OFFICIAL_MARKETPLACE_NAME && <Text dimColor> 670: {' · '} 671: {formatInstallCount(installCounts.get(plugin_6.pluginId) ?? 0)}{' '} 672: installs 673: </Text>} 674: </Text> 675: </Box> 676: {plugin_6.entry.description && <Box marginLeft={4}> 677: <Text dimColor> 678: {truncateToWidth(plugin_6.entry.description, 60)} 679: </Text> 680: {plugin_6.entry.version && <Text dimColor> · v{plugin_6.entry.version}</Text>} 681: </Box>} 682: </Box>; 683: })} 684: {} 685: {pagination.scrollPosition.canScrollDown && <Box> 686: <Text dimColor> {figures.arrowDown} more below</Text> 687: </Box>} 688: {} 689: {error && <Box marginTop={1}> 690: <Text color="error"> 691: {figures.cross} {error} 692: </Text> 693: </Box>} 694: <PluginSelectionKeyHint hasSelection={selectedForInstall.size > 0} /> 695: </Box>; 696: }

File: src/commands/plugin/DiscoverPlugins.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import * as React from 'react'; 4: import { useCallback, useEffect, useMemo, useState } from 'react'; 5: import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; 6: import { Byline } from '../../components/design-system/Byline.js'; 7: import { SearchBox } from '../../components/SearchBox.js'; 8: import { useSearchInput } from '../../hooks/useSearchInput.js'; 9: import { useTerminalSize } from '../../hooks/useTerminalSize.js'; 10: import { Box, Text, useInput, useTerminalFocus } from '../../ink.js'; 11: import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; 12: import type { LoadedPlugin } from '../../types/plugin.js'; 13: import { count } from '../../utils/array.js'; 14: import { openBrowser } from '../../utils/browser.js'; 15: import { logForDebugging } from '../../utils/debug.js'; 16: import { errorMessage } from '../../utils/errors.js'; 17: import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; 18: import { formatInstallCount, getInstallCounts } from '../../utils/plugins/installCounts.js'; 19: import { isPluginGloballyInstalled } from '../../utils/plugins/installedPluginsManager.js'; 20: import { createPluginId, detectEmptyMarketplaceReason, type EmptyMarketplaceReason, formatFailureDetails, formatMarketplaceLoadingErrors, loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js'; 21: import { loadKnownMarketplacesConfig } from '../../utils/plugins/marketplaceManager.js'; 22: import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js'; 23: import { installPluginFromMarketplace } from '../../utils/plugins/pluginInstallationHelpers.js'; 24: import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js'; 25: import { plural } from '../../utils/stringUtils.js'; 26: import { truncateToWidth } from '../../utils/truncate.js'; 27: import { findPluginOptionsTarget, PluginOptionsFlow } from './PluginOptionsFlow.js'; 28: import { PluginTrustWarning } from './PluginTrustWarning.js'; 29: import { buildPluginDetailsMenuOptions, extractGitHubRepo, type InstallablePlugin } from './pluginDetailsHelpers.js'; 30: import type { ViewState as ParentViewState } from './types.js'; 31: import { usePagination } from './usePagination.js'; 32: type Props = { 33: error: string | null; 34: setError: (error: string | null) => void; 35: result: string | null; 36: setResult: (result: string | null) => void; 37: setViewState: (state: ParentViewState) => void; 38: onInstallComplete?: () => void | Promise<void>; 39: onSearchModeChange?: (isActive: boolean) => void; 40: targetPlugin?: string; 41: }; 42: type ViewState = 'plugin-list' | 'plugin-details' | { 43: type: 'plugin-options'; 44: plugin: LoadedPlugin; 45: pluginId: string; 46: }; 47: export function DiscoverPlugins({ 48: error, 49: setError, 50: result: _result, 51: setResult, 52: setViewState: setParentViewState, 53: onInstallComplete, 54: onSearchModeChange, 55: targetPlugin 56: }: Props): React.ReactNode { 57: const [viewState, setViewState] = useState<ViewState>('plugin-list'); 58: const [selectedPlugin, setSelectedPlugin] = useState<InstallablePlugin | null>(null); 59: const [availablePlugins, setAvailablePlugins] = useState<InstallablePlugin[]>([]); 60: const [loading, setLoading] = useState(true); 61: const [installCounts, setInstallCounts] = useState<Map<string, number> | null>(null); 62: const [isSearchMode, setIsSearchModeRaw] = useState(false); 63: const setIsSearchMode = useCallback((active: boolean) => { 64: setIsSearchModeRaw(active); 65: onSearchModeChange?.(active); 66: }, [onSearchModeChange]); 67: const { 68: query: searchQuery, 69: setQuery: setSearchQuery, 70: cursorOffset: searchCursorOffset 71: } = useSearchInput({ 72: isActive: viewState === 'plugin-list' && isSearchMode && !loading, 73: onExit: () => { 74: setIsSearchMode(false); 75: } 76: }); 77: const isTerminalFocused = useTerminalFocus(); 78: const { 79: columns: terminalWidth 80: } = useTerminalSize(); 81: const filteredPlugins = useMemo(() => { 82: if (!searchQuery) return availablePlugins; 83: const lowerQuery = searchQuery.toLowerCase(); 84: return availablePlugins.filter(plugin => plugin.entry.name.toLowerCase().includes(lowerQuery) || plugin.entry.description?.toLowerCase().includes(lowerQuery) || plugin.marketplaceName.toLowerCase().includes(lowerQuery)); 85: }, [availablePlugins, searchQuery]); 86: const [selectedIndex, setSelectedIndex] = useState(0); 87: const [selectedForInstall, setSelectedForInstall] = useState<Set<string>>(new Set()); 88: const [installingPlugins, setInstallingPlugins] = useState<Set<string>>(new Set()); 89: const pagination = usePagination<InstallablePlugin>({ 90: totalItems: filteredPlugins.length, 91: selectedIndex 92: }); 93: useEffect(() => { 94: setSelectedIndex(0); 95: }, [searchQuery]); 96: const [detailsMenuIndex, setDetailsMenuIndex] = useState(0); 97: const [isInstalling, setIsInstalling] = useState(false); 98: const [installError, setInstallError] = useState<string | null>(null); 99: const [warning, setWarning] = useState<string | null>(null); 100: const [emptyReason, setEmptyReason] = useState<EmptyMarketplaceReason | null>(null); 101: useEffect(() => { 102: async function loadAllPlugins() { 103: try { 104: const config = await loadKnownMarketplacesConfig(); 105: const { 106: marketplaces, 107: failures 108: } = await loadMarketplacesWithGracefulDegradation(config); 109: const allPlugins: InstallablePlugin[] = []; 110: for (const { 111: name, 112: data: marketplace 113: } of marketplaces) { 114: if (marketplace) { 115: for (const entry of marketplace.plugins) { 116: const pluginId = createPluginId(entry.name, name); 117: allPlugins.push({ 118: entry, 119: marketplaceName: name, 120: pluginId, 121: isInstalled: isPluginGloballyInstalled(pluginId) 122: }); 123: } 124: } 125: } 126: const uninstalledPlugins = allPlugins.filter(p => !p.isInstalled && !isPluginBlockedByPolicy(p.pluginId)); 127: try { 128: const counts = await getInstallCounts(); 129: setInstallCounts(counts); 130: if (counts) { 131: uninstalledPlugins.sort((a_0, b_0) => { 132: const countA = counts.get(a_0.pluginId) ?? 0; 133: const countB = counts.get(b_0.pluginId) ?? 0; 134: if (countA !== countB) return countB - countA; 135: return a_0.entry.name.localeCompare(b_0.entry.name); 136: }); 137: } else { 138: uninstalledPlugins.sort((a_1, b_1) => a_1.entry.name.localeCompare(b_1.entry.name)); 139: } 140: } catch (error_0) { 141: logForDebugging(`Failed to fetch install counts: ${errorMessage(error_0)}`); 142: uninstalledPlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name)); 143: } 144: setAvailablePlugins(uninstalledPlugins); 145: const configuredCount = Object.keys(config).length; 146: if (uninstalledPlugins.length === 0) { 147: const reason = await detectEmptyMarketplaceReason({ 148: configuredMarketplaceCount: configuredCount, 149: failedMarketplaceCount: failures.length 150: }); 151: setEmptyReason(reason); 152: } 153: const successCount = count(marketplaces, m => m.data !== null); 154: const errorResult = formatMarketplaceLoadingErrors(failures, successCount); 155: if (errorResult) { 156: if (errorResult.type === 'warning') { 157: setWarning(errorResult.message + '. Showing available plugins.'); 158: } else { 159: throw new Error(errorResult.message); 160: } 161: } 162: if (targetPlugin) { 163: const foundPlugin = allPlugins.find(p_0 => p_0.entry.name === targetPlugin); 164: if (foundPlugin) { 165: if (foundPlugin.isInstalled) { 166: setError(`Plugin '${foundPlugin.pluginId}' is already installed. Use '/plugin' to manage existing plugins.`); 167: } else { 168: setSelectedPlugin(foundPlugin); 169: setViewState('plugin-details'); 170: } 171: } else { 172: setError(`Plugin "${targetPlugin}" not found in any marketplace`); 173: } 174: } 175: } catch (err) { 176: setError(err instanceof Error ? err.message : 'Failed to load plugins'); 177: } finally { 178: setLoading(false); 179: } 180: } 181: void loadAllPlugins(); 182: }, [setError, targetPlugin]); 183: const installSelectedPlugins = async () => { 184: if (selectedForInstall.size === 0) return; 185: const pluginsToInstall = availablePlugins.filter(p_1 => selectedForInstall.has(p_1.pluginId)); 186: setInstallingPlugins(new Set(pluginsToInstall.map(p_2 => p_2.pluginId))); 187: let successCount_0 = 0; 188: let failureCount = 0; 189: const newFailedPlugins: Array<{ 190: name: string; 191: reason: string; 192: }> = []; 193: for (const plugin_0 of pluginsToInstall) { 194: const result = await installPluginFromMarketplace({ 195: pluginId: plugin_0.pluginId, 196: entry: plugin_0.entry, 197: marketplaceName: plugin_0.marketplaceName, 198: scope: 'user' 199: }); 200: if (result.success) { 201: successCount_0++; 202: } else { 203: failureCount++; 204: newFailedPlugins.push({ 205: name: plugin_0.entry.name, 206: reason: result.error 207: }); 208: } 209: } 210: setInstallingPlugins(new Set()); 211: setSelectedForInstall(new Set()); 212: clearAllCaches(); 213: if (failureCount === 0) { 214: const message = `✓ Installed ${successCount_0} ${plural(successCount_0, 'plugin')}. ` + `Run /reload-plugins to activate.`; 215: setResult(message); 216: } else if (successCount_0 === 0) { 217: setError(`Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`); 218: } else { 219: const message_0 = `✓ Installed ${successCount_0} of ${successCount_0 + failureCount} plugins. ` + `Failed: ${formatFailureDetails(newFailedPlugins, false)}. ` + `Run /reload-plugins to activate successfully installed plugins.`; 220: setResult(message_0); 221: } 222: if (successCount_0 > 0) { 223: if (onInstallComplete) { 224: await onInstallComplete(); 225: } 226: } 227: setParentViewState({ 228: type: 'menu' 229: }); 230: }; 231: const handleSinglePluginInstall = async (plugin_1: InstallablePlugin, scope: 'user' | 'project' | 'local' = 'user') => { 232: setIsInstalling(true); 233: setInstallError(null); 234: const result_0 = await installPluginFromMarketplace({ 235: pluginId: plugin_1.pluginId, 236: entry: plugin_1.entry, 237: marketplaceName: plugin_1.marketplaceName, 238: scope 239: }); 240: if (result_0.success) { 241: const loaded = await findPluginOptionsTarget(plugin_1.pluginId); 242: if (loaded) { 243: setIsInstalling(false); 244: setViewState({ 245: type: 'plugin-options', 246: plugin: loaded, 247: pluginId: plugin_1.pluginId 248: }); 249: return; 250: } 251: setResult(result_0.message); 252: if (onInstallComplete) { 253: await onInstallComplete(); 254: } 255: setParentViewState({ 256: type: 'menu' 257: }); 258: } else { 259: setIsInstalling(false); 260: setInstallError(result_0.error); 261: } 262: }; 263: useEffect(() => { 264: if (error) { 265: setResult(error); 266: } 267: }, [error, setResult]); 268: useKeybinding('confirm:no', () => { 269: setViewState('plugin-list'); 270: setSelectedPlugin(null); 271: }, { 272: context: 'Confirmation', 273: isActive: viewState === 'plugin-details' 274: }); 275: useKeybinding('confirm:no', () => { 276: setParentViewState({ 277: type: 'menu' 278: }); 279: }, { 280: context: 'Confirmation', 281: isActive: viewState === 'plugin-list' && !isSearchMode 282: }); 283: useInput((input, _key) => { 284: const keyIsNotCtrlOrMeta = !_key.ctrl && !_key.meta; 285: if (!isSearchMode) { 286: if (input === '/' && keyIsNotCtrlOrMeta) { 287: setIsSearchMode(true); 288: setSearchQuery(''); 289: } else if (keyIsNotCtrlOrMeta && input.length > 0 && !/^\s+$/.test(input) && 290: // Don't enter search mode for navigation keys 291: input !== 'j' && input !== 'k' && input !== 'i') { 292: setIsSearchMode(true); 293: setSearchQuery(input); 294: } 295: } 296: }, { 297: isActive: viewState === 'plugin-list' && !loading 298: }); 299: useKeybindings({ 300: 'select:previous': () => { 301: if (selectedIndex === 0) { 302: setIsSearchMode(true); 303: } else { 304: pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex); 305: } 306: }, 307: 'select:next': () => { 308: if (selectedIndex < filteredPlugins.length - 1) { 309: pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex); 310: } 311: }, 312: 'select:accept': () => { 313: if (selectedIndex === filteredPlugins.length && selectedForInstall.size > 0) { 314: void installSelectedPlugins(); 315: } else if (selectedIndex < filteredPlugins.length) { 316: const plugin_2 = filteredPlugins[selectedIndex]; 317: if (plugin_2) { 318: if (plugin_2.isInstalled) { 319: setParentViewState({ 320: type: 'manage-plugins', 321: targetPlugin: plugin_2.entry.name, 322: targetMarketplace: plugin_2.marketplaceName 323: }); 324: } else { 325: setSelectedPlugin(plugin_2); 326: setViewState('plugin-details'); 327: setDetailsMenuIndex(0); 328: setInstallError(null); 329: } 330: } 331: } 332: } 333: }, { 334: context: 'Select', 335: isActive: viewState === 'plugin-list' && !isSearchMode 336: }); 337: useKeybindings({ 338: 'plugin:toggle': () => { 339: if (selectedIndex < filteredPlugins.length) { 340: const plugin_3 = filteredPlugins[selectedIndex]; 341: if (plugin_3 && !plugin_3.isInstalled) { 342: const newSelection = new Set(selectedForInstall); 343: if (newSelection.has(plugin_3.pluginId)) { 344: newSelection.delete(plugin_3.pluginId); 345: } else { 346: newSelection.add(plugin_3.pluginId); 347: } 348: setSelectedForInstall(newSelection); 349: } 350: } 351: }, 352: 'plugin:install': () => { 353: if (selectedForInstall.size > 0) { 354: void installSelectedPlugins(); 355: } 356: } 357: }, { 358: context: 'Plugin', 359: isActive: viewState === 'plugin-list' && !isSearchMode 360: }); 361: const detailsMenuOptions = React.useMemo(() => { 362: if (!selectedPlugin) return []; 363: const hasHomepage = selectedPlugin.entry.homepage; 364: const githubRepo = extractGitHubRepo(selectedPlugin); 365: return buildPluginDetailsMenuOptions(hasHomepage, githubRepo); 366: }, [selectedPlugin]); 367: useKeybindings({ 368: 'select:previous': () => { 369: if (detailsMenuIndex > 0) { 370: setDetailsMenuIndex(detailsMenuIndex - 1); 371: } 372: }, 373: 'select:next': () => { 374: if (detailsMenuIndex < detailsMenuOptions.length - 1) { 375: setDetailsMenuIndex(detailsMenuIndex + 1); 376: } 377: }, 378: 'select:accept': () => { 379: if (!selectedPlugin) return; 380: const action = detailsMenuOptions[detailsMenuIndex]?.action; 381: const hasHomepage_0 = selectedPlugin.entry.homepage; 382: const githubRepo_0 = extractGitHubRepo(selectedPlugin); 383: if (action === 'install-user') { 384: void handleSinglePluginInstall(selectedPlugin, 'user'); 385: } else if (action === 'install-project') { 386: void handleSinglePluginInstall(selectedPlugin, 'project'); 387: } else if (action === 'install-local') { 388: void handleSinglePluginInstall(selectedPlugin, 'local'); 389: } else if (action === 'homepage' && hasHomepage_0) { 390: void openBrowser(hasHomepage_0); 391: } else if (action === 'github' && githubRepo_0) { 392: void openBrowser(`https://github.com/${githubRepo_0}`); 393: } else if (action === 'back') { 394: setViewState('plugin-list'); 395: setSelectedPlugin(null); 396: } 397: } 398: }, { 399: context: 'Select', 400: isActive: viewState === 'plugin-details' && !!selectedPlugin 401: }); 402: if (typeof viewState === 'object' && viewState.type === 'plugin-options') { 403: const { 404: plugin: plugin_4, 405: pluginId: pluginId_0 406: } = viewState; 407: function finish(msg: string): void { 408: setResult(msg); 409: if (onInstallComplete) { 410: void onInstallComplete(); 411: } 412: setParentViewState({ 413: type: 'menu' 414: }); 415: } 416: return <PluginOptionsFlow plugin={plugin_4} pluginId={pluginId_0} onDone={(outcome, detail) => { 417: switch (outcome) { 418: case 'configured': 419: finish(`✓ Installed and configured ${plugin_4.name}. Run /reload-plugins to apply.`); 420: break; 421: case 'skipped': 422: finish(`✓ Installed ${plugin_4.name}. Run /reload-plugins to apply.`); 423: break; 424: case 'error': 425: finish(`Installed but failed to save config: ${detail}`); 426: break; 427: } 428: }} />; 429: } 430: if (loading) { 431: return <Text>Loading…</Text>; 432: } 433: if (error) { 434: return <Text color="error">{error}</Text>; 435: } 436: if (viewState === 'plugin-details' && selectedPlugin) { 437: const hasHomepage_1 = selectedPlugin.entry.homepage; 438: const githubRepo_1 = extractGitHubRepo(selectedPlugin); 439: const menuOptions = buildPluginDetailsMenuOptions(hasHomepage_1, githubRepo_1); 440: return <Box flexDirection="column"> 441: <Box marginBottom={1}> 442: <Text bold>Plugin details</Text> 443: </Box> 444: <Box flexDirection="column" marginBottom={1}> 445: <Text bold>{selectedPlugin.entry.name}</Text> 446: <Text dimColor>from {selectedPlugin.marketplaceName}</Text> 447: {selectedPlugin.entry.version && <Text dimColor>Version: {selectedPlugin.entry.version}</Text>} 448: {selectedPlugin.entry.description && <Box marginTop={1}> 449: <Text>{selectedPlugin.entry.description}</Text> 450: </Box>} 451: {selectedPlugin.entry.author && <Box marginTop={1}> 452: <Text dimColor> 453: By:{' '} 454: {typeof selectedPlugin.entry.author === 'string' ? selectedPlugin.entry.author : selectedPlugin.entry.author.name} 455: </Text> 456: </Box>} 457: </Box> 458: <PluginTrustWarning /> 459: {installError && <Box marginBottom={1}> 460: <Text color="error">Error: {installError}</Text> 461: </Box>} 462: <Box flexDirection="column"> 463: {menuOptions.map((option, index) => <Box key={option.action}> 464: {detailsMenuIndex === index && <Text>{'> '}</Text>} 465: {detailsMenuIndex !== index && <Text>{' '}</Text>} 466: <Text bold={detailsMenuIndex === index}> 467: {isInstalling && option.action.startsWith('install-') ? 'Installing…' : option.label} 468: </Text> 469: </Box>)} 470: </Box> 471: <Box marginTop={1}> 472: <Text dimColor> 473: <Byline> 474: <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" /> 475: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" /> 476: </Byline> 477: </Text> 478: </Box> 479: </Box>; 480: } 481: if (availablePlugins.length === 0) { 482: return <Box flexDirection="column"> 483: <Box marginBottom={1}> 484: <Text bold>Discover plugins</Text> 485: </Box> 486: <EmptyStateMessage reason={emptyReason} /> 487: <Box marginTop={1}> 488: <Text dimColor italic> 489: Esc to go back 490: </Text> 491: </Box> 492: </Box>; 493: } 494: const visiblePlugins = pagination.getVisibleItems(filteredPlugins); 495: return <Box flexDirection="column"> 496: <Box> 497: <Text bold>Discover plugins</Text> 498: {pagination.needsPagination && <Text dimColor> 499: {' '} 500: ({pagination.scrollPosition.current}/ 501: {pagination.scrollPosition.total}) 502: </Text>} 503: </Box> 504: {} 505: <Box marginBottom={1}> 506: <SearchBox query={searchQuery} isFocused={isSearchMode} isTerminalFocused={isTerminalFocused} width={terminalWidth - 4} cursorOffset={searchCursorOffset} /> 507: </Box> 508: {} 509: {warning && <Box marginBottom={1}> 510: <Text color="warning"> 511: {figures.warning} {warning} 512: </Text> 513: </Box>} 514: {} 515: {filteredPlugins.length === 0 && searchQuery && <Box marginBottom={1}> 516: <Text dimColor>No plugins match &quot;{searchQuery}&quot;</Text> 517: </Box>} 518: {} 519: {pagination.scrollPosition.canScrollUp && <Box> 520: <Text dimColor> {figures.arrowUp} more above</Text> 521: </Box>} 522: {} 523: {visiblePlugins.map((plugin_5, visibleIndex) => { 524: const actualIndex = pagination.toActualIndex(visibleIndex); 525: const isSelected = selectedIndex === actualIndex; 526: const isSelectedForInstall = selectedForInstall.has(plugin_5.pluginId); 527: const isInstallingThis = installingPlugins.has(plugin_5.pluginId); 528: const isLast = visibleIndex === visiblePlugins.length - 1; 529: return <Box key={`${pagination.startIndex}-${plugin_5.pluginId}`} flexDirection="column" marginBottom={isLast && !error ? 0 : 1}> 530: <Box> 531: <Text color={isSelected && !isSearchMode ? 'suggestion' : undefined}> 532: {isSelected && !isSearchMode ? figures.pointer : ' '}{' '} 533: </Text> 534: <Text> 535: {isInstallingThis ? figures.ellipsis : isSelectedForInstall ? figures.radioOn : figures.radioOff}{' '} 536: {plugin_5.entry.name} 537: <Text dimColor> · {plugin_5.marketplaceName}</Text> 538: {plugin_5.entry.tags?.includes('community-managed') && <Text dimColor> [Community Managed]</Text>} 539: {installCounts && plugin_5.marketplaceName === OFFICIAL_MARKETPLACE_NAME && <Text dimColor> 540: {' · '} 541: {formatInstallCount(installCounts.get(plugin_5.pluginId) ?? 0)}{' '} 542: installs 543: </Text>} 544: </Text> 545: </Box> 546: {plugin_5.entry.description && <Box marginLeft={4}> 547: <Text dimColor> 548: {truncateToWidth(plugin_5.entry.description, 60)} 549: </Text> 550: </Box>} 551: </Box>; 552: })} 553: {} 554: {pagination.scrollPosition.canScrollDown && <Box> 555: <Text dimColor> {figures.arrowDown} more below</Text> 556: </Box>} 557: {} 558: {error && <Box marginTop={1}> 559: <Text color="error"> 560: {figures.cross} {error} 561: </Text> 562: </Box>} 563: <DiscoverPluginsKeyHint hasSelection={selectedForInstall.size > 0} canToggle={selectedIndex < filteredPlugins.length && !filteredPlugins[selectedIndex]?.isInstalled} /> 564: </Box>; 565: } 566: function DiscoverPluginsKeyHint(t0) { 567: const $ = _c(10); 568: const { 569: hasSelection, 570: canToggle 571: } = t0; 572: let t1; 573: if ($[0] !== hasSelection) { 574: t1 = hasSelection && <ConfigurableShortcutHint action="plugin:install" context="Plugin" fallback="i" description="install" bold={true} />; 575: $[0] = hasSelection; 576: $[1] = t1; 577: } else { 578: t1 = $[1]; 579: } 580: let t2; 581: if ($[2] === Symbol.for("react.memo_cache_sentinel")) { 582: t2 = <Text>type to search</Text>; 583: $[2] = t2; 584: } else { 585: t2 = $[2]; 586: } 587: let t3; 588: if ($[3] !== canToggle) { 589: t3 = canToggle && <ConfigurableShortcutHint action="plugin:toggle" context="Plugin" fallback="Space" description="toggle" />; 590: $[3] = canToggle; 591: $[4] = t3; 592: } else { 593: t3 = $[4]; 594: } 595: let t4; 596: let t5; 597: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 598: t4 = <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="details" />; 599: t5 = <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />; 600: $[5] = t4; 601: $[6] = t5; 602: } else { 603: t4 = $[5]; 604: t5 = $[6]; 605: } 606: let t6; 607: if ($[7] !== t1 || $[8] !== t3) { 608: t6 = <Box marginTop={1}><Text dimColor={true} italic={true}><Byline>{t1}{t2}{t3}{t4}{t5}</Byline></Text></Box>; 609: $[7] = t1; 610: $[8] = t3; 611: $[9] = t6; 612: } else { 613: t6 = $[9]; 614: } 615: return t6; 616: } 617: function EmptyStateMessage(t0) { 618: const $ = _c(6); 619: const { 620: reason 621: } = t0; 622: switch (reason) { 623: case "git-not-installed": 624: { 625: let t1; 626: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 627: t1 = <><Text dimColor={true}>Git is required to install marketplaces.</Text><Text dimColor={true}>Please install git and restart Claude Code.</Text></>; 628: $[0] = t1; 629: } else { 630: t1 = $[0]; 631: } 632: return t1; 633: } 634: case "all-blocked-by-policy": 635: { 636: let t1; 637: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 638: t1 = <><Text dimColor={true}>Your organization policy does not allow any external marketplaces.</Text><Text dimColor={true}>Contact your administrator.</Text></>; 639: $[1] = t1; 640: } else { 641: t1 = $[1]; 642: } 643: return t1; 644: } 645: case "policy-restricts-sources": 646: { 647: let t1; 648: if ($[2] === Symbol.for("react.memo_cache_sentinel")) { 649: t1 = <><Text dimColor={true}>Your organization restricts which marketplaces can be added.</Text><Text dimColor={true}>Switch to the Marketplaces tab to view allowed sources.</Text></>; 650: $[2] = t1; 651: } else { 652: t1 = $[2]; 653: } 654: return t1; 655: } 656: case "all-marketplaces-failed": 657: { 658: let t1; 659: if ($[3] === Symbol.for("react.memo_cache_sentinel")) { 660: t1 = <><Text dimColor={true}>Failed to load marketplace data.</Text><Text dimColor={true}>Check your network connection.</Text></>; 661: $[3] = t1; 662: } else { 663: t1 = $[3]; 664: } 665: return t1; 666: } 667: case "all-plugins-installed": 668: { 669: let t1; 670: if ($[4] === Symbol.for("react.memo_cache_sentinel")) { 671: t1 = <><Text dimColor={true}>All available plugins are already installed.</Text><Text dimColor={true}>Check for new plugins later or add more marketplaces.</Text></>; 672: $[4] = t1; 673: } else { 674: t1 = $[4]; 675: } 676: return t1; 677: } 678: case "no-marketplaces-configured": 679: default: 680: { 681: let t1; 682: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 683: t1 = <><Text dimColor={true}>No plugins available.</Text><Text dimColor={true}>Add a marketplace first using the Marketplaces tab.</Text></>; 684: $[5] = t1; 685: } else { 686: t1 = $[5]; 687: } 688: return t1; 689: } 690: } 691: }

File: src/commands/plugin/index.tsx

typescript 1: import type { Command } from '../../commands.js'; 2: const plugin = { 3: type: 'local-jsx', 4: name: 'plugin', 5: aliases: ['plugins', 'marketplace'], 6: description: 'Manage Claude Code plugins', 7: immediate: true, 8: load: () => import('./plugin.js') 9: } satisfies Command; 10: export default plugin;

File: src/commands/plugin/ManageMarketplaces.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import * as React from 'react'; 4: import { useEffect, useRef, useState } from 'react'; 5: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; 6: import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; 7: import { Byline } from '../../components/design-system/Byline.js'; 8: import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; 9: import { Box, Text, useInput } from '../../ink.js'; 10: import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; 11: import type { LoadedPlugin } from '../../types/plugin.js'; 12: import { count } from '../../utils/array.js'; 13: import { shouldSkipPluginAutoupdate } from '../../utils/config.js'; 14: import { errorMessage } from '../../utils/errors.js'; 15: import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; 16: import { createPluginId, formatMarketplaceLoadingErrors, getMarketplaceSourceDisplay, loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js'; 17: import { loadKnownMarketplacesConfig, refreshMarketplace, removeMarketplaceSource, setMarketplaceAutoUpdate } from '../../utils/plugins/marketplaceManager.js'; 18: import { updatePluginsForMarketplaces } from '../../utils/plugins/pluginAutoupdate.js'; 19: import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'; 20: import { isMarketplaceAutoUpdate } from '../../utils/plugins/schemas.js'; 21: import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'; 22: import { plural } from '../../utils/stringUtils.js'; 23: import type { ViewState } from './types.js'; 24: type Props = { 25: setViewState: (state: ViewState) => void; 26: error?: string | null; 27: setError?: (error: string | null) => void; 28: setResult: (result: string | null) => void; 29: exitState: { 30: pending: boolean; 31: keyName: 'Ctrl-C' | 'Ctrl-D' | null; 32: }; 33: onManageComplete?: () => void | Promise<void>; 34: targetMarketplace?: string; 35: action?: 'update' | 'remove'; 36: }; 37: type MarketplaceState = { 38: name: string; 39: source: string; 40: lastUpdated?: string; 41: pluginCount?: number; 42: installedPlugins?: LoadedPlugin[]; 43: pendingUpdate?: boolean; 44: pendingRemove?: boolean; 45: autoUpdate?: boolean; 46: }; 47: type InternalViewState = 'list' | 'details' | 'confirm-remove'; 48: export function ManageMarketplaces({ 49: setViewState, 50: error, 51: setError, 52: setResult, 53: exitState, 54: onManageComplete, 55: targetMarketplace, 56: action 57: }: Props): React.ReactNode { 58: const [marketplaceStates, setMarketplaceStates] = useState<MarketplaceState[]>([]); 59: const [loading, setLoading] = useState(true); 60: const [selectedIndex, setSelectedIndex] = useState(0); 61: const [isProcessing, setIsProcessing] = useState(false); 62: const [processError, setProcessError] = useState<string | null>(null); 63: const [successMessage, setSuccessMessage] = useState<string | null>(null); 64: const [progressMessage, setProgressMessage] = useState<string | null>(null); 65: const [internalView, setInternalView] = useState<InternalViewState>('list'); 66: const [selectedMarketplace, setSelectedMarketplace] = useState<MarketplaceState | null>(null); 67: const [detailsMenuIndex, setDetailsMenuIndex] = useState(0); 68: const hasAttemptedAutoAction = useRef(false); 69: useEffect(() => { 70: async function loadMarketplaces() { 71: try { 72: const config = await loadKnownMarketplacesConfig(); 73: const { 74: enabled, 75: disabled 76: } = await loadAllPlugins(); 77: const allPlugins = [...enabled, ...disabled]; 78: const { 79: marketplaces, 80: failures 81: } = await loadMarketplacesWithGracefulDegradation(config); 82: const states: MarketplaceState[] = []; 83: for (const { 84: name, 85: config: entry, 86: data: marketplace 87: } of marketplaces) { 88: const installedFromMarketplace = allPlugins.filter(plugin => plugin.source.endsWith(`@${name}`)); 89: states.push({ 90: name, 91: source: getMarketplaceSourceDisplay(entry.source), 92: lastUpdated: entry.lastUpdated, 93: pluginCount: marketplace?.plugins.length, 94: installedPlugins: installedFromMarketplace, 95: pendingUpdate: false, 96: pendingRemove: false, 97: autoUpdate: isMarketplaceAutoUpdate(name, entry) 98: }); 99: } 100: states.sort((a, b) => { 101: if (a.name === 'claude-plugin-directory') return -1; 102: if (b.name === 'claude-plugin-directory') return 1; 103: return a.name.localeCompare(b.name); 104: }); 105: setMarketplaceStates(states); 106: const successCount = count(marketplaces, m => m.data !== null); 107: const errorResult = formatMarketplaceLoadingErrors(failures, successCount); 108: if (errorResult) { 109: if (errorResult.type === 'warning') { 110: setProcessError(errorResult.message); 111: } else { 112: throw new Error(errorResult.message); 113: } 114: } 115: if (targetMarketplace && !hasAttemptedAutoAction.current && !error) { 116: hasAttemptedAutoAction.current = true; 117: const targetIndex = states.findIndex(s => s.name === targetMarketplace); 118: if (targetIndex >= 0) { 119: const targetState = states[targetIndex]; 120: if (action) { 121: setSelectedIndex(targetIndex + 1); 122: const newStates = [...states]; 123: if (action === 'update') { 124: newStates[targetIndex]!.pendingUpdate = true; 125: } else if (action === 'remove') { 126: newStates[targetIndex]!.pendingRemove = true; 127: } 128: setMarketplaceStates(newStates); 129: setTimeout(applyChanges, 100, newStates); 130: } else if (targetState) { 131: setSelectedIndex(targetIndex + 1); 132: setSelectedMarketplace(targetState); 133: setInternalView('details'); 134: } 135: } else if (setError) { 136: setError(`Marketplace not found: ${targetMarketplace}`); 137: } 138: } 139: } catch (err) { 140: if (setError) { 141: setError(err instanceof Error ? err.message : 'Failed to load marketplaces'); 142: } 143: setProcessError(err instanceof Error ? err.message : 'Failed to load marketplaces'); 144: } finally { 145: setLoading(false); 146: } 147: } 148: void loadMarketplaces(); 149: }, [targetMarketplace, action, error]); 150: const hasPendingChanges = () => { 151: return marketplaceStates.some(state => state.pendingUpdate || state.pendingRemove); 152: }; 153: const getPendingCounts = () => { 154: const updateCount = count(marketplaceStates, s => s.pendingUpdate); 155: const removeCount = count(marketplaceStates, s => s.pendingRemove); 156: return { 157: updateCount, 158: removeCount 159: }; 160: }; 161: const applyChanges = async (states?: MarketplaceState[]) => { 162: const statesToProcess = states || marketplaceStates; 163: const wasInDetailsView = internalView === 'details'; 164: setIsProcessing(true); 165: setProcessError(null); 166: setSuccessMessage(null); 167: setProgressMessage(null); 168: try { 169: const settings = getSettingsForSource('userSettings'); 170: let updatedCount = 0; 171: let removedCount = 0; 172: const refreshedMarketplaces = new Set<string>(); 173: for (const state of statesToProcess) { 174: if (state.pendingRemove) { 175: if (state.installedPlugins && state.installedPlugins.length > 0) { 176: const newEnabledPlugins = { 177: ...settings?.enabledPlugins 178: }; 179: for (const plugin of state.installedPlugins) { 180: const pluginId = createPluginId(plugin.name, state.name); 181: newEnabledPlugins[pluginId] = false; 182: } 183: updateSettingsForSource('userSettings', { 184: enabledPlugins: newEnabledPlugins 185: }); 186: } 187: await removeMarketplaceSource(state.name); 188: removedCount++; 189: logEvent('tengu_marketplace_removed', { 190: marketplace_name: state.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 191: plugins_uninstalled: state.installedPlugins?.length || 0 192: }); 193: continue; 194: } 195: if (state.pendingUpdate) { 196: await refreshMarketplace(state.name, (message: string) => { 197: setProgressMessage(message); 198: }); 199: updatedCount++; 200: refreshedMarketplaces.add(state.name.toLowerCase()); 201: logEvent('tengu_marketplace_updated', { 202: marketplace_name: state.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 203: }); 204: } 205: } 206: let updatedPluginCount = 0; 207: if (refreshedMarketplaces.size > 0) { 208: const updatedPluginIds = await updatePluginsForMarketplaces(refreshedMarketplaces); 209: updatedPluginCount = updatedPluginIds.length; 210: } 211: clearAllCaches(); 212: if (onManageComplete) { 213: await onManageComplete(); 214: } 215: const config = await loadKnownMarketplacesConfig(); 216: const { 217: enabled, 218: disabled 219: } = await loadAllPlugins(); 220: const allPlugins = [...enabled, ...disabled]; 221: const { 222: marketplaces 223: } = await loadMarketplacesWithGracefulDegradation(config); 224: const newStates: MarketplaceState[] = []; 225: for (const { 226: name, 227: config: entry, 228: data: marketplace 229: } of marketplaces) { 230: const installedFromMarketplace = allPlugins.filter(plugin => plugin.source.endsWith(`@${name}`)); 231: newStates.push({ 232: name, 233: source: getMarketplaceSourceDisplay(entry.source), 234: lastUpdated: entry.lastUpdated, 235: pluginCount: marketplace?.plugins.length, 236: installedPlugins: installedFromMarketplace, 237: pendingUpdate: false, 238: pendingRemove: false, 239: autoUpdate: isMarketplaceAutoUpdate(name, entry) 240: }); 241: } 242: newStates.sort((a, b) => { 243: if (a.name === 'claude-plugin-directory') return -1; 244: if (b.name === 'claude-plugin-directory') return 1; 245: return a.name.localeCompare(b.name); 246: }); 247: setMarketplaceStates(newStates); 248: if (wasInDetailsView && selectedMarketplace) { 249: const updatedMarketplace = newStates.find(s => s.name === selectedMarketplace.name); 250: if (updatedMarketplace) { 251: setSelectedMarketplace(updatedMarketplace); 252: } 253: } 254: const actions: string[] = []; 255: if (updatedCount > 0) { 256: const pluginPart = updatedPluginCount > 0 ? ` (${updatedPluginCount} ${plural(updatedPluginCount, 'plugin')} bumped)` : ''; 257: actions.push(`Updated ${updatedCount} ${plural(updatedCount, 'marketplace')}${pluginPart}`); 258: } 259: if (removedCount > 0) { 260: actions.push(`Removed ${removedCount} ${plural(removedCount, 'marketplace')}`); 261: } 262: if (actions.length > 0) { 263: const successMsg = `${figures.tick} ${actions.join(', ')}`; 264: // If we were in details view, stay there and show success 265: if (wasInDetailsView) { 266: setSuccessMessage(successMsg); 267: } else { 268: // Otherwise show result and exit to menu 269: setResult(successMsg); 270: setTimeout(setViewState, 2000, { 271: type: 'menu' as const 272: }); 273: } 274: } else if (!wasInDetailsView) { 275: setViewState({ 276: type: 'menu' 277: }); 278: } 279: } catch (err) { 280: const errorMsg = errorMessage(err); 281: setProcessError(errorMsg); 282: if (setError) { 283: setError(errorMsg); 284: } 285: } finally { 286: setIsProcessing(false); 287: setProgressMessage(null); 288: } 289: }; 290: // Handle confirming marketplace removal 291: const confirmRemove = async () => { 292: if (!selectedMarketplace) return; 293: // Mark for removal and apply 294: const newStates = marketplaceStates.map(state => state.name === selectedMarketplace.name ? { 295: ...state, 296: pendingRemove: true 297: } : state); 298: setMarketplaceStates(newStates); 299: await applyChanges(newStates); 300: }; 301: // Build menu options for details view 302: const buildDetailsMenuOptions = (marketplace: MarketplaceState | null): Array<{ 303: label: string; 304: secondaryLabel?: string; 305: value: string; 306: }> => { 307: if (!marketplace) return []; 308: const options: Array<{ 309: label: string; 310: secondaryLabel?: string; 311: value: string; 312: }> = [{ 313: label: `Browse plugins (${marketplace.pluginCount ?? 0})`, 314: value: 'browse' 315: }, { 316: label: 'Update marketplace', 317: secondaryLabel: marketplace.lastUpdated ? `(last updated ${new Date(marketplace.lastUpdated).toLocaleDateString()})` : undefined, 318: value: 'update' 319: }]; 320: if (!shouldSkipPluginAutoupdate()) { 321: options.push({ 322: label: marketplace.autoUpdate ? 'Disable auto-update' : 'Enable auto-update', 323: value: 'toggle-auto-update' 324: }); 325: } 326: options.push({ 327: label: 'Remove marketplace', 328: value: 'remove' 329: }); 330: return options; 331: }; 332: const handleToggleAutoUpdate = async (marketplace: MarketplaceState) => { 333: const newAutoUpdate = !marketplace.autoUpdate; 334: try { 335: await setMarketplaceAutoUpdate(marketplace.name, newAutoUpdate); 336: setMarketplaceStates(prev => prev.map(state => state.name === marketplace.name ? { 337: ...state, 338: autoUpdate: newAutoUpdate 339: } : state)); 340: setSelectedMarketplace(prev => prev ? { 341: ...prev, 342: autoUpdate: newAutoUpdate 343: } : prev); 344: } catch (err) { 345: setProcessError(err instanceof Error ? err.message : 'Failed to update setting'); 346: } 347: }; 348: useKeybinding('confirm:no', () => { 349: setInternalView('list'); 350: setDetailsMenuIndex(0); 351: }, { 352: context: 'Confirmation', 353: isActive: !isProcessing && (internalView === 'details' || internalView === 'confirm-remove') 354: }); 355: useKeybinding('confirm:no', () => { 356: setMarketplaceStates(prev => prev.map(state => ({ 357: ...state, 358: pendingUpdate: false, 359: pendingRemove: false 360: }))); 361: setSelectedIndex(0); 362: }, { 363: context: 'Confirmation', 364: isActive: !isProcessing && internalView === 'list' && hasPendingChanges() 365: }); 366: useKeybinding('confirm:no', () => { 367: setViewState({ 368: type: 'menu' 369: }); 370: }, { 371: context: 'Confirmation', 372: isActive: !isProcessing && internalView === 'list' && !hasPendingChanges() 373: }); 374: useKeybindings({ 375: 'select:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), 376: 'select:next': () => { 377: const totalItems = marketplaceStates.length + 1; 378: setSelectedIndex(prev => Math.min(totalItems - 1, prev + 1)); 379: }, 380: 'select:accept': () => { 381: const marketplaceIndex = selectedIndex - 1; 382: if (selectedIndex === 0) { 383: setViewState({ 384: type: 'add-marketplace' 385: }); 386: } else if (hasPendingChanges()) { 387: void applyChanges(); 388: } else { 389: const marketplace = marketplaceStates[marketplaceIndex]; 390: if (marketplace) { 391: setSelectedMarketplace(marketplace); 392: setInternalView('details'); 393: setDetailsMenuIndex(0); 394: } 395: } 396: } 397: }, { 398: context: 'Select', 399: isActive: !isProcessing && internalView === 'list' 400: }); 401: useInput(input => { 402: const marketplaceIndex = selectedIndex - 1; 403: if ((input === 'u' || input === 'U') && marketplaceIndex >= 0) { 404: setMarketplaceStates(prev => prev.map((state, idx) => idx === marketplaceIndex ? { 405: ...state, 406: pendingUpdate: !state.pendingUpdate, 407: pendingRemove: state.pendingUpdate ? state.pendingRemove : false 408: } : state)); 409: } else if ((input === 'r' || input === 'R') && marketplaceIndex >= 0) { 410: const marketplace = marketplaceStates[marketplaceIndex]; 411: if (marketplace) { 412: setSelectedMarketplace(marketplace); 413: setInternalView('confirm-remove'); 414: } 415: } 416: }, { 417: isActive: !isProcessing && internalView === 'list' 418: }); 419: useKeybindings({ 420: 'select:previous': () => setDetailsMenuIndex(prev => Math.max(0, prev - 1)), 421: 'select:next': () => { 422: const menuOptions = buildDetailsMenuOptions(selectedMarketplace); 423: setDetailsMenuIndex(prev => Math.min(menuOptions.length - 1, prev + 1)); 424: }, 425: 'select:accept': () => { 426: if (!selectedMarketplace) return; 427: const menuOptions = buildDetailsMenuOptions(selectedMarketplace); 428: const selectedOption = menuOptions[detailsMenuIndex]; 429: if (selectedOption?.value === 'browse') { 430: setViewState({ 431: type: 'browse-marketplace', 432: targetMarketplace: selectedMarketplace.name 433: }); 434: } else if (selectedOption?.value === 'update') { 435: const newStates = marketplaceStates.map(state => state.name === selectedMarketplace.name ? { 436: ...state, 437: pendingUpdate: true 438: } : state); 439: setMarketplaceStates(newStates); 440: void applyChanges(newStates); 441: } else if (selectedOption?.value === 'toggle-auto-update') { 442: void handleToggleAutoUpdate(selectedMarketplace); 443: } else if (selectedOption?.value === 'remove') { 444: setInternalView('confirm-remove'); 445: } 446: } 447: }, { 448: context: 'Select', 449: isActive: !isProcessing && internalView === 'details' 450: }); 451: useInput(input => { 452: if (input === 'y' || input === 'Y') { 453: void confirmRemove(); 454: } else if (input === 'n' || input === 'N') { 455: setInternalView('list'); 456: setSelectedMarketplace(null); 457: } 458: }, { 459: isActive: !isProcessing && internalView === 'confirm-remove' 460: }); 461: if (loading) { 462: return <Text>Loading marketplaces…</Text>; 463: } 464: if (marketplaceStates.length === 0) { 465: return <Box flexDirection="column"> 466: <Box marginBottom={1}> 467: <Text bold>Manage marketplaces</Text> 468: </Box> 469: {} 470: <Box flexDirection="row" gap={1}> 471: <Text color="suggestion">{figures.pointer} +</Text> 472: <Text bold color="suggestion"> 473: Add Marketplace 474: </Text> 475: </Box> 476: <Box marginLeft={3}> 477: <Text dimColor italic> 478: {exitState.pending ? <>Press {exitState.keyName} again to go back</> : <Byline> 479: <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" /> 480: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" /> 481: </Byline>} 482: </Text> 483: </Box> 484: </Box>; 485: } 486: if (internalView === 'confirm-remove' && selectedMarketplace) { 487: const pluginCount = selectedMarketplace.installedPlugins?.length || 0; 488: return <Box flexDirection="column"> 489: <Text bold color="warning"> 490: Remove marketplace <Text italic>{selectedMarketplace.name}</Text>? 491: </Text> 492: <Box flexDirection="column"> 493: {pluginCount > 0 && <Box marginTop={1}> 494: <Text color="warning"> 495: This will also uninstall {pluginCount}{' '} 496: {plural(pluginCount, 'plugin')} from this marketplace: 497: </Text> 498: </Box>} 499: {selectedMarketplace.installedPlugins && selectedMarketplace.installedPlugins.length > 0 && <Box flexDirection="column" marginTop={1} marginLeft={2}> 500: {selectedMarketplace.installedPlugins.map(plugin => <Text key={plugin.name} dimColor> 501: • {plugin.name} 502: </Text>)} 503: </Box>} 504: <Box marginTop={1}> 505: <Text> 506: Press <Text bold>y</Text> to confirm or <Text bold>n</Text> to 507: cancel 508: </Text> 509: </Box> 510: </Box> 511: </Box>; 512: } 513: if (internalView === 'details' && selectedMarketplace) { 514: const isUpdating = selectedMarketplace.pendingUpdate || isProcessing; 515: const menuOptions = buildDetailsMenuOptions(selectedMarketplace); 516: return <Box flexDirection="column"> 517: <Text bold>{selectedMarketplace.name}</Text> 518: <Text dimColor>{selectedMarketplace.source}</Text> 519: <Box marginTop={1}> 520: <Text> 521: {selectedMarketplace.pluginCount || 0} available{' '} 522: {plural(selectedMarketplace.pluginCount || 0, 'plugin')} 523: </Text> 524: </Box> 525: {} 526: {selectedMarketplace.installedPlugins && selectedMarketplace.installedPlugins.length > 0 && <Box flexDirection="column" marginTop={1}> 527: <Text bold> 528: Installed plugins ({selectedMarketplace.installedPlugins.length} 529: ): 530: </Text> 531: <Box flexDirection="column" marginLeft={1}> 532: {selectedMarketplace.installedPlugins.map(plugin => <Box key={plugin.name} flexDirection="row" gap={1}> 533: <Text>{figures.bullet}</Text> 534: <Box flexDirection="column"> 535: <Text>{plugin.name}</Text> 536: <Text dimColor>{plugin.manifest.description}</Text> 537: </Box> 538: </Box>)} 539: </Box> 540: </Box>} 541: {} 542: {isUpdating && <Box marginTop={1} flexDirection="column"> 543: <Text color="claude">Updating marketplace…</Text> 544: {progressMessage && <Text dimColor>{progressMessage}</Text>} 545: </Box>} 546: {} 547: {!isUpdating && successMessage && <Box marginTop={1}> 548: <Text color="claude">{successMessage}</Text> 549: </Box>} 550: {} 551: {!isUpdating && processError && <Box marginTop={1}> 552: <Text color="error">{processError}</Text> 553: </Box>} 554: {} 555: {!isUpdating && <Box flexDirection="column" marginTop={1}> 556: {menuOptions.map((option, idx) => { 557: if (!option) return null; 558: const isSelected = idx === detailsMenuIndex; 559: return <Box key={option.value}> 560: <Text color={isSelected ? 'suggestion' : undefined}> 561: {isSelected ? figures.pointer : ' '} {option.label} 562: </Text> 563: {option.secondaryLabel && <Text dimColor> {option.secondaryLabel}</Text>} 564: </Box>; 565: })} 566: </Box>} 567: {} 568: {!isUpdating && !shouldSkipPluginAutoupdate() && selectedMarketplace.autoUpdate && <Box marginTop={1}> 569: <Text dimColor> 570: Auto-update enabled. Claude Code will automatically update this 571: marketplace and its installed plugins. 572: </Text> 573: </Box>} 574: <Box marginLeft={3}> 575: <Text dimColor italic> 576: {isUpdating ? <>Please wait…</> : <Byline> 577: <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" /> 578: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" /> 579: </Byline>} 580: </Text> 581: </Box> 582: </Box>; 583: } 584: const { 585: updateCount, 586: removeCount 587: } = getPendingCounts(); 588: return <Box flexDirection="column"> 589: <Box marginBottom={1}> 590: <Text bold>Manage marketplaces</Text> 591: </Box> 592: {} 593: <Box flexDirection="row" gap={1} marginBottom={1}> 594: <Text color={selectedIndex === 0 ? 'suggestion' : undefined}> 595: {selectedIndex === 0 ? figures.pointer : ' '} + 596: </Text> 597: <Text bold color={selectedIndex === 0 ? 'suggestion' : undefined}> 598: Add Marketplace 599: </Text> 600: </Box> 601: {} 602: <Box flexDirection="column"> 603: {marketplaceStates.map((state, idx) => { 604: const isSelected = idx + 1 === selectedIndex; 605: const indicators: string[] = []; 606: if (state.pendingUpdate) indicators.push('UPDATE'); 607: if (state.pendingRemove) indicators.push('REMOVE'); 608: return <Box key={state.name} flexDirection="row" gap={1} marginBottom={1}> 609: <Text color={isSelected ? 'suggestion' : undefined}> 610: {isSelected ? figures.pointer : ' '}{' '} 611: {state.pendingRemove ? figures.cross : figures.bullet} 612: </Text> 613: <Box flexDirection="column" flexGrow={1}> 614: <Box flexDirection="row" gap={1}> 615: <Text bold strikethrough={state.pendingRemove} dimColor={state.pendingRemove}> 616: {state.name === 'claude-plugins-official' && <Text color="claude">✻ </Text>} 617: {state.name} 618: {state.name === 'claude-plugins-official' && <Text color="claude"> ✻</Text>} 619: </Text> 620: {indicators.length > 0 && <Text color="warning">[{indicators.join(', ')}]</Text>} 621: </Box> 622: <Text dimColor>{state.source}</Text> 623: <Text dimColor> 624: {state.pluginCount !== undefined && <>{state.pluginCount} available</>} 625: {state.installedPlugins && state.installedPlugins.length > 0 && <> • {state.installedPlugins.length} installed</>} 626: {state.lastUpdated && <> 627: {' '} 628: • Updated{' '} 629: {new Date(state.lastUpdated).toLocaleDateString()} 630: </>} 631: </Text> 632: </Box> 633: </Box>; 634: })} 635: </Box> 636: {} 637: {hasPendingChanges() && <Box marginTop={1} flexDirection="column"> 638: <Text> 639: <Text bold>Pending changes:</Text>{' '} 640: <Text dimColor>Enter to apply</Text> 641: </Text> 642: {updateCount > 0 && <Text> 643: • Update {updateCount} {plural(updateCount, 'marketplace')} 644: </Text>} 645: {removeCount > 0 && <Text color="warning"> 646: • Remove {removeCount} {plural(removeCount, 'marketplace')} 647: </Text>} 648: </Box>} 649: {} 650: {isProcessing && <Box marginTop={1}> 651: <Text color="claude">Processing changes…</Text> 652: </Box>} 653: {} 654: {processError && <Box marginTop={1}> 655: <Text color="error">{processError}</Text> 656: </Box>} 657: <ManageMarketplacesKeyHints exitState={exitState} hasPendingActions={hasPendingChanges()} /> 658: </Box>; 659: } 660: type ManageMarketplacesKeyHintsProps = { 661: exitState: Props['exitState']; 662: hasPendingActions: boolean; 663: }; 664: function ManageMarketplacesKeyHints(t0) { 665: const $ = _c(18); 666: const { 667: exitState, 668: hasPendingActions 669: } = t0; 670: if (exitState.pending) { 671: let t1; 672: if ($[0] !== exitState.keyName) { 673: t1 = <Box marginTop={1}><Text dimColor={true} italic={true}>Press {exitState.keyName} again to go back</Text></Box>; 674: $[0] = exitState.keyName; 675: $[1] = t1; 676: } else { 677: t1 = $[1]; 678: } 679: return t1; 680: } 681: let t1; 682: if ($[2] !== hasPendingActions) { 683: t1 = hasPendingActions && <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="apply changes" />; 684: $[2] = hasPendingActions; 685: $[3] = t1; 686: } else { 687: t1 = $[3]; 688: } 689: let t2; 690: if ($[4] !== hasPendingActions) { 691: t2 = !hasPendingActions && <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" />; 692: $[4] = hasPendingActions; 693: $[5] = t2; 694: } else { 695: t2 = $[5]; 696: } 697: let t3; 698: if ($[6] !== hasPendingActions) { 699: t3 = !hasPendingActions && <KeyboardShortcutHint shortcut="u" action="update" />; 700: $[6] = hasPendingActions; 701: $[7] = t3; 702: } else { 703: t3 = $[7]; 704: } 705: let t4; 706: if ($[8] !== hasPendingActions) { 707: t4 = !hasPendingActions && <KeyboardShortcutHint shortcut="r" action="remove" />; 708: $[8] = hasPendingActions; 709: $[9] = t4; 710: } else { 711: t4 = $[9]; 712: } 713: const t5 = hasPendingActions ? "cancel" : "go back"; 714: let t6; 715: if ($[10] !== t5) { 716: t6 = <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description={t5} />; 717: $[10] = t5; 718: $[11] = t6; 719: } else { 720: t6 = $[11]; 721: } 722: let t7; 723: if ($[12] !== t1 || $[13] !== t2 || $[14] !== t3 || $[15] !== t4 || $[16] !== t6) { 724: t7 = <Box marginTop={1}><Text dimColor={true} italic={true}><Byline>{t1}{t2}{t3}{t4}{t6}</Byline></Text></Box>; 725: $[12] = t1; 726: $[13] = t2; 727: $[14] = t3; 728: $[15] = t4; 729: $[16] = t6; 730: $[17] = t7; 731: } else { 732: t7 = $[17]; 733: } 734: return t7; 735: }

File: src/commands/plugin/ManagePlugins.tsx

typescript 1: import figures from 'figures'; 2: import type { Dirent } from 'fs'; 3: import * as fs from 'fs/promises'; 4: import * as path from 'path'; 5: import * as React from 'react'; 6: import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 7: import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; 8: import { Byline } from '../../components/design-system/Byline.js'; 9: import { MCPRemoteServerMenu } from '../../components/mcp/MCPRemoteServerMenu.js'; 10: import { MCPStdioServerMenu } from '../../components/mcp/MCPStdioServerMenu.js'; 11: import { MCPToolDetailView } from '../../components/mcp/MCPToolDetailView.js'; 12: import { MCPToolListView } from '../../components/mcp/MCPToolListView.js'; 13: import type { ClaudeAIServerInfo, HTTPServerInfo, SSEServerInfo, StdioServerInfo } from '../../components/mcp/types.js'; 14: import { SearchBox } from '../../components/SearchBox.js'; 15: import { useSearchInput } from '../../hooks/useSearchInput.js'; 16: import { useTerminalSize } from '../../hooks/useTerminalSize.js'; 17: import { Box, Text, useInput, useTerminalFocus } from '../../ink.js'; 18: import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; 19: import { getBuiltinPluginDefinition } from '../../plugins/builtinPlugins.js'; 20: import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'; 21: import type { MCPServerConnection, McpClaudeAIProxyServerConfig, McpHTTPServerConfig, McpSSEServerConfig, McpStdioServerConfig } from '../../services/mcp/types.js'; 22: import { filterToolsByServer } from '../../services/mcp/utils.js'; 23: import { disablePluginOp, enablePluginOp, getPluginInstallationFromV2, isInstallableScope, isPluginEnabledAtProjectScope, uninstallPluginOp, updatePluginOp } from '../../services/plugins/pluginOperations.js'; 24: import { useAppState } from '../../state/AppState.js'; 25: import type { Tool } from '../../Tool.js'; 26: import type { LoadedPlugin, PluginError } from '../../types/plugin.js'; 27: import { count } from '../../utils/array.js'; 28: import { openBrowser } from '../../utils/browser.js'; 29: import { logForDebugging } from '../../utils/debug.js'; 30: import { errorMessage, toError } from '../../utils/errors.js'; 31: import { logError } from '../../utils/log.js'; 32: import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; 33: import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'; 34: import { getMarketplace } from '../../utils/plugins/marketplaceManager.js'; 35: import { isMcpbSource, loadMcpbFile, type McpbNeedsConfigResult, type UserConfigValues } from '../../utils/plugins/mcpbHandler.js'; 36: import { getPluginDataDirSize, pluginDataDirPath } from '../../utils/plugins/pluginDirectories.js'; 37: import { getFlaggedPlugins, markFlaggedPluginsSeen, removeFlaggedPlugin } from '../../utils/plugins/pluginFlagging.js'; 38: import { type PersistablePluginScope, parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'; 39: import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'; 40: import { loadPluginOptions, type PluginOptionSchema, savePluginOptions } from '../../utils/plugins/pluginOptionsStorage.js'; 41: import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js'; 42: import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js'; 43: import { getSettings_DEPRECATED, getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'; 44: import { jsonParse } from '../../utils/slowOperations.js'; 45: import { plural } from '../../utils/stringUtils.js'; 46: import { formatErrorMessage, getErrorGuidance } from './PluginErrors.js'; 47: import { PluginOptionsDialog } from './PluginOptionsDialog.js'; 48: import { PluginOptionsFlow } from './PluginOptionsFlow.js'; 49: import type { ViewState as ParentViewState } from './types.js'; 50: import { UnifiedInstalledCell } from './UnifiedInstalledCell.js'; 51: import type { UnifiedInstalledItem } from './unifiedTypes.js'; 52: import { usePagination } from './usePagination.js'; 53: type Props = { 54: setViewState: (state: ParentViewState) => void; 55: setResult: (result: string | null) => void; 56: onManageComplete?: () => void | Promise<void>; 57: onSearchModeChange?: (isActive: boolean) => void; 58: targetPlugin?: string; 59: targetMarketplace?: string; 60: action?: 'enable' | 'disable' | 'uninstall'; 61: }; 62: type FlaggedPluginInfo = { 63: id: string; 64: name: string; 65: marketplace: string; 66: reason: string; 67: text: string; 68: flaggedAt: string; 69: }; 70: type FailedPluginInfo = { 71: id: string; 72: name: string; 73: marketplace: string; 74: errors: PluginError[]; 75: scope: PersistablePluginScope; 76: }; 77: type ViewState = 'plugin-list' | 'plugin-details' | 'configuring' | { 78: type: 'plugin-options'; 79: } | { 80: type: 'configuring-options'; 81: schema: PluginOptionSchema; 82: } | 'confirm-project-uninstall' | { 83: type: 'confirm-data-cleanup'; 84: size: { 85: bytes: number; 86: human: string; 87: }; 88: } | { 89: type: 'flagged-detail'; 90: plugin: FlaggedPluginInfo; 91: } | { 92: type: 'failed-plugin-details'; 93: plugin: FailedPluginInfo; 94: } | { 95: type: 'mcp-detail'; 96: client: MCPServerConnection; 97: } | { 98: type: 'mcp-tools'; 99: client: MCPServerConnection; 100: } | { 101: type: 'mcp-tool-detail'; 102: client: MCPServerConnection; 103: tool: Tool; 104: }; 105: type MarketplaceInfo = { 106: name: string; 107: installedPlugins: LoadedPlugin[]; 108: enabledCount?: number; 109: disabledCount?: number; 110: }; 111: type PluginState = { 112: plugin: LoadedPlugin; 113: marketplace: string; 114: scope?: 'user' | 'project' | 'local' | 'managed' | 'builtin'; 115: pendingEnable?: boolean; 116: pendingUpdate?: boolean; 117: }; 118: async function getBaseFileNames(dirPath: string): Promise<string[]> { 119: try { 120: const entries = await fs.readdir(dirPath, { 121: withFileTypes: true 122: }); 123: return entries.filter((entry: Dirent) => entry.isFile() && entry.name.endsWith('.md')).map((entry: Dirent) => { 124: const baseName = path.basename(entry.name, '.md'); 125: return baseName; 126: }); 127: } catch (error) { 128: const errorMsg = errorMessage(error); 129: logForDebugging(`Failed to read plugin components from ${dirPath}: ${errorMsg}`, { 130: level: 'error' 131: }); 132: logError(toError(error)); 133: return []; 134: } 135: } 136: async function getSkillDirNames(dirPath: string): Promise<string[]> { 137: try { 138: const entries = await fs.readdir(dirPath, { 139: withFileTypes: true 140: }); 141: const skillNames: string[] = []; 142: for (const entry of entries) { 143: if (entry.isDirectory() || entry.isSymbolicLink()) { 144: const skillFilePath = path.join(dirPath, entry.name, 'SKILL.md'); 145: try { 146: const st = await fs.stat(skillFilePath); 147: if (st.isFile()) { 148: skillNames.push(entry.name); 149: } 150: } catch { 151: } 152: } 153: } 154: return skillNames; 155: } catch (error) { 156: const errorMsg = errorMessage(error); 157: logForDebugging(`Failed to read skill directories from ${dirPath}: ${errorMsg}`, { 158: level: 'error' 159: }); 160: logError(toError(error)); 161: return []; 162: } 163: } 164: function PluginComponentsDisplay({ 165: plugin, 166: marketplace 167: }: { 168: plugin: LoadedPlugin; 169: marketplace: string; 170: }): React.ReactNode { 171: const [components, setComponents] = useState<{ 172: commands?: string | string[] | Record<string, unknown> | null; 173: agents?: string | string[] | Record<string, unknown> | null; 174: skills?: string | string[] | Record<string, unknown> | null; 175: hooks?: unknown; 176: mcpServers?: unknown; 177: } | null>(null); 178: const [loading, setLoading] = useState(true); 179: const [error, setError] = useState<string | null>(null); 180: useEffect(() => { 181: async function loadComponents() { 182: try { 183: if (marketplace === 'builtin') { 184: const builtinDef = getBuiltinPluginDefinition(plugin.name); 185: if (builtinDef) { 186: const skillNames = builtinDef.skills?.map(s => s.name) ?? []; 187: const hookEvents = builtinDef.hooks ? Object.keys(builtinDef.hooks) : []; 188: const mcpServerNames = builtinDef.mcpServers ? Object.keys(builtinDef.mcpServers) : []; 189: setComponents({ 190: commands: null, 191: agents: null, 192: skills: skillNames.length > 0 ? skillNames : null, 193: hooks: hookEvents.length > 0 ? hookEvents : null, 194: mcpServers: mcpServerNames.length > 0 ? mcpServerNames : null 195: }); 196: } else { 197: setError(`Built-in plugin ${plugin.name} not found`); 198: } 199: setLoading(false); 200: return; 201: } 202: const marketplaceData = await getMarketplace(marketplace); 203: const pluginEntry = marketplaceData.plugins.find(p => p.name === plugin.name); 204: if (pluginEntry) { 205: const commandPathList = []; 206: if (plugin.commandsPath) { 207: commandPathList.push(plugin.commandsPath); 208: } 209: if (plugin.commandsPaths) { 210: commandPathList.push(...plugin.commandsPaths); 211: } 212: const commandList: string[] = []; 213: for (const commandPath of commandPathList) { 214: if (typeof commandPath === 'string') { 215: const baseNames = await getBaseFileNames(commandPath); 216: commandList.push(...baseNames); 217: } 218: } 219: const agentPathList = []; 220: if (plugin.agentsPath) { 221: agentPathList.push(plugin.agentsPath); 222: } 223: if (plugin.agentsPaths) { 224: agentPathList.push(...plugin.agentsPaths); 225: } 226: const agentList: string[] = []; 227: for (const agentPath of agentPathList) { 228: if (typeof agentPath === 'string') { 229: const baseNames_0 = await getBaseFileNames(agentPath); 230: agentList.push(...baseNames_0); 231: } 232: } 233: const skillPathList = []; 234: if (plugin.skillsPath) { 235: skillPathList.push(plugin.skillsPath); 236: } 237: if (plugin.skillsPaths) { 238: skillPathList.push(...plugin.skillsPaths); 239: } 240: const skillList: string[] = []; 241: for (const skillPath of skillPathList) { 242: if (typeof skillPath === 'string') { 243: const skillDirNames = await getSkillDirNames(skillPath); 244: skillList.push(...skillDirNames); 245: } 246: } 247: const hooksList = []; 248: if (plugin.hooksConfig) { 249: hooksList.push(Object.keys(plugin.hooksConfig)); 250: } 251: if (pluginEntry.hooks) { 252: hooksList.push(pluginEntry.hooks); 253: } 254: const mcpServersList = []; 255: if (plugin.mcpServers) { 256: mcpServersList.push(Object.keys(plugin.mcpServers)); 257: } 258: if (pluginEntry.mcpServers) { 259: mcpServersList.push(pluginEntry.mcpServers); 260: } 261: setComponents({ 262: commands: commandList.length > 0 ? commandList : null, 263: agents: agentList.length > 0 ? agentList : null, 264: skills: skillList.length > 0 ? skillList : null, 265: hooks: hooksList.length > 0 ? hooksList : null, 266: mcpServers: mcpServersList.length > 0 ? mcpServersList : null 267: }); 268: } else { 269: setError(`Plugin ${plugin.name} not found in marketplace`); 270: } 271: } catch (err) { 272: setError(err instanceof Error ? err.message : 'Failed to load components'); 273: } finally { 274: setLoading(false); 275: } 276: } 277: void loadComponents(); 278: }, [plugin.name, plugin.commandsPath, plugin.commandsPaths, plugin.agentsPath, plugin.agentsPaths, plugin.skillsPath, plugin.skillsPaths, plugin.hooksConfig, plugin.mcpServers, marketplace]); 279: if (loading) { 280: return null; 281: } 282: if (error) { 283: return <Box flexDirection="column" marginBottom={1}> 284: <Text bold>Components:</Text> 285: <Text dimColor>Error: {error}</Text> 286: </Box>; 287: } 288: if (!components) { 289: return null; 290: } 291: const hasComponents = components.commands || components.agents || components.skills || components.hooks || components.mcpServers; 292: if (!hasComponents) { 293: return null; 294: } 295: return <Box flexDirection="column" marginBottom={1}> 296: <Text bold>Installed components:</Text> 297: {components.commands ? <Text dimColor> 298: • Commands:{' '} 299: {typeof components.commands === 'string' ? components.commands : Array.isArray(components.commands) ? components.commands.join(', ') : Object.keys(components.commands).join(', ')} 300: </Text> : null} 301: {components.agents ? <Text dimColor> 302: • Agents:{' '} 303: {typeof components.agents === 'string' ? components.agents : Array.isArray(components.agents) ? components.agents.join(', ') : Object.keys(components.agents).join(', ')} 304: </Text> : null} 305: {components.skills ? <Text dimColor> 306: • Skills:{' '} 307: {typeof components.skills === 'string' ? components.skills : Array.isArray(components.skills) ? components.skills.join(', ') : Object.keys(components.skills).join(', ')} 308: </Text> : null} 309: {components.hooks ? <Text dimColor> 310: • Hooks:{' '} 311: {typeof components.hooks === 'string' ? components.hooks : Array.isArray(components.hooks) ? components.hooks.map(String).join(', ') : typeof components.hooks === 'object' && components.hooks !== null ? Object.keys(components.hooks).join(', ') : String(components.hooks)} 312: </Text> : null} 313: {components.mcpServers ? <Text dimColor> 314: • MCP Servers:{' '} 315: {typeof components.mcpServers === 'string' ? components.mcpServers : Array.isArray(components.mcpServers) ? components.mcpServers.map(String).join(', ') : typeof components.mcpServers === 'object' && components.mcpServers !== null ? Object.keys(components.mcpServers).join(', ') : String(components.mcpServers)} 316: </Text> : null} 317: </Box>; 318: } 319: async function checkIfLocalPlugin(pluginName: string, marketplaceName: string): Promise<string | null> { 320: const marketplace = await getMarketplace(marketplaceName); 321: const entry = marketplace?.plugins.find(p => p.name === pluginName); 322: if (entry && typeof entry.source === 'string') { 323: return `Local plugins cannot be updated remotely. To update, modify the source at: ${entry.source}`; 324: } 325: return null; 326: } 327: export function filterManagedDisabledPlugins(plugins: LoadedPlugin[]): LoadedPlugin[] { 328: return plugins.filter(plugin => { 329: const marketplace = plugin.source.split('@')[1] || 'local'; 330: return !isPluginBlockedByPolicy(`${plugin.name}@${marketplace}`); 331: }); 332: } 333: export function ManagePlugins({ 334: setViewState: setParentViewState, 335: setResult, 336: onManageComplete, 337: onSearchModeChange, 338: targetPlugin, 339: targetMarketplace, 340: action 341: }: Props): React.ReactNode { 342: const mcpClients = useAppState(s => s.mcp.clients); 343: const mcpTools = useAppState(s_0 => s_0.mcp.tools); 344: const pluginErrors = useAppState(s_1 => s_1.plugins.errors); 345: const flaggedPlugins = getFlaggedPlugins(); 346: const [isSearchMode, setIsSearchModeRaw] = useState(false); 347: const setIsSearchMode = useCallback((active: boolean) => { 348: setIsSearchModeRaw(active); 349: onSearchModeChange?.(active); 350: }, [onSearchModeChange]); 351: const isTerminalFocused = useTerminalFocus(); 352: const { 353: columns: terminalWidth 354: } = useTerminalSize(); 355: const [viewState, setViewState] = useState<ViewState>('plugin-list'); 356: const { 357: query: searchQuery, 358: setQuery: setSearchQuery, 359: cursorOffset: searchCursorOffset 360: } = useSearchInput({ 361: isActive: viewState === 'plugin-list' && isSearchMode, 362: onExit: () => { 363: setIsSearchMode(false); 364: } 365: }); 366: const [selectedPlugin, setSelectedPlugin] = useState<PluginState | null>(null); 367: const [marketplaces, setMarketplaces] = useState<MarketplaceInfo[]>([]); 368: const [pluginStates, setPluginStates] = useState<PluginState[]>([]); 369: const [loading, setLoading] = useState(true); 370: const [pendingToggles, setPendingToggles] = useState<Map<string, 'will-enable' | 'will-disable'>>(new Map()); 371: const hasAutoNavigated = useRef(false); 372: const pendingAutoActionRef = useRef<'enable' | 'disable' | 'uninstall' | undefined>(undefined); 373: const toggleMcpServer = useMcpToggleEnabled(); 374: const handleBack = React.useCallback(() => { 375: if (viewState === 'plugin-details') { 376: setViewState('plugin-list'); 377: setSelectedPlugin(null); 378: setProcessError(null); 379: } else if (typeof viewState === 'object' && viewState.type === 'failed-plugin-details') { 380: setViewState('plugin-list'); 381: setProcessError(null); 382: } else if (viewState === 'configuring') { 383: setViewState('plugin-details'); 384: setConfigNeeded(null); 385: } else if (typeof viewState === 'object' && (viewState.type === 'plugin-options' || viewState.type === 'configuring-options')) { 386: setViewState('plugin-list'); 387: setSelectedPlugin(null); 388: setResult('Plugin enabled. Configuration skipped — run /reload-plugins to apply.'); 389: if (onManageComplete) { 390: void onManageComplete(); 391: } 392: } else if (typeof viewState === 'object' && viewState.type === 'flagged-detail') { 393: setViewState('plugin-list'); 394: setProcessError(null); 395: } else if (typeof viewState === 'object' && viewState.type === 'mcp-detail') { 396: setViewState('plugin-list'); 397: setProcessError(null); 398: } else if (typeof viewState === 'object' && viewState.type === 'mcp-tools') { 399: setViewState({ 400: type: 'mcp-detail', 401: client: viewState.client 402: }); 403: } else if (typeof viewState === 'object' && viewState.type === 'mcp-tool-detail') { 404: setViewState({ 405: type: 'mcp-tools', 406: client: viewState.client 407: }); 408: } else { 409: if (pendingToggles.size > 0) { 410: setResult('Run /reload-plugins to apply plugin changes.'); 411: return; 412: } 413: setParentViewState({ 414: type: 'menu' 415: }); 416: } 417: }, [viewState, setParentViewState, pendingToggles, setResult]); 418: useKeybinding('confirm:no', handleBack, { 419: context: 'Confirmation', 420: isActive: (viewState !== 'plugin-list' || !isSearchMode) && viewState !== 'confirm-project-uninstall' && !(typeof viewState === 'object' && viewState.type === 'confirm-data-cleanup') 421: }); 422: const getMcpStatus = (client: MCPServerConnection): 'connected' | 'disabled' | 'pending' | 'needs-auth' | 'failed' => { 423: if (client.type === 'connected') return 'connected'; 424: if (client.type === 'disabled') return 'disabled'; 425: if (client.type === 'pending') return 'pending'; 426: if (client.type === 'needs-auth') return 'needs-auth'; 427: return 'failed'; 428: }; 429: const unifiedItems = useMemo(() => { 430: const mergedSettings = getSettings_DEPRECATED(); 431: const pluginMcpMap = new Map<string, Array<{ 432: displayName: string; 433: client: MCPServerConnection; 434: }>>(); 435: for (const client_0 of mcpClients) { 436: if (client_0.name.startsWith('plugin:')) { 437: const parts = client_0.name.split(':'); 438: if (parts.length >= 3) { 439: const pluginName = parts[1]!; 440: const serverName = parts.slice(2).join(':'); 441: const existing = pluginMcpMap.get(pluginName) || []; 442: existing.push({ 443: displayName: serverName, 444: client: client_0 445: }); 446: pluginMcpMap.set(pluginName, existing); 447: } 448: } 449: } 450: type PluginWithChildren = { 451: item: UnifiedInstalledItem & { 452: type: 'plugin'; 453: }; 454: originalScope: 'user' | 'project' | 'local' | 'managed' | 'builtin'; 455: childMcps: Array<{ 456: displayName: string; 457: client: MCPServerConnection; 458: }>; 459: }; 460: const pluginsWithChildren: PluginWithChildren[] = []; 461: for (const state of pluginStates) { 462: const pluginId = `${state.plugin.name}@${state.marketplace}`; 463: const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false; 464: const errors = pluginErrors.filter(e => 'plugin' in e && e.plugin === state.plugin.name || e.source === pluginId || e.source.startsWith(`${state.plugin.name}@`)); 465: const originalScope = state.plugin.isBuiltin ? 'builtin' : state.scope || 'user'; 466: pluginsWithChildren.push({ 467: item: { 468: type: 'plugin', 469: id: pluginId, 470: name: state.plugin.name, 471: description: state.plugin.manifest.description, 472: marketplace: state.marketplace, 473: scope: originalScope, 474: isEnabled, 475: errorCount: errors.length, 476: errors, 477: plugin: state.plugin, 478: pendingEnable: state.pendingEnable, 479: pendingUpdate: state.pendingUpdate, 480: pendingToggle: pendingToggles.get(pluginId) 481: }, 482: originalScope, 483: childMcps: pluginMcpMap.get(state.plugin.name) || [] 484: }); 485: } 486: const matchedPluginIds = new Set(pluginsWithChildren.map(({ 487: item 488: }) => item.id)); 489: const matchedPluginNames = new Set(pluginsWithChildren.map(({ 490: item: item_0 491: }) => item_0.name)); 492: const orphanErrorsBySource = new Map<string, typeof pluginErrors>(); 493: for (const error of pluginErrors) { 494: if (matchedPluginIds.has(error.source) || 'plugin' in error && typeof error.plugin === 'string' && matchedPluginNames.has(error.plugin)) { 495: continue; 496: } 497: const existing_0 = orphanErrorsBySource.get(error.source) || []; 498: existing_0.push(error); 499: orphanErrorsBySource.set(error.source, existing_0); 500: } 501: const pluginScopes = getPluginEditableScopes(); 502: const failedPluginItems: UnifiedInstalledItem[] = []; 503: for (const [pluginId_0, errors_0] of orphanErrorsBySource) { 504: if (pluginId_0 in flaggedPlugins) continue; 505: const parsed = parsePluginIdentifier(pluginId_0); 506: const pluginName_0 = parsed.name || pluginId_0; 507: const marketplace = parsed.marketplace || 'unknown'; 508: const rawScope = pluginScopes.get(pluginId_0); 509: const scope = rawScope === 'flag' || rawScope === undefined ? 'user' : rawScope; 510: failedPluginItems.push({ 511: type: 'failed-plugin', 512: id: pluginId_0, 513: name: pluginName_0, 514: marketplace, 515: scope, 516: errorCount: errors_0.length, 517: errors: errors_0 518: }); 519: } 520: const standaloneMcps: UnifiedInstalledItem[] = []; 521: for (const client_1 of mcpClients) { 522: if (client_1.name === 'ide') continue; 523: if (client_1.name.startsWith('plugin:')) continue; 524: standaloneMcps.push({ 525: type: 'mcp', 526: id: `mcp:${client_1.name}`, 527: name: client_1.name, 528: description: undefined, 529: scope: client_1.config.scope, 530: status: getMcpStatus(client_1), 531: client: client_1 532: }); 533: } 534: const scopeOrder: Record<string, number> = { 535: flagged: -1, 536: project: 0, 537: local: 1, 538: user: 2, 539: enterprise: 3, 540: managed: 4, 541: dynamic: 5, 542: builtin: 6 543: }; 544: const unified: UnifiedInstalledItem[] = []; 545: const itemsByScope = new Map<string, UnifiedInstalledItem[]>(); 546: for (const { 547: item: item_1, 548: originalScope: originalScope_0, 549: childMcps 550: } of pluginsWithChildren) { 551: const scope_0 = item_1.scope; 552: if (!itemsByScope.has(scope_0)) { 553: itemsByScope.set(scope_0, []); 554: } 555: itemsByScope.get(scope_0)!.push(item_1); 556: for (const { 557: displayName, 558: client: client_2 559: } of childMcps) { 560: const displayScope = originalScope_0 === 'builtin' ? 'user' : originalScope_0; 561: if (!itemsByScope.has(displayScope)) { 562: itemsByScope.set(displayScope, []); 563: } 564: itemsByScope.get(displayScope)!.push({ 565: type: 'mcp', 566: id: `mcp:${client_2.name}`, 567: name: displayName, 568: description: undefined, 569: scope: displayScope, 570: status: getMcpStatus(client_2), 571: client: client_2, 572: indented: true 573: }); 574: } 575: } 576: for (const mcp of standaloneMcps) { 577: const scope_1 = mcp.scope; 578: if (!itemsByScope.has(scope_1)) { 579: itemsByScope.set(scope_1, []); 580: } 581: itemsByScope.get(scope_1)!.push(mcp); 582: } 583: for (const failedPlugin of failedPluginItems) { 584: const scope_2 = failedPlugin.scope; 585: if (!itemsByScope.has(scope_2)) { 586: itemsByScope.set(scope_2, []); 587: } 588: itemsByScope.get(scope_2)!.push(failedPlugin); 589: } 590: for (const [pluginId_1, entry] of Object.entries(flaggedPlugins)) { 591: const parsed_0 = parsePluginIdentifier(pluginId_1); 592: const pluginName_1 = parsed_0.name || pluginId_1; 593: const marketplace_0 = parsed_0.marketplace || 'unknown'; 594: if (!itemsByScope.has('flagged')) { 595: itemsByScope.set('flagged', []); 596: } 597: itemsByScope.get('flagged')!.push({ 598: type: 'flagged-plugin', 599: id: pluginId_1, 600: name: pluginName_1, 601: marketplace: marketplace_0, 602: scope: 'flagged', 603: reason: 'delisted', 604: text: 'Removed from marketplace', 605: flaggedAt: entry.flaggedAt 606: }); 607: } 608: const sortedScopes = [...itemsByScope.keys()].sort((a, b) => (scopeOrder[a] ?? 99) - (scopeOrder[b] ?? 99)); 609: for (const scope_3 of sortedScopes) { 610: const items = itemsByScope.get(scope_3)!; 611: const pluginGroups: UnifiedInstalledItem[][] = []; 612: const standaloneMcpsInScope: UnifiedInstalledItem[] = []; 613: let i = 0; 614: while (i < items.length) { 615: const item_2 = items[i]!; 616: if (item_2.type === 'plugin' || item_2.type === 'failed-plugin' || item_2.type === 'flagged-plugin') { 617: const group: UnifiedInstalledItem[] = [item_2]; 618: i++; 619: let nextItem = items[i]; 620: while (nextItem?.type === 'mcp' && nextItem.indented) { 621: group.push(nextItem); 622: i++; 623: nextItem = items[i]; 624: } 625: pluginGroups.push(group); 626: } else if (item_2.type === 'mcp' && !item_2.indented) { 627: standaloneMcpsInScope.push(item_2); 628: i++; 629: } else { 630: i++; 631: } 632: } 633: pluginGroups.sort((a_0, b_0) => a_0[0]!.name.localeCompare(b_0[0]!.name)); 634: standaloneMcpsInScope.sort((a_1, b_1) => a_1.name.localeCompare(b_1.name)); 635: for (const group_0 of pluginGroups) { 636: unified.push(...group_0); 637: } 638: unified.push(...standaloneMcpsInScope); 639: } 640: return unified; 641: }, [pluginStates, mcpClients, pluginErrors, pendingToggles, flaggedPlugins]); 642: const flaggedIds = useMemo(() => unifiedItems.filter(item_3 => item_3.type === 'flagged-plugin').map(item_4 => item_4.id), [unifiedItems]); 643: useEffect(() => { 644: if (flaggedIds.length > 0) { 645: void markFlaggedPluginsSeen(flaggedIds); 646: } 647: }, [flaggedIds]); 648: const filteredItems = useMemo(() => { 649: if (!searchQuery) return unifiedItems; 650: const lowerQuery = searchQuery.toLowerCase(); 651: return unifiedItems.filter(item_5 => item_5.name.toLowerCase().includes(lowerQuery) || 'description' in item_5 && item_5.description?.toLowerCase().includes(lowerQuery)); 652: }, [unifiedItems, searchQuery]); 653: const [selectedIndex, setSelectedIndex] = useState(0); 654: const pagination = usePagination<UnifiedInstalledItem>({ 655: totalItems: filteredItems.length, 656: selectedIndex, 657: maxVisible: 8 658: }); 659: const [detailsMenuIndex, setDetailsMenuIndex] = useState(0); 660: const [isProcessing, setIsProcessing] = useState(false); 661: const [processError, setProcessError] = useState<string | null>(null); 662: const [configNeeded, setConfigNeeded] = useState<McpbNeedsConfigResult | null>(null); 663: const [_isLoadingConfig, setIsLoadingConfig] = useState(false); 664: const [selectedPluginHasMcpb, setSelectedPluginHasMcpb] = useState(false); 665: useEffect(() => { 666: if (!selectedPlugin) { 667: setSelectedPluginHasMcpb(false); 668: return; 669: } 670: async function detectMcpb() { 671: const mcpServersSpec = selectedPlugin!.plugin.manifest.mcpServers; 672: let hasMcpb = false; 673: if (mcpServersSpec) { 674: hasMcpb = typeof mcpServersSpec === 'string' && isMcpbSource(mcpServersSpec) || Array.isArray(mcpServersSpec) && mcpServersSpec.some(s_2 => typeof s_2 === 'string' && isMcpbSource(s_2)); 675: } 676: if (!hasMcpb) { 677: try { 678: const marketplaceDir = path.join(selectedPlugin!.plugin.path, '..'); 679: const marketplaceJsonPath = path.join(marketplaceDir, '.claude-plugin', 'marketplace.json'); 680: const content = await fs.readFile(marketplaceJsonPath, 'utf-8'); 681: const marketplace_1 = jsonParse(content); 682: const entry_0 = marketplace_1.plugins?.find((p: { 683: name: string; 684: }) => p.name === selectedPlugin!.plugin.name); 685: if (entry_0?.mcpServers) { 686: const spec = entry_0.mcpServers; 687: hasMcpb = typeof spec === 'string' && isMcpbSource(spec) || Array.isArray(spec) && spec.some((s_3: unknown) => typeof s_3 === 'string' && isMcpbSource(s_3)); 688: } 689: } catch (err) { 690: logForDebugging(`Failed to read raw marketplace.json: ${err}`); 691: } 692: } 693: setSelectedPluginHasMcpb(hasMcpb); 694: } 695: void detectMcpb(); 696: }, [selectedPlugin]); 697: useEffect(() => { 698: async function loadInstalledPlugins() { 699: setLoading(true); 700: try { 701: const { 702: enabled, 703: disabled 704: } = await loadAllPlugins(); 705: const mergedSettings = getSettings_DEPRECATED(); 706: const allPlugins = filterManagedDisabledPlugins([...enabled, ...disabled]); 707: const pluginsByMarketplace: Record<string, LoadedPlugin[]> = {}; 708: for (const plugin of allPlugins) { 709: const marketplace = plugin.source.split('@')[1] || 'local'; 710: if (!pluginsByMarketplace[marketplace]) { 711: pluginsByMarketplace[marketplace] = []; 712: } 713: pluginsByMarketplace[marketplace]!.push(plugin); 714: } 715: const marketplaceInfos: MarketplaceInfo[] = []; 716: for (const [name, plugins] of Object.entries(pluginsByMarketplace)) { 717: const enabledCount = count(plugins, p => { 718: const pluginId = `${p.name}@${name}`; 719: return mergedSettings?.enabledPlugins?.[pluginId] !== false; 720: }); 721: const disabledCount = plugins.length - enabledCount; 722: marketplaceInfos.push({ 723: name, 724: installedPlugins: plugins, 725: enabledCount, 726: disabledCount 727: }); 728: } 729: marketplaceInfos.sort((a, b) => { 730: if (a.name === 'claude-plugin-directory') return -1; 731: if (b.name === 'claude-plugin-directory') return 1; 732: return a.name.localeCompare(b.name); 733: }); 734: setMarketplaces(marketplaceInfos); 735: const allStates: PluginState[] = []; 736: for (const marketplace of marketplaceInfos) { 737: for (const plugin of marketplace.installedPlugins) { 738: const pluginId = `${plugin.name}@${marketplace.name}`; 739: const scope = plugin.isBuiltin ? 'builtin' : getPluginInstallationFromV2(pluginId).scope; 740: allStates.push({ 741: plugin, 742: marketplace: marketplace.name, 743: scope, 744: pendingEnable: undefined, 745: pendingUpdate: false 746: }); 747: } 748: } 749: setPluginStates(allStates); 750: setSelectedIndex(0); 751: } finally { 752: setLoading(false); 753: } 754: } 755: void loadInstalledPlugins(); 756: }, []); 757: useEffect(() => { 758: if (hasAutoNavigated.current) return; 759: if (targetPlugin && marketplaces.length > 0 && !loading) { 760: const { 761: name: targetName, 762: marketplace: targetMktFromId 763: } = parsePluginIdentifier(targetPlugin); 764: const effectiveTargetMarketplace = targetMarketplace ?? targetMktFromId; 765: const marketplacesToSearch = effectiveTargetMarketplace ? marketplaces.filter(m => m.name === effectiveTargetMarketplace) : marketplaces; 766: for (const marketplace_2 of marketplacesToSearch) { 767: const plugin = marketplace_2.installedPlugins.find(p_0 => p_0.name === targetName); 768: if (plugin) { 769: const pluginId_2 = `${plugin.name}@${marketplace_2.name}`; 770: const { 771: scope: scope_4 772: } = getPluginInstallationFromV2(pluginId_2); 773: const pluginState: PluginState = { 774: plugin, 775: marketplace: marketplace_2.name, 776: scope: scope_4, 777: pendingEnable: undefined, 778: pendingUpdate: false 779: }; 780: setSelectedPlugin(pluginState); 781: setViewState('plugin-details'); 782: pendingAutoActionRef.current = action; 783: hasAutoNavigated.current = true; 784: return; 785: } 786: } 787: const failedItem = unifiedItems.find(item_6 => item_6.type === 'failed-plugin' && item_6.name === targetName); 788: if (failedItem && failedItem.type === 'failed-plugin') { 789: setViewState({ 790: type: 'failed-plugin-details', 791: plugin: { 792: id: failedItem.id, 793: name: failedItem.name, 794: marketplace: failedItem.marketplace, 795: errors: failedItem.errors, 796: scope: failedItem.scope 797: } 798: }); 799: hasAutoNavigated.current = true; 800: } 801: if (!hasAutoNavigated.current && action) { 802: hasAutoNavigated.current = true; 803: setResult(`Plugin "${targetPlugin}" is not installed in this project`); 804: } 805: } 806: }, [targetPlugin, targetMarketplace, marketplaces, loading, unifiedItems, action, setResult]); 807: const handleSingleOperation = async (operation: 'enable' | 'disable' | 'update' | 'uninstall') => { 808: if (!selectedPlugin) return; 809: const pluginScope = selectedPlugin.scope || 'user'; 810: const isBuiltin = pluginScope === 'builtin'; 811: if (isBuiltin && (operation === 'update' || operation === 'uninstall')) { 812: setProcessError('Built-in plugins cannot be updated or uninstalled.'); 813: return; 814: } 815: if (!isBuiltin && !isInstallableScope(pluginScope) && operation !== 'update') { 816: setProcessError('This plugin is managed by your organization. Contact your admin to disable it.'); 817: return; 818: } 819: setIsProcessing(true); 820: setProcessError(null); 821: try { 822: const pluginId_3 = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; 823: let reverseDependents: string[] | undefined; 824: switch (operation) { 825: case 'enable': 826: { 827: const enableResult = await enablePluginOp(pluginId_3); 828: if (!enableResult.success) { 829: throw new Error(enableResult.message); 830: } 831: break; 832: } 833: case 'disable': 834: { 835: const disableResult = await disablePluginOp(pluginId_3); 836: if (!disableResult.success) { 837: throw new Error(disableResult.message); 838: } 839: reverseDependents = disableResult.reverseDependents; 840: break; 841: } 842: case 'uninstall': 843: { 844: if (isBuiltin) break; 845: if (!isInstallableScope(pluginScope)) break; 846: if (isPluginEnabledAtProjectScope(pluginId_3)) { 847: setIsProcessing(false); 848: setViewState('confirm-project-uninstall'); 849: return; 850: } 851: const installs = loadInstalledPluginsV2().plugins[pluginId_3]; 852: const isLastScope = !installs || installs.length <= 1; 853: const dataSize = isLastScope ? await getPluginDataDirSize(pluginId_3) : null; 854: if (dataSize) { 855: setIsProcessing(false); 856: setViewState({ 857: type: 'confirm-data-cleanup', 858: size: dataSize 859: }); 860: return; 861: } 862: const result_0 = await uninstallPluginOp(pluginId_3, pluginScope); 863: if (!result_0.success) { 864: throw new Error(result_0.message); 865: } 866: reverseDependents = result_0.reverseDependents; 867: break; 868: } 869: case 'update': 870: { 871: if (isBuiltin) break; 872: const result = await updatePluginOp(pluginId_3, pluginScope); 873: if (!result.success) { 874: throw new Error(result.message); 875: } 876: if (result.alreadyUpToDate) { 877: setResult(`${selectedPlugin.plugin.name} is already at the latest version (${result.newVersion}).`); 878: if (onManageComplete) { 879: await onManageComplete(); 880: } 881: setParentViewState({ 882: type: 'menu' 883: }); 884: return; 885: } 886: break; 887: } 888: } 889: clearAllCaches(); 890: const pluginIdNow = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; 891: const settingsAfter = getSettings_DEPRECATED(); 892: const enabledAfter = settingsAfter?.enabledPlugins?.[pluginIdNow] !== false; 893: if (enabledAfter) { 894: setIsProcessing(false); 895: setViewState({ 896: type: 'plugin-options' 897: }); 898: return; 899: } 900: const operationName = operation === 'enable' ? 'Enabled' : operation === 'disable' ? 'Disabled' : operation === 'update' ? 'Updated' : 'Uninstalled'; 901: const depWarn = reverseDependents && reverseDependents.length > 0 ? ` · required by ${reverseDependents.join(', ')}` : ''; 902: const message = `✓ ${operationName} ${selectedPlugin.plugin.name}${depWarn}. Run /reload-plugins to apply.`; 903: setResult(message); 904: if (onManageComplete) { 905: await onManageComplete(); 906: } 907: setParentViewState({ 908: type: 'menu' 909: }); 910: } catch (error_0) { 911: setIsProcessing(false); 912: const errorMessage = error_0 instanceof Error ? error_0.message : String(error_0); 913: setProcessError(`Failed to ${operation}: ${errorMessage}`); 914: logError(toError(error_0)); 915: } 916: }; 917: const handleSingleOperationRef = useRef(handleSingleOperation); 918: handleSingleOperationRef.current = handleSingleOperation; 919: useEffect(() => { 920: if (viewState === 'plugin-details' && selectedPlugin && pendingAutoActionRef.current) { 921: const pending = pendingAutoActionRef.current; 922: pendingAutoActionRef.current = undefined; 923: void handleSingleOperationRef.current(pending); 924: } 925: }, [viewState, selectedPlugin]); 926: const handleToggle = React.useCallback(() => { 927: if (selectedIndex >= filteredItems.length) return; 928: const item_7 = filteredItems[selectedIndex]; 929: if (item_7?.type === 'flagged-plugin') return; 930: if (item_7?.type === 'plugin') { 931: const pluginId_4 = `${item_7.plugin.name}@${item_7.marketplace}`; 932: const mergedSettings_0 = getSettings_DEPRECATED(); 933: const currentPending = pendingToggles.get(pluginId_4); 934: const isEnabled_0 = mergedSettings_0?.enabledPlugins?.[pluginId_4] !== false; 935: const pluginScope_0 = item_7.scope; 936: const isBuiltin_0 = pluginScope_0 === 'builtin'; 937: if (isBuiltin_0 || isInstallableScope(pluginScope_0)) { 938: const newPending = new Map(pendingToggles); 939: if (currentPending) { 940: newPending.delete(pluginId_4); 941: void (async () => { 942: try { 943: if (currentPending === 'will-disable') { 944: await enablePluginOp(pluginId_4); 945: } else { 946: await disablePluginOp(pluginId_4); 947: } 948: clearAllCaches(); 949: } catch (err_0) { 950: logError(err_0); 951: } 952: })(); 953: } else { 954: newPending.set(pluginId_4, isEnabled_0 ? 'will-disable' : 'will-enable'); 955: void (async () => { 956: try { 957: if (isEnabled_0) { 958: await disablePluginOp(pluginId_4); 959: } else { 960: await enablePluginOp(pluginId_4); 961: } 962: clearAllCaches(); 963: } catch (err_1) { 964: logError(err_1); 965: } 966: })(); 967: } 968: setPendingToggles(newPending); 969: } 970: } else if (item_7?.type === 'mcp') { 971: void toggleMcpServer(item_7.client.name); 972: } 973: }, [selectedIndex, filteredItems, pendingToggles, pluginStates, toggleMcpServer]); 974: const handleAccept = React.useCallback(() => { 975: if (selectedIndex >= filteredItems.length) return; 976: const item_8 = filteredItems[selectedIndex]; 977: if (item_8?.type === 'plugin') { 978: const state_0 = pluginStates.find(s_4 => s_4.plugin.name === item_8.plugin.name && s_4.marketplace === item_8.marketplace); 979: if (state_0) { 980: setSelectedPlugin(state_0); 981: setViewState('plugin-details'); 982: setDetailsMenuIndex(0); 983: setProcessError(null); 984: } 985: } else if (item_8?.type === 'flagged-plugin') { 986: setViewState({ 987: type: 'flagged-detail', 988: plugin: { 989: id: item_8.id, 990: name: item_8.name, 991: marketplace: item_8.marketplace, 992: reason: item_8.reason, 993: text: item_8.text, 994: flaggedAt: item_8.flaggedAt 995: } 996: }); 997: setProcessError(null); 998: } else if (item_8?.type === 'failed-plugin') { 999: setViewState({ 1000: type: 'failed-plugin-details', 1001: plugin: { 1002: id: item_8.id, 1003: name: item_8.name, 1004: marketplace: item_8.marketplace, 1005: errors: item_8.errors, 1006: scope: item_8.scope 1007: } 1008: }); 1009: setDetailsMenuIndex(0); 1010: setProcessError(null); 1011: } else if (item_8?.type === 'mcp') { 1012: setViewState({ 1013: type: 'mcp-detail', 1014: client: item_8.client 1015: }); 1016: setProcessError(null); 1017: } 1018: }, [selectedIndex, filteredItems, pluginStates]); 1019: useKeybindings({ 1020: 'select:previous': () => { 1021: if (selectedIndex === 0) { 1022: setIsSearchMode(true); 1023: } else { 1024: pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex); 1025: } 1026: }, 1027: 'select:next': () => { 1028: if (selectedIndex < filteredItems.length - 1) { 1029: pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex); 1030: } 1031: }, 1032: 'select:accept': handleAccept 1033: }, { 1034: context: 'Select', 1035: isActive: viewState === 'plugin-list' && !isSearchMode 1036: }); 1037: useKeybindings({ 1038: 'plugin:toggle': handleToggle 1039: }, { 1040: context: 'Plugin', 1041: isActive: viewState === 'plugin-list' && !isSearchMode 1042: }); 1043: const handleFlaggedDismiss = React.useCallback(() => { 1044: if (typeof viewState !== 'object' || viewState.type !== 'flagged-detail') return; 1045: void removeFlaggedPlugin(viewState.plugin.id); 1046: setViewState('plugin-list'); 1047: }, [viewState]); 1048: useKeybindings({ 1049: 'select:accept': handleFlaggedDismiss 1050: }, { 1051: context: 'Select', 1052: isActive: typeof viewState === 'object' && viewState.type === 'flagged-detail' 1053: }); 1054: const detailsMenuItems = React.useMemo(() => { 1055: if (viewState !== 'plugin-details' || !selectedPlugin) return []; 1056: const mergedSettings_1 = getSettings_DEPRECATED(); 1057: const pluginId_5 = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; 1058: const isEnabled_1 = mergedSettings_1?.enabledPlugins?.[pluginId_5] !== false; 1059: const isBuiltin_1 = selectedPlugin.marketplace === 'builtin'; 1060: const menuItems: Array<{ 1061: label: string; 1062: action: () => void; 1063: }> = []; 1064: menuItems.push({ 1065: label: isEnabled_1 ? 'Disable plugin' : 'Enable plugin', 1066: action: () => void handleSingleOperation(isEnabled_1 ? 'disable' : 'enable') 1067: }); 1068: if (!isBuiltin_1) { 1069: menuItems.push({ 1070: label: selectedPlugin.pendingUpdate ? 'Unmark for update' : 'Mark for update', 1071: action: async () => { 1072: try { 1073: const localError = await checkIfLocalPlugin(selectedPlugin.plugin.name, selectedPlugin.marketplace); 1074: if (localError) { 1075: setProcessError(localError); 1076: return; 1077: } 1078: const newStates = [...pluginStates]; 1079: const index = newStates.findIndex(s_5 => s_5.plugin.name === selectedPlugin.plugin.name && s_5.marketplace === selectedPlugin.marketplace); 1080: if (index !== -1) { 1081: newStates[index]!.pendingUpdate = !selectedPlugin.pendingUpdate; 1082: setPluginStates(newStates); 1083: setSelectedPlugin({ 1084: ...selectedPlugin, 1085: pendingUpdate: !selectedPlugin.pendingUpdate 1086: }); 1087: } 1088: } catch (error_1) { 1089: setProcessError(error_1 instanceof Error ? error_1.message : 'Failed to check plugin update availability'); 1090: } 1091: } 1092: }); 1093: if (selectedPluginHasMcpb) { 1094: menuItems.push({ 1095: label: 'Configure', 1096: action: async () => { 1097: setIsLoadingConfig(true); 1098: try { 1099: const mcpServersSpec_0 = selectedPlugin.plugin.manifest.mcpServers; 1100: let mcpbPath: string | null = null; 1101: if (typeof mcpServersSpec_0 === 'string' && isMcpbSource(mcpServersSpec_0)) { 1102: mcpbPath = mcpServersSpec_0; 1103: } else if (Array.isArray(mcpServersSpec_0)) { 1104: for (const spec_0 of mcpServersSpec_0) { 1105: if (typeof spec_0 === 'string' && isMcpbSource(spec_0)) { 1106: mcpbPath = spec_0; 1107: break; 1108: } 1109: } 1110: } 1111: if (!mcpbPath) { 1112: setProcessError('No MCPB file found in plugin'); 1113: setIsLoadingConfig(false); 1114: return; 1115: } 1116: const pluginId_6 = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; 1117: const result_1 = await loadMcpbFile(mcpbPath, selectedPlugin.plugin.path, pluginId_6, undefined, undefined, true); 1118: if ('status' in result_1 && result_1.status === 'needs-config') { 1119: setConfigNeeded(result_1); 1120: setViewState('configuring'); 1121: } else { 1122: setProcessError('Failed to load MCPB for configuration'); 1123: } 1124: } catch (err_2) { 1125: const errorMsg = errorMessage(err_2); 1126: setProcessError(`Failed to load configuration: ${errorMsg}`); 1127: } finally { 1128: setIsLoadingConfig(false); 1129: } 1130: } 1131: }); 1132: } 1133: if (selectedPlugin.plugin.manifest.userConfig && Object.keys(selectedPlugin.plugin.manifest.userConfig).length > 0) { 1134: menuItems.push({ 1135: label: 'Configure options', 1136: action: () => { 1137: setViewState({ 1138: type: 'configuring-options', 1139: schema: selectedPlugin.plugin.manifest.userConfig! 1140: }); 1141: } 1142: }); 1143: } 1144: menuItems.push({ 1145: label: 'Update now', 1146: action: () => void handleSingleOperation('update') 1147: }); 1148: menuItems.push({ 1149: label: 'Uninstall', 1150: action: () => void handleSingleOperation('uninstall') 1151: }); 1152: } 1153: if (selectedPlugin.plugin.manifest.homepage) { 1154: menuItems.push({ 1155: label: 'Open homepage', 1156: action: () => void openBrowser(selectedPlugin.plugin.manifest.homepage!) 1157: }); 1158: } 1159: if (selectedPlugin.plugin.manifest.repository) { 1160: menuItems.push({ 1161: label: 'View repository', 1162: action: () => void openBrowser(selectedPlugin.plugin.manifest.repository!) 1163: }); 1164: } 1165: menuItems.push({ 1166: label: 'Back to plugin list', 1167: action: () => { 1168: setViewState('plugin-list'); 1169: setSelectedPlugin(null); 1170: setProcessError(null); 1171: } 1172: }); 1173: return menuItems; 1174: }, [viewState, selectedPlugin, selectedPluginHasMcpb, pluginStates]); 1175: useKeybindings({ 1176: 'select:previous': () => { 1177: if (detailsMenuIndex > 0) { 1178: setDetailsMenuIndex(detailsMenuIndex - 1); 1179: } 1180: }, 1181: 'select:next': () => { 1182: if (detailsMenuIndex < detailsMenuItems.length - 1) { 1183: setDetailsMenuIndex(detailsMenuIndex + 1); 1184: } 1185: }, 1186: 'select:accept': () => { 1187: if (detailsMenuItems[detailsMenuIndex]) { 1188: detailsMenuItems[detailsMenuIndex]!.action(); 1189: } 1190: } 1191: }, { 1192: context: 'Select', 1193: isActive: viewState === 'plugin-details' && !!selectedPlugin 1194: }); 1195: useKeybindings({ 1196: 'select:accept': () => { 1197: if (typeof viewState === 'object' && viewState.type === 'failed-plugin-details') { 1198: void (async () => { 1199: setIsProcessing(true); 1200: setProcessError(null); 1201: const pluginId_7 = viewState.plugin.id; 1202: const pluginScope_1 = viewState.plugin.scope; 1203: const result_2 = isInstallableScope(pluginScope_1) ? await uninstallPluginOp(pluginId_7, pluginScope_1, false) : await uninstallPluginOp(pluginId_7, 'user', false); 1204: let success = result_2.success; 1205: if (!success) { 1206: const editableSources = ['userSettings' as const, 'projectSettings' as const, 'localSettings' as const]; 1207: for (const source of editableSources) { 1208: const settings = getSettingsForSource(source); 1209: if (settings?.enabledPlugins?.[pluginId_7] !== undefined) { 1210: updateSettingsForSource(source, { 1211: enabledPlugins: { 1212: ...settings.enabledPlugins, 1213: [pluginId_7]: undefined 1214: } 1215: }); 1216: success = true; 1217: } 1218: } 1219: clearAllCaches(); 1220: } 1221: if (success) { 1222: if (onManageComplete) { 1223: await onManageComplete(); 1224: } 1225: setIsProcessing(false); 1226: setViewState('plugin-list'); 1227: } else { 1228: setIsProcessing(false); 1229: setProcessError(result_2.message); 1230: } 1231: })(); 1232: } 1233: } 1234: }, { 1235: context: 'Select', 1236: isActive: typeof viewState === 'object' && viewState.type === 'failed-plugin-details' && viewState.plugin.scope !== 'managed' 1237: }); 1238: useKeybindings({ 1239: 'confirm:yes': () => { 1240: if (!selectedPlugin) return; 1241: setIsProcessing(true); 1242: setProcessError(null); 1243: const pluginId_8 = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; 1244: const { 1245: error: error_2 1246: } = updateSettingsForSource('localSettings', { 1247: enabledPlugins: { 1248: ...getSettingsForSource('localSettings')?.enabledPlugins, 1249: [pluginId_8]: false 1250: } 1251: }); 1252: if (error_2) { 1253: setIsProcessing(false); 1254: setProcessError(`Failed to write settings: ${error_2.message}`); 1255: return; 1256: } 1257: clearAllCaches(); 1258: setResult(`✓ Disabled ${selectedPlugin.plugin.name} in .claude/settings.local.json. Run /reload-plugins to apply.`); 1259: if (onManageComplete) void onManageComplete(); 1260: setParentViewState({ 1261: type: 'menu' 1262: }); 1263: }, 1264: 'confirm:no': () => { 1265: setViewState('plugin-details'); 1266: setProcessError(null); 1267: } 1268: }, { 1269: context: 'Confirmation', 1270: isActive: viewState === 'confirm-project-uninstall' && !!selectedPlugin && !isProcessing 1271: }); 1272: useInput((input, key) => { 1273: if (!selectedPlugin) return; 1274: const pluginId_9 = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; 1275: const pluginScope_2 = selectedPlugin.scope; 1276: if (!pluginScope_2 || pluginScope_2 === 'builtin' || !isInstallableScope(pluginScope_2)) return; 1277: const doUninstall = async (deleteDataDir: boolean) => { 1278: setIsProcessing(true); 1279: setProcessError(null); 1280: try { 1281: const result_3 = await uninstallPluginOp(pluginId_9, pluginScope_2, deleteDataDir); 1282: if (!result_3.success) throw new Error(result_3.message); 1283: clearAllCaches(); 1284: const suffix = deleteDataDir ? '' : ' · data preserved'; 1285: setResult(`${figures.tick} ${result_3.message}${suffix}`); 1286: if (onManageComplete) void onManageComplete(); 1287: setParentViewState({ 1288: type: 'menu' 1289: }); 1290: } catch (e_0) { 1291: setIsProcessing(false); 1292: setProcessError(e_0 instanceof Error ? e_0.message : String(e_0)); 1293: } 1294: }; 1295: if (input === 'y' || input === 'Y') { 1296: void doUninstall(true); 1297: } else if (input === 'n' || input === 'N') { 1298: void doUninstall(false); 1299: } else if (key.escape) { 1300: setViewState('plugin-details'); 1301: setProcessError(null); 1302: } 1303: }, { 1304: isActive: typeof viewState === 'object' && viewState.type === 'confirm-data-cleanup' && !!selectedPlugin && !isProcessing 1305: }); 1306: React.useEffect(() => { 1307: setSelectedIndex(0); 1308: }, [searchQuery]); 1309: useInput((input_0, key_0) => { 1310: const keyIsNotCtrlOrMeta = !key_0.ctrl && !key_0.meta; 1311: if (isSearchMode) { 1312: return; 1313: } 1314: if (input_0 === '/' && keyIsNotCtrlOrMeta) { 1315: setIsSearchMode(true); 1316: setSearchQuery(''); 1317: setSelectedIndex(0); 1318: } else if (keyIsNotCtrlOrMeta && input_0.length > 0 && !/^\s+$/.test(input_0) && input_0 !== 'j' && input_0 !== 'k' && input_0 !== ' ') { 1319: setIsSearchMode(true); 1320: setSearchQuery(input_0); 1321: setSelectedIndex(0); 1322: } 1323: }, { 1324: isActive: viewState === 'plugin-list' 1325: }); 1326: if (loading) { 1327: return <Text>Loading installed plugins…</Text>; 1328: } 1329: if (unifiedItems.length === 0) { 1330: return <Box flexDirection="column"> 1331: <Box marginBottom={1}> 1332: <Text bold>Manage plugins</Text> 1333: </Box> 1334: <Text>No plugins or MCP servers installed.</Text> 1335: <Box marginTop={1}> 1336: <Text dimColor>Esc to go back</Text> 1337: </Box> 1338: </Box>; 1339: } 1340: if (typeof viewState === 'object' && viewState.type === 'plugin-options' && selectedPlugin) { 1341: const pluginId_10 = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; 1342: function finish(msg: string): void { 1343: setResult(msg); 1344: if (onManageComplete) { 1345: void onManageComplete(); 1346: } 1347: setParentViewState({ 1348: type: 'menu' 1349: }); 1350: } 1351: return <PluginOptionsFlow plugin={selectedPlugin.plugin} pluginId={pluginId_10} onDone={(outcome, detail) => { 1352: switch (outcome) { 1353: case 'configured': 1354: finish(`✓ Enabled and configured ${selectedPlugin.plugin.name}. Run /reload-plugins to apply.`); 1355: break; 1356: case 'skipped': 1357: finish(`✓ Enabled ${selectedPlugin.plugin.name}. Run /reload-plugins to apply.`); 1358: break; 1359: case 'error': 1360: finish(`Failed to save configuration: ${detail}`); 1361: break; 1362: } 1363: }} />; 1364: } 1365: if (typeof viewState === 'object' && viewState.type === 'configuring-options' && selectedPlugin) { 1366: const pluginId_11 = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; 1367: return <PluginOptionsDialog title={`Configure ${selectedPlugin.plugin.name}`} subtitle="Plugin options" configSchema={viewState.schema} initialValues={loadPluginOptions(pluginId_11)} onSave={values => { 1368: try { 1369: savePluginOptions(pluginId_11, values, viewState.schema); 1370: clearAllCaches(); 1371: setResult('Configuration saved. Run /reload-plugins for changes to take effect.'); 1372: } catch (err_3) { 1373: setProcessError(`Failed to save configuration: ${errorMessage(err_3)}`); 1374: } 1375: setViewState('plugin-details'); 1376: }} onCancel={() => setViewState('plugin-details')} />; 1377: } 1378: if (viewState === 'configuring' && configNeeded && selectedPlugin) { 1379: const pluginId_12 = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; 1380: async function handleSave(config: UserConfigValues) { 1381: if (!configNeeded || !selectedPlugin) return; 1382: try { 1383: const mcpServersSpec_1 = selectedPlugin.plugin.manifest.mcpServers; 1384: let mcpbPath_0: string | null = null; 1385: if (typeof mcpServersSpec_1 === 'string' && isMcpbSource(mcpServersSpec_1)) { 1386: mcpbPath_0 = mcpServersSpec_1; 1387: } else if (Array.isArray(mcpServersSpec_1)) { 1388: for (const spec_1 of mcpServersSpec_1) { 1389: if (typeof spec_1 === 'string' && isMcpbSource(spec_1)) { 1390: mcpbPath_0 = spec_1; 1391: break; 1392: } 1393: } 1394: } 1395: if (!mcpbPath_0) { 1396: setProcessError('No MCPB file found'); 1397: setViewState('plugin-details'); 1398: return; 1399: } 1400: await loadMcpbFile(mcpbPath_0, selectedPlugin.plugin.path, pluginId_12, undefined, config); 1401: setProcessError(null); 1402: setConfigNeeded(null); 1403: setViewState('plugin-details'); 1404: setResult('Configuration saved. Run /reload-plugins for changes to take effect.'); 1405: } catch (err_4) { 1406: const errorMsg_0 = errorMessage(err_4); 1407: setProcessError(`Failed to save configuration: ${errorMsg_0}`); 1408: setViewState('plugin-details'); 1409: } 1410: } 1411: function handleCancel() { 1412: setConfigNeeded(null); 1413: setViewState('plugin-details'); 1414: } 1415: return <PluginOptionsDialog title={`Configure ${configNeeded.manifest.name}`} subtitle={`Plugin: ${selectedPlugin.plugin.name}`} configSchema={configNeeded.configSchema} initialValues={configNeeded.existingConfig} onSave={handleSave} onCancel={handleCancel} />; 1416: } 1417: if (typeof viewState === 'object' && viewState.type === 'flagged-detail') { 1418: const fp = viewState.plugin; 1419: return <Box flexDirection="column"> 1420: <Box> 1421: <Text bold> 1422: {fp.name} @ {fp.marketplace} 1423: </Text> 1424: </Box> 1425: <Box marginBottom={1}> 1426: <Text dimColor>Status: </Text> 1427: <Text color="error">Removed</Text> 1428: </Box> 1429: <Box marginBottom={1} flexDirection="column"> 1430: <Text color="error"> 1431: Removed from marketplace · reason: {fp.reason} 1432: </Text> 1433: <Text>{fp.text}</Text> 1434: <Text dimColor> 1435: Flagged on {new Date(fp.flaggedAt).toLocaleDateString()} 1436: </Text> 1437: </Box> 1438: <Box marginTop={1} flexDirection="column"> 1439: <Box> 1440: <Text>{figures.pointer} </Text> 1441: <Text color="suggestion">Dismiss</Text> 1442: </Box> 1443: </Box> 1444: <Byline> 1445: <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="dismiss" /> 1446: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" /> 1447: </Byline> 1448: </Box>; 1449: } 1450: if (viewState === 'confirm-project-uninstall' && selectedPlugin) { 1451: return <Box flexDirection="column"> 1452: <Text bold color="warning"> 1453: {selectedPlugin.plugin.name} is enabled in .claude/settings.json 1454: (shared with your team) 1455: </Text> 1456: <Box marginTop={1} flexDirection="column"> 1457: <Text>Disable it just for you in .claude/settings.local.json?</Text> 1458: <Text dimColor> 1459: This has the same effect as uninstalling, without affecting other 1460: contributors. 1461: </Text> 1462: </Box> 1463: {processError && <Box marginTop={1}> 1464: <Text color="error">{processError}</Text> 1465: </Box>} 1466: <Box marginTop={1}> 1467: {isProcessing ? <Text dimColor>Disabling…</Text> : <Byline> 1468: <ConfigurableShortcutHint action="confirm:yes" context="Confirmation" fallback="y" description="disable" /> 1469: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /> 1470: </Byline>} 1471: </Box> 1472: </Box>; 1473: } 1474: if (typeof viewState === 'object' && viewState.type === 'confirm-data-cleanup' && selectedPlugin) { 1475: return <Box flexDirection="column"> 1476: <Text bold> 1477: {selectedPlugin.plugin.name} has {viewState.size.human} of persistent 1478: data 1479: </Text> 1480: <Box marginTop={1} flexDirection="column"> 1481: <Text>Delete it along with the plugin?</Text> 1482: <Text dimColor> 1483: {pluginDataDirPath(`${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`)} 1484: </Text> 1485: </Box> 1486: {processError && <Box marginTop={1}> 1487: <Text color="error">{processError}</Text> 1488: </Box>} 1489: <Box marginTop={1}> 1490: {isProcessing ? <Text dimColor>Uninstalling…</Text> : <Text> 1491: <Text bold>y</Text> to delete · <Text bold>n</Text> to keep ·{' '} 1492: <Text bold>esc</Text> to cancel 1493: </Text>} 1494: </Box> 1495: </Box>; 1496: } 1497: if (viewState === 'plugin-details' && selectedPlugin) { 1498: const mergedSettings_2 = getSettings_DEPRECATED(); 1499: const pluginId_13 = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; 1500: const isEnabled_2 = mergedSettings_2?.enabledPlugins?.[pluginId_13] !== false; 1501: const filteredPluginErrors = pluginErrors.filter(e_1 => 'plugin' in e_1 && e_1.plugin === selectedPlugin.plugin.name || e_1.source === pluginId_13 || e_1.source.startsWith(`${selectedPlugin.plugin.name}@`)); 1502: const pluginErrorsSection = filteredPluginErrors.length === 0 ? null : <Box flexDirection="column" marginBottom={1}> 1503: <Text bold color="error"> 1504: {filteredPluginErrors.length}{' '} 1505: {plural(filteredPluginErrors.length, 'error')}: 1506: </Text> 1507: {filteredPluginErrors.map((error_3, i_0) => { 1508: const guidance = getErrorGuidance(error_3); 1509: return <Box key={i_0} flexDirection="column" marginLeft={2}> 1510: <Text color="error">{formatErrorMessage(error_3)}</Text> 1511: {guidance && <Text dimColor italic> 1512: {figures.arrowRight} {guidance} 1513: </Text>} 1514: </Box>; 1515: })} 1516: </Box>; 1517: return <Box flexDirection="column"> 1518: <Box> 1519: <Text bold> 1520: {selectedPlugin.plugin.name} @ {selectedPlugin.marketplace} 1521: </Text> 1522: </Box> 1523: {} 1524: <Box> 1525: <Text dimColor>Scope: </Text> 1526: <Text>{selectedPlugin.scope || 'user'}</Text> 1527: </Box> 1528: {} 1529: {selectedPlugin.plugin.manifest.version && <Box> 1530: <Text dimColor>Version: </Text> 1531: <Text>{selectedPlugin.plugin.manifest.version}</Text> 1532: </Box>} 1533: {selectedPlugin.plugin.manifest.description && <Box marginBottom={1}> 1534: <Text>{selectedPlugin.plugin.manifest.description}</Text> 1535: </Box>} 1536: {selectedPlugin.plugin.manifest.author && <Box> 1537: <Text dimColor>Author: </Text> 1538: <Text>{selectedPlugin.plugin.manifest.author.name}</Text> 1539: </Box>} 1540: {} 1541: <Box marginBottom={1}> 1542: <Text dimColor>Status: </Text> 1543: <Text color={isEnabled_2 ? 'success' : 'warning'}> 1544: {isEnabled_2 ? 'Enabled' : 'Disabled'} 1545: </Text> 1546: {selectedPlugin.pendingUpdate && <Text color="suggestion"> · Marked for update</Text>} 1547: </Box> 1548: {} 1549: <PluginComponentsDisplay plugin={selectedPlugin.plugin} marketplace={selectedPlugin.marketplace} /> 1550: {} 1551: {pluginErrorsSection} 1552: {} 1553: <Box marginTop={1} flexDirection="column"> 1554: {detailsMenuItems.map((item_9, index_0) => { 1555: const isSelected = index_0 === detailsMenuIndex; 1556: return <Box key={index_0}> 1557: {isSelected && <Text>{figures.pointer} </Text>} 1558: {!isSelected && <Text>{' '}</Text>} 1559: <Text bold={isSelected} color={item_9.label.includes('Uninstall') ? 'error' : item_9.label.includes('Update') ? 'suggestion' : undefined}> 1560: {item_9.label} 1561: </Text> 1562: </Box>; 1563: })} 1564: </Box> 1565: {} 1566: {isProcessing && <Box marginTop={1}> 1567: <Text>Processing…</Text> 1568: </Box>} 1569: {} 1570: {processError && <Box marginTop={1}> 1571: <Text color="error">{processError}</Text> 1572: </Box>} 1573: <Box marginTop={1}> 1574: <Text dimColor italic> 1575: <Byline> 1576: <ConfigurableShortcutHint action="select:previous" context="Select" fallback="↑" description="navigate" /> 1577: <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="select" /> 1578: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" /> 1579: </Byline> 1580: </Text> 1581: </Box> 1582: </Box>; 1583: } 1584: if (typeof viewState === 'object' && viewState.type === 'failed-plugin-details') { 1585: const failedPlugin_0 = viewState.plugin; 1586: const firstError = failedPlugin_0.errors[0]; 1587: const errorMessage_0 = firstError ? formatErrorMessage(firstError) : 'Failed to load'; 1588: return <Box flexDirection="column"> 1589: <Text> 1590: <Text bold>{failedPlugin_0.name}</Text> 1591: <Text dimColor> @ {failedPlugin_0.marketplace}</Text> 1592: <Text dimColor> ({failedPlugin_0.scope})</Text> 1593: </Text> 1594: <Text color="error">{errorMessage_0}</Text> 1595: {failedPlugin_0.scope === 'managed' ? <Box marginTop={1}> 1596: <Text dimColor> 1597: Managed by your organization — contact your admin 1598: </Text> 1599: </Box> : <Box marginTop={1}> 1600: <Text color="suggestion">{figures.pointer} </Text> 1601: <Text bold>Remove</Text> 1602: </Box>} 1603: {isProcessing && <Text>Processing…</Text>} 1604: {processError && <Text color="error">{processError}</Text>} 1605: <Box marginTop={1}> 1606: <Text dimColor italic> 1607: <Byline> 1608: {failedPlugin_0.scope !== 'managed' && <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="remove" />} 1609: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" /> 1610: </Byline> 1611: </Text> 1612: </Box> 1613: </Box>; 1614: } 1615: if (typeof viewState === 'object' && viewState.type === 'mcp-detail') { 1616: const client_3 = viewState.client; 1617: const serverToolsCount = filterToolsByServer(mcpTools, client_3.name).length; 1618: const handleMcpViewTools = () => { 1619: setViewState({ 1620: type: 'mcp-tools', 1621: client: client_3 1622: }); 1623: }; 1624: const handleMcpCancel = () => { 1625: setViewState('plugin-list'); 1626: }; 1627: const handleMcpComplete = (result_4?: string) => { 1628: if (result_4) { 1629: setResult(result_4); 1630: } 1631: setViewState('plugin-list'); 1632: }; 1633: const scope_5 = client_3.config.scope; 1634: const configType = client_3.config.type; 1635: if (configType === 'stdio') { 1636: const server: StdioServerInfo = { 1637: name: client_3.name, 1638: client: client_3, 1639: scope: scope_5, 1640: transport: 'stdio', 1641: config: client_3.config as McpStdioServerConfig 1642: }; 1643: return <MCPStdioServerMenu server={server} serverToolsCount={serverToolsCount} onViewTools={handleMcpViewTools} onCancel={handleMcpCancel} onComplete={handleMcpComplete} borderless />; 1644: } else if (configType === 'sse') { 1645: const server_0: SSEServerInfo = { 1646: name: client_3.name, 1647: client: client_3, 1648: scope: scope_5, 1649: transport: 'sse', 1650: isAuthenticated: undefined, 1651: config: client_3.config as McpSSEServerConfig 1652: }; 1653: return <MCPRemoteServerMenu server={server_0} serverToolsCount={serverToolsCount} onViewTools={handleMcpViewTools} onCancel={handleMcpCancel} onComplete={handleMcpComplete} borderless />; 1654: } else if (configType === 'http') { 1655: const server_1: HTTPServerInfo = { 1656: name: client_3.name, 1657: client: client_3, 1658: scope: scope_5, 1659: transport: 'http', 1660: isAuthenticated: undefined, 1661: config: client_3.config as McpHTTPServerConfig 1662: }; 1663: return <MCPRemoteServerMenu server={server_1} serverToolsCount={serverToolsCount} onViewTools={handleMcpViewTools} onCancel={handleMcpCancel} onComplete={handleMcpComplete} borderless />; 1664: } else if (configType === 'claudeai-proxy') { 1665: const server_2: ClaudeAIServerInfo = { 1666: name: client_3.name, 1667: client: client_3, 1668: scope: scope_5, 1669: transport: 'claudeai-proxy', 1670: isAuthenticated: undefined, 1671: config: client_3.config as McpClaudeAIProxyServerConfig 1672: }; 1673: return <MCPRemoteServerMenu server={server_2} serverToolsCount={serverToolsCount} onViewTools={handleMcpViewTools} onCancel={handleMcpCancel} onComplete={handleMcpComplete} borderless />; 1674: } 1675: setViewState('plugin-list'); 1676: return null; 1677: } 1678: if (typeof viewState === 'object' && viewState.type === 'mcp-tools') { 1679: const client_4 = viewState.client; 1680: const scope_6 = client_4.config.scope; 1681: const configType_0 = client_4.config.type; 1682: let server_3: StdioServerInfo | SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo; 1683: if (configType_0 === 'stdio') { 1684: server_3 = { 1685: name: client_4.name, 1686: client: client_4, 1687: scope: scope_6, 1688: transport: 'stdio', 1689: config: client_4.config as McpStdioServerConfig 1690: }; 1691: } else if (configType_0 === 'sse') { 1692: server_3 = { 1693: name: client_4.name, 1694: client: client_4, 1695: scope: scope_6, 1696: transport: 'sse', 1697: isAuthenticated: undefined, 1698: config: client_4.config as McpSSEServerConfig 1699: }; 1700: } else if (configType_0 === 'http') { 1701: server_3 = { 1702: name: client_4.name, 1703: client: client_4, 1704: scope: scope_6, 1705: transport: 'http', 1706: isAuthenticated: undefined, 1707: config: client_4.config as McpHTTPServerConfig 1708: }; 1709: } else { 1710: server_3 = { 1711: name: client_4.name, 1712: client: client_4, 1713: scope: scope_6, 1714: transport: 'claudeai-proxy', 1715: isAuthenticated: undefined, 1716: config: client_4.config as McpClaudeAIProxyServerConfig 1717: }; 1718: } 1719: return <MCPToolListView server={server_3} onSelectTool={(tool: Tool) => { 1720: setViewState({ 1721: type: 'mcp-tool-detail', 1722: client: client_4, 1723: tool 1724: }); 1725: }} onBack={() => setViewState({ 1726: type: 'mcp-detail', 1727: client: client_4 1728: })} />; 1729: } 1730: if (typeof viewState === 'object' && viewState.type === 'mcp-tool-detail') { 1731: const { 1732: client: client_5, 1733: tool: tool_0 1734: } = viewState; 1735: const scope_7 = client_5.config.scope; 1736: const configType_1 = client_5.config.type; 1737: let server_4: StdioServerInfo | SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo; 1738: if (configType_1 === 'stdio') { 1739: server_4 = { 1740: name: client_5.name, 1741: client: client_5, 1742: scope: scope_7, 1743: transport: 'stdio', 1744: config: client_5.config as McpStdioServerConfig 1745: }; 1746: } else if (configType_1 === 'sse') { 1747: server_4 = { 1748: name: client_5.name, 1749: client: client_5, 1750: scope: scope_7, 1751: transport: 'sse', 1752: isAuthenticated: undefined, 1753: config: client_5.config as McpSSEServerConfig 1754: }; 1755: } else if (configType_1 === 'http') { 1756: server_4 = { 1757: name: client_5.name, 1758: client: client_5, 1759: scope: scope_7, 1760: transport: 'http', 1761: isAuthenticated: undefined, 1762: config: client_5.config as McpHTTPServerConfig 1763: }; 1764: } else { 1765: server_4 = { 1766: name: client_5.name, 1767: client: client_5, 1768: scope: scope_7, 1769: transport: 'claudeai-proxy', 1770: isAuthenticated: undefined, 1771: config: client_5.config as McpClaudeAIProxyServerConfig 1772: }; 1773: } 1774: return <MCPToolDetailView tool={tool_0} server={server_4} onBack={() => setViewState({ 1775: type: 'mcp-tools', 1776: client: client_5 1777: })} />; 1778: } 1779: const visibleItems = pagination.getVisibleItems(filteredItems); 1780: return <Box flexDirection="column"> 1781: {} 1782: <Box marginBottom={1}> 1783: <SearchBox query={searchQuery} isFocused={isSearchMode} isTerminalFocused={isTerminalFocused} width={terminalWidth - 4} cursorOffset={searchCursorOffset} /> 1784: </Box> 1785: {} 1786: {filteredItems.length === 0 && searchQuery && <Box marginBottom={1}> 1787: <Text dimColor>No items match &quot;{searchQuery}&quot;</Text> 1788: </Box>} 1789: {} 1790: {pagination.scrollPosition.canScrollUp && <Box> 1791: <Text dimColor> {figures.arrowUp} more above</Text> 1792: </Box>} 1793: {} 1794: {visibleItems.map((item_10, visibleIndex) => { 1795: const actualIndex = pagination.toActualIndex(visibleIndex); 1796: const isSelected_0 = actualIndex === selectedIndex && !isSearchMode; 1797: const prevItem = visibleIndex > 0 ? visibleItems[visibleIndex - 1] : null; 1798: const showScopeHeader = !prevItem || prevItem.scope !== item_10.scope; 1799: const getScopeLabel = (scope_8: string): string => { 1800: switch (scope_8) { 1801: case 'flagged': 1802: return 'Flagged'; 1803: case 'project': 1804: return 'Project'; 1805: case 'local': 1806: return 'Local'; 1807: case 'user': 1808: return 'User'; 1809: case 'enterprise': 1810: return 'Enterprise'; 1811: case 'managed': 1812: return 'Managed'; 1813: case 'builtin': 1814: return 'Built-in'; 1815: case 'dynamic': 1816: return 'Built-in'; 1817: default: 1818: return scope_8; 1819: } 1820: }; 1821: return <React.Fragment key={item_10.id}> 1822: {showScopeHeader && <Box marginTop={visibleIndex > 0 ? 1 : 0} paddingLeft={2}> 1823: <Text dimColor={item_10.scope !== 'flagged'} color={item_10.scope === 'flagged' ? 'warning' : undefined} bold={item_10.scope === 'flagged'}> 1824: {getScopeLabel(item_10.scope)} 1825: </Text> 1826: </Box>} 1827: <UnifiedInstalledCell item={item_10} isSelected={isSelected_0} /> 1828: </React.Fragment>; 1829: })} 1830: {} 1831: {pagination.scrollPosition.canScrollDown && <Box> 1832: <Text dimColor> {figures.arrowDown} more below</Text> 1833: </Box>} 1834: {} 1835: <Box marginTop={1} marginLeft={1}> 1836: <Text dimColor italic> 1837: <Byline> 1838: <Text>type to search</Text> 1839: <ConfigurableShortcutHint action="plugin:toggle" context="Plugin" fallback="Space" description="toggle" /> 1840: <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="details" /> 1841: <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" /> 1842: </Byline> 1843: </Text> 1844: </Box> 1845: {} 1846: {pendingToggles.size > 0 && <Box marginLeft={1}> 1847: <Text dimColor italic> 1848: Run /reload-plugins to apply changes 1849: </Text> 1850: </Box>} 1851: </Box>; 1852: }

File: src/commands/plugin/parseArgs.ts

typescript 1: export type ParsedCommand = 2: | { type: 'menu' } 3: | { type: 'help' } 4: | { type: 'install'; marketplace?: string; plugin?: string } 5: | { type: 'manage' } 6: | { type: 'uninstall'; plugin?: string } 7: | { type: 'enable'; plugin?: string } 8: | { type: 'disable'; plugin?: string } 9: | { type: 'validate'; path?: string } 10: | { 11: type: 'marketplace' 12: action?: 'add' | 'remove' | 'update' | 'list' 13: target?: string 14: } 15: export function parsePluginArgs(args?: string): ParsedCommand { 16: if (!args) { 17: return { type: 'menu' } 18: } 19: const parts = args.trim().split(/\s+/) 20: const command = parts[0]?.toLowerCase() 21: switch (command) { 22: case 'help': 23: case '--help': 24: case '-h': 25: return { type: 'help' } 26: case 'install': 27: case 'i': { 28: const target = parts[1] 29: if (!target) { 30: return { type: 'install' } 31: } 32: if (target.includes('@')) { 33: const [plugin, marketplace] = target.split('@') 34: return { type: 'install', plugin, marketplace } 35: } 36: const isMarketplace = 37: target.startsWith('http://') || 38: target.startsWith('https://') || 39: target.startsWith('file://') || 40: target.includes('/') || 41: target.includes('\\') 42: if (isMarketplace) { 43: // This is a marketplace URL/path, no plugin specified 44: return { type: 'install', marketplace: target } 45: } 46: return { type: 'install', plugin: target } 47: } 48: case 'manage': 49: return { type: 'manage' } 50: case 'uninstall': 51: return { type: 'uninstall', plugin: parts[1] } 52: case 'enable': 53: return { type: 'enable', plugin: parts[1] } 54: case 'disable': 55: return { type: 'disable', plugin: parts[1] } 56: case 'validate': { 57: const target = parts.slice(1).join(' ').trim() 58: return { type: 'validate', path: target || undefined } 59: } 60: case 'marketplace': 61: case 'market': { 62: const action = parts[1]?.toLowerCase() 63: const target = parts.slice(2).join(' ') 64: switch (action) { 65: case 'add': 66: return { type: 'marketplace', action: 'add', target } 67: case 'remove': 68: case 'rm': 69: return { type: 'marketplace', action: 'remove', target } 70: case 'update': 71: return { type: 'marketplace', action: 'update', target } 72: case 'list': 73: return { type: 'marketplace', action: 'list' } 74: default: 75: return { type: 'marketplace' } 76: } 77: } 78: default: 79: return { type: 'menu' } 80: } 81: }

File: src/commands/plugin/plugin.tsx

typescript 1: import * as React from 'react'; 2: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 3: import { PluginSettings } from './PluginSettings.js'; 4: export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> { 5: return <PluginSettings onComplete={onDone} args={args} />; 6: }

File: src/commands/plugin/pluginDetailsHelpers.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import * as React from 'react'; 3: import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; 4: import { Byline } from '../../components/design-system/Byline.js'; 5: import { Box, Text } from '../../ink.js'; 6: import type { PluginMarketplaceEntry } from '../../utils/plugins/schemas.js'; 7: export type InstallablePlugin = { 8: entry: PluginMarketplaceEntry; 9: marketplaceName: string; 10: pluginId: string; 11: isInstalled: boolean; 12: }; 13: export type PluginDetailsMenuOption = { 14: label: string; 15: action: string; 16: }; 17: export function extractGitHubRepo(plugin: InstallablePlugin): string | null { 18: const isGitHub = plugin.entry.source && typeof plugin.entry.source === 'object' && 'source' in plugin.entry.source && plugin.entry.source.source === 'github'; 19: if (isGitHub && typeof plugin.entry.source === 'object' && 'repo' in plugin.entry.source) { 20: return plugin.entry.source.repo; 21: } 22: return null; 23: } 24: export function buildPluginDetailsMenuOptions(hasHomepage: string | undefined, githubRepo: string | null): PluginDetailsMenuOption[] { 25: const options: PluginDetailsMenuOption[] = [{ 26: label: 'Install for you (user scope)', 27: action: 'install-user' 28: }, { 29: label: 'Install for all collaborators on this repository (project scope)', 30: action: 'install-project' 31: }, { 32: label: 'Install for you, in this repo only (local scope)', 33: action: 'install-local' 34: }]; 35: if (hasHomepage) { 36: options.push({ 37: label: 'Open homepage', 38: action: 'homepage' 39: }); 40: } 41: if (githubRepo) { 42: options.push({ 43: label: 'View on GitHub', 44: action: 'github' 45: }); 46: } 47: options.push({ 48: label: 'Back to plugin list', 49: action: 'back' 50: }); 51: return options; 52: } 53: export function PluginSelectionKeyHint(t0) { 54: const $ = _c(7); 55: const { 56: hasSelection 57: } = t0; 58: let t1; 59: if ($[0] !== hasSelection) { 60: t1 = hasSelection && <ConfigurableShortcutHint action="plugin:install" context="Plugin" fallback="i" description="install" bold={true} />; 61: $[0] = hasSelection; 62: $[1] = t1; 63: } else { 64: t1 = $[1]; 65: } 66: let t2; 67: let t3; 68: let t4; 69: if ($[2] === Symbol.for("react.memo_cache_sentinel")) { 70: t2 = <ConfigurableShortcutHint action="plugin:toggle" context="Plugin" fallback="Space" description="toggle" />; 71: t3 = <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="details" />; 72: t4 = <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />; 73: $[2] = t2; 74: $[3] = t3; 75: $[4] = t4; 76: } else { 77: t2 = $[2]; 78: t3 = $[3]; 79: t4 = $[4]; 80: } 81: let t5; 82: if ($[5] !== t1) { 83: t5 = <Box marginTop={1}><Text dimColor={true} italic={true}><Byline>{t1}{t2}{t3}{t4}</Byline></Text></Box>; 84: $[5] = t1; 85: $[6] = t5; 86: } else { 87: t5 = $[6]; 88: } 89: return t5; 90: }

File: src/commands/plugin/PluginErrors.tsx

typescript 1: import { getPluginErrorMessage, type PluginError } from '../../types/plugin.js'; 2: export function formatErrorMessage(error: PluginError): string { 3: switch (error.type) { 4: case 'path-not-found': 5: return `${error.component} path not found: ${error.path}`; 6: case 'git-auth-failed': 7: return `Git ${error.authType.toUpperCase()} authentication failed for ${error.gitUrl}`; 8: case 'git-timeout': 9: return `Git ${error.operation} timed out for ${error.gitUrl}`; 10: case 'network-error': 11: return `Network error accessing ${error.url}${error.details ? `: ${error.details}` : ''}`; 12: case 'manifest-parse-error': 13: return `Failed to parse manifest at ${error.manifestPath}: ${error.parseError}`; 14: case 'manifest-validation-error': 15: return `Invalid manifest at ${error.manifestPath}: ${error.validationErrors.join(', ')}`; 16: case 'plugin-not-found': 17: return `Plugin "${error.pluginId}" not found in marketplace "${error.marketplace}"`; 18: case 'marketplace-not-found': 19: return `Marketplace "${error.marketplace}" not found`; 20: case 'marketplace-load-failed': 21: return `Failed to load marketplace "${error.marketplace}": ${error.reason}`; 22: case 'mcp-config-invalid': 23: return `Invalid MCP server config for "${error.serverName}": ${error.validationError}`; 24: case 'mcp-server-suppressed-duplicate': 25: { 26: const dup = error.duplicateOf.startsWith('plugin:') ? `server provided by plugin "${error.duplicateOf.split(':')[1] ?? '?'}"` : `already-configured "${error.duplicateOf}"`; 27: return `MCP server "${error.serverName}" skipped — same command/URL as ${dup}`; 28: } 29: case 'hook-load-failed': 30: return `Failed to load hooks from ${error.hookPath}: ${error.reason}`; 31: case 'component-load-failed': 32: return `Failed to load ${error.component} from ${error.path}: ${error.reason}`; 33: case 'mcpb-download-failed': 34: return `Failed to download MCPB from ${error.url}: ${error.reason}`; 35: case 'mcpb-extract-failed': 36: return `Failed to extract MCPB ${error.mcpbPath}: ${error.reason}`; 37: case 'mcpb-invalid-manifest': 38: return `MCPB manifest invalid at ${error.mcpbPath}: ${error.validationError}`; 39: case 'marketplace-blocked-by-policy': 40: return error.blockedByBlocklist ? `Marketplace "${error.marketplace}" is blocked by enterprise policy` : `Marketplace "${error.marketplace}" is not in the allowed marketplace list`; 41: case 'dependency-unsatisfied': 42: return error.reason === 'not-enabled' ? `Dependency "${error.dependency}" is disabled` : `Dependency "${error.dependency}" is not installed`; 43: case 'lsp-config-invalid': 44: return `Invalid LSP server config for "${error.serverName}": ${error.validationError}`; 45: case 'lsp-server-start-failed': 46: return `LSP server "${error.serverName}" failed to start: ${error.reason}`; 47: case 'lsp-server-crashed': 48: return error.signal ? `LSP server "${error.serverName}" crashed with signal ${error.signal}` : `LSP server "${error.serverName}" crashed with exit code ${error.exitCode ?? 'unknown'}`; 49: case 'lsp-request-timeout': 50: return `LSP server "${error.serverName}" timed out on ${error.method} after ${error.timeoutMs}ms`; 51: case 'lsp-request-failed': 52: return `LSP server "${error.serverName}" ${error.method} failed: ${error.error}`; 53: case 'plugin-cache-miss': 54: return `Plugin "${error.plugin}" not cached at ${error.installPath}`; 55: case 'generic-error': 56: return error.error; 57: } 58: const _exhaustive: never = error; 59: return getPluginErrorMessage(_exhaustive); 60: } 61: export function getErrorGuidance(error: PluginError): string | null { 62: switch (error.type) { 63: case 'path-not-found': 64: return 'Check that the path in your manifest or marketplace config is correct'; 65: case 'git-auth-failed': 66: return error.authType === 'ssh' ? 'Configure SSH keys or use HTTPS URL instead' : 'Configure credentials or use SSH URL instead'; 67: case 'git-timeout': 68: case 'network-error': 69: return 'Check your internet connection and try again'; 70: case 'manifest-parse-error': 71: return 'Check manifest file syntax in the plugin directory'; 72: case 'manifest-validation-error': 73: return 'Check manifest file follows the required schema'; 74: case 'plugin-not-found': 75: return `Plugin may not exist in marketplace "${error.marketplace}"`; 76: case 'marketplace-not-found': 77: return error.availableMarketplaces.length > 0 ? `Available marketplaces: ${error.availableMarketplaces.join(', ')}` : 'Add the marketplace first using /plugin marketplace add'; 78: case 'mcp-config-invalid': 79: return 'Check MCP server configuration in .mcp.json or manifest'; 80: case 'mcp-server-suppressed-duplicate': 81: { 82: if (error.duplicateOf.startsWith('plugin:')) { 83: const winningPlugin = error.duplicateOf.split(':')[1] ?? 'the other plugin'; 84: return `Disable plugin "${winningPlugin}" if you want this plugin's version instead`; 85: } 86: return `Remove "${error.duplicateOf}" from your MCP config if you want the plugin's version instead`; 87: } 88: case 'hook-load-failed': 89: return 'Check hooks.json file syntax and structure'; 90: case 'component-load-failed': 91: return `Check ${error.component} directory structure and file permissions`; 92: case 'mcpb-download-failed': 93: return 'Check your internet connection and URL accessibility'; 94: case 'mcpb-extract-failed': 95: return 'Verify the MCPB file is valid and not corrupted'; 96: case 'mcpb-invalid-manifest': 97: return 'Contact the plugin author about the invalid manifest'; 98: case 'marketplace-blocked-by-policy': 99: if (error.blockedByBlocklist) { 100: return 'This marketplace source is explicitly blocked by your administrator'; 101: } 102: return error.allowedSources.length > 0 ? `Allowed sources: ${error.allowedSources.join(', ')}` : 'Contact your administrator to configure allowed marketplace sources'; 103: case 'dependency-unsatisfied': 104: return error.reason === 'not-enabled' ? `Enable "${error.dependency}" or uninstall "${error.plugin}"` : `Install "${error.dependency}" or uninstall "${error.plugin}"`; 105: case 'lsp-config-invalid': 106: return 'Check LSP server configuration in the plugin manifest'; 107: case 'lsp-server-start-failed': 108: case 'lsp-server-crashed': 109: case 'lsp-request-timeout': 110: case 'lsp-request-failed': 111: return 'Check LSP server logs with --debug for details'; 112: case 'plugin-cache-miss': 113: return 'Run /plugins to refresh the plugin cache'; 114: case 'marketplace-load-failed': 115: case 'generic-error': 116: return null; 117: } 118: const _exhaustive: never = error; 119: return null; 120: }

File: src/commands/plugin/PluginOptionsDialog.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import React, { useCallback, useState } from 'react'; 4: import { Dialog } from '../../components/design-system/Dialog.js'; 5: import { stringWidth } from '../../ink/stringWidth.js'; 6: import { Box, Text, useInput } from '../../ink.js'; 7: import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; 8: import { isEnvTruthy } from '../../utils/envUtils.js'; 9: import type { PluginOptionSchema, PluginOptionValues } from '../../utils/plugins/pluginOptionsStorage.js'; 10: export function buildFinalValues(fields: string[], collected: Record<string, string>, configSchema: PluginOptionSchema, initialValues: PluginOptionValues | undefined): PluginOptionValues { 11: const finalValues: PluginOptionValues = {}; 12: for (const fieldKey of fields) { 13: const schema = configSchema[fieldKey]; 14: const value = collected[fieldKey] ?? ''; 15: if (schema?.sensitive === true && value === '' && initialValues?.[fieldKey] !== undefined) { 16: continue; 17: } 18: if (schema?.type === 'number') { 19: if (value.trim() === '') continue; 20: const num = Number(value); 21: finalValues[fieldKey] = Number.isNaN(num) ? value : num; 22: } else if (schema?.type === 'boolean') { 23: finalValues[fieldKey] = isEnvTruthy(value); 24: } else { 25: finalValues[fieldKey] = value; 26: } 27: } 28: return finalValues; 29: } 30: type Props = { 31: title: string; 32: subtitle: string; 33: configSchema: PluginOptionSchema; 34: initialValues?: PluginOptionValues; 35: onSave: (config: PluginOptionValues) => void; 36: onCancel: () => void; 37: }; 38: export function PluginOptionsDialog(t0) { 39: const $ = _c(70); 40: const { 41: title, 42: subtitle, 43: configSchema, 44: initialValues, 45: onSave, 46: onCancel 47: } = t0; 48: let t1; 49: if ($[0] !== configSchema) { 50: t1 = Object.keys(configSchema); 51: $[0] = configSchema; 52: $[1] = t1; 53: } else { 54: t1 = $[1]; 55: } 56: const fields = t1; 57: let t2; 58: if ($[2] !== configSchema || $[3] !== initialValues) { 59: t2 = key => { 60: if (configSchema[key]?.sensitive === true) { 61: return ""; 62: } 63: const v = initialValues?.[key]; 64: return v === undefined ? "" : String(v); 65: }; 66: $[2] = configSchema; 67: $[3] = initialValues; 68: $[4] = t2; 69: } else { 70: t2 = $[4]; 71: } 72: const initialFor = t2; 73: const [currentFieldIndex, setCurrentFieldIndex] = useState(0); 74: let t3; 75: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 76: t3 = {}; 77: $[5] = t3; 78: } else { 79: t3 = $[5]; 80: } 81: const [values, setValues] = useState(t3); 82: let t4; 83: if ($[6] !== fields[0] || $[7] !== initialFor) { 84: t4 = () => fields[0] ? initialFor(fields[0]) : ""; 85: $[6] = fields[0]; 86: $[7] = initialFor; 87: $[8] = t4; 88: } else { 89: t4 = $[8]; 90: } 91: const [currentInput, setCurrentInput] = useState(t4); 92: const currentField = fields[currentFieldIndex]; 93: const fieldSchema = currentField ? configSchema[currentField] : null; 94: let t5; 95: if ($[9] === Symbol.for("react.memo_cache_sentinel")) { 96: t5 = { 97: context: "Settings" 98: }; 99: $[9] = t5; 100: } else { 101: t5 = $[9]; 102: } 103: useKeybinding("confirm:no", onCancel, t5); 104: let t6; 105: if ($[10] !== currentField || $[11] !== currentFieldIndex || $[12] !== currentInput || $[13] !== fields || $[14] !== initialFor) { 106: t6 = () => { 107: if (currentFieldIndex < fields.length - 1 && currentField) { 108: setValues(prev => ({ 109: ...prev, 110: [currentField]: currentInput 111: })); 112: setCurrentFieldIndex(_temp); 113: const nextKey = fields[currentFieldIndex + 1]; 114: setCurrentInput(nextKey ? initialFor(nextKey) : ""); 115: } 116: }; 117: $[10] = currentField; 118: $[11] = currentFieldIndex; 119: $[12] = currentInput; 120: $[13] = fields; 121: $[14] = initialFor; 122: $[15] = t6; 123: } else { 124: t6 = $[15]; 125: } 126: const handleNextField = t6; 127: let t7; 128: if ($[16] !== configSchema || $[17] !== currentField || $[18] !== currentFieldIndex || $[19] !== currentInput || $[20] !== fields || $[21] !== initialFor || $[22] !== initialValues || $[23] !== onSave || $[24] !== values) { 129: t7 = () => { 130: if (!currentField) { 131: return; 132: } 133: const newValues = { 134: ...values, 135: [currentField]: currentInput 136: }; 137: if (currentFieldIndex === fields.length - 1) { 138: onSave(buildFinalValues(fields, newValues, configSchema, initialValues)); 139: } else { 140: setValues(newValues); 141: setCurrentFieldIndex(_temp2); 142: const nextKey_0 = fields[currentFieldIndex + 1]; 143: setCurrentInput(nextKey_0 ? initialFor(nextKey_0) : ""); 144: } 145: }; 146: $[16] = configSchema; 147: $[17] = currentField; 148: $[18] = currentFieldIndex; 149: $[19] = currentInput; 150: $[20] = fields; 151: $[21] = initialFor; 152: $[22] = initialValues; 153: $[23] = onSave; 154: $[24] = values; 155: $[25] = t7; 156: } else { 157: t7 = $[25]; 158: } 159: const handleConfirm = t7; 160: let t8; 161: if ($[26] !== handleConfirm || $[27] !== handleNextField) { 162: t8 = { 163: "confirm:nextField": handleNextField, 164: "confirm:yes": handleConfirm 165: }; 166: $[26] = handleConfirm; 167: $[27] = handleNextField; 168: $[28] = t8; 169: } else { 170: t8 = $[28]; 171: } 172: let t9; 173: if ($[29] === Symbol.for("react.memo_cache_sentinel")) { 174: t9 = { 175: context: "Confirmation" 176: }; 177: $[29] = t9; 178: } else { 179: t9 = $[29]; 180: } 181: useKeybindings(t8, t9); 182: let t10; 183: if ($[30] === Symbol.for("react.memo_cache_sentinel")) { 184: t10 = (char, key_0) => { 185: if (key_0.backspace || key_0.delete) { 186: setCurrentInput(_temp3); 187: return; 188: } 189: if (char && !key_0.ctrl && !key_0.meta && !key_0.tab && !key_0.return) { 190: setCurrentInput(prev_3 => prev_3 + char); 191: } 192: }; 193: $[30] = t10; 194: } else { 195: t10 = $[30]; 196: } 197: useInput(t10); 198: if (!fieldSchema || !currentField) { 199: return null; 200: } 201: const isSensitive = fieldSchema.sensitive === true; 202: const isRequired = fieldSchema.required === true; 203: let t11; 204: if ($[31] !== currentInput || $[32] !== isSensitive) { 205: t11 = isSensitive ? "*".repeat(stringWidth(currentInput)) : currentInput; 206: $[31] = currentInput; 207: $[32] = isSensitive; 208: $[33] = t11; 209: } else { 210: t11 = $[33]; 211: } 212: const displayValue = t11; 213: const t12 = fieldSchema.title || currentField; 214: let t13; 215: if ($[34] !== isRequired) { 216: t13 = isRequired && <Text color="error"> *</Text>; 217: $[34] = isRequired; 218: $[35] = t13; 219: } else { 220: t13 = $[35]; 221: } 222: let t14; 223: if ($[36] !== t12 || $[37] !== t13) { 224: t14 = <Text bold={true}>{t12}{t13}</Text>; 225: $[36] = t12; 226: $[37] = t13; 227: $[38] = t14; 228: } else { 229: t14 = $[38]; 230: } 231: let t15; 232: if ($[39] !== fieldSchema.description) { 233: t15 = fieldSchema.description && <Text dimColor={true}>{fieldSchema.description}</Text>; 234: $[39] = fieldSchema.description; 235: $[40] = t15; 236: } else { 237: t15 = $[40]; 238: } 239: let t16; 240: if ($[41] === Symbol.for("react.memo_cache_sentinel")) { 241: t16 = <Text>{figures.pointerSmall} </Text>; 242: $[41] = t16; 243: } else { 244: t16 = $[41]; 245: } 246: let t17; 247: if ($[42] !== displayValue) { 248: t17 = <Text>{displayValue}</Text>; 249: $[42] = displayValue; 250: $[43] = t17; 251: } else { 252: t17 = $[43]; 253: } 254: let t18; 255: if ($[44] === Symbol.for("react.memo_cache_sentinel")) { 256: t18 = <Text>█</Text>; 257: $[44] = t18; 258: } else { 259: t18 = $[44]; 260: } 261: let t19; 262: if ($[45] !== t17) { 263: t19 = <Box marginTop={1}>{t16}{t17}{t18}</Box>; 264: $[45] = t17; 265: $[46] = t19; 266: } else { 267: t19 = $[46]; 268: } 269: let t20; 270: if ($[47] !== t14 || $[48] !== t15 || $[49] !== t19) { 271: t20 = <Box flexDirection="column">{t14}{t15}{t19}</Box>; 272: $[47] = t14; 273: $[48] = t15; 274: $[49] = t19; 275: $[50] = t20; 276: } else { 277: t20 = $[50]; 278: } 279: const t21 = currentFieldIndex + 1; 280: let t22; 281: if ($[51] !== fields.length || $[52] !== t21) { 282: t22 = <Text dimColor={true}>Field {t21} of {fields.length}</Text>; 283: $[51] = fields.length; 284: $[52] = t21; 285: $[53] = t22; 286: } else { 287: t22 = $[53]; 288: } 289: let t23; 290: if ($[54] !== currentFieldIndex || $[55] !== fields.length) { 291: t23 = currentFieldIndex < fields.length - 1 && <Text dimColor={true}>Tab: Next field · Enter: Save and continue</Text>; 292: $[54] = currentFieldIndex; 293: $[55] = fields.length; 294: $[56] = t23; 295: } else { 296: t23 = $[56]; 297: } 298: let t24; 299: if ($[57] !== currentFieldIndex || $[58] !== fields.length) { 300: t24 = currentFieldIndex === fields.length - 1 && <Text dimColor={true}>Enter: Save configuration</Text>; 301: $[57] = currentFieldIndex; 302: $[58] = fields.length; 303: $[59] = t24; 304: } else { 305: t24 = $[59]; 306: } 307: let t25; 308: if ($[60] !== t22 || $[61] !== t23 || $[62] !== t24) { 309: t25 = <Box flexDirection="column">{t22}{t23}{t24}</Box>; 310: $[60] = t22; 311: $[61] = t23; 312: $[62] = t24; 313: $[63] = t25; 314: } else { 315: t25 = $[63]; 316: } 317: let t26; 318: if ($[64] !== onCancel || $[65] !== subtitle || $[66] !== t20 || $[67] !== t25 || $[68] !== title) { 319: t26 = <Dialog title={title} subtitle={subtitle} onCancel={onCancel} isCancelActive={false}>{t20}{t25}</Dialog>; 320: $[64] = onCancel; 321: $[65] = subtitle; 322: $[66] = t20; 323: $[67] = t25; 324: $[68] = title; 325: $[69] = t26; 326: } else { 327: t26 = $[69]; 328: } 329: return t26; 330: } 331: function _temp3(prev_2) { 332: return prev_2.slice(0, -1); 333: } 334: function _temp2(prev_1) { 335: return prev_1 + 1; 336: } 337: function _temp(prev_0) { 338: return prev_0 + 1; 339: }

File: src/commands/plugin/PluginOptionsFlow.tsx

typescript 1: import * as React from 'react'; 2: import type { LoadedPlugin } from '../../types/plugin.js'; 3: import { errorMessage } from '../../utils/errors.js'; 4: import { loadMcpServerUserConfig, saveMcpServerUserConfig } from '../../utils/plugins/mcpbHandler.js'; 5: import { getUnconfiguredChannels, type UnconfiguredChannel } from '../../utils/plugins/mcpPluginIntegration.js'; 6: import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'; 7: import { getUnconfiguredOptions, loadPluginOptions, type PluginOptionSchema, type PluginOptionValues, savePluginOptions } from '../../utils/plugins/pluginOptionsStorage.js'; 8: import { PluginOptionsDialog } from './PluginOptionsDialog.js'; 9: export async function findPluginOptionsTarget(pluginId: string): Promise<LoadedPlugin | undefined> { 10: const { 11: enabled, 12: disabled 13: } = await loadAllPlugins(); 14: return [...enabled, ...disabled].find(p => p.repository === pluginId || p.source === pluginId); 15: } 16: type ConfigStep = { 17: key: string; 18: title: string; 19: subtitle: string; 20: schema: PluginOptionSchema; 21: load: () => PluginOptionValues | undefined; 22: save: (values: PluginOptionValues) => void; 23: }; 24: type Props = { 25: plugin: LoadedPlugin; 26: pluginId: string; 27: onDone: (outcome: 'configured' | 'skipped' | 'error', detail?: string) => void; 28: }; 29: export function PluginOptionsFlow({ 30: plugin, 31: pluginId, 32: onDone 33: }: Props): React.ReactNode { 34: const [steps] = React.useState<ConfigStep[]>(() => { 35: const result: ConfigStep[] = []; 36: const unconfigured = getUnconfiguredOptions(plugin); 37: if (Object.keys(unconfigured).length > 0) { 38: result.push({ 39: key: 'top-level', 40: title: `Configure ${plugin.name}`, 41: subtitle: 'Plugin options', 42: schema: unconfigured, 43: load: () => loadPluginOptions(pluginId), 44: save: values => savePluginOptions(pluginId, values, plugin.manifest.userConfig!) 45: }); 46: } 47: const channels: UnconfiguredChannel[] = getUnconfiguredChannels(plugin); 48: for (const channel of channels) { 49: result.push({ 50: key: `channel:${channel.server}`, 51: title: `Configure ${channel.displayName}`, 52: subtitle: `Plugin: ${plugin.name}`, 53: schema: channel.configSchema, 54: load: () => loadMcpServerUserConfig(pluginId, channel.server) ?? undefined, 55: save: values_0 => saveMcpServerUserConfig(pluginId, channel.server, values_0, channel.configSchema) 56: }); 57: } 58: return result; 59: }); 60: const [index, setIndex] = React.useState(0); 61: const onDoneRef = React.useRef(onDone); 62: onDoneRef.current = onDone; 63: React.useEffect(() => { 64: if (steps.length === 0) { 65: onDoneRef.current('skipped'); 66: } 67: }, [steps.length]); 68: if (steps.length === 0) { 69: return null; 70: } 71: const current = steps[index]!; 72: function handleSave(values_1: PluginOptionValues): void { 73: try { 74: current.save(values_1); 75: } catch (err) { 76: onDone('error', errorMessage(err)); 77: return; 78: } 79: const next = index + 1; 80: if (next < steps.length) { 81: setIndex(next); 82: } else { 83: onDone('configured'); 84: } 85: } 86: return <PluginOptionsDialog key={current.key} title={current.title} subtitle={current.subtitle} configSchema={current.schema} initialValues={current.load()} onSave={handleSave} onCancel={() => onDone('skipped')} />; 87: }

File: src/commands/plugin/PluginSettings.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import * as React from 'react'; 4: import { useCallback, useEffect, useState } from 'react'; 5: import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; 6: import { Byline } from '../../components/design-system/Byline.js'; 7: import { Pane } from '../../components/design-system/Pane.js'; 8: import { Tab, Tabs } from '../../components/design-system/Tabs.js'; 9: import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; 10: import { Box, Text } from '../../ink.js'; 11: import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; 12: import { useAppState, useSetAppState } from '../../state/AppState.js'; 13: import type { PluginError } from '../../types/plugin.js'; 14: import { errorMessage } from '../../utils/errors.js'; 15: import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; 16: import { loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js'; 17: import { loadKnownMarketplacesConfig, removeMarketplaceSource } from '../../utils/plugins/marketplaceManager.js'; 18: import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js'; 19: import type { EditableSettingSource } from '../../utils/settings/constants.js'; 20: import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'; 21: import { AddMarketplace } from './AddMarketplace.js'; 22: import { BrowseMarketplace } from './BrowseMarketplace.js'; 23: import { DiscoverPlugins } from './DiscoverPlugins.js'; 24: import { ManageMarketplaces } from './ManageMarketplaces.js'; 25: import { ManagePlugins } from './ManagePlugins.js'; 26: import { formatErrorMessage, getErrorGuidance } from './PluginErrors.js'; 27: import { type ParsedCommand, parsePluginArgs } from './parseArgs.js'; 28: import type { PluginSettingsProps, ViewState } from './types.js'; 29: import { ValidatePlugin } from './ValidatePlugin.js'; 30: type TabId = 'discover' | 'installed' | 'marketplaces' | 'errors'; 31: function MarketplaceList(t0) { 32: const $ = _c(4); 33: const { 34: onComplete 35: } = t0; 36: let t1; 37: let t2; 38: if ($[0] !== onComplete) { 39: t1 = () => { 40: const loadList = async function loadList() { 41: ; 42: try { 43: const config = await loadKnownMarketplacesConfig(); 44: const names = Object.keys(config); 45: if (names.length === 0) { 46: onComplete("No marketplaces configured"); 47: } else { 48: onComplete(`Configured marketplaces:\n${names.map(_temp).join("\n")}`); 49: } 50: } catch (t3) { 51: const err = t3; 52: onComplete(`Error loading marketplaces: ${errorMessage(err)}`); 53: } 54: }; 55: loadList(); 56: }; 57: t2 = [onComplete]; 58: $[0] = onComplete; 59: $[1] = t1; 60: $[2] = t2; 61: } else { 62: t1 = $[1]; 63: t2 = $[2]; 64: } 65: useEffect(t1, t2); 66: let t3; 67: if ($[3] === Symbol.for("react.memo_cache_sentinel")) { 68: t3 = <Text>Loading marketplaces...</Text>; 69: $[3] = t3; 70: } else { 71: t3 = $[3]; 72: } 73: return t3; 74: } 75: function _temp(n) { 76: return ` • ${n}`; 77: } 78: function McpRedirectBanner() { 79: return null; 80: } 81: type ErrorRowAction = { 82: kind: 'navigate'; 83: tab: TabId; 84: viewState: ViewState; 85: } | { 86: kind: 'remove-extra-marketplace'; 87: name: string; 88: sources: Array<{ 89: source: EditableSettingSource; 90: scope: string; 91: }>; 92: } | { 93: kind: 'remove-installed-marketplace'; 94: name: string; 95: } | { 96: kind: 'managed-only'; 97: name: string; 98: } | { 99: kind: 'none'; 100: }; 101: type ErrorRow = { 102: label: string; 103: message: string; 104: guidance?: string | null; 105: action: ErrorRowAction; 106: scope?: string; 107: }; 108: function getExtraMarketplaceSourceInfo(name: string): { 109: editableSources: Array<{ 110: source: EditableSettingSource; 111: scope: string; 112: }>; 113: isInPolicy: boolean; 114: } { 115: const editableSources: Array<{ 116: source: EditableSettingSource; 117: scope: string; 118: }> = []; 119: const sourcesToCheck = [{ 120: source: 'userSettings' as const, 121: scope: 'user' 122: }, { 123: source: 'projectSettings' as const, 124: scope: 'project' 125: }, { 126: source: 'localSettings' as const, 127: scope: 'local' 128: }]; 129: for (const { 130: source, 131: scope 132: } of sourcesToCheck) { 133: const settings = getSettingsForSource(source); 134: if (settings?.extraKnownMarketplaces?.[name]) { 135: editableSources.push({ 136: source, 137: scope 138: }); 139: } 140: } 141: const policySettings = getSettingsForSource('policySettings'); 142: const isInPolicy = Boolean(policySettings?.extraKnownMarketplaces?.[name]); 143: return { 144: editableSources, 145: isInPolicy 146: }; 147: } 148: function buildMarketplaceAction(name: string): ErrorRowAction { 149: const { 150: editableSources, 151: isInPolicy 152: } = getExtraMarketplaceSourceInfo(name); 153: if (editableSources.length > 0) { 154: return { 155: kind: 'remove-extra-marketplace', 156: name, 157: sources: editableSources 158: }; 159: } 160: if (isInPolicy) { 161: return { 162: kind: 'managed-only', 163: name 164: }; 165: } 166: return { 167: kind: 'navigate', 168: tab: 'marketplaces', 169: viewState: { 170: type: 'manage-marketplaces', 171: targetMarketplace: name, 172: action: 'remove' 173: } 174: }; 175: } 176: function buildPluginAction(pluginName: string): ErrorRowAction { 177: return { 178: kind: 'navigate', 179: tab: 'installed', 180: viewState: { 181: type: 'manage-plugins', 182: targetPlugin: pluginName, 183: action: 'uninstall' 184: } 185: }; 186: } 187: const TRANSIENT_ERROR_TYPES = new Set(['git-auth-failed', 'git-timeout', 'network-error']); 188: function isTransientError(error: PluginError): boolean { 189: return TRANSIENT_ERROR_TYPES.has(error.type); 190: } 191: function getPluginNameFromError(error: PluginError): string | undefined { 192: if ('pluginId' in error && error.pluginId) return error.pluginId; 193: if ('plugin' in error && error.plugin) return error.plugin; 194: if (error.source.includes('@')) return error.source.split('@')[0]; 195: return undefined; 196: } 197: function buildErrorRows(failedMarketplaces: Array<{ 198: name: string; 199: error?: string; 200: }>, extraMarketplaceErrors: PluginError[], pluginLoadingErrors: PluginError[], otherErrors: PluginError[], brokenInstalledMarketplaces: Array<{ 201: name: string; 202: error: string; 203: }>, transientErrors: PluginError[], pluginScopes: Map<string, string>): ErrorRow[] { 204: const rows: ErrorRow[] = []; 205: for (const error of transientErrors) { 206: const pluginName = 'pluginId' in error ? error.pluginId : 'plugin' in error ? error.plugin : undefined; 207: rows.push({ 208: label: pluginName ?? error.source, 209: message: formatErrorMessage(error), 210: guidance: 'Restart to retry loading plugins', 211: action: { 212: kind: 'none' 213: } 214: }); 215: } 216: const shownMarketplaceNames = new Set<string>(); 217: for (const m of failedMarketplaces) { 218: shownMarketplaceNames.add(m.name); 219: const action = buildMarketplaceAction(m.name); 220: const sourceInfo = getExtraMarketplaceSourceInfo(m.name); 221: const scope = sourceInfo.isInPolicy ? 'managed' : sourceInfo.editableSources[0]?.scope; 222: rows.push({ 223: label: m.name, 224: message: m.error ?? 'Installation failed', 225: guidance: action.kind === 'managed-only' ? 'Managed by your organization — contact your admin' : undefined, 226: action, 227: scope 228: }); 229: } 230: for (const e of extraMarketplaceErrors) { 231: const marketplace = 'marketplace' in e ? e.marketplace : e.source; 232: if (shownMarketplaceNames.has(marketplace)) continue; 233: shownMarketplaceNames.add(marketplace); 234: const action = buildMarketplaceAction(marketplace); 235: const sourceInfo = getExtraMarketplaceSourceInfo(marketplace); 236: const scope = sourceInfo.isInPolicy ? 'managed' : sourceInfo.editableSources[0]?.scope; 237: rows.push({ 238: label: marketplace, 239: message: formatErrorMessage(e), 240: guidance: action.kind === 'managed-only' ? 'Managed by your organization — contact your admin' : getErrorGuidance(e), 241: action, 242: scope 243: }); 244: } 245: for (const m of brokenInstalledMarketplaces) { 246: if (shownMarketplaceNames.has(m.name)) continue; 247: shownMarketplaceNames.add(m.name); 248: rows.push({ 249: label: m.name, 250: message: m.error, 251: action: { 252: kind: 'remove-installed-marketplace', 253: name: m.name 254: } 255: }); 256: } 257: const shownPluginNames = new Set<string>(); 258: for (const error of pluginLoadingErrors) { 259: const pluginName = getPluginNameFromError(error); 260: if (pluginName && shownPluginNames.has(pluginName)) continue; 261: if (pluginName) shownPluginNames.add(pluginName); 262: const marketplace = 'marketplace' in error ? error.marketplace : undefined; 263: const scope = pluginName ? pluginScopes.get(error.source) ?? pluginScopes.get(pluginName) : undefined; 264: rows.push({ 265: label: pluginName ? marketplace ? `${pluginName} @ ${marketplace}` : pluginName : error.source, 266: message: formatErrorMessage(error), 267: guidance: getErrorGuidance(error), 268: action: pluginName ? buildPluginAction(pluginName) : { 269: kind: 'none' 270: }, 271: scope 272: }); 273: } 274: for (const error of otherErrors) { 275: rows.push({ 276: label: error.source, 277: message: formatErrorMessage(error), 278: guidance: getErrorGuidance(error), 279: action: { 280: kind: 'none' 281: } 282: }); 283: } 284: return rows; 285: } 286: function removeExtraMarketplace(name: string, sources: Array<{ 287: source: EditableSettingSource; 288: }>): void { 289: for (const { 290: source 291: } of sources) { 292: const settings = getSettingsForSource(source); 293: if (!settings) continue; 294: const updates: Record<string, unknown> = {}; 295: if (settings.extraKnownMarketplaces?.[name]) { 296: updates.extraKnownMarketplaces = { 297: ...settings.extraKnownMarketplaces, 298: [name]: undefined 299: }; 300: } 301: if (settings.enabledPlugins) { 302: const suffix = `@${name}`; 303: let removedPlugins = false; 304: const updatedPlugins = { 305: ...settings.enabledPlugins 306: }; 307: for (const pluginId in updatedPlugins) { 308: if (pluginId.endsWith(suffix)) { 309: updatedPlugins[pluginId] = undefined; 310: removedPlugins = true; 311: } 312: } 313: if (removedPlugins) { 314: updates.enabledPlugins = updatedPlugins; 315: } 316: } 317: if (Object.keys(updates).length > 0) { 318: updateSettingsForSource(source, updates); 319: } 320: } 321: } 322: function ErrorsTabContent(t0) { 323: const $ = _c(26); 324: const { 325: setViewState, 326: setActiveTab, 327: markPluginsChanged 328: } = t0; 329: const errors = useAppState(_temp2); 330: const installationStatus = useAppState(_temp3); 331: const setAppState = useSetAppState(); 332: const [selectedIndex, setSelectedIndex] = useState(0); 333: const [actionMessage, setActionMessage] = useState(null); 334: let t1; 335: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 336: t1 = []; 337: $[0] = t1; 338: } else { 339: t1 = $[0]; 340: } 341: const [marketplaceLoadFailures, setMarketplaceLoadFailures] = useState(t1); 342: let t2; 343: let t3; 344: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 345: t2 = () => { 346: (async () => { 347: try { 348: const config = await loadKnownMarketplacesConfig(); 349: const { 350: failures 351: } = await loadMarketplacesWithGracefulDegradation(config); 352: setMarketplaceLoadFailures(failures); 353: } catch {} 354: })(); 355: }; 356: t3 = []; 357: $[1] = t2; 358: $[2] = t3; 359: } else { 360: t2 = $[1]; 361: t3 = $[2]; 362: } 363: useEffect(t2, t3); 364: const failedMarketplaces = installationStatus.marketplaces.filter(_temp4); 365: const failedMarketplaceNames = new Set(failedMarketplaces.map(_temp5)); 366: const transientErrors = errors.filter(isTransientError); 367: const extraMarketplaceErrors = errors.filter(e => (e.type === "marketplace-not-found" || e.type === "marketplace-load-failed" || e.type === "marketplace-blocked-by-policy") && !failedMarketplaceNames.has(e.marketplace)); 368: const pluginLoadingErrors = errors.filter(_temp6); 369: const otherErrors = errors.filter(_temp7); 370: const pluginScopes = getPluginEditableScopes(); 371: const rows = buildErrorRows(failedMarketplaces, extraMarketplaceErrors, pluginLoadingErrors, otherErrors, marketplaceLoadFailures, transientErrors, pluginScopes); 372: let t4; 373: if ($[3] !== setViewState) { 374: t4 = () => { 375: setViewState({ 376: type: "menu" 377: }); 378: }; 379: $[3] = setViewState; 380: $[4] = t4; 381: } else { 382: t4 = $[4]; 383: } 384: let t5; 385: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 386: t5 = { 387: context: "Confirmation" 388: }; 389: $[5] = t5; 390: } else { 391: t5 = $[5]; 392: } 393: useKeybinding("confirm:no", t4, t5); 394: const handleSelect = () => { 395: const row = rows[selectedIndex]; 396: if (!row) { 397: return; 398: } 399: const { 400: action 401: } = row; 402: bb77: switch (action.kind) { 403: case "navigate": 404: { 405: setActiveTab(action.tab); 406: setViewState(action.viewState); 407: break bb77; 408: } 409: case "remove-extra-marketplace": 410: { 411: const scopes = action.sources.map(_temp8).join(", "); 412: removeExtraMarketplace(action.name, action.sources); 413: clearAllCaches(); 414: setAppState(prev_0 => ({ 415: ...prev_0, 416: plugins: { 417: ...prev_0.plugins, 418: errors: prev_0.plugins.errors.filter(e_2 => !("marketplace" in e_2 && e_2.marketplace === action.name)), 419: installationStatus: { 420: ...prev_0.plugins.installationStatus, 421: marketplaces: prev_0.plugins.installationStatus.marketplaces.filter(m_1 => m_1.name !== action.name) 422: } 423: } 424: })); 425: setActionMessage(`${figures.tick} Removed "${action.name}" from ${scopes} settings`); 426: markPluginsChanged(); 427: break bb77; 428: } 429: case "remove-installed-marketplace": 430: { 431: (async () => { 432: ; 433: try { 434: await removeMarketplaceSource(action.name); 435: clearAllCaches(); 436: setMarketplaceLoadFailures(prev => prev.filter(f => f.name !== action.name)); 437: setActionMessage(`${figures.tick} Removed marketplace "${action.name}"`); 438: markPluginsChanged(); 439: } catch (t6) { 440: const err = t6; 441: setActionMessage(`Failed to remove "${action.name}": ${err instanceof Error ? err.message : String(err)}`); 442: } 443: })(); 444: break bb77; 445: } 446: case "managed-only": 447: { 448: break bb77; 449: } 450: case "none": 451: } 452: }; 453: let t7; 454: if ($[6] === Symbol.for("react.memo_cache_sentinel")) { 455: t7 = () => setSelectedIndex(_temp9); 456: $[6] = t7; 457: } else { 458: t7 = $[6]; 459: } 460: const t8 = rows.length > 0; 461: let t9; 462: if ($[7] !== t8) { 463: t9 = { 464: context: "Select", 465: isActive: t8 466: }; 467: $[7] = t8; 468: $[8] = t9; 469: } else { 470: t9 = $[8]; 471: } 472: useKeybindings({ 473: "select:previous": t7, 474: "select:next": () => setSelectedIndex(prev_2 => Math.min(rows.length - 1, prev_2 + 1)), 475: "select:accept": handleSelect 476: }, t9); 477: const clampedIndex = Math.min(selectedIndex, Math.max(0, rows.length - 1)); 478: if (clampedIndex !== selectedIndex) { 479: setSelectedIndex(clampedIndex); 480: } 481: const selectedAction = rows[clampedIndex]?.action; 482: const hasAction = selectedAction && selectedAction.kind !== "none" && selectedAction.kind !== "managed-only"; 483: if (rows.length === 0) { 484: let t10; 485: if ($[9] === Symbol.for("react.memo_cache_sentinel")) { 486: t10 = <Box marginLeft={1}><Text dimColor={true}>No plugin errors</Text></Box>; 487: $[9] = t10; 488: } else { 489: t10 = $[9]; 490: } 491: let t11; 492: if ($[10] === Symbol.for("react.memo_cache_sentinel")) { 493: t11 = <Box flexDirection="column">{t10}<Box marginTop={1}><Text dimColor={true} italic={true}><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" /></Text></Box></Box>; 494: $[10] = t11; 495: } else { 496: t11 = $[10]; 497: } 498: return t11; 499: } 500: const T0 = Box; 501: const t10 = "column"; 502: let t11; 503: if ($[11] !== clampedIndex) { 504: t11 = (row_0, idx) => { 505: const isSelected = idx === clampedIndex; 506: return <Box key={idx} marginLeft={1} flexDirection="column" marginBottom={1}><Text><Text color={isSelected ? "suggestion" : "error"}>{isSelected ? figures.pointer : figures.cross}{" "}</Text><Text bold={isSelected}>{row_0.label}</Text>{row_0.scope && <Text dimColor={true}> ({row_0.scope})</Text>}</Text><Box marginLeft={3}><Text color="error">{row_0.message}</Text></Box>{row_0.guidance && <Box marginLeft={3}><Text dimColor={true} italic={true}>{row_0.guidance}</Text></Box>}</Box>; 507: }; 508: $[11] = clampedIndex; 509: $[12] = t11; 510: } else { 511: t11 = $[12]; 512: } 513: const t12 = rows.map(t11); 514: let t13; 515: if ($[13] !== actionMessage) { 516: t13 = actionMessage && <Box marginTop={1} marginLeft={1}><Text color="claude">{actionMessage}</Text></Box>; 517: $[13] = actionMessage; 518: $[14] = t13; 519: } else { 520: t13 = $[14]; 521: } 522: let t14; 523: if ($[15] === Symbol.for("react.memo_cache_sentinel")) { 524: t14 = <ConfigurableShortcutHint action="select:previous" context="Select" fallback={"\u2191"} description="navigate" />; 525: $[15] = t14; 526: } else { 527: t14 = $[15]; 528: } 529: let t15; 530: if ($[16] !== hasAction) { 531: t15 = hasAction && <ConfigurableShortcutHint action="select:accept" context="Select" fallback="Enter" description="resolve" />; 532: $[16] = hasAction; 533: $[17] = t15; 534: } else { 535: t15 = $[17]; 536: } 537: let t16; 538: if ($[18] === Symbol.for("react.memo_cache_sentinel")) { 539: t16 = <ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />; 540: $[18] = t16; 541: } else { 542: t16 = $[18]; 543: } 544: let t17; 545: if ($[19] !== t15) { 546: t17 = <Box marginTop={1}><Text dimColor={true} italic={true}><Byline>{t14}{t15}{t16}</Byline></Text></Box>; 547: $[19] = t15; 548: $[20] = t17; 549: } else { 550: t17 = $[20]; 551: } 552: let t18; 553: if ($[21] !== T0 || $[22] !== t12 || $[23] !== t13 || $[24] !== t17) { 554: t18 = <T0 flexDirection={t10}>{t12}{t13}{t17}</T0>; 555: $[21] = T0; 556: $[22] = t12; 557: $[23] = t13; 558: $[24] = t17; 559: $[25] = t18; 560: } else { 561: t18 = $[25]; 562: } 563: return t18; 564: } 565: function _temp9(prev_1) { 566: return Math.max(0, prev_1 - 1); 567: } 568: function _temp8(s_1) { 569: return s_1.scope; 570: } 571: function _temp7(e_1) { 572: if (isTransientError(e_1)) { 573: return false; 574: } 575: if (e_1.type === "marketplace-not-found" || e_1.type === "marketplace-load-failed" || e_1.type === "marketplace-blocked-by-policy") { 576: return false; 577: } 578: return getPluginNameFromError(e_1) === undefined; 579: } 580: function _temp6(e_0) { 581: if (isTransientError(e_0)) { 582: return false; 583: } 584: if (e_0.type === "marketplace-not-found" || e_0.type === "marketplace-load-failed" || e_0.type === "marketplace-blocked-by-policy") { 585: return false; 586: } 587: return getPluginNameFromError(e_0) !== undefined; 588: } 589: function _temp5(m_0) { 590: return m_0.name; 591: } 592: function _temp4(m) { 593: return m.status === "failed"; 594: } 595: function _temp3(s_0) { 596: return s_0.plugins.installationStatus; 597: } 598: function _temp2(s) { 599: return s.plugins.errors; 600: } 601: function getInitialViewState(parsedCommand: ParsedCommand): ViewState { 602: switch (parsedCommand.type) { 603: case 'help': 604: return { 605: type: 'help' 606: }; 607: case 'validate': 608: return { 609: type: 'validate', 610: path: parsedCommand.path 611: }; 612: case 'install': 613: if (parsedCommand.marketplace) { 614: return { 615: type: 'browse-marketplace', 616: targetMarketplace: parsedCommand.marketplace, 617: targetPlugin: parsedCommand.plugin 618: }; 619: } 620: if (parsedCommand.plugin) { 621: return { 622: type: 'discover-plugins', 623: targetPlugin: parsedCommand.plugin 624: }; 625: } 626: return { 627: type: 'discover-plugins' 628: }; 629: case 'manage': 630: return { 631: type: 'manage-plugins' 632: }; 633: case 'uninstall': 634: return { 635: type: 'manage-plugins', 636: targetPlugin: parsedCommand.plugin, 637: action: 'uninstall' 638: }; 639: case 'enable': 640: return { 641: type: 'manage-plugins', 642: targetPlugin: parsedCommand.plugin, 643: action: 'enable' 644: }; 645: case 'disable': 646: return { 647: type: 'manage-plugins', 648: targetPlugin: parsedCommand.plugin, 649: action: 'disable' 650: }; 651: case 'marketplace': 652: if (parsedCommand.action === 'list') { 653: return { 654: type: 'marketplace-list' 655: }; 656: } 657: if (parsedCommand.action === 'add') { 658: return { 659: type: 'add-marketplace', 660: initialValue: parsedCommand.target 661: }; 662: } 663: if (parsedCommand.action === 'remove') { 664: return { 665: type: 'manage-marketplaces', 666: targetMarketplace: parsedCommand.target, 667: action: 'remove' 668: }; 669: } 670: if (parsedCommand.action === 'update') { 671: return { 672: type: 'manage-marketplaces', 673: targetMarketplace: parsedCommand.target, 674: action: 'update' 675: }; 676: } 677: return { 678: type: 'marketplace-menu' 679: }; 680: case 'menu': 681: default: 682: return { 683: type: 'discover-plugins' 684: }; 685: } 686: } 687: function getInitialTab(viewState: ViewState): TabId { 688: if (viewState.type === 'manage-plugins') return 'installed'; 689: if (viewState.type === 'manage-marketplaces') return 'marketplaces'; 690: return 'discover'; 691: } 692: export function PluginSettings(t0) { 693: const $ = _c(75); 694: const { 695: onComplete, 696: args, 697: showMcpRedirectMessage 698: } = t0; 699: let parsedCommand; 700: let t1; 701: if ($[0] !== args) { 702: parsedCommand = parsePluginArgs(args); 703: t1 = getInitialViewState(parsedCommand); 704: $[0] = args; 705: $[1] = parsedCommand; 706: $[2] = t1; 707: } else { 708: parsedCommand = $[1]; 709: t1 = $[2]; 710: } 711: const initialViewState = t1; 712: const [viewState, setViewState] = useState(initialViewState); 713: let t2; 714: if ($[3] !== initialViewState) { 715: t2 = getInitialTab(initialViewState); 716: $[3] = initialViewState; 717: $[4] = t2; 718: } else { 719: t2 = $[4]; 720: } 721: const [activeTab, setActiveTab] = useState(t2); 722: const [inputValue, setInputValue] = useState(viewState.type === "add-marketplace" ? viewState.initialValue || "" : ""); 723: const [cursorOffset, setCursorOffset] = useState(0); 724: const [error, setError] = useState(null); 725: const [result, setResult] = useState(null); 726: const [childSearchActive, setChildSearchActive] = useState(false); 727: const setAppState = useSetAppState(); 728: const pluginErrorCount = useAppState(_temp0); 729: const errorsTabTitle = pluginErrorCount > 0 ? `Errors (${pluginErrorCount})` : "Errors"; 730: const exitState = useExitOnCtrlCDWithKeybindings(); 731: const cliMode = parsedCommand.type === "marketplace" && parsedCommand.action === "add" && parsedCommand.target !== undefined; 732: let t3; 733: if ($[5] !== setAppState) { 734: t3 = () => { 735: setAppState(_temp1); 736: }; 737: $[5] = setAppState; 738: $[6] = t3; 739: } else { 740: t3 = $[6]; 741: } 742: const markPluginsChanged = t3; 743: let t4; 744: if ($[7] === Symbol.for("react.memo_cache_sentinel")) { 745: t4 = tabId => { 746: const tab = tabId as TabId; 747: setActiveTab(tab); 748: setError(null); 749: bb37: switch (tab) { 750: case "discover": 751: { 752: setViewState({ 753: type: "discover-plugins" 754: }); 755: break bb37; 756: } 757: case "installed": 758: { 759: setViewState({ 760: type: "manage-plugins" 761: }); 762: break bb37; 763: } 764: case "marketplaces": 765: { 766: setViewState({ 767: type: "manage-marketplaces" 768: }); 769: break bb37; 770: } 771: case "errors": 772: } 773: }; 774: $[7] = t4; 775: } else { 776: t4 = $[7]; 777: } 778: const handleTabChange = t4; 779: let t5; 780: let t6; 781: if ($[8] !== onComplete || $[9] !== result || $[10] !== viewState.type) { 782: t5 = () => { 783: if (viewState.type === "menu" && !result) { 784: onComplete(); 785: } 786: }; 787: t6 = [viewState.type, result, onComplete]; 788: $[8] = onComplete; 789: $[9] = result; 790: $[10] = viewState.type; 791: $[11] = t5; 792: $[12] = t6; 793: } else { 794: t5 = $[11]; 795: t6 = $[12]; 796: } 797: useEffect(t5, t6); 798: let t7; 799: let t8; 800: if ($[13] !== activeTab || $[14] !== viewState.type) { 801: t7 = () => { 802: if (viewState.type === "browse-marketplace" && activeTab !== "discover") { 803: setActiveTab("discover"); 804: } 805: }; 806: t8 = [viewState.type, activeTab]; 807: $[13] = activeTab; 808: $[14] = viewState.type; 809: $[15] = t7; 810: $[16] = t8; 811: } else { 812: t7 = $[15]; 813: t8 = $[16]; 814: } 815: useEffect(t7, t8); 816: let t9; 817: if ($[17] === Symbol.for("react.memo_cache_sentinel")) { 818: t9 = () => { 819: setActiveTab("marketplaces"); 820: setViewState({ 821: type: "manage-marketplaces" 822: }); 823: setInputValue(""); 824: setError(null); 825: }; 826: $[17] = t9; 827: } else { 828: t9 = $[17]; 829: } 830: const handleAddMarketplaceEscape = t9; 831: const t10 = viewState.type === "add-marketplace"; 832: let t11; 833: if ($[18] !== t10) { 834: t11 = { 835: context: "Settings", 836: isActive: t10 837: }; 838: $[18] = t10; 839: $[19] = t11; 840: } else { 841: t11 = $[19]; 842: } 843: useKeybinding("confirm:no", handleAddMarketplaceEscape, t11); 844: let t12; 845: let t13; 846: if ($[20] !== onComplete || $[21] !== result) { 847: t12 = () => { 848: if (result) { 849: onComplete(result); 850: } 851: }; 852: t13 = [result, onComplete]; 853: $[20] = onComplete; 854: $[21] = result; 855: $[22] = t12; 856: $[23] = t13; 857: } else { 858: t12 = $[22]; 859: t13 = $[23]; 860: } 861: useEffect(t12, t13); 862: let t14; 863: let t15; 864: if ($[24] !== onComplete || $[25] !== viewState.type) { 865: t14 = () => { 866: if (viewState.type === "help") { 867: onComplete(); 868: } 869: }; 870: t15 = [viewState.type, onComplete]; 871: $[24] = onComplete; 872: $[25] = viewState.type; 873: $[26] = t14; 874: $[27] = t15; 875: } else { 876: t14 = $[26]; 877: t15 = $[27]; 878: } 879: useEffect(t14, t15); 880: if (viewState.type === "help") { 881: let t16; 882: if ($[28] === Symbol.for("react.memo_cache_sentinel")) { 883: t16 = <Box flexDirection="column"><Text bold={true}>Plugin Command Usage:</Text><Text> </Text><Text dimColor={true}>Installation:</Text><Text> /plugin install - Browse and install plugins</Text><Text>{" "}{"/plugin install <marketplace> - Install from specific marketplace"}</Text><Text>{" /plugin install <plugin> - Install specific plugin"}</Text><Text>{" "}{"/plugin install <plugin>@<market> - Install plugin from marketplace"}</Text><Text> </Text><Text dimColor={true}>Management:</Text><Text> /plugin manage - Manage installed plugins</Text><Text>{" /plugin enable <plugin> - Enable a plugin"}</Text><Text>{" /plugin disable <plugin> - Disable a plugin"}</Text><Text>{" /plugin uninstall <plugin> - Uninstall a plugin"}</Text><Text> </Text><Text dimColor={true}>Marketplaces:</Text><Text> /plugin marketplace - Marketplace management menu</Text><Text> /plugin marketplace add - Add a marketplace</Text><Text>{" "}{"/plugin marketplace add <path/url> - Add marketplace directly"}</Text><Text> /plugin marketplace update - Update marketplaces</Text><Text>{" "}{"/plugin marketplace update <name> - Update specific marketplace"}</Text><Text> /plugin marketplace remove - Remove a marketplace</Text><Text>{" "}{"/plugin marketplace remove <name> - Remove specific marketplace"}</Text><Text> /plugin marketplace list - List all marketplaces</Text><Text> </Text><Text dimColor={true}>Validation:</Text><Text>{" "}{"/plugin validate <path> - Validate a manifest file or directory"}</Text><Text> </Text><Text dimColor={true}>Other:</Text><Text> /plugin - Main plugin menu</Text><Text> /plugin help - Show this help</Text><Text> /plugins - Alias for /plugin</Text></Box>; 884: $[28] = t16; 885: } else { 886: t16 = $[28]; 887: } 888: return t16; 889: } 890: if (viewState.type === "validate") { 891: let t16; 892: if ($[29] !== onComplete || $[30] !== viewState.path) { 893: t16 = <ValidatePlugin onComplete={onComplete} path={viewState.path} />; 894: $[29] = onComplete; 895: $[30] = viewState.path; 896: $[31] = t16; 897: } else { 898: t16 = $[31]; 899: } 900: return t16; 901: } 902: if (viewState.type === "marketplace-menu") { 903: setViewState({ 904: type: "menu" 905: }); 906: return null; 907: } 908: if (viewState.type === "marketplace-list") { 909: let t16; 910: if ($[32] !== onComplete) { 911: t16 = <MarketplaceList onComplete={onComplete} />; 912: $[32] = onComplete; 913: $[33] = t16; 914: } else { 915: t16 = $[33]; 916: } 917: return t16; 918: } 919: if (viewState.type === "add-marketplace") { 920: let t16; 921: if ($[34] !== cliMode || $[35] !== cursorOffset || $[36] !== error || $[37] !== inputValue || $[38] !== markPluginsChanged || $[39] !== result) { 922: t16 = <AddMarketplace inputValue={inputValue} setInputValue={setInputValue} cursorOffset={cursorOffset} setCursorOffset={setCursorOffset} error={error} setError={setError} result={result} setResult={setResult} setViewState={setViewState} onAddComplete={markPluginsChanged} cliMode={cliMode} />; 923: $[34] = cliMode; 924: $[35] = cursorOffset; 925: $[36] = error; 926: $[37] = inputValue; 927: $[38] = markPluginsChanged; 928: $[39] = result; 929: $[40] = t16; 930: } else { 931: t16 = $[40]; 932: } 933: return t16; 934: } 935: let t16; 936: if ($[41] !== activeTab || $[42] !== showMcpRedirectMessage) { 937: t16 = showMcpRedirectMessage && activeTab === "installed" ? <McpRedirectBanner /> : undefined; 938: $[41] = activeTab; 939: $[42] = showMcpRedirectMessage; 940: $[43] = t16; 941: } else { 942: t16 = $[43]; 943: } 944: let t17; 945: if ($[44] !== error || $[45] !== markPluginsChanged || $[46] !== result || $[47] !== viewState.targetMarketplace || $[48] !== viewState.targetPlugin || $[49] !== viewState.type) { 946: t17 = <Tab id="discover" title="Discover">{viewState.type === "browse-marketplace" ? <BrowseMarketplace error={error} setError={setError} result={result} setResult={setResult} setViewState={setViewState} onInstallComplete={markPluginsChanged} targetMarketplace={viewState.targetMarketplace} targetPlugin={viewState.targetPlugin} /> : <DiscoverPlugins error={error} setError={setError} result={result} setResult={setResult} setViewState={setViewState} onInstallComplete={markPluginsChanged} onSearchModeChange={setChildSearchActive} targetPlugin={viewState.type === "discover-plugins" ? viewState.targetPlugin : undefined} />}</Tab>; 947: $[44] = error; 948: $[45] = markPluginsChanged; 949: $[46] = result; 950: $[47] = viewState.targetMarketplace; 951: $[48] = viewState.targetPlugin; 952: $[49] = viewState.type; 953: $[50] = t17; 954: } else { 955: t17 = $[50]; 956: } 957: const t18 = viewState.type === "manage-plugins" ? viewState.targetPlugin : undefined; 958: const t19 = viewState.type === "manage-plugins" ? viewState.targetMarketplace : undefined; 959: const t20 = viewState.type === "manage-plugins" ? viewState.action : undefined; 960: let t21; 961: if ($[51] !== markPluginsChanged || $[52] !== t18 || $[53] !== t19 || $[54] !== t20) { 962: t21 = <Tab id="installed" title="Installed"><ManagePlugins setViewState={setViewState} setResult={setResult} onManageComplete={markPluginsChanged} onSearchModeChange={setChildSearchActive} targetPlugin={t18} targetMarketplace={t19} action={t20} /></Tab>; 963: $[51] = markPluginsChanged; 964: $[52] = t18; 965: $[53] = t19; 966: $[54] = t20; 967: $[55] = t21; 968: } else { 969: t21 = $[55]; 970: } 971: const t22 = viewState.type === "manage-marketplaces" ? viewState.targetMarketplace : undefined; 972: const t23 = viewState.type === "manage-marketplaces" ? viewState.action : undefined; 973: let t24; 974: if ($[56] !== error || $[57] !== exitState || $[58] !== markPluginsChanged || $[59] !== t22 || $[60] !== t23) { 975: t24 = <Tab id="marketplaces" title="Marketplaces"><ManageMarketplaces setViewState={setViewState} error={error} setError={setError} setResult={setResult} exitState={exitState} onManageComplete={markPluginsChanged} targetMarketplace={t22} action={t23} /></Tab>; 976: $[56] = error; 977: $[57] = exitState; 978: $[58] = markPluginsChanged; 979: $[59] = t22; 980: $[60] = t23; 981: $[61] = t24; 982: } else { 983: t24 = $[61]; 984: } 985: let t25; 986: if ($[62] !== markPluginsChanged) { 987: t25 = <ErrorsTabContent setViewState={setViewState} setActiveTab={setActiveTab} markPluginsChanged={markPluginsChanged} />; 988: $[62] = markPluginsChanged; 989: $[63] = t25; 990: } else { 991: t25 = $[63]; 992: } 993: let t26; 994: if ($[64] !== errorsTabTitle || $[65] !== t25) { 995: t26 = <Tab id="errors" title={errorsTabTitle}>{t25}</Tab>; 996: $[64] = errorsTabTitle; 997: $[65] = t25; 998: $[66] = t26; 999: } else { 1000: t26 = $[66]; 1001: } 1002: let t27; 1003: if ($[67] !== activeTab || $[68] !== childSearchActive || $[69] !== t16 || $[70] !== t17 || $[71] !== t21 || $[72] !== t24 || $[73] !== t26) { 1004: t27 = <Pane color="suggestion"><Tabs title="Plugins" selectedTab={activeTab} onTabChange={handleTabChange} color="suggestion" disableNavigation={childSearchActive} banner={t16}>{t17}{t21}{t24}{t26}</Tabs></Pane>; 1005: $[67] = activeTab; 1006: $[68] = childSearchActive; 1007: $[69] = t16; 1008: $[70] = t17; 1009: $[71] = t21; 1010: $[72] = t24; 1011: $[73] = t26; 1012: $[74] = t27; 1013: } else { 1014: t27 = $[74]; 1015: } 1016: return t27; 1017: } 1018: function _temp1(prev) { 1019: return prev.plugins.needsRefresh ? prev : { 1020: ...prev, 1021: plugins: { 1022: ...prev.plugins, 1023: needsRefresh: true 1024: } 1025: }; 1026: } 1027: function _temp0(s) { 1028: let count = s.plugins.errors.length; 1029: for (const m of s.plugins.installationStatus.marketplaces) { 1030: if (m.status === "failed") { 1031: count++; 1032: } 1033: } 1034: return count; 1035: }

File: src/commands/plugin/PluginTrustWarning.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import * as React from 'react'; 4: import { Box, Text } from '../../ink.js'; 5: import { getPluginTrustMessage } from '../../utils/plugins/marketplaceHelpers.js'; 6: export function PluginTrustWarning() { 7: const $ = _c(3); 8: let t0; 9: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 10: t0 = getPluginTrustMessage(); 11: $[0] = t0; 12: } else { 13: t0 = $[0]; 14: } 15: const customMessage = t0; 16: let t1; 17: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 18: t1 = <Text color="claude">{figures.warning} </Text>; 19: $[1] = t1; 20: } else { 21: t1 = $[1]; 22: } 23: let t2; 24: if ($[2] === Symbol.for("react.memo_cache_sentinel")) { 25: t2 = <Box marginBottom={1}>{t1}<Text dimColor={true} italic={true}>Make sure you trust a plugin before installing, updating, or using it. Anthropic does not control what MCP servers, files, or other software are included in plugins and cannot verify that they will work as intended or that they won't change. See each plugin's homepage for more information.{customMessage ? ` ${customMessage}` : ""}</Text></Box>; 26: $[2] = t2; 27: } else { 28: t2 = $[2]; 29: } 30: return t2; 31: }

File: src/commands/plugin/UnifiedInstalledCell.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import * as React from 'react'; 4: import { Box, color, Text, useTheme } from '../../ink.js'; 5: import { plural } from '../../utils/stringUtils.js'; 6: import type { UnifiedInstalledItem } from './unifiedTypes.js'; 7: type Props = { 8: item: UnifiedInstalledItem; 9: isSelected: boolean; 10: }; 11: export function UnifiedInstalledCell(t0) { 12: const $ = _c(142); 13: const { 14: item, 15: isSelected 16: } = t0; 17: const [theme] = useTheme(); 18: if (item.type === "plugin") { 19: let statusIcon; 20: let statusText; 21: if (item.pendingToggle) { 22: let t1; 23: if ($[0] !== theme) { 24: t1 = color("suggestion", theme)(figures.arrowRight); 25: $[0] = theme; 26: $[1] = t1; 27: } else { 28: t1 = $[1]; 29: } 30: statusIcon = t1; 31: statusText = item.pendingToggle === "will-enable" ? "will enable" : "will disable"; 32: } else { 33: if (item.errorCount > 0) { 34: let t1; 35: if ($[2] !== theme) { 36: t1 = color("error", theme)(figures.cross); 37: $[2] = theme; 38: $[3] = t1; 39: } else { 40: t1 = $[3]; 41: } 42: statusIcon = t1; 43: const t2 = item.errorCount; 44: let t3; 45: if ($[4] !== item.errorCount) { 46: t3 = plural(item.errorCount, "error"); 47: $[4] = item.errorCount; 48: $[5] = t3; 49: } else { 50: t3 = $[5]; 51: } 52: statusText = `${t2} ${t3}`; 53: } else { 54: if (!item.isEnabled) { 55: let t1; 56: if ($[6] !== theme) { 57: t1 = color("inactive", theme)(figures.radioOff); 58: $[6] = theme; 59: $[7] = t1; 60: } else { 61: t1 = $[7]; 62: } 63: statusIcon = t1; 64: statusText = "disabled"; 65: } else { 66: let t1; 67: if ($[8] !== theme) { 68: t1 = color("success", theme)(figures.tick); 69: $[8] = theme; 70: $[9] = t1; 71: } else { 72: t1 = $[9]; 73: } 74: statusIcon = t1; 75: statusText = "enabled"; 76: } 77: } 78: } 79: const t1 = isSelected ? "suggestion" : undefined; 80: const t2 = isSelected ? `${figures.pointer} ` : " "; 81: let t3; 82: if ($[10] !== t1 || $[11] !== t2) { 83: t3 = <Text color={t1}>{t2}</Text>; 84: $[10] = t1; 85: $[11] = t2; 86: $[12] = t3; 87: } else { 88: t3 = $[12]; 89: } 90: const t4 = isSelected ? "suggestion" : undefined; 91: let t5; 92: if ($[13] !== item.name || $[14] !== t4) { 93: t5 = <Text color={t4}>{item.name}</Text>; 94: $[13] = item.name; 95: $[14] = t4; 96: $[15] = t5; 97: } else { 98: t5 = $[15]; 99: } 100: const t6 = !isSelected; 101: let t7; 102: if ($[16] === Symbol.for("react.memo_cache_sentinel")) { 103: t7 = <Text backgroundColor="userMessageBackground">Plugin</Text>; 104: $[16] = t7; 105: } else { 106: t7 = $[16]; 107: } 108: let t8; 109: if ($[17] !== t6) { 110: t8 = <Text dimColor={t6}>{" "}{t7}</Text>; 111: $[17] = t6; 112: $[18] = t8; 113: } else { 114: t8 = $[18]; 115: } 116: let t9; 117: if ($[19] !== item.marketplace) { 118: t9 = <Text dimColor={true}> · {item.marketplace}</Text>; 119: $[19] = item.marketplace; 120: $[20] = t9; 121: } else { 122: t9 = $[20]; 123: } 124: const t10 = !isSelected; 125: let t11; 126: if ($[21] !== statusIcon || $[22] !== t10) { 127: t11 = <Text dimColor={t10}> · {statusIcon} </Text>; 128: $[21] = statusIcon; 129: $[22] = t10; 130: $[23] = t11; 131: } else { 132: t11 = $[23]; 133: } 134: const t12 = !isSelected; 135: let t13; 136: if ($[24] !== statusText || $[25] !== t12) { 137: t13 = <Text dimColor={t12}>{statusText}</Text>; 138: $[24] = statusText; 139: $[25] = t12; 140: $[26] = t13; 141: } else { 142: t13 = $[26]; 143: } 144: let t14; 145: if ($[27] !== t11 || $[28] !== t13 || $[29] !== t3 || $[30] !== t5 || $[31] !== t8 || $[32] !== t9) { 146: t14 = <Box>{t3}{t5}{t8}{t9}{t11}{t13}</Box>; 147: $[27] = t11; 148: $[28] = t13; 149: $[29] = t3; 150: $[30] = t5; 151: $[31] = t8; 152: $[32] = t9; 153: $[33] = t14; 154: } else { 155: t14 = $[33]; 156: } 157: return t14; 158: } 159: if (item.type === "flagged-plugin") { 160: let t1; 161: if ($[34] !== theme) { 162: t1 = color("warning", theme)(figures.warning); 163: $[34] = theme; 164: $[35] = t1; 165: } else { 166: t1 = $[35]; 167: } 168: const statusIcon_0 = t1; 169: const t2 = isSelected ? "suggestion" : undefined; 170: const t3 = isSelected ? `${figures.pointer} ` : " "; 171: let t4; 172: if ($[36] !== t2 || $[37] !== t3) { 173: t4 = <Text color={t2}>{t3}</Text>; 174: $[36] = t2; 175: $[37] = t3; 176: $[38] = t4; 177: } else { 178: t4 = $[38]; 179: } 180: const t5 = isSelected ? "suggestion" : undefined; 181: let t6; 182: if ($[39] !== item.name || $[40] !== t5) { 183: t6 = <Text color={t5}>{item.name}</Text>; 184: $[39] = item.name; 185: $[40] = t5; 186: $[41] = t6; 187: } else { 188: t6 = $[41]; 189: } 190: const t7 = !isSelected; 191: let t8; 192: if ($[42] === Symbol.for("react.memo_cache_sentinel")) { 193: t8 = <Text backgroundColor="userMessageBackground">Plugin</Text>; 194: $[42] = t8; 195: } else { 196: t8 = $[42]; 197: } 198: let t9; 199: if ($[43] !== t7) { 200: t9 = <Text dimColor={t7}>{" "}{t8}</Text>; 201: $[43] = t7; 202: $[44] = t9; 203: } else { 204: t9 = $[44]; 205: } 206: let t10; 207: if ($[45] !== item.marketplace) { 208: t10 = <Text dimColor={true}> · {item.marketplace}</Text>; 209: $[45] = item.marketplace; 210: $[46] = t10; 211: } else { 212: t10 = $[46]; 213: } 214: const t11 = !isSelected; 215: let t12; 216: if ($[47] !== statusIcon_0 || $[48] !== t11) { 217: t12 = <Text dimColor={t11}> · {statusIcon_0} </Text>; 218: $[47] = statusIcon_0; 219: $[48] = t11; 220: $[49] = t12; 221: } else { 222: t12 = $[49]; 223: } 224: const t13 = !isSelected; 225: let t14; 226: if ($[50] !== t13) { 227: t14 = <Text dimColor={t13}>removed</Text>; 228: $[50] = t13; 229: $[51] = t14; 230: } else { 231: t14 = $[51]; 232: } 233: let t15; 234: if ($[52] !== t10 || $[53] !== t12 || $[54] !== t14 || $[55] !== t4 || $[56] !== t6 || $[57] !== t9) { 235: t15 = <Box>{t4}{t6}{t9}{t10}{t12}{t14}</Box>; 236: $[52] = t10; 237: $[53] = t12; 238: $[54] = t14; 239: $[55] = t4; 240: $[56] = t6; 241: $[57] = t9; 242: $[58] = t15; 243: } else { 244: t15 = $[58]; 245: } 246: return t15; 247: } 248: if (item.type === "failed-plugin") { 249: let t1; 250: if ($[59] !== theme) { 251: t1 = color("error", theme)(figures.cross); 252: $[59] = theme; 253: $[60] = t1; 254: } else { 255: t1 = $[60]; 256: } 257: const statusIcon_1 = t1; 258: const t2 = item.errorCount; 259: let t3; 260: if ($[61] !== item.errorCount) { 261: t3 = plural(item.errorCount, "error"); 262: $[61] = item.errorCount; 263: $[62] = t3; 264: } else { 265: t3 = $[62]; 266: } 267: const statusText_0 = `failed to load · ${t2} ${t3}`; 268: const t4 = isSelected ? "suggestion" : undefined; 269: const t5 = isSelected ? `${figures.pointer} ` : " "; 270: let t6; 271: if ($[63] !== t4 || $[64] !== t5) { 272: t6 = <Text color={t4}>{t5}</Text>; 273: $[63] = t4; 274: $[64] = t5; 275: $[65] = t6; 276: } else { 277: t6 = $[65]; 278: } 279: const t7 = isSelected ? "suggestion" : undefined; 280: let t8; 281: if ($[66] !== item.name || $[67] !== t7) { 282: t8 = <Text color={t7}>{item.name}</Text>; 283: $[66] = item.name; 284: $[67] = t7; 285: $[68] = t8; 286: } else { 287: t8 = $[68]; 288: } 289: const t9 = !isSelected; 290: let t10; 291: if ($[69] === Symbol.for("react.memo_cache_sentinel")) { 292: t10 = <Text backgroundColor="userMessageBackground">Plugin</Text>; 293: $[69] = t10; 294: } else { 295: t10 = $[69]; 296: } 297: let t11; 298: if ($[70] !== t9) { 299: t11 = <Text dimColor={t9}>{" "}{t10}</Text>; 300: $[70] = t9; 301: $[71] = t11; 302: } else { 303: t11 = $[71]; 304: } 305: let t12; 306: if ($[72] !== item.marketplace) { 307: t12 = <Text dimColor={true}> · {item.marketplace}</Text>; 308: $[72] = item.marketplace; 309: $[73] = t12; 310: } else { 311: t12 = $[73]; 312: } 313: const t13 = !isSelected; 314: let t14; 315: if ($[74] !== statusIcon_1 || $[75] !== t13) { 316: t14 = <Text dimColor={t13}> · {statusIcon_1} </Text>; 317: $[74] = statusIcon_1; 318: $[75] = t13; 319: $[76] = t14; 320: } else { 321: t14 = $[76]; 322: } 323: const t15 = !isSelected; 324: let t16; 325: if ($[77] !== statusText_0 || $[78] !== t15) { 326: t16 = <Text dimColor={t15}>{statusText_0}</Text>; 327: $[77] = statusText_0; 328: $[78] = t15; 329: $[79] = t16; 330: } else { 331: t16 = $[79]; 332: } 333: let t17; 334: if ($[80] !== t11 || $[81] !== t12 || $[82] !== t14 || $[83] !== t16 || $[84] !== t6 || $[85] !== t8) { 335: t17 = <Box>{t6}{t8}{t11}{t12}{t14}{t16}</Box>; 336: $[80] = t11; 337: $[81] = t12; 338: $[82] = t14; 339: $[83] = t16; 340: $[84] = t6; 341: $[85] = t8; 342: $[86] = t17; 343: } else { 344: t17 = $[86]; 345: } 346: return t17; 347: } 348: let statusIcon_2; 349: let statusText_1; 350: if (item.status === "connected") { 351: let t1; 352: if ($[87] !== theme) { 353: t1 = color("success", theme)(figures.tick); 354: $[87] = theme; 355: $[88] = t1; 356: } else { 357: t1 = $[88]; 358: } 359: statusIcon_2 = t1; 360: statusText_1 = "connected"; 361: } else { 362: if (item.status === "disabled") { 363: let t1; 364: if ($[89] !== theme) { 365: t1 = color("inactive", theme)(figures.radioOff); 366: $[89] = theme; 367: $[90] = t1; 368: } else { 369: t1 = $[90]; 370: } 371: statusIcon_2 = t1; 372: statusText_1 = "disabled"; 373: } else { 374: if (item.status === "pending") { 375: let t1; 376: if ($[91] !== theme) { 377: t1 = color("inactive", theme)(figures.radioOff); 378: $[91] = theme; 379: $[92] = t1; 380: } else { 381: t1 = $[92]; 382: } 383: statusIcon_2 = t1; 384: statusText_1 = "connecting\u2026"; 385: } else { 386: if (item.status === "needs-auth") { 387: let t1; 388: if ($[93] !== theme) { 389: t1 = color("warning", theme)(figures.triangleUpOutline); 390: $[93] = theme; 391: $[94] = t1; 392: } else { 393: t1 = $[94]; 394: } 395: statusIcon_2 = t1; 396: statusText_1 = "Enter to auth"; 397: } else { 398: let t1; 399: if ($[95] !== theme) { 400: t1 = color("error", theme)(figures.cross); 401: $[95] = theme; 402: $[96] = t1; 403: } else { 404: t1 = $[96]; 405: } 406: statusIcon_2 = t1; 407: statusText_1 = "failed"; 408: } 409: } 410: } 411: } 412: if (item.indented) { 413: const t1 = isSelected ? "suggestion" : undefined; 414: const t2 = isSelected ? `${figures.pointer} ` : " "; 415: let t3; 416: if ($[97] !== t1 || $[98] !== t2) { 417: t3 = <Text color={t1}>{t2}</Text>; 418: $[97] = t1; 419: $[98] = t2; 420: $[99] = t3; 421: } else { 422: t3 = $[99]; 423: } 424: const t4 = !isSelected; 425: let t5; 426: if ($[100] !== t4) { 427: t5 = <Text dimColor={t4}>└ </Text>; 428: $[100] = t4; 429: $[101] = t5; 430: } else { 431: t5 = $[101]; 432: } 433: const t6 = isSelected ? "suggestion" : undefined; 434: let t7; 435: if ($[102] !== item.name || $[103] !== t6) { 436: t7 = <Text color={t6}>{item.name}</Text>; 437: $[102] = item.name; 438: $[103] = t6; 439: $[104] = t7; 440: } else { 441: t7 = $[104]; 442: } 443: const t8 = !isSelected; 444: let t9; 445: if ($[105] === Symbol.for("react.memo_cache_sentinel")) { 446: t9 = <Text backgroundColor="userMessageBackground">MCP</Text>; 447: $[105] = t9; 448: } else { 449: t9 = $[105]; 450: } 451: let t10; 452: if ($[106] !== t8) { 453: t10 = <Text dimColor={t8}>{" "}{t9}</Text>; 454: $[106] = t8; 455: $[107] = t10; 456: } else { 457: t10 = $[107]; 458: } 459: const t11 = !isSelected; 460: let t12; 461: if ($[108] !== statusIcon_2 || $[109] !== t11) { 462: t12 = <Text dimColor={t11}> · {statusIcon_2} </Text>; 463: $[108] = statusIcon_2; 464: $[109] = t11; 465: $[110] = t12; 466: } else { 467: t12 = $[110]; 468: } 469: const t13 = !isSelected; 470: let t14; 471: if ($[111] !== statusText_1 || $[112] !== t13) { 472: t14 = <Text dimColor={t13}>{statusText_1}</Text>; 473: $[111] = statusText_1; 474: $[112] = t13; 475: $[113] = t14; 476: } else { 477: t14 = $[113]; 478: } 479: let t15; 480: if ($[114] !== t10 || $[115] !== t12 || $[116] !== t14 || $[117] !== t3 || $[118] !== t5 || $[119] !== t7) { 481: t15 = <Box>{t3}{t5}{t7}{t10}{t12}{t14}</Box>; 482: $[114] = t10; 483: $[115] = t12; 484: $[116] = t14; 485: $[117] = t3; 486: $[118] = t5; 487: $[119] = t7; 488: $[120] = t15; 489: } else { 490: t15 = $[120]; 491: } 492: return t15; 493: } 494: const t1 = isSelected ? "suggestion" : undefined; 495: const t2 = isSelected ? `${figures.pointer} ` : " "; 496: let t3; 497: if ($[121] !== t1 || $[122] !== t2) { 498: t3 = <Text color={t1}>{t2}</Text>; 499: $[121] = t1; 500: $[122] = t2; 501: $[123] = t3; 502: } else { 503: t3 = $[123]; 504: } 505: const t4 = isSelected ? "suggestion" : undefined; 506: let t5; 507: if ($[124] !== item.name || $[125] !== t4) { 508: t5 = <Text color={t4}>{item.name}</Text>; 509: $[124] = item.name; 510: $[125] = t4; 511: $[126] = t5; 512: } else { 513: t5 = $[126]; 514: } 515: const t6 = !isSelected; 516: let t7; 517: if ($[127] === Symbol.for("react.memo_cache_sentinel")) { 518: t7 = <Text backgroundColor="userMessageBackground">MCP</Text>; 519: $[127] = t7; 520: } else { 521: t7 = $[127]; 522: } 523: let t8; 524: if ($[128] !== t6) { 525: t8 = <Text dimColor={t6}>{" "}{t7}</Text>; 526: $[128] = t6; 527: $[129] = t8; 528: } else { 529: t8 = $[129]; 530: } 531: const t9 = !isSelected; 532: let t10; 533: if ($[130] !== statusIcon_2 || $[131] !== t9) { 534: t10 = <Text dimColor={t9}> · {statusIcon_2} </Text>; 535: $[130] = statusIcon_2; 536: $[131] = t9; 537: $[132] = t10; 538: } else { 539: t10 = $[132]; 540: } 541: const t11 = !isSelected; 542: let t12; 543: if ($[133] !== statusText_1 || $[134] !== t11) { 544: t12 = <Text dimColor={t11}>{statusText_1}</Text>; 545: $[133] = statusText_1; 546: $[134] = t11; 547: $[135] = t12; 548: } else { 549: t12 = $[135]; 550: } 551: let t13; 552: if ($[136] !== t10 || $[137] !== t12 || $[138] !== t3 || $[139] !== t5 || $[140] !== t8) { 553: t13 = <Box>{t3}{t5}{t8}{t10}{t12}</Box>; 554: $[136] = t10; 555: $[137] = t12; 556: $[138] = t3; 557: $[139] = t5; 558: $[140] = t8; 559: $[141] = t13; 560: } else { 561: t13 = $[141]; 562: } 563: return t13; 564: }

File: src/commands/plugin/usePagination.ts

typescript 1: import { useCallback, useMemo, useRef } from 'react' 2: const DEFAULT_MAX_VISIBLE = 5 3: type UsePaginationOptions = { 4: totalItems: number 5: maxVisible?: number 6: selectedIndex?: number 7: } 8: type UsePaginationResult<T> = { 9: currentPage: number 10: totalPages: number 11: startIndex: number 12: endIndex: number 13: needsPagination: boolean 14: pageSize: number 15: getVisibleItems: (items: T[]) => T[] 16: toActualIndex: (visibleIndex: number) => number 17: isOnCurrentPage: (actualIndex: number) => boolean 18: goToPage: (page: number) => void 19: nextPage: () => void 20: prevPage: () => void 21: handleSelectionChange: ( 22: newIndex: number, 23: setSelectedIndex: (index: number) => void, 24: ) => void 25: handlePageNavigation: ( 26: direction: 'left' | 'right', 27: setSelectedIndex: (index: number) => void, 28: ) => boolean 29: scrollPosition: { 30: current: number 31: total: number 32: canScrollUp: boolean 33: canScrollDown: boolean 34: } 35: } 36: export function usePagination<T>({ 37: totalItems, 38: maxVisible = DEFAULT_MAX_VISIBLE, 39: selectedIndex = 0, 40: }: UsePaginationOptions): UsePaginationResult<T> { 41: const needsPagination = totalItems > maxVisible 42: const scrollOffsetRef = useRef(0) 43: const scrollOffset = useMemo(() => { 44: if (!needsPagination) return 0 45: const prevOffset = scrollOffsetRef.current 46: if (selectedIndex < prevOffset) { 47: scrollOffsetRef.current = selectedIndex 48: return selectedIndex 49: } 50: if (selectedIndex >= prevOffset + maxVisible) { 51: const newOffset = selectedIndex - maxVisible + 1 52: scrollOffsetRef.current = newOffset 53: return newOffset 54: } 55: const maxOffset = Math.max(0, totalItems - maxVisible) 56: const clampedOffset = Math.min(prevOffset, maxOffset) 57: scrollOffsetRef.current = clampedOffset 58: return clampedOffset 59: }, [selectedIndex, maxVisible, needsPagination, totalItems]) 60: const startIndex = scrollOffset 61: const endIndex = Math.min(scrollOffset + maxVisible, totalItems) 62: const getVisibleItems = useCallback( 63: (items: T[]): T[] => { 64: if (!needsPagination) return items 65: return items.slice(startIndex, endIndex) 66: }, 67: [needsPagination, startIndex, endIndex], 68: ) 69: const toActualIndex = useCallback( 70: (visibleIndex: number): number => { 71: return startIndex + visibleIndex 72: }, 73: [startIndex], 74: ) 75: const isOnCurrentPage = useCallback( 76: (actualIndex: number): boolean => { 77: return actualIndex >= startIndex && actualIndex < endIndex 78: }, 79: [startIndex, endIndex], 80: ) 81: const goToPage = useCallback((_page: number) => { 82: }, []) 83: const nextPage = useCallback(() => { 84: }, []) 85: const prevPage = useCallback(() => { 86: }, []) 87: const handleSelectionChange = useCallback( 88: (newIndex: number, setSelectedIndex: (index: number) => void) => { 89: const clampedIndex = Math.max(0, Math.min(newIndex, totalItems - 1)) 90: setSelectedIndex(clampedIndex) 91: }, 92: [totalItems], 93: ) 94: const handlePageNavigation = useCallback( 95: ( 96: _direction: 'left' | 'right', 97: _setSelectedIndex: (index: number) => void, 98: ): boolean => { 99: return false 100: }, 101: [], 102: ) 103: const totalPages = Math.max(1, Math.ceil(totalItems / maxVisible)) 104: const currentPage = Math.floor(scrollOffset / maxVisible) 105: return { 106: currentPage, 107: totalPages, 108: startIndex, 109: endIndex, 110: needsPagination, 111: pageSize: maxVisible, 112: getVisibleItems, 113: toActualIndex, 114: isOnCurrentPage, 115: goToPage, 116: nextPage, 117: prevPage, 118: handleSelectionChange, 119: handlePageNavigation, 120: scrollPosition: { 121: current: selectedIndex + 1, 122: total: totalItems, 123: canScrollUp: scrollOffset > 0, 124: canScrollDown: scrollOffset + maxVisible < totalItems, 125: }, 126: } 127: }

File: src/commands/plugin/ValidatePlugin.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import * as React from 'react'; 4: import { useEffect } from 'react'; 5: import { Box, Text } from '../../ink.js'; 6: import { errorMessage } from '../../utils/errors.js'; 7: import { logError } from '../../utils/log.js'; 8: import { validateManifest } from '../../utils/plugins/validatePlugin.js'; 9: import { plural } from '../../utils/stringUtils.js'; 10: type Props = { 11: onComplete: (result?: string) => void; 12: path?: string; 13: }; 14: export function ValidatePlugin(t0) { 15: const $ = _c(5); 16: const { 17: onComplete, 18: path 19: } = t0; 20: let t1; 21: let t2; 22: if ($[0] !== onComplete || $[1] !== path) { 23: t1 = () => { 24: const runValidation = async function runValidation() { 25: if (!path) { 26: onComplete("Usage: /plugin validate <path>\n\nValidate a plugin or marketplace manifest file or directory.\n\nExamples:\n /plugin validate .claude-plugin/plugin.json\n /plugin validate /path/to/plugin-directory\n /plugin validate .\n\nWhen given a directory, automatically validates .claude-plugin/marketplace.json\nor .claude-plugin/plugin.json (prefers marketplace if both exist).\n\nOr from the command line:\n claude plugin validate <path>"); 27: return; 28: } 29: ; 30: try { 31: const result = await validateManifest(path); 32: let output = ""; 33: output = output + `Validating ${result.fileType} manifest: ${result.filePath}\n\n`; 34: output; 35: if (result.errors.length > 0) { 36: output = output + `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, "error")}:\n\n`; 37: output; 38: result.errors.forEach(error_0 => { 39: output = output + ` ${figures.pointer} ${error_0.path}: ${error_0.message}\n`; 40: output; 41: }); 42: output = output + "\n"; 43: output; 44: } 45: if (result.warnings.length > 0) { 46: output = output + `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, "warning")}:\n\n`; 47: output; 48: result.warnings.forEach(warning => { 49: output = output + ` ${figures.pointer} ${warning.path}: ${warning.message}\n`; 50: output; 51: }); 52: output = output + "\n"; 53: output; 54: } 55: if (result.success) { 56: if (result.warnings.length > 0) { 57: output = output + `${figures.tick} Validation passed with warnings\n`; 58: output; 59: } else { 60: output = output + `${figures.tick} Validation passed\n`; 61: output; 62: } 63: process.exitCode = 0; 64: } else { 65: output = output + `${figures.cross} Validation failed\n`; 66: output; 67: process.exitCode = 1; 68: } 69: onComplete(output); 70: } catch (t3) { 71: const error = t3; 72: process.exitCode = 2; 73: logError(error); 74: onComplete(`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`); 75: } 76: }; 77: runValidation(); 78: }; 79: t2 = [onComplete, path]; 80: $[0] = onComplete; 81: $[1] = path; 82: $[2] = t1; 83: $[3] = t2; 84: } else { 85: t1 = $[2]; 86: t2 = $[3]; 87: } 88: useEffect(t1, t2); 89: let t3; 90: if ($[4] === Symbol.for("react.memo_cache_sentinel")) { 91: t3 = <Box flexDirection="column"><Text>Running validation...</Text></Box>; 92: $[4] = t3; 93: } else { 94: t3 = $[4]; 95: } 96: return t3; 97: }

File: src/commands/pr_comments/index.ts

typescript 1: import { createMovedToPluginCommand } from '../createMovedToPluginCommand.js' 2: export default createMovedToPluginCommand({ 3: name: 'pr-comments', 4: description: 'Get comments from a GitHub pull request', 5: progressMessage: 'fetching PR comments', 6: pluginName: 'pr-comments', 7: pluginCommand: 'pr-comments', 8: async getPromptWhileMarketplaceIsPrivate(args) { 9: return [ 10: { 11: type: 'text', 12: text: `You are an AI assistant integrated into a git-based version control system. Your task is to fetch and display comments from a GitHub pull request. 13: Follow these steps: 14: 1. Use \`gh pr view --json number,headRepository\` to get the PR number and repository info 15: 2. Use \`gh api /repos/{owner}/{repo}/issues/{number}/comments\` to get PR-level comments 16: 3. Use \`gh api /repos/{owner}/{repo}/pulls/{number}/comments\` to get review comments. Pay particular attention to the following fields: \`body\`, \`diff_hunk\`, \`path\`, \`line\`, etc. If the comment references some code, consider fetching it using eg \`gh api /repos/{owner}/{repo}/contents/{path}?ref={branch} | jq .content -r | base64 -d\` 17: 4. Parse and format all comments in a readable way 18: 5. Return ONLY the formatted comments, with no additional text 19: Format the comments as: 20: ## Comments 21: [For each comment thread:] 22: - @author file.ts#line: 23: \`\`\`diff 24: [diff_hunk from the API response] 25: \`\`\` 26: > quoted comment text 27: [any replies indented] 28: If there are no comments, return "No comments found." 29: Remember: 30: 1. Only show the actual comments, no explanatory text 31: 2. Include both PR-level and code review comments 32: 3. Preserve the threading/nesting of comment replies 33: 4. Show the file and line number context for code review comments 34: 5. Use jq to parse the JSON responses from the GitHub API 35: ${args ? 'Additional user input: ' + args : ''} 36: `, 37: }, 38: ] 39: }, 40: })

File: src/commands/privacy-settings/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { isConsumerSubscriber } from '../../utils/auth.js' 3: const privacySettings = { 4: type: 'local-jsx', 5: name: 'privacy-settings', 6: description: 'View and update your privacy settings', 7: isEnabled: () => { 8: return isConsumerSubscriber() 9: }, 10: load: () => import('./privacy-settings.js'), 11: } satisfies Command 12: export default privacySettings

File: src/commands/privacy-settings/privacy-settings.tsx

typescript 1: import * as React from 'react'; 2: import { type GroveDecision, GroveDialog, PrivacySettingsDialog } from '../../components/grove/Grove.js'; 3: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; 4: import { getGroveNoticeConfig, getGroveSettings, isQualifiedForGrove } from '../../services/api/grove.js'; 5: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 6: const FALLBACK_MESSAGE = 'Review and manage your privacy settings at https://claude.ai/settings/data-privacy-controls'; 7: export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode | null> { 8: const qualified = await isQualifiedForGrove(); 9: if (!qualified) { 10: onDone(FALLBACK_MESSAGE); 11: return null; 12: } 13: const [settingsResult, configResult] = await Promise.all([getGroveSettings(), getGroveNoticeConfig()]); 14: if (!settingsResult.success) { 15: onDone(FALLBACK_MESSAGE); 16: return null; 17: } 18: const settings = settingsResult.data; 19: const config = configResult.success ? configResult.data : null; 20: async function onDoneWithDecision(decision: GroveDecision) { 21: if (decision === 'escape' || decision === 'defer') { 22: onDone('Privacy settings dialog dismissed', { 23: display: 'system' 24: }); 25: return; 26: } 27: await onDoneWithSettingsCheck(); 28: } 29: async function onDoneWithSettingsCheck() { 30: const updatedSettingsResult = await getGroveSettings(); 31: if (!updatedSettingsResult.success) { 32: onDone('Unable to retrieve updated privacy settings', { 33: display: 'system' 34: }); 35: return; 36: } 37: const updatedSettings = updatedSettingsResult.data; 38: const groveStatus = updatedSettings.grove_enabled ? 'true' : 'false'; 39: onDone(`"Help improve Claude" set to ${groveStatus}.`); 40: if (settings.grove_enabled !== null && settings.grove_enabled !== updatedSettings.grove_enabled) { 41: logEvent('tengu_grove_policy_toggled', { 42: state: updatedSettings.grove_enabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 43: location: 'settings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 44: }); 45: } 46: } 47: if (settings.grove_enabled !== null) { 48: return <PrivacySettingsDialog settings={settings} domainExcluded={config?.domain_excluded} onDone={onDoneWithSettingsCheck}></PrivacySettingsDialog>; 49: } 50: return <GroveDialog showIfAlreadyViewed={true} onDone={onDoneWithDecision} location={'settings'} />; 51: }

File: src/commands/rate-limit-options/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { isClaudeAISubscriber } from '../../utils/auth.js' 3: const rateLimitOptions = { 4: type: 'local-jsx', 5: name: 'rate-limit-options', 6: description: 'Show options when rate limit is reached', 7: isEnabled: () => { 8: if (!isClaudeAISubscriber()) { 9: return false 10: } 11: return true 12: }, 13: isHidden: true, 14: load: () => import('./rate-limit-options.js'), 15: } satisfies Command 16: export default rateLimitOptions

File: src/commands/rate-limit-options/rate-limit-options.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { useMemo, useState } from 'react'; 3: import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js'; 4: import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js'; 5: import { Dialog } from '../../components/design-system/Dialog.js'; 6: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; 7: import { logEvent } from '../../services/analytics/index.js'; 8: import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js'; 9: import type { ToolUseContext } from '../../Tool.js'; 10: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 11: import { getOauthAccountInfo, getRateLimitTier, getSubscriptionType } from '../../utils/auth.js'; 12: import { hasClaudeAiBillingAccess } from '../../utils/billing.js'; 13: import { call as extraUsageCall } from '../extra-usage/extra-usage.js'; 14: import { extraUsage } from '../extra-usage/index.js'; 15: import upgrade from '../upgrade/index.js'; 16: import { call as upgradeCall } from '../upgrade/upgrade.js'; 17: type RateLimitOptionsMenuOptionType = 'upgrade' | 'extra-usage' | 'cancel'; 18: type RateLimitOptionsMenuProps = { 19: onDone: (result?: string, options?: { 20: display?: CommandResultDisplay | undefined; 21: } | undefined) => void; 22: context: ToolUseContext & LocalJSXCommandContext; 23: }; 24: function RateLimitOptionsMenu(t0) { 25: const $ = _c(25); 26: const { 27: onDone, 28: context 29: } = t0; 30: const [subCommandJSX, setSubCommandJSX] = useState(null); 31: const claudeAiLimits = useClaudeAiLimits(); 32: let t1; 33: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 34: t1 = getSubscriptionType(); 35: $[0] = t1; 36: } else { 37: t1 = $[0]; 38: } 39: const subscriptionType = t1; 40: let t2; 41: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 42: t2 = getRateLimitTier(); 43: $[1] = t2; 44: } else { 45: t2 = $[1]; 46: } 47: const rateLimitTier = t2; 48: const hasExtraUsageEnabled = getOauthAccountInfo()?.hasExtraUsageEnabled === true; 49: const isMax = subscriptionType === "max"; 50: const isMax20x = isMax && rateLimitTier === "default_claude_max_20x"; 51: const isTeamOrEnterprise = subscriptionType === "team" || subscriptionType === "enterprise"; 52: const buyFirst = getFeatureValue_CACHED_MAY_BE_STALE("tengu_jade_anvil_4", false); 53: let t3; 54: bb0: { 55: let actionOptions; 56: if ($[2] !== claudeAiLimits.overageDisabledReason || $[3] !== claudeAiLimits.overageStatus) { 57: actionOptions = []; 58: if (extraUsage.isEnabled()) { 59: const hasBillingAccess = hasClaudeAiBillingAccess(); 60: const needsToRequestFromAdmin = isTeamOrEnterprise && !hasBillingAccess; 61: const isOrgSpendCapDepleted = claudeAiLimits.overageDisabledReason === "out_of_credits" || claudeAiLimits.overageDisabledReason === "org_level_disabled_until" || claudeAiLimits.overageDisabledReason === "org_service_zero_credit_limit"; 62: if (needsToRequestFromAdmin && isOrgSpendCapDepleted) {} else { 63: const isOverageState = claudeAiLimits.overageStatus === "rejected" || claudeAiLimits.overageStatus === "allowed_warning"; 64: let label; 65: if (needsToRequestFromAdmin) { 66: label = isOverageState ? "Request more" : "Request extra usage"; 67: } else { 68: label = hasExtraUsageEnabled ? "Add funds to continue with extra usage" : "Switch to extra usage"; 69: } 70: let t4; 71: if ($[5] !== label) { 72: t4 = { 73: label, 74: value: "extra-usage" 75: }; 76: $[5] = label; 77: $[6] = t4; 78: } else { 79: t4 = $[6]; 80: } 81: actionOptions.push(t4); 82: } 83: } 84: if (!isMax20x && !isTeamOrEnterprise && upgrade.isEnabled()) { 85: let t4; 86: if ($[7] === Symbol.for("react.memo_cache_sentinel")) { 87: t4 = { 88: label: "Upgrade your plan", 89: value: "upgrade" 90: }; 91: $[7] = t4; 92: } else { 93: t4 = $[7]; 94: } 95: actionOptions.push(t4); 96: } 97: $[2] = claudeAiLimits.overageDisabledReason; 98: $[3] = claudeAiLimits.overageStatus; 99: $[4] = actionOptions; 100: } else { 101: actionOptions = $[4]; 102: } 103: let t4; 104: if ($[8] === Symbol.for("react.memo_cache_sentinel")) { 105: t4 = { 106: label: "Stop and wait for limit to reset", 107: value: "cancel" 108: }; 109: $[8] = t4; 110: } else { 111: t4 = $[8]; 112: } 113: const cancelOption = t4; 114: if (buyFirst) { 115: let t5; 116: if ($[9] !== actionOptions) { 117: t5 = [...actionOptions, cancelOption]; 118: $[9] = actionOptions; 119: $[10] = t5; 120: } else { 121: t5 = $[10]; 122: } 123: t3 = t5; 124: break bb0; 125: } 126: let t5; 127: if ($[11] !== actionOptions) { 128: t5 = [cancelOption, ...actionOptions]; 129: $[11] = actionOptions; 130: $[12] = t5; 131: } else { 132: t5 = $[12]; 133: } 134: t3 = t5; 135: } 136: const options = t3; 137: let t4; 138: if ($[13] !== onDone) { 139: t4 = function handleCancel() { 140: logEvent("tengu_rate_limit_options_menu_cancel", {}); 141: onDone(undefined, { 142: display: "skip" 143: }); 144: }; 145: $[13] = onDone; 146: $[14] = t4; 147: } else { 148: t4 = $[14]; 149: } 150: const handleCancel = t4; 151: let t5; 152: if ($[15] !== context || $[16] !== handleCancel || $[17] !== onDone) { 153: t5 = function handleSelect(value) { 154: if (value === "upgrade") { 155: logEvent("tengu_rate_limit_options_menu_select_upgrade", {}); 156: upgradeCall(onDone, context).then(jsx => { 157: if (jsx) { 158: setSubCommandJSX(jsx); 159: } 160: }); 161: } else { 162: if (value === "extra-usage") { 163: logEvent("tengu_rate_limit_options_menu_select_extra_usage", {}); 164: extraUsageCall(onDone, context).then(jsx_0 => { 165: if (jsx_0) { 166: setSubCommandJSX(jsx_0); 167: } 168: }); 169: } else { 170: if (value === "cancel") { 171: handleCancel(); 172: } 173: } 174: } 175: }; 176: $[15] = context; 177: $[16] = handleCancel; 178: $[17] = onDone; 179: $[18] = t5; 180: } else { 181: t5 = $[18]; 182: } 183: const handleSelect = t5; 184: if (subCommandJSX) { 185: return subCommandJSX; 186: } 187: let t6; 188: if ($[19] !== handleSelect || $[20] !== options) { 189: t6 = <Select options={options} onChange={handleSelect} visibleOptionCount={options.length} />; 190: $[19] = handleSelect; 191: $[20] = options; 192: $[21] = t6; 193: } else { 194: t6 = $[21]; 195: } 196: let t7; 197: if ($[22] !== handleCancel || $[23] !== t6) { 198: t7 = <Dialog title="What do you want to do?" onCancel={handleCancel} color="suggestion">{t6}</Dialog>; 199: $[22] = handleCancel; 200: $[23] = t6; 201: $[24] = t7; 202: } else { 203: t7 = $[24]; 204: } 205: return t7; 206: } 207: export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext & LocalJSXCommandContext): Promise<React.ReactNode> { 208: return <RateLimitOptionsMenu onDone={onDone} context={context} />; 209: }

File: src/commands/release-notes/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const releaseNotes: Command = { 3: description: 'View release notes', 4: name: 'release-notes', 5: type: 'local', 6: supportsNonInteractive: true, 7: load: () => import('./release-notes.js'), 8: } 9: export default releaseNotes

File: src/commands/release-notes/release-notes.ts

typescript 1: import type { LocalCommandResult } from '../../types/command.js' 2: import { 3: CHANGELOG_URL, 4: fetchAndStoreChangelog, 5: getAllReleaseNotes, 6: getStoredChangelog, 7: } from '../../utils/releaseNotes.js' 8: function formatReleaseNotes(notes: Array<[string, string[]]>): string { 9: return notes 10: .map(([version, notes]) => { 11: const header = `Version ${version}:` 12: const bulletPoints = notes.map(note => `· ${note}`).join('\n') 13: return `${header}\n${bulletPoints}` 14: }) 15: .join('\n\n') 16: } 17: export async function call(): Promise<LocalCommandResult> { 18: let freshNotes: Array<[string, string[]]> = [] 19: try { 20: const timeoutPromise = new Promise<void>((_, reject) => { 21: setTimeout(rej => rej(new Error('Timeout')), 500, reject) 22: }) 23: await Promise.race([fetchAndStoreChangelog(), timeoutPromise]) 24: freshNotes = getAllReleaseNotes(await getStoredChangelog()) 25: } catch { 26: } 27: if (freshNotes.length > 0) { 28: return { type: 'text', value: formatReleaseNotes(freshNotes) } 29: } 30: const cachedNotes = getAllReleaseNotes(await getStoredChangelog()) 31: if (cachedNotes.length > 0) { 32: return { type: 'text', value: formatReleaseNotes(cachedNotes) } 33: } 34: return { 35: type: 'text', 36: value: `See the full changelog at: ${CHANGELOG_URL}`, 37: } 38: }

File: src/commands/reload-plugins/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const reloadPlugins = { 3: type: 'local', 4: name: 'reload-plugins', 5: description: 'Activate pending plugin changes in the current session', 6: supportsNonInteractive: false, 7: load: () => import('./reload-plugins.js'), 8: } satisfies Command 9: export default reloadPlugins

File: src/commands/reload-plugins/reload-plugins.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { getIsRemoteMode } from '../../bootstrap/state.js' 3: import { redownloadUserSettings } from '../../services/settingsSync/index.js' 4: import type { LocalCommandCall } from '../../types/command.js' 5: import { isEnvTruthy } from '../../utils/envUtils.js' 6: import { refreshActivePlugins } from '../../utils/plugins/refresh.js' 7: import { settingsChangeDetector } from '../../utils/settings/changeDetector.js' 8: import { plural } from '../../utils/stringUtils.js' 9: export const call: LocalCommandCall = async (_args, context) => { 10: if ( 11: feature('DOWNLOAD_USER_SETTINGS') && 12: (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) || getIsRemoteMode()) 13: ) { 14: const applied = await redownloadUserSettings() 15: if (applied) { 16: settingsChangeDetector.notifyChange('userSettings') 17: } 18: } 19: const r = await refreshActivePlugins(context.setAppState) 20: const parts = [ 21: n(r.enabled_count, 'plugin'), 22: n(r.command_count, 'skill'), 23: n(r.agent_count, 'agent'), 24: n(r.hook_count, 'hook'), 25: n(r.mcp_count, 'plugin MCP server'), 26: n(r.lsp_count, 'plugin LSP server'), 27: ] 28: let msg = `Reloaded: ${parts.join(' · ')}` 29: if (r.error_count > 0) { 30: msg += `\n${n(r.error_count, 'error')} during load. Run /doctor for details.` 31: } 32: return { type: 'text', value: msg } 33: } 34: function n(count: number, noun: string): string { 35: return `${count} ${plural(count, noun)}` 36: }

File: src/commands/remote-env/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { isPolicyAllowed } from '../../services/policyLimits/index.js' 3: import { isClaudeAISubscriber } from '../../utils/auth.js' 4: export default { 5: type: 'local-jsx', 6: name: 'remote-env', 7: description: 'Configure the default remote environment for teleport sessions', 8: isEnabled: () => 9: isClaudeAISubscriber() && isPolicyAllowed('allow_remote_sessions'), 10: get isHidden() { 11: return !isClaudeAISubscriber() || !isPolicyAllowed('allow_remote_sessions') 12: }, 13: load: () => import('./remote-env.js'), 14: } satisfies Command

File: src/commands/remote-env/remote-env.tsx

typescript 1: import * as React from 'react'; 2: import { RemoteEnvironmentDialog } from '../../components/RemoteEnvironmentDialog.js'; 3: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 4: export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> { 5: return <RemoteEnvironmentDialog onDone={onDone} />; 6: }

File: src/commands/remote-setup/api.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig } from '../../constants/oauth.js' 3: import { logForDebugging } from '../../utils/debug.js' 4: import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js' 5: import { fetchEnvironments } from '../../utils/teleport/environments.js' 6: const CCR_BYOC_BETA_HEADER = 'ccr-byoc-2025-07-29' 7: export class RedactedGithubToken { 8: readonly #value: string 9: constructor(raw: string) { 10: this.#value = raw 11: } 12: reveal(): string { 13: return this.#value 14: } 15: toString(): string { 16: return '[REDACTED:gh-token]' 17: } 18: toJSON(): string { 19: return '[REDACTED:gh-token]' 20: } 21: [Symbol.for('nodejs.util.inspect.custom')](): string { 22: return '[REDACTED:gh-token]' 23: } 24: } 25: export type ImportTokenResult = { 26: github_username: string 27: } 28: export type ImportTokenError = 29: | { kind: 'not_signed_in' } 30: | { kind: 'invalid_token' } 31: | { kind: 'server'; status: number } 32: | { kind: 'network' } 33: export async function importGithubToken( 34: token: RedactedGithubToken, 35: ): Promise< 36: | { ok: true; result: ImportTokenResult } 37: | { ok: false; error: ImportTokenError } 38: > { 39: let accessToken: string, orgUUID: string 40: try { 41: ;({ accessToken, orgUUID } = await prepareApiRequest()) 42: } catch { 43: return { ok: false, error: { kind: 'not_signed_in' } } 44: } 45: const url = `${getOauthConfig().BASE_API_URL}/v1/code/github/import-token` 46: const headers = { 47: ...getOAuthHeaders(accessToken), 48: 'anthropic-beta': CCR_BYOC_BETA_HEADER, 49: 'x-organization-uuid': orgUUID, 50: } 51: try { 52: const response = await axios.post<ImportTokenResult>( 53: url, 54: { token: token.reveal() }, 55: { headers, timeout: 15000, validateStatus: () => true }, 56: ) 57: if (response.status === 200) { 58: return { ok: true, result: response.data } 59: } 60: if (response.status === 400) { 61: return { ok: false, error: { kind: 'invalid_token' } } 62: } 63: if (response.status === 401) { 64: return { ok: false, error: { kind: 'not_signed_in' } } 65: } 66: logForDebugging(`import-token returned ${response.status}`, { 67: level: 'error', 68: }) 69: return { ok: false, error: { kind: 'server', status: response.status } } 70: } catch (err) { 71: if (axios.isAxiosError(err)) { 72: logForDebugging(`import-token network error: ${err.code ?? 'unknown'}`, { 73: level: 'error', 74: }) 75: } 76: return { ok: false, error: { kind: 'network' } } 77: } 78: } 79: async function hasExistingEnvironment(): Promise<boolean> { 80: try { 81: const envs = await fetchEnvironments() 82: return envs.length > 0 83: } catch { 84: return false 85: } 86: } 87: export async function createDefaultEnvironment(): Promise<boolean> { 88: let accessToken: string, orgUUID: string 89: try { 90: ;({ accessToken, orgUUID } = await prepareApiRequest()) 91: } catch { 92: return false 93: } 94: if (await hasExistingEnvironment()) { 95: return true 96: } 97: const url = `${getOauthConfig().BASE_API_URL}/v1/environment_providers/cloud/create` 98: const headers = { 99: ...getOAuthHeaders(accessToken), 100: 'x-organization-uuid': orgUUID, 101: } 102: try { 103: const response = await axios.post( 104: url, 105: { 106: name: 'Default', 107: kind: 'anthropic_cloud', 108: description: 'Default - trusted network access', 109: config: { 110: environment_type: 'anthropic', 111: cwd: '/home/user', 112: init_script: null, 113: environment: {}, 114: languages: [ 115: { name: 'python', version: '3.11' }, 116: { name: 'node', version: '20' }, 117: ], 118: network_config: { 119: allowed_hosts: [], 120: allow_default_hosts: true, 121: }, 122: }, 123: }, 124: { headers, timeout: 15000, validateStatus: () => true }, 125: ) 126: return response.status >= 200 && response.status < 300 127: } catch { 128: return false 129: } 130: } 131: export async function isSignedIn(): Promise<boolean> { 132: try { 133: await prepareApiRequest() 134: return true 135: } catch { 136: return false 137: } 138: } 139: export function getCodeWebUrl(): string { 140: return `${getOauthConfig().CLAUDE_AI_ORIGIN}/code` 141: }

File: src/commands/remote-setup/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 3: import { isPolicyAllowed } from '../../services/policyLimits/index.js' 4: const web = { 5: type: 'local-jsx', 6: name: 'web-setup', 7: description: 8: 'Setup Claude Code on the web (requires connecting your GitHub account)', 9: availability: ['claude-ai'], 10: isEnabled: () => 11: getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) && 12: isPolicyAllowed('allow_remote_sessions'), 13: get isHidden() { 14: return !isPolicyAllowed('allow_remote_sessions') 15: }, 16: load: () => import('./remote-setup.js'), 17: } satisfies Command 18: export default web

File: src/commands/remote-setup/remote-setup.tsx

typescript 1: import { execa } from 'execa'; 2: import * as React from 'react'; 3: import { useEffect, useState } from 'react'; 4: import { Select } from '../../components/CustomSelect/index.js'; 5: import { Dialog } from '../../components/design-system/Dialog.js'; 6: import { LoadingState } from '../../components/design-system/LoadingState.js'; 7: import { Box, Text } from '../../ink.js'; 8: import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as SafeString } from '../../services/analytics/index.js'; 9: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 10: import { openBrowser } from '../../utils/browser.js'; 11: import { getGhAuthStatus } from '../../utils/github/ghAuthStatus.js'; 12: import { createDefaultEnvironment, getCodeWebUrl, type ImportTokenError, importGithubToken, isSignedIn, RedactedGithubToken } from './api.js'; 13: type CheckResult = { 14: status: 'not_signed_in'; 15: } | { 16: status: 'has_gh_token'; 17: token: RedactedGithubToken; 18: } | { 19: status: 'gh_not_installed'; 20: } | { 21: status: 'gh_not_authenticated'; 22: }; 23: async function checkLoginState(): Promise<CheckResult> { 24: if (!(await isSignedIn())) { 25: return { 26: status: 'not_signed_in' 27: }; 28: } 29: const ghStatus = await getGhAuthStatus(); 30: if (ghStatus === 'not_installed') { 31: return { 32: status: 'gh_not_installed' 33: }; 34: } 35: if (ghStatus === 'not_authenticated') { 36: return { 37: status: 'gh_not_authenticated' 38: }; 39: } 40: const { 41: stdout 42: } = await execa('gh', ['auth', 'token'], { 43: stdout: 'pipe', 44: stderr: 'ignore', 45: timeout: 5000, 46: reject: false 47: }); 48: const trimmed = stdout.trim(); 49: if (!trimmed) { 50: return { 51: status: 'gh_not_authenticated' 52: }; 53: } 54: return { 55: status: 'has_gh_token', 56: token: new RedactedGithubToken(trimmed) 57: }; 58: } 59: function errorMessage(err: ImportTokenError, codeUrl: string): string { 60: switch (err.kind) { 61: case 'not_signed_in': 62: return `Login failed. Please visit ${codeUrl} and login using the GitHub App`; 63: case 'invalid_token': 64: return 'GitHub rejected that token. Run `gh auth login` and try again.'; 65: case 'server': 66: return `Server error (${err.status}). Try again in a moment.`; 67: case 'network': 68: return "Couldn't reach the server. Check your connection."; 69: } 70: } 71: type Step = { 72: name: 'checking'; 73: } | { 74: name: 'confirm'; 75: token: RedactedGithubToken; 76: } | { 77: name: 'uploading'; 78: }; 79: function Web({ 80: onDone 81: }: { 82: onDone: LocalJSXCommandOnDone; 83: }) { 84: const [step, setStep] = useState<Step>({ 85: name: 'checking' 86: }); 87: useEffect(() => { 88: logEvent('tengu_remote_setup_started', {}); 89: void checkLoginState().then(async result => { 90: switch (result.status) { 91: case 'not_signed_in': 92: logEvent('tengu_remote_setup_result', { 93: result: 'not_signed_in' as SafeString 94: }); 95: onDone('Not signed in to Claude. Run /login first.'); 96: return; 97: case 'gh_not_installed': 98: case 'gh_not_authenticated': 99: { 100: const url = `${getCodeWebUrl()}/onboarding?step=alt-auth`; 101: await openBrowser(url); 102: logEvent('tengu_remote_setup_result', { 103: result: result.status as SafeString 104: }); 105: onDone(result.status === 'gh_not_installed' ? `GitHub CLI not found. Install it via https://cli.github.com/, then run \`gh auth login\`, or connect GitHub on the web: ${url}` : `GitHub CLI not authenticated. Run \`gh auth login\` and try again, or connect GitHub on the web: ${url}`); 106: return; 107: } 108: case 'has_gh_token': 109: setStep({ 110: name: 'confirm', 111: token: result.token 112: }); 113: } 114: }); 115: }, []); 116: const handleCancel = () => { 117: logEvent('tengu_remote_setup_result', { 118: result: 'cancelled' as SafeString 119: }); 120: onDone(); 121: }; 122: const handleConfirm = async (token: RedactedGithubToken) => { 123: setStep({ 124: name: 'uploading' 125: }); 126: const result = await importGithubToken(token); 127: if (!result.ok) { 128: logEvent('tengu_remote_setup_result', { 129: result: 'import_failed' as SafeString, 130: error_kind: result.error.kind as SafeString 131: }); 132: onDone(errorMessage(result.error, getCodeWebUrl())); 133: return; 134: } 135: await createDefaultEnvironment(); 136: const url = getCodeWebUrl(); 137: await openBrowser(url); 138: logEvent('tengu_remote_setup_result', { 139: result: 'success' as SafeString 140: }); 141: onDone(`Connected as ${result.result.github_username}. Opened ${url}`); 142: }; 143: if (step.name === 'checking') { 144: return <LoadingState message="Checking login status…" />; 145: } 146: if (step.name === 'uploading') { 147: return <LoadingState message="Connecting GitHub to Claude…" />; 148: } 149: const token = step.token; 150: return <Dialog title="Connect Claude on the web to GitHub?" onCancel={handleCancel} hideInputGuide> 151: <Box flexDirection="column"> 152: <Text> 153: Claude on the web requires connecting to your GitHub account to clone 154: and push code on your behalf. 155: </Text> 156: <Text dimColor> 157: Your local credentials are used to authenticate with GitHub 158: </Text> 159: </Box> 160: <Select options={[{ 161: label: 'Continue', 162: value: 'send' 163: }, { 164: label: 'Cancel', 165: value: 'cancel' 166: }]} onChange={value => { 167: if (value === 'send') { 168: void handleConfirm(token); 169: } else { 170: handleCancel(); 171: } 172: }} onCancel={handleCancel} /> 173: </Dialog>; 174: } 175: export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> { 176: return <Web onDone={onDone} />; 177: }

File: src/commands/rename/generateSessionName.ts

typescript 1: import { queryHaiku } from '../../services/api/claude.js' 2: import type { Message } from '../../types/message.js' 3: import { logForDebugging } from '../../utils/debug.js' 4: import { errorMessage } from '../../utils/errors.js' 5: import { safeParseJSON } from '../../utils/json.js' 6: import { extractTextContent } from '../../utils/messages.js' 7: import { extractConversationText } from '../../utils/sessionTitle.js' 8: import { asSystemPrompt } from '../../utils/systemPromptType.js' 9: export async function generateSessionName( 10: messages: Message[], 11: signal: AbortSignal, 12: ): Promise<string | null> { 13: const conversationText = extractConversationText(messages) 14: if (!conversationText) { 15: return null 16: } 17: try { 18: const result = await queryHaiku({ 19: systemPrompt: asSystemPrompt([ 20: 'Generate a short kebab-case name (2-4 words) that captures the main topic of this conversation. Use lowercase words separated by hyphens. Examples: "fix-login-bug", "add-auth-feature", "refactor-api-client", "debug-test-failures". Return JSON with a "name" field.', 21: ]), 22: userPrompt: conversationText, 23: outputFormat: { 24: type: 'json_schema', 25: schema: { 26: type: 'object', 27: properties: { 28: name: { type: 'string' }, 29: }, 30: required: ['name'], 31: additionalProperties: false, 32: }, 33: }, 34: signal, 35: options: { 36: querySource: 'rename_generate_name', 37: agents: [], 38: isNonInteractiveSession: false, 39: hasAppendSystemPrompt: false, 40: mcpTools: [], 41: }, 42: }) 43: const content = extractTextContent(result.message.content) 44: const response = safeParseJSON(content) 45: if ( 46: response && 47: typeof response === 'object' && 48: 'name' in response && 49: typeof (response as { name: unknown }).name === 'string' 50: ) { 51: return (response as { name: string }).name 52: } 53: return null 54: } catch (error) { 55: logForDebugging(`generateSessionName failed: ${errorMessage(error)}`, { 56: level: 'error', 57: }) 58: return null 59: } 60: }

File: src/commands/rename/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const rename = { 3: type: 'local-jsx', 4: name: 'rename', 5: description: 'Rename the current conversation', 6: immediate: true, 7: argumentHint: '[name]', 8: load: () => import('./rename.js'), 9: } satisfies Command 10: export default rename

File: src/commands/rename/rename.ts

typescript 1: import type { UUID } from 'crypto' 2: import { getSessionId } from '../../bootstrap/state.js' 3: import { 4: getBridgeBaseUrlOverride, 5: getBridgeTokenOverride, 6: } from '../../bridge/bridgeConfig.js' 7: import type { ToolUseContext } from '../../Tool.js' 8: import type { 9: LocalJSXCommandContext, 10: LocalJSXCommandOnDone, 11: } from '../../types/command.js' 12: import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' 13: import { 14: getTranscriptPath, 15: saveAgentName, 16: saveCustomTitle, 17: } from '../../utils/sessionStorage.js' 18: import { isTeammate } from '../../utils/teammate.js' 19: import { generateSessionName } from './generateSessionName.js' 20: export async function call( 21: onDone: LocalJSXCommandOnDone, 22: context: ToolUseContext & LocalJSXCommandContext, 23: args: string, 24: ): Promise<null> { 25: if (isTeammate()) { 26: onDone( 27: 'Cannot rename: This session is a swarm teammate. Teammate names are set by the team leader.', 28: { display: 'system' }, 29: ) 30: return null 31: } 32: let newName: string 33: if (!args || args.trim() === '') { 34: const generated = await generateSessionName( 35: getMessagesAfterCompactBoundary(context.messages), 36: context.abortController.signal, 37: ) 38: if (!generated) { 39: onDone( 40: 'Could not generate a name: no conversation context yet. Usage: /rename <name>', 41: { display: 'system' }, 42: ) 43: return null 44: } 45: newName = generated 46: } else { 47: newName = args.trim() 48: } 49: const sessionId = getSessionId() as UUID 50: const fullPath = getTranscriptPath() 51: await saveCustomTitle(sessionId, newName, fullPath) 52: const appState = context.getAppState() 53: const bridgeSessionId = appState.replBridgeSessionId 54: if (bridgeSessionId) { 55: const tokenOverride = getBridgeTokenOverride() 56: void import('../../bridge/createSession.js').then( 57: ({ updateBridgeSessionTitle }) => 58: updateBridgeSessionTitle(bridgeSessionId, newName, { 59: baseUrl: getBridgeBaseUrlOverride(), 60: getAccessToken: tokenOverride ? () => tokenOverride : undefined, 61: }).catch(() => {}), 62: ) 63: } 64: await saveAgentName(sessionId, newName, fullPath) 65: context.setAppState(prev => ({ 66: ...prev, 67: standaloneAgentContext: { 68: ...prev.standaloneAgentContext, 69: name: newName, 70: }, 71: })) 72: onDone(`Session renamed to: ${newName}`, { display: 'system' }) 73: return null 74: }

File: src/commands/reset-limits/index.js

javascript 1: const stub = { isEnabled: () => false, isHidden: true, name: 'stub' }; 2: export default stub; 3: export const resetLimits = stub; 4: export const resetLimitsNonInteractive = stub;

File: src/commands/resume/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const resume: Command = { 3: type: 'local-jsx', 4: name: 'resume', 5: description: 'Resume a previous conversation', 6: aliases: ['continue'], 7: argumentHint: '[conversation id or search term]', 8: load: () => import('./resume.js'), 9: } 10: export default resume

File: src/commands/resume/resume.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import chalk from 'chalk'; 3: import type { UUID } from 'crypto'; 4: import figures from 'figures'; 5: import * as React from 'react'; 6: import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'; 7: import type { CommandResultDisplay, ResumeEntrypoint } from '../../commands.js'; 8: import { LogSelector } from '../../components/LogSelector.js'; 9: import { MessageResponse } from '../../components/MessageResponse.js'; 10: import { Spinner } from '../../components/Spinner.js'; 11: import { useIsInsideModal } from '../../context/modalContext.js'; 12: import { useTerminalSize } from '../../hooks/useTerminalSize.js'; 13: import { setClipboard } from '../../ink/termio/osc.js'; 14: import { Box, Text } from '../../ink.js'; 15: import type { LocalJSXCommandCall } from '../../types/command.js'; 16: import type { LogOption } from '../../types/logs.js'; 17: import { agenticSessionSearch } from '../../utils/agenticSessionSearch.js'; 18: import { checkCrossProjectResume } from '../../utils/crossProjectResume.js'; 19: import { getWorktreePaths } from '../../utils/getWorktreePaths.js'; 20: import { logError } from '../../utils/log.js'; 21: import { getLastSessionLog, getSessionIdFromLog, isCustomTitleEnabled, isLiteLog, loadAllProjectsMessageLogs, loadFullLog, loadSameRepoMessageLogs, searchSessionsByCustomTitle } from '../../utils/sessionStorage.js'; 22: import { validateUuid } from '../../utils/uuid.js'; 23: type ResumeResult = { 24: resultType: 'sessionNotFound'; 25: arg: string; 26: } | { 27: resultType: 'multipleMatches'; 28: arg: string; 29: count: number; 30: }; 31: function resumeHelpMessage(result: ResumeResult): string { 32: switch (result.resultType) { 33: case 'sessionNotFound': 34: return `Session ${chalk.bold(result.arg)} was not found.`; 35: case 'multipleMatches': 36: return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`; 37: } 38: } 39: function ResumeError(t0) { 40: const $ = _c(10); 41: const { 42: message, 43: args, 44: onDone 45: } = t0; 46: let t1; 47: let t2; 48: if ($[0] !== onDone) { 49: t1 = () => { 50: const timer = setTimeout(onDone, 0); 51: return () => clearTimeout(timer); 52: }; 53: t2 = [onDone]; 54: $[0] = onDone; 55: $[1] = t1; 56: $[2] = t2; 57: } else { 58: t1 = $[1]; 59: t2 = $[2]; 60: } 61: React.useEffect(t1, t2); 62: let t3; 63: if ($[3] !== args) { 64: t3 = <Text dimColor={true}>{figures.pointer} /resume {args}</Text>; 65: $[3] = args; 66: $[4] = t3; 67: } else { 68: t3 = $[4]; 69: } 70: let t4; 71: if ($[5] !== message) { 72: t4 = <MessageResponse><Text>{message}</Text></MessageResponse>; 73: $[5] = message; 74: $[6] = t4; 75: } else { 76: t4 = $[6]; 77: } 78: let t5; 79: if ($[7] !== t3 || $[8] !== t4) { 80: t5 = <Box flexDirection="column">{t3}{t4}</Box>; 81: $[7] = t3; 82: $[8] = t4; 83: $[9] = t5; 84: } else { 85: t5 = $[9]; 86: } 87: return t5; 88: } 89: function ResumeCommand({ 90: onDone, 91: onResume 92: }: { 93: onDone: (result?: string, options?: { 94: display?: CommandResultDisplay; 95: }) => void; 96: onResume: (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => Promise<void>; 97: }): React.ReactNode { 98: const [logs, setLogs] = React.useState<LogOption[]>([]); 99: const [worktreePaths, setWorktreePaths] = React.useState<string[]>([]); 100: const [loading, setLoading] = React.useState(true); 101: const [resuming, setResuming] = React.useState(false); 102: const [showAllProjects, setShowAllProjects] = React.useState(false); 103: const { 104: rows 105: } = useTerminalSize(); 106: const insideModal = useIsInsideModal(); 107: const loadLogs = React.useCallback(async (allProjects: boolean, paths: string[]) => { 108: setLoading(true); 109: try { 110: const allLogs = allProjects ? await loadAllProjectsMessageLogs() : await loadSameRepoMessageLogs(paths); 111: const resumable = filterResumableSessions(allLogs, getSessionId()); 112: if (resumable.length === 0) { 113: onDone('No conversations found to resume'); 114: return; 115: } 116: setLogs(resumable); 117: } catch (_err) { 118: onDone('Failed to load conversations'); 119: } finally { 120: setLoading(false); 121: } 122: }, [onDone]); 123: React.useEffect(() => { 124: async function init() { 125: const paths_0 = await getWorktreePaths(getOriginalCwd()); 126: setWorktreePaths(paths_0); 127: void loadLogs(false, paths_0); 128: } 129: void init(); 130: }, [loadLogs]); 131: const handleToggleAllProjects = React.useCallback(() => { 132: const newValue = !showAllProjects; 133: setShowAllProjects(newValue); 134: void loadLogs(newValue, worktreePaths); 135: }, [showAllProjects, loadLogs, worktreePaths]); 136: async function handleSelect(log: LogOption) { 137: const sessionId = validateUuid(getSessionIdFromLog(log)); 138: if (!sessionId) { 139: onDone('Failed to resume conversation'); 140: return; 141: } 142: const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; 143: const crossProjectCheck = checkCrossProjectResume(fullLog, showAllProjects, worktreePaths); 144: if (crossProjectCheck.isCrossProject) { 145: if (crossProjectCheck.isSameRepoWorktree) { 146: setResuming(true); 147: void onResume(sessionId, fullLog, 'slash_command_picker'); 148: return; 149: } 150: const raw = await setClipboard(crossProjectCheck.command); 151: if (raw) process.stdout.write(raw); 152: const message = ['', 'This conversation is from a different directory.', '', 'To resume, run:', ` ${crossProjectCheck.command}`, '', '(Command copied to clipboard)', ''].join('\n'); 153: onDone(message, { 154: display: 'user' 155: }); 156: return; 157: } 158: setResuming(true); 159: void onResume(sessionId, fullLog, 'slash_command_picker'); 160: } 161: function handleCancel() { 162: onDone('Resume cancelled', { 163: display: 'system' 164: }); 165: } 166: if (loading) { 167: return <Box> 168: <Spinner /> 169: <Text> Loading conversations…</Text> 170: </Box>; 171: } 172: if (resuming) { 173: return <Box> 174: <Spinner /> 175: <Text> Resuming conversation…</Text> 176: </Box>; 177: } 178: return <LogSelector logs={logs} maxHeight={insideModal ? Math.floor(rows / 2) : rows - 2} onCancel={handleCancel} onSelect={handleSelect} onLogsChanged={() => loadLogs(showAllProjects, worktreePaths)} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />; 179: } 180: export function filterResumableSessions(logs: LogOption[], currentSessionId: string): LogOption[] { 181: return logs.filter(l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId); 182: } 183: export const call: LocalJSXCommandCall = async (onDone, context, args) => { 184: const onResume = async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => { 185: try { 186: await context.resume?.(sessionId, log, entrypoint); 187: onDone(undefined, { 188: display: 'skip' 189: }); 190: } catch (error) { 191: logError(error as Error); 192: onDone(`Failed to resume: ${(error as Error).message}`); 193: } 194: }; 195: const arg = args?.trim(); 196: if (!arg) { 197: return <ResumeCommand key={Date.now()} onDone={onDone} onResume={onResume} />; 198: } 199: const worktreePaths = await getWorktreePaths(getOriginalCwd()); 200: const logs = await loadSameRepoMessageLogs(worktreePaths); 201: if (logs.length === 0) { 202: const message = 'No conversations found to resume.'; 203: return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />; 204: } 205: const maybeSessionId = validateUuid(arg); 206: if (maybeSessionId) { 207: const matchingLogs = logs.filter(l => getSessionIdFromLog(l) === maybeSessionId).sort((a, b) => b.modified.getTime() - a.modified.getTime()); 208: if (matchingLogs.length > 0) { 209: const log = matchingLogs[0]!; 210: const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; 211: void onResume(maybeSessionId, fullLog, 'slash_command_session_id'); 212: return null; 213: } 214: const directLog = await getLastSessionLog(maybeSessionId); 215: if (directLog) { 216: void onResume(maybeSessionId, directLog, 'slash_command_session_id'); 217: return null; 218: } 219: } 220: if (isCustomTitleEnabled()) { 221: const titleMatches = await searchSessionsByCustomTitle(arg, { 222: exact: true 223: }); 224: if (titleMatches.length === 1) { 225: const log = titleMatches[0]!; 226: const sessionId = getSessionIdFromLog(log); 227: if (sessionId) { 228: const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; 229: void onResume(sessionId, fullLog, 'slash_command_title'); 230: return null; 231: } 232: } 233: if (titleMatches.length > 1) { 234: const message = resumeHelpMessage({ 235: resultType: 'multipleMatches', 236: arg, 237: count: titleMatches.length 238: }); 239: return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />; 240: } 241: } 242: const message = resumeHelpMessage({ 243: resultType: 'sessionNotFound', 244: arg 245: }); 246: return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />; 247: };

File: src/commands/review/reviewRemote.ts

typescript 1: import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js' 2: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 3: import { 4: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 5: logEvent, 6: } from '../../services/analytics/index.js' 7: import { fetchUltrareviewQuota } from '../../services/api/ultrareviewQuota.js' 8: import { fetchUtilization } from '../../services/api/usage.js' 9: import type { ToolUseContext } from '../../Tool.js' 10: import { 11: checkRemoteAgentEligibility, 12: formatPreconditionError, 13: getRemoteTaskSessionUrl, 14: registerRemoteAgentTask, 15: } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' 16: import { isEnterpriseSubscriber, isTeamSubscriber } from '../../utils/auth.js' 17: import { detectCurrentRepositoryWithHost } from '../../utils/detectRepository.js' 18: import { execFileNoThrow } from '../../utils/execFileNoThrow.js' 19: import { getDefaultBranch, gitExe } from '../../utils/git.js' 20: import { teleportToRemote } from '../../utils/teleport.js' 21: let sessionOverageConfirmed = false 22: export function confirmOverage(): void { 23: sessionOverageConfirmed = true 24: } 25: export type OverageGate = 26: | { kind: 'proceed'; billingNote: string } 27: | { kind: 'not-enabled' } 28: | { kind: 'low-balance'; available: number } 29: | { kind: 'needs-confirm' } 30: export async function checkOverageGate(): Promise<OverageGate> { 31: if (isTeamSubscriber() || isEnterpriseSubscriber()) { 32: return { kind: 'proceed', billingNote: '' } 33: } 34: const [quota, utilization] = await Promise.all([ 35: fetchUltrareviewQuota(), 36: fetchUtilization().catch(() => null), 37: ]) 38: // No quota info (non-subscriber or endpoint down) — let it through, 39: // server-side billing will handle it. 40: if (!quota) { 41: return { kind: 'proceed', billingNote: '' } 42: } 43: if (quota.reviews_remaining > 0) { 44: return { 45: kind: 'proceed', 46: billingNote: ` This is free ultrareview ${quota.reviews_used + 1} of ${quota.reviews_limit}.`, 47: } 48: } 49: if (!utilization) { 50: return { kind: 'proceed', billingNote: '' } 51: } 52: // Free reviews exhausted — check Extra Usage setup. 53: const extraUsage = utilization.extra_usage 54: if (!extraUsage?.is_enabled) { 55: logEvent('tengu_review_overage_not_enabled', {}) 56: return { kind: 'not-enabled' } 57: } 58: const monthlyLimit = extraUsage.monthly_limit 59: const usedCredits = extraUsage.used_credits ?? 0 60: const available = 61: monthlyLimit === null || monthlyLimit === undefined 62: ? Infinity 63: : monthlyLimit - usedCredits 64: if (available < 10) { 65: logEvent('tengu_review_overage_low_balance', { available }) 66: return { kind: 'low-balance', available } 67: } 68: if (!sessionOverageConfirmed) { 69: logEvent('tengu_review_overage_dialog_shown', {}) 70: return { kind: 'needs-confirm' } 71: } 72: return { 73: kind: 'proceed', 74: billingNote: ' This review bills as Extra Usage.', 75: } 76: } 77: export async function launchRemoteReview( 78: args: string, 79: context: ToolUseContext, 80: billingNote?: string, 81: ): Promise<ContentBlockParam[] | null> { 82: const eligibility = await checkRemoteAgentEligibility() 83: if (!eligibility.eligible) { 84: const blockers = eligibility.errors.filter( 85: e => e.type !== 'no_remote_environment', 86: ) 87: if (blockers.length > 0) { 88: logEvent('tengu_review_remote_precondition_failed', { 89: precondition_errors: blockers 90: .map(e => e.type) 91: .join( 92: ',', 93: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 94: }) 95: const reasons = blockers.map(formatPreconditionError).join('\n') 96: return [ 97: { 98: type: 'text', 99: text: `Ultrareview cannot launch:\n${reasons}`, 100: }, 101: ] 102: } 103: } 104: const resolvedBillingNote = billingNote ?? '' 105: const prNumber = args.trim() 106: const isPrNumber = /^\d+$/.test(prNumber) 107: // Synthetic code_review env. Go taggedid.FromUUID(TagEnvironment, 108: // UUID{...,0x02}) encodes with version prefix '01' — NOT Python's 109: const CODE_REVIEW_ENV_ID = 'env_011111111111111111111113' 110: const raw = getFeatureValue_CACHED_MAY_BE_STALE<Record< 111: string, 112: unknown 113: > | null>('tengu_review_bughunter_config', null) 114: const posInt = (v: unknown, fallback: number, max?: number): number => { 115: if (typeof v !== 'number' || !Number.isFinite(v)) return fallback 116: const n = Math.floor(v) 117: if (n <= 0) return fallback 118: return max !== undefined && n > max ? fallback : n 119: } 120: const commonEnvVars = { 121: BUGHUNTER_DRY_RUN: '1', 122: BUGHUNTER_FLEET_SIZE: String(posInt(raw?.fleet_size, 5, 20)), 123: BUGHUNTER_MAX_DURATION: String(posInt(raw?.max_duration_minutes, 10, 25)), 124: BUGHUNTER_AGENT_TIMEOUT: String( 125: posInt(raw?.agent_timeout_seconds, 600, 1800), 126: ), 127: BUGHUNTER_TOTAL_WALLCLOCK: String( 128: posInt(raw?.total_wallclock_minutes, 22, 27), 129: ), 130: ...(process.env.BUGHUNTER_DEV_BUNDLE_B64 && { 131: BUGHUNTER_DEV_BUNDLE_B64: process.env.BUGHUNTER_DEV_BUNDLE_B64, 132: }), 133: } 134: let session 135: let command 136: let target 137: if (isPrNumber) { 138: const repo = await detectCurrentRepositoryWithHost() 139: if (!repo || repo.host !== 'github.com') { 140: logEvent('tengu_review_remote_precondition_failed', {}) 141: return null 142: } 143: session = await teleportToRemote({ 144: initialMessage: null, 145: description: `ultrareview: ${repo.owner}/${repo.name}#${prNumber}`, 146: signal: context.abortController.signal, 147: branchName: `refs/pull/${prNumber}/head`, 148: environmentId: CODE_REVIEW_ENV_ID, 149: environmentVariables: { 150: BUGHUNTER_PR_NUMBER: prNumber, 151: BUGHUNTER_REPOSITORY: `${repo.owner}/${repo.name}`, 152: ...commonEnvVars, 153: }, 154: }) 155: command = `/ultrareview ${prNumber}` 156: target = `${repo.owner}/${repo.name}#${prNumber}` 157: } else { 158: const baseBranch = (await getDefaultBranch()) || 'main' 159: const { stdout: mbOut, code: mbCode } = await execFileNoThrow( 160: gitExe(), 161: ['merge-base', baseBranch, 'HEAD'], 162: { preserveOutputOnError: false }, 163: ) 164: const mergeBaseSha = mbOut.trim() 165: if (mbCode !== 0 || !mergeBaseSha) { 166: logEvent('tengu_review_remote_precondition_failed', {}) 167: return [ 168: { 169: type: 'text', 170: text: `Could not find merge-base with ${baseBranch}. Make sure you're in a git repo with a ${baseBranch} branch.`, 171: }, 172: ] 173: } 174: const { stdout: diffStat, code: diffCode } = await execFileNoThrow( 175: gitExe(), 176: ['diff', '--shortstat', mergeBaseSha], 177: { preserveOutputOnError: false }, 178: ) 179: if (diffCode === 0 && !diffStat.trim()) { 180: logEvent('tengu_review_remote_precondition_failed', {}) 181: return [ 182: { 183: type: 'text', 184: text: `No changes against the ${baseBranch} fork point. Make some commits or stage files first.`, 185: }, 186: ] 187: } 188: session = await teleportToRemote({ 189: initialMessage: null, 190: description: `ultrareview: ${baseBranch}`, 191: signal: context.abortController.signal, 192: useBundle: true, 193: environmentId: CODE_REVIEW_ENV_ID, 194: environmentVariables: { 195: BUGHUNTER_BASE_BRANCH: mergeBaseSha, 196: ...commonEnvVars, 197: }, 198: }) 199: if (!session) { 200: logEvent('tengu_review_remote_teleport_failed', {}) 201: return [ 202: { 203: type: 'text', 204: text: 'Repo is too large. Push a PR and use `/ultrareview <PR#>` instead.', 205: }, 206: ] 207: } 208: command = '/ultrareview' 209: target = baseBranch 210: } 211: if (!session) { 212: logEvent('tengu_review_remote_teleport_failed', {}) 213: return null 214: } 215: registerRemoteAgentTask({ 216: remoteTaskType: 'ultrareview', 217: session, 218: command, 219: context, 220: isRemoteReview: true, 221: }) 222: logEvent('tengu_review_remote_launched', {}) 223: const sessionUrl = getRemoteTaskSessionUrl(session.id) 224: return [ 225: { 226: type: 'text', 227: text: `Ultrareview launched for ${target} (~10–20 min, runs in the cloud). Track: ${sessionUrl}${resolvedBillingNote} Findings arrive via task-notification. Briefly acknowledge the launch to the user without repeating the target or URL — both are already visible in the tool output above.`, 228: }, 229: ] 230: }

File: src/commands/review/ultrareviewCommand.tsx

typescript 1: import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js'; 2: import React from 'react'; 3: import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js'; 4: import { checkOverageGate, confirmOverage, launchRemoteReview } from './reviewRemote.js'; 5: import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js'; 6: function contentBlocksToString(blocks: ContentBlockParam[]): string { 7: return blocks.map(b => b.type === 'text' ? b.text : '').filter(Boolean).join('\n'); 8: } 9: async function launchAndDone(args: string, context: Parameters<LocalJSXCommandCall>[1], onDone: LocalJSXCommandOnDone, billingNote: string, signal?: AbortSignal): Promise<void> { 10: const result = await launchRemoteReview(args, context, billingNote); 11: if (signal?.aborted) return; 12: if (result) { 13: onDone(contentBlocksToString(result), { 14: shouldQuery: true 15: }); 16: } else { 17: onDone('Ultrareview failed to launch the remote session. Check that this is a GitHub repo and try again.', { 18: display: 'system' 19: }); 20: } 21: } 22: export const call: LocalJSXCommandCall = async (onDone, context, args) => { 23: const gate = await checkOverageGate(); 24: if (gate.kind === 'not-enabled') { 25: onDone('Free ultrareviews used. Enable Extra Usage at https://claude.ai/settings/billing to continue.', { 26: display: 'system' 27: }); 28: return null; 29: } 30: if (gate.kind === 'low-balance') { 31: onDone(`Balance too low to launch ultrareview ($${gate.available.toFixed(2)} available, $10 minimum). Top up at https://claude.ai/settings/billing`, { 32: display: 'system' 33: }); 34: return null; 35: } 36: if (gate.kind === 'needs-confirm') { 37: return <UltrareviewOverageDialog onProceed={async signal => { 38: await launchAndDone(args, context, onDone, ' This review bills as Extra Usage.', signal); 39: if (!signal.aborted) confirmOverage(); 40: }} onCancel={() => onDone('Ultrareview cancelled.', { 41: display: 'system' 42: })} />; 43: } 44: await launchAndDone(args, context, onDone, gate.billingNote); 45: return null; 46: };

File: src/commands/review/ultrareviewEnabled.ts

typescript 1: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 2: export function isUltrareviewEnabled(): boolean { 3: const cfg = getFeatureValue_CACHED_MAY_BE_STALE<Record< 4: string, 5: unknown 6: > | null>('tengu_review_bughunter_config', null) 7: return cfg?.enabled === true 8: }

File: src/commands/review/UltrareviewOverageDialog.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { useCallback, useRef, useState } from 'react'; 3: import { Select } from '../../components/CustomSelect/select.js'; 4: import { Dialog } from '../../components/design-system/Dialog.js'; 5: import { Box, Text } from '../../ink.js'; 6: type Props = { 7: onProceed: (signal: AbortSignal) => Promise<void>; 8: onCancel: () => void; 9: }; 10: export function UltrareviewOverageDialog(t0) { 11: const $ = _c(15); 12: const { 13: onProceed, 14: onCancel 15: } = t0; 16: const [isLaunching, setIsLaunching] = useState(false); 17: let t1; 18: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 19: t1 = new AbortController(); 20: $[0] = t1; 21: } else { 22: t1 = $[0]; 23: } 24: const abortControllerRef = useRef(t1); 25: let t2; 26: if ($[1] !== onCancel || $[2] !== onProceed) { 27: t2 = value => { 28: if (value === "proceed") { 29: setIsLaunching(true); 30: onProceed(abortControllerRef.current.signal).catch(() => setIsLaunching(false)); 31: } else { 32: onCancel(); 33: } 34: }; 35: $[1] = onCancel; 36: $[2] = onProceed; 37: $[3] = t2; 38: } else { 39: t2 = $[3]; 40: } 41: const handleSelect = t2; 42: let t3; 43: if ($[4] !== onCancel) { 44: t3 = () => { 45: abortControllerRef.current.abort(); 46: onCancel(); 47: }; 48: $[4] = onCancel; 49: $[5] = t3; 50: } else { 51: t3 = $[5]; 52: } 53: const handleCancel = t3; 54: let t4; 55: if ($[6] === Symbol.for("react.memo_cache_sentinel")) { 56: t4 = [{ 57: label: "Proceed with Extra Usage billing", 58: value: "proceed" 59: }, { 60: label: "Cancel", 61: value: "cancel" 62: }]; 63: $[6] = t4; 64: } else { 65: t4 = $[6]; 66: } 67: const options = t4; 68: let t5; 69: if ($[7] === Symbol.for("react.memo_cache_sentinel")) { 70: t5 = <Text>Your free ultrareviews for this organization are used. Further reviews bill as Extra Usage (pay-per-use).</Text>; 71: $[7] = t5; 72: } else { 73: t5 = $[7]; 74: } 75: let t6; 76: if ($[8] !== handleCancel || $[9] !== handleSelect || $[10] !== isLaunching) { 77: t6 = <Box flexDirection="column" gap={1}>{t5}{isLaunching ? <Text color="background">Launching…</Text> : <Select options={options} onChange={handleSelect} onCancel={handleCancel} />}</Box>; 78: $[8] = handleCancel; 79: $[9] = handleSelect; 80: $[10] = isLaunching; 81: $[11] = t6; 82: } else { 83: t6 = $[11]; 84: } 85: let t7; 86: if ($[12] !== handleCancel || $[13] !== t6) { 87: t7 = <Dialog title="Ultrareview billing" onCancel={handleCancel} color="background">{t6}</Dialog>; 88: $[12] = handleCancel; 89: $[13] = t6; 90: $[14] = t7; 91: } else { 92: t7 = $[14]; 93: } 94: return t7; 95: }

File: src/commands/rewind/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const rewind = { 3: description: `Restore the code and/or conversation to a previous point`, 4: name: 'rewind', 5: aliases: ['checkpoint'], 6: argumentHint: '', 7: type: 'local', 8: supportsNonInteractive: false, 9: load: () => import('./rewind.js'), 10: } satisfies Command 11: export default rewind

File: src/commands/rewind/rewind.ts

typescript 1: import type { LocalCommandResult } from '../../commands.js' 2: import type { ToolUseContext } from '../../Tool.js' 3: export async function call( 4: _args: string, 5: context: ToolUseContext, 6: ): Promise<LocalCommandResult> { 7: if (context.openMessageSelector) { 8: context.openMessageSelector() 9: } 10: return { type: 'skip' } 11: }

File: src/commands/sandbox-toggle/index.ts

typescript 1: import figures from 'figures' 2: import type { Command } from '../../commands.js' 3: import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' 4: const command = { 5: name: 'sandbox', 6: get description() { 7: const currentlyEnabled = SandboxManager.isSandboxingEnabled() 8: const autoAllow = SandboxManager.isAutoAllowBashIfSandboxedEnabled() 9: const allowUnsandboxed = SandboxManager.areUnsandboxedCommandsAllowed() 10: const isLocked = SandboxManager.areSandboxSettingsLockedByPolicy() 11: const hasDeps = SandboxManager.checkDependencies().errors.length === 0 12: let icon: string 13: if (!hasDeps) { 14: icon = figures.warning 15: } else { 16: icon = currentlyEnabled ? figures.tick : figures.circle 17: } 18: let statusText = 'sandbox disabled' 19: if (currentlyEnabled) { 20: statusText = autoAllow 21: ? 'sandbox enabled (auto-allow)' 22: : 'sandbox enabled' 23: statusText += allowUnsandboxed ? ', fallback allowed' : '' 24: } 25: if (isLocked) { 26: statusText += ' (managed)' 27: } 28: return `${icon} ${statusText} (⏎ to configure)` 29: }, 30: argumentHint: 'exclude "command pattern"', 31: get isHidden() { 32: return ( 33: !SandboxManager.isSupportedPlatform() || 34: !SandboxManager.isPlatformInEnabledList() 35: ) 36: }, 37: immediate: true, 38: type: 'local-jsx', 39: load: () => import('./sandbox-toggle.js'), 40: } satisfies Command 41: export default command

File: src/commands/sandbox-toggle/sandbox-toggle.tsx

typescript 1: import { relative } from 'path'; 2: import React from 'react'; 3: import { getCwdState } from '../../bootstrap/state.js'; 4: import { SandboxSettings } from '../../components/sandbox/SandboxSettings.js'; 5: import { color } from '../../ink.js'; 6: import { getPlatform } from '../../utils/platform.js'; 7: import { addToExcludedCommands, SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; 8: import { getSettings_DEPRECATED, getSettingsFilePathForSource } from '../../utils/settings/settings.js'; 9: import type { ThemeName } from '../../utils/theme.js'; 10: export async function call(onDone: (result?: string) => void, _context: unknown, args?: string): Promise<React.ReactNode | null> { 11: const settings = getSettings_DEPRECATED(); 12: const themeName: ThemeName = settings.theme as ThemeName || 'light'; 13: const platform = getPlatform(); 14: if (!SandboxManager.isSupportedPlatform()) { 15: const errorMessage = platform === 'wsl' ? 'Error: Sandboxing requires WSL2. WSL1 is not supported.' : 'Error: Sandboxing is currently only supported on macOS, Linux, and WSL2.'; 16: const message = color('error', themeName)(errorMessage); 17: onDone(message); 18: return null; 19: } 20: const depCheck = SandboxManager.checkDependencies(); 21: if (!SandboxManager.isPlatformInEnabledList()) { 22: const message = color('error', themeName)(`Error: Sandboxing is disabled for this platform (${platform}) via the enabledPlatforms setting.`); 23: onDone(message); 24: return null; 25: } 26: if (SandboxManager.areSandboxSettingsLockedByPolicy()) { 27: const message = color('error', themeName)('Error: Sandbox settings are overridden by a higher-priority configuration and cannot be changed locally.'); 28: onDone(message); 29: return null; 30: } 31: const trimmedArgs = args?.trim() || ''; 32: // If no args, show the interactive menu 33: if (!trimmedArgs) { 34: return <SandboxSettings onComplete={onDone} depCheck={depCheck} />; 35: } 36: // Handle subcommands 37: if (trimmedArgs) { 38: const parts = trimmedArgs.split(' '); 39: const subcommand = parts[0]; 40: if (subcommand === 'exclude') { 41: const commandPattern = trimmedArgs.slice('exclude '.length).trim(); 42: if (!commandPattern) { 43: const message = color('error', themeName)('Error: Please provide a command pattern to exclude (e.g., /sandbox exclude "npm run test:*")'); 44: onDone(message); 45: return null; 46: } 47: const cleanPattern = commandPattern.replace(/^["']|["']$/g, ''); 48: // Add to excludedCommands 49: addToExcludedCommands(cleanPattern); 50: // Get the local settings path and make it relative to cwd 51: const localSettingsPath = getSettingsFilePathForSource('localSettings'); 52: const relativePath = localSettingsPath ? relative(getCwdState(), localSettingsPath) : '.claude/settings.local.json'; 53: const message = color('success', themeName)(`Added "${cleanPattern}" to excluded commands in ${relativePath}`); 54: onDone(message); 55: return null; 56: } else { 57: const message = color('error', themeName)(`Error: Unknown subcommand "${subcommand}". Available subcommand: exclude`); 58: onDone(message); 59: return null; 60: } 61: } 62: return null; 63: }

File: src/commands/session/index.ts

typescript 1: import { getIsRemoteMode } from '../../bootstrap/state.js' 2: import type { Command } from '../../commands.js' 3: const session = { 4: type: 'local-jsx', 5: name: 'session', 6: aliases: ['remote'], 7: description: 'Show remote session URL and QR code', 8: isEnabled: () => getIsRemoteMode(), 9: get isHidden() { 10: return !getIsRemoteMode() 11: }, 12: load: () => import('./session.js'), 13: } satisfies Command 14: export default session

File: src/commands/session/session.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { toString as qrToString } from 'qrcode'; 3: import * as React from 'react'; 4: import { useEffect, useState } from 'react'; 5: import { Pane } from '../../components/design-system/Pane.js'; 6: import { Box, Text } from '../../ink.js'; 7: import { useKeybinding } from '../../keybindings/useKeybinding.js'; 8: import { useAppState } from '../../state/AppState.js'; 9: import type { LocalJSXCommandCall } from '../../types/command.js'; 10: import { logForDebugging } from '../../utils/debug.js'; 11: type Props = { 12: onDone: () => void; 13: }; 14: function SessionInfo(t0) { 15: const $ = _c(19); 16: const { 17: onDone 18: } = t0; 19: const remoteSessionUrl = useAppState(_temp); 20: const [qrCode, setQrCode] = useState(""); 21: let t1; 22: let t2; 23: if ($[0] !== remoteSessionUrl) { 24: t1 = () => { 25: if (!remoteSessionUrl) { 26: return; 27: } 28: const url = remoteSessionUrl; 29: const generateQRCode = async function generateQRCode() { 30: const qr = await qrToString(url, { 31: type: "utf8", 32: errorCorrectionLevel: "L" 33: }); 34: setQrCode(qr); 35: }; 36: generateQRCode().catch(_temp2); 37: }; 38: t2 = [remoteSessionUrl]; 39: $[0] = remoteSessionUrl; 40: $[1] = t1; 41: $[2] = t2; 42: } else { 43: t1 = $[1]; 44: t2 = $[2]; 45: } 46: useEffect(t1, t2); 47: let t3; 48: if ($[3] === Symbol.for("react.memo_cache_sentinel")) { 49: t3 = { 50: context: "Confirmation" 51: }; 52: $[3] = t3; 53: } else { 54: t3 = $[3]; 55: } 56: useKeybinding("confirm:no", onDone, t3); 57: if (!remoteSessionUrl) { 58: let t4; 59: if ($[4] === Symbol.for("react.memo_cache_sentinel")) { 60: t4 = <Pane><Text color="warning">Not in remote mode. Start with `claude --remote` to use this command.</Text><Text dimColor={true}>(press esc to close)</Text></Pane>; 61: $[4] = t4; 62: } else { 63: t4 = $[4]; 64: } 65: return t4; 66: } 67: let T0; 68: let t4; 69: let t5; 70: if ($[5] !== qrCode) { 71: const lines = qrCode.split("\n").filter(_temp3); 72: const isLoading = lines.length === 0; 73: T0 = Pane; 74: if ($[9] === Symbol.for("react.memo_cache_sentinel")) { 75: t4 = <Box marginBottom={1}><Text bold={true}>Remote session</Text></Box>; 76: $[9] = t4; 77: } else { 78: t4 = $[9]; 79: } 80: t5 = isLoading ? <Text dimColor={true}>Generating QR code…</Text> : lines.map(_temp4); 81: $[5] = qrCode; 82: $[6] = T0; 83: $[7] = t4; 84: $[8] = t5; 85: } else { 86: T0 = $[6]; 87: t4 = $[7]; 88: t5 = $[8]; 89: } 90: let t6; 91: if ($[10] === Symbol.for("react.memo_cache_sentinel")) { 92: t6 = <Text dimColor={true}>Open in browser: </Text>; 93: $[10] = t6; 94: } else { 95: t6 = $[10]; 96: } 97: let t7; 98: if ($[11] !== remoteSessionUrl) { 99: t7 = <Box marginTop={1}>{t6}<Text color="ide">{remoteSessionUrl}</Text></Box>; 100: $[11] = remoteSessionUrl; 101: $[12] = t7; 102: } else { 103: t7 = $[12]; 104: } 105: let t8; 106: if ($[13] === Symbol.for("react.memo_cache_sentinel")) { 107: t8 = <Box marginTop={1}><Text dimColor={true}>(press esc to close)</Text></Box>; 108: $[13] = t8; 109: } else { 110: t8 = $[13]; 111: } 112: let t9; 113: if ($[14] !== T0 || $[15] !== t4 || $[16] !== t5 || $[17] !== t7) { 114: t9 = <T0>{t4}{t5}{t7}{t8}</T0>; 115: $[14] = T0; 116: $[15] = t4; 117: $[16] = t5; 118: $[17] = t7; 119: $[18] = t9; 120: } else { 121: t9 = $[18]; 122: } 123: return t9; 124: } 125: function _temp4(line_0, i) { 126: return <Text key={i}>{line_0}</Text>; 127: } 128: function _temp3(line) { 129: return line.length > 0; 130: } 131: function _temp2(e) { 132: logForDebugging("QR code generation failed", e); 133: } 134: function _temp(s) { 135: return s.remoteSessionUrl; 136: } 137: export const call: LocalJSXCommandCall = async onDone => { 138: return <SessionInfo onDone={onDone} />; 139: };

File: src/commands/share/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/skills/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const skills = { 3: type: 'local-jsx', 4: name: 'skills', 5: description: 'List available skills', 6: load: () => import('./skills.js'), 7: } satisfies Command 8: export default skills

File: src/commands/skills/skills.tsx

typescript 1: import * as React from 'react'; 2: import type { LocalJSXCommandContext } from '../../commands.js'; 3: import { SkillsMenu } from '../../components/skills/SkillsMenu.js'; 4: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 5: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> { 6: return <SkillsMenu onExit={onDone} commands={context.options.commands} />; 7: }

File: src/commands/stats/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const stats = { 3: type: 'local-jsx', 4: name: 'stats', 5: description: 'Show your Claude Code usage statistics and activity', 6: load: () => import('./stats.js'), 7: } satisfies Command 8: export default stats

File: src/commands/stats/stats.tsx

typescript 1: import * as React from 'react'; 2: import { Stats } from '../../components/Stats.js'; 3: import type { LocalJSXCommandCall } from '../../types/command.js'; 4: export const call: LocalJSXCommandCall = async onDone => { 5: return <Stats onClose={onDone} />; 6: };

File: src/commands/status/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const status = { 3: type: 'local-jsx', 4: name: 'status', 5: description: 6: 'Show Claude Code status including version, model, account, API connectivity, and tool statuses', 7: immediate: true, 8: load: () => import('./status.js'), 9: } satisfies Command 10: export default status

File: src/commands/status/status.tsx

typescript 1: import * as React from 'react'; 2: import type { LocalJSXCommandContext } from '../../commands.js'; 3: import { Settings } from '../../components/Settings/Settings.js'; 4: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 5: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> { 6: return <Settings onClose={onDone} context={context} defaultTab="Status" />; 7: }

File: src/commands/stickers/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const stickers = { 3: type: 'local', 4: name: 'stickers', 5: description: 'Order Claude Code stickers', 6: supportsNonInteractive: false, 7: load: () => import('./stickers.js'), 8: } satisfies Command 9: export default stickers

File: src/commands/stickers/stickers.ts

typescript 1: import type { LocalCommandResult } from '../../types/command.js' 2: import { openBrowser } from '../../utils/browser.js' 3: export async function call(): Promise<LocalCommandResult> { 4: const url = 'https://www.stickermule.com/claudecode' 5: const success = await openBrowser(url) 6: if (success) { 7: return { type: 'text', value: 'Opening sticker page in browser…' } 8: } else { 9: return { 10: type: 'text', 11: value: `Failed to open browser. Visit: ${url}`, 12: } 13: } 14: }

File: src/commands/summary/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/tag/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const tag = { 3: type: 'local-jsx', 4: name: 'tag', 5: description: 'Toggle a searchable tag on the current session', 6: isEnabled: () => process.env.USER_TYPE === 'ant', 7: argumentHint: '<tag-name>', 8: load: () => import('./tag.js'), 9: } satisfies Command 10: export default tag

File: src/commands/tag/tag.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import chalk from 'chalk'; 3: import type { UUID } from 'crypto'; 4: import * as React from 'react'; 5: import { getSessionId } from '../../bootstrap/state.js'; 6: import type { CommandResultDisplay } from '../../commands.js'; 7: import { Select } from '../../components/CustomSelect/select.js'; 8: import { Dialog } from '../../components/design-system/Dialog.js'; 9: import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'; 10: import { Box, Text } from '../../ink.js'; 11: import { logEvent } from '../../services/analytics/index.js'; 12: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 13: import { recursivelySanitizeUnicode } from '../../utils/sanitization.js'; 14: import { getCurrentSessionTag, getTranscriptPath, saveTag } from '../../utils/sessionStorage.js'; 15: function ConfirmRemoveTag(t0) { 16: const $ = _c(11); 17: const { 18: tagName, 19: onConfirm, 20: onCancel 21: } = t0; 22: const t1 = `Current tag: #${tagName}`; 23: let t2; 24: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 25: t2 = <Text>This will remove the tag from the current session.</Text>; 26: $[0] = t2; 27: } else { 28: t2 = $[0]; 29: } 30: let t3; 31: if ($[1] !== onCancel || $[2] !== onConfirm) { 32: t3 = value => value === "yes" ? onConfirm() : onCancel(); 33: $[1] = onCancel; 34: $[2] = onConfirm; 35: $[3] = t3; 36: } else { 37: t3 = $[3]; 38: } 39: let t4; 40: if ($[4] === Symbol.for("react.memo_cache_sentinel")) { 41: t4 = [{ 42: label: "Yes, remove tag", 43: value: "yes" 44: }, { 45: label: "No, keep tag", 46: value: "no" 47: }]; 48: $[4] = t4; 49: } else { 50: t4 = $[4]; 51: } 52: let t5; 53: if ($[5] !== t3) { 54: t5 = <Box flexDirection="column" gap={1}>{t2}<Select onChange={t3} options={t4} /></Box>; 55: $[5] = t3; 56: $[6] = t5; 57: } else { 58: t5 = $[6]; 59: } 60: let t6; 61: if ($[7] !== onCancel || $[8] !== t1 || $[9] !== t5) { 62: t6 = <Dialog title="Remove tag?" subtitle={t1} onCancel={onCancel} color="warning">{t5}</Dialog>; 63: $[7] = onCancel; 64: $[8] = t1; 65: $[9] = t5; 66: $[10] = t6; 67: } else { 68: t6 = $[10]; 69: } 70: return t6; 71: } 72: function ToggleTagAndClose(t0) { 73: const $ = _c(17); 74: const { 75: tagName, 76: onDone 77: } = t0; 78: const [showConfirm, setShowConfirm] = React.useState(false); 79: const [sessionId, setSessionId] = React.useState(null); 80: let t1; 81: if ($[0] !== tagName) { 82: t1 = recursivelySanitizeUnicode(tagName).trim(); 83: $[0] = tagName; 84: $[1] = t1; 85: } else { 86: t1 = $[1]; 87: } 88: const normalizedTag = t1; 89: let t2; 90: let t3; 91: if ($[2] !== normalizedTag || $[3] !== onDone) { 92: t2 = () => { 93: const id = getSessionId() as UUID; 94: if (!id) { 95: onDone("No active session to tag", { 96: display: "system" 97: }); 98: return; 99: } 100: if (!normalizedTag) { 101: onDone("Tag name cannot be empty", { 102: display: "system" 103: }); 104: return; 105: } 106: setSessionId(id); 107: const currentTag = getCurrentSessionTag(id); 108: if (currentTag === normalizedTag) { 109: logEvent("tengu_tag_command_remove_prompt", {}); 110: setShowConfirm(true); 111: } else { 112: const isReplacing = !!currentTag; 113: logEvent("tengu_tag_command_add", { 114: is_replacing: isReplacing 115: }); 116: (async () => { 117: const fullPath = getTranscriptPath(); 118: await saveTag(id, normalizedTag, fullPath); 119: onDone(`Tagged session with ${chalk.cyan(`#${normalizedTag}`)}`, { 120: display: "system" 121: }); 122: })(); 123: } 124: }; 125: t3 = [normalizedTag, onDone]; 126: $[2] = normalizedTag; 127: $[3] = onDone; 128: $[4] = t2; 129: $[5] = t3; 130: } else { 131: t2 = $[4]; 132: t3 = $[5]; 133: } 134: React.useEffect(t2, t3); 135: if (showConfirm && sessionId) { 136: let t4; 137: if ($[6] !== normalizedTag || $[7] !== onDone || $[8] !== sessionId) { 138: t4 = async () => { 139: logEvent("tengu_tag_command_remove_confirmed", {}); 140: const fullPath_0 = getTranscriptPath(); 141: await saveTag(sessionId, "", fullPath_0); 142: onDone(`Removed tag ${chalk.cyan(`#${normalizedTag}`)}`, { 143: display: "system" 144: }); 145: }; 146: $[6] = normalizedTag; 147: $[7] = onDone; 148: $[8] = sessionId; 149: $[9] = t4; 150: } else { 151: t4 = $[9]; 152: } 153: let t5; 154: if ($[10] !== normalizedTag || $[11] !== onDone) { 155: t5 = () => { 156: logEvent("tengu_tag_command_remove_cancelled", {}); 157: onDone(`Kept tag ${chalk.cyan(`#${normalizedTag}`)}`, { 158: display: "system" 159: }); 160: }; 161: $[10] = normalizedTag; 162: $[11] = onDone; 163: $[12] = t5; 164: } else { 165: t5 = $[12]; 166: } 167: let t6; 168: if ($[13] !== normalizedTag || $[14] !== t4 || $[15] !== t5) { 169: t6 = <ConfirmRemoveTag tagName={normalizedTag} onConfirm={t4} onCancel={t5} />; 170: $[13] = normalizedTag; 171: $[14] = t4; 172: $[15] = t5; 173: $[16] = t6; 174: } else { 175: t6 = $[16]; 176: } 177: return t6; 178: } 179: return null; 180: } 181: function ShowHelp(t0) { 182: const $ = _c(3); 183: const { 184: onDone 185: } = t0; 186: let t1; 187: let t2; 188: if ($[0] !== onDone) { 189: t1 = () => { 190: onDone("Usage: /tag <tag-name>\n\nToggle a searchable tag on the current session.\nRun the same command again to remove the tag.\nTags are displayed after the branch name in /resume and can be searched with /.\n\nExamples:\n /tag bugfix # Add tag\n /tag bugfix # Remove tag (toggle)\n /tag feature-auth\n /tag wip", { 191: display: "system" 192: }); 193: }; 194: t2 = [onDone]; 195: $[0] = onDone; 196: $[1] = t1; 197: $[2] = t2; 198: } else { 199: t1 = $[1]; 200: t2 = $[2]; 201: } 202: React.useEffect(t1, t2); 203: return null; 204: } 205: export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> { 206: args = args?.trim() || ''; 207: if (COMMON_INFO_ARGS.includes(args) || COMMON_HELP_ARGS.includes(args)) { 208: return <ShowHelp onDone={onDone} />; 209: } 210: if (!args) { 211: return <ShowHelp onDone={onDone} />; 212: } 213: return <ToggleTagAndClose tagName={args} onDone={onDone} />; 214: }

File: src/commands/tasks/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const tasks = { 3: type: 'local-jsx', 4: name: 'tasks', 5: aliases: ['bashes'], 6: description: 'List and manage background tasks', 7: load: () => import('./tasks.js'), 8: } satisfies Command 9: export default tasks

File: src/commands/tasks/tasks.tsx

typescript 1: import * as React from 'react'; 2: import type { LocalJSXCommandContext } from '../../commands.js'; 3: import { BackgroundTasksDialog } from '../../components/tasks/BackgroundTasksDialog.js'; 4: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 5: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> { 6: return <BackgroundTasksDialog toolUseContext={context} onDone={onDone} />; 7: }

File: src/commands/teleport/index.js

javascript 1: export default { isEnabled: () => false, isHidden: true, name: 'stub' };

File: src/commands/terminalSetup/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { env } from '../../utils/env.js' 3: const NATIVE_CSIU_TERMINALS: Record<string, string> = { 4: ghostty: 'Ghostty', 5: kitty: 'Kitty', 6: 'iTerm.app': 'iTerm2', 7: WezTerm: 'WezTerm', 8: } 9: const terminalSetup = { 10: type: 'local-jsx', 11: name: 'terminal-setup', 12: description: 13: env.terminal === 'Apple_Terminal' 14: ? 'Enable Option+Enter key binding for newlines and visual bell' 15: : 'Install Shift+Enter key binding for newlines', 16: isHidden: env.terminal !== null && env.terminal in NATIVE_CSIU_TERMINALS, 17: load: () => import('./terminalSetup.js'), 18: } satisfies Command 19: export default terminalSetup

File: src/commands/terminalSetup/terminalSetup.tsx

typescript 1: import chalk from 'chalk'; 2: import { randomBytes } from 'crypto'; 3: import { copyFile, mkdir, readFile, writeFile } from 'fs/promises'; 4: import { homedir, platform } from 'os'; 5: import { dirname, join } from 'path'; 6: import type { ThemeName } from 'src/utils/theme.js'; 7: import { pathToFileURL } from 'url'; 8: import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js'; 9: import { color } from '../../ink.js'; 10: import { maybeMarkProjectOnboardingComplete } from '../../projectOnboardingState.js'; 11: import type { ToolUseContext } from '../../Tool.js'; 12: import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; 13: import { backupTerminalPreferences, checkAndRestoreTerminalBackup, getTerminalPlistPath, markTerminalSetupComplete } from '../../utils/appleTerminalBackup.js'; 14: import { setupShellCompletion } from '../../utils/completionCache.js'; 15: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; 16: import { env } from '../../utils/env.js'; 17: import { isFsInaccessible } from '../../utils/errors.js'; 18: import { execFileNoThrow } from '../../utils/execFileNoThrow.js'; 19: import { addItemToJSONCArray, safeParseJSONC } from '../../utils/json.js'; 20: import { logError } from '../../utils/log.js'; 21: import { getPlatform } from '../../utils/platform.js'; 22: import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'; 23: const EOL = '\n'; 24: const NATIVE_CSIU_TERMINALS: Record<string, string> = { 25: ghostty: 'Ghostty', 26: kitty: 'Kitty', 27: 'iTerm.app': 'iTerm2', 28: WezTerm: 'WezTerm', 29: WarpTerminal: 'Warp' 30: }; 31: function isVSCodeRemoteSSH(): boolean { 32: const askpassMain = process.env.VSCODE_GIT_ASKPASS_MAIN ?? ''; 33: const path = process.env.PATH ?? ''; 34: // Check both env vars - VSCODE_GIT_ASKPASS_MAIN is more reliable when git extension 35: // is active, and PATH is a fallback. Omit path separator for Windows compatibility. 36: return askpassMain.includes('.vscode-server') || askpassMain.includes('.cursor-server') || askpassMain.includes('.windsurf-server') || path.includes('.vscode-server') || path.includes('.cursor-server') || path.includes('.windsurf-server'); 37: } 38: export function getNativeCSIuTerminalDisplayName(): string | null { 39: if (!env.terminal || !(env.terminal in NATIVE_CSIU_TERMINALS)) { 40: return null; 41: } 42: return NATIVE_CSIU_TERMINALS[env.terminal] ?? null; 43: } 44: function formatPathLink(filePath: string): string { 45: if (!supportsHyperlinks()) { 46: return filePath; 47: } 48: const fileUrl = pathToFileURL(filePath).href; 49: return `\x1b]8;;${fileUrl}\x07${filePath}\x1b]8;;\x07`; 50: } 51: export function shouldOfferTerminalSetup(): boolean { 52: return platform() === 'darwin' && env.terminal === 'Apple_Terminal' || env.terminal === 'vscode' || env.terminal === 'cursor' || env.terminal === 'windsurf' || env.terminal === 'alacritty' || env.terminal === 'zed'; 53: } 54: export async function setupTerminal(theme: ThemeName): Promise<string> { 55: let result = ''; 56: switch (env.terminal) { 57: case 'Apple_Terminal': 58: result = await enableOptionAsMetaForTerminal(theme); 59: break; 60: case 'vscode': 61: result = await installBindingsForVSCodeTerminal('VSCode', theme); 62: break; 63: case 'cursor': 64: result = await installBindingsForVSCodeTerminal('Cursor', theme); 65: break; 66: case 'windsurf': 67: result = await installBindingsForVSCodeTerminal('Windsurf', theme); 68: break; 69: case 'alacritty': 70: result = await installBindingsForAlacritty(theme); 71: break; 72: case 'zed': 73: result = await installBindingsForZed(theme); 74: break; 75: case null: 76: break; 77: } 78: saveGlobalConfig(current => { 79: if (['vscode', 'cursor', 'windsurf', 'alacritty', 'zed'].includes(env.terminal ?? '')) { 80: if (current.shiftEnterKeyBindingInstalled === true) return current; 81: return { 82: ...current, 83: shiftEnterKeyBindingInstalled: true 84: }; 85: } else if (env.terminal === 'Apple_Terminal') { 86: if (current.optionAsMetaKeyInstalled === true) return current; 87: return { 88: ...current, 89: optionAsMetaKeyInstalled: true 90: }; 91: } 92: return current; 93: }); 94: maybeMarkProjectOnboardingComplete(); 95: if ("external" === 'ant') { 96: result += await setupShellCompletion(theme); 97: } 98: return result; 99: } 100: export function isShiftEnterKeyBindingInstalled(): boolean { 101: return getGlobalConfig().shiftEnterKeyBindingInstalled === true; 102: } 103: export function hasUsedBackslashReturn(): boolean { 104: return getGlobalConfig().hasUsedBackslashReturn === true; 105: } 106: export function markBackslashReturnUsed(): void { 107: const config = getGlobalConfig(); 108: if (!config.hasUsedBackslashReturn) { 109: saveGlobalConfig(current => ({ 110: ...current, 111: hasUsedBackslashReturn: true 112: })); 113: } 114: } 115: export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext & LocalJSXCommandContext, _args: string): Promise<null> { 116: if (env.terminal && env.terminal in NATIVE_CSIU_TERMINALS) { 117: const message = `Shift+Enter is natively supported in ${NATIVE_CSIU_TERMINALS[env.terminal]}. 118: No configuration needed. Just use Shift+Enter to add newlines.`; 119: onDone(message); 120: return null; 121: } 122: if (!shouldOfferTerminalSetup()) { 123: const terminalName = env.terminal || 'your current terminal'; 124: const currentPlatform = getPlatform(); 125: let platformTerminals = ''; 126: if (currentPlatform === 'macos') { 127: platformTerminals = ' • macOS: Apple Terminal\n'; 128: } else if (currentPlatform === 'windows') { 129: platformTerminals = ' • Windows: Windows Terminal\n'; 130: } 131: const message = `Terminal setup cannot be run from ${terminalName}. 132: This command configures a convenient Shift+Enter shortcut for multi-line prompts. 133: ${chalk.dim('Note: You can already use backslash (\\\\) + return to add newlines.')} 134: To set up the shortcut (optional): 135: 1. Exit tmux/screen temporarily 136: 2. Run /terminal-setup directly in one of these terminals: 137: ${platformTerminals} • IDE: VSCode, Cursor, Windsurf, Zed 138: • Other: Alacritty 139: 3. Return to tmux/screen - settings will persist 140: ${chalk.dim('Note: iTerm2, WezTerm, Ghostty, Kitty, and Warp support Shift+Enter natively.')}`; 141: onDone(message); 142: return null; 143: } 144: const result = await setupTerminal(context.options.theme); 145: onDone(result); 146: return null; 147: } 148: type VSCodeKeybinding = { 149: key: string; 150: command: string; 151: args: { 152: text: string; 153: }; 154: when: string; 155: }; 156: async function installBindingsForVSCodeTerminal(editor: 'VSCode' | 'Cursor' | 'Windsurf' = 'VSCode', theme: ThemeName): Promise<string> { 157: if (isVSCodeRemoteSSH()) { 158: return `${color('warning', theme)(`Cannot install keybindings from a remote ${editor} session.`)}${EOL}${EOL}${editor} keybindings must be installed on your local machine, not the remote server.${EOL}${EOL}To install the Shift+Enter keybinding:${EOL}1. Open ${editor} on your local machine (not connected to remote)${EOL}2. Open the Command Palette (Cmd/Ctrl+Shift+P) → "Preferences: Open Keyboard Shortcuts (JSON)"${EOL}3. Add this keybinding (the file must be a JSON array):${EOL}${EOL}${chalk.dim(`[ 159: { 160: "key": "shift+enter", 161: "command": "workbench.action.terminal.sendSequence", 162: "args": { "text": "\\u001b\\r" }, 163: "when": "terminalFocus" 164: } 165: ]`)}${EOL}`; 166: } 167: const editorDir = editor === 'VSCode' ? 'Code' : editor; 168: const userDirPath = join(homedir(), platform() === 'win32' ? join('AppData', 'Roaming', editorDir, 'User') : platform() === 'darwin' ? join('Library', 'Application Support', editorDir, 'User') : join('.config', editorDir, 'User')); 169: const keybindingsPath = join(userDirPath, 'keybindings.json'); 170: try { 171: await mkdir(userDirPath, { 172: recursive: true 173: }); 174: let content = '[]'; 175: let keybindings: VSCodeKeybinding[] = []; 176: let fileExists = false; 177: try { 178: content = await readFile(keybindingsPath, { 179: encoding: 'utf-8' 180: }); 181: fileExists = true; 182: keybindings = safeParseJSONC(content) as VSCodeKeybinding[] ?? []; 183: } catch (e: unknown) { 184: if (!isFsInaccessible(e)) throw e; 185: } 186: if (fileExists) { 187: const randomSha = randomBytes(4).toString('hex'); 188: const backupPath = `${keybindingsPath}.${randomSha}.bak`; 189: try { 190: await copyFile(keybindingsPath, backupPath); 191: } catch { 192: return `${color('warning', theme)(`Error backing up existing ${editor} terminal keybindings. Bailing out.`)}${EOL}${chalk.dim(`See ${formatPathLink(keybindingsPath)}`)}${EOL}${chalk.dim(`Backup path: ${formatPathLink(backupPath)}`)}${EOL}`; 193: } 194: } 195: const existingBinding = keybindings.find(binding => binding.key === 'shift+enter' && binding.command === 'workbench.action.terminal.sendSequence' && binding.when === 'terminalFocus'); 196: if (existingBinding) { 197: return `${color('warning', theme)(`Found existing ${editor} terminal Shift+Enter key binding. Remove it to continue.`)}${EOL}${chalk.dim(`See ${formatPathLink(keybindingsPath)}`)}${EOL}`; 198: } 199: const newKeybinding: VSCodeKeybinding = { 200: key: 'shift+enter', 201: command: 'workbench.action.terminal.sendSequence', 202: args: { 203: text: '\u001b\r' 204: }, 205: when: 'terminalFocus' 206: }; 207: const updatedContent = addItemToJSONCArray(content, newKeybinding); 208: await writeFile(keybindingsPath, updatedContent, { 209: encoding: 'utf-8' 210: }); 211: return `${color('success', theme)(`Installed ${editor} terminal Shift+Enter key binding`)}${EOL}${chalk.dim(`See ${formatPathLink(keybindingsPath)}`)}${EOL}`; 212: } catch (error) { 213: logError(error); 214: throw new Error(`Failed to install ${editor} terminal Shift+Enter key binding`); 215: } 216: } 217: async function enableOptionAsMetaForProfile(profileName: string): Promise<boolean> { 218: const { 219: code: addCode 220: } = await execFileNoThrow('/usr/libexec/PlistBuddy', ['-c', `Add :'Window Settings':'${profileName}':useOptionAsMetaKey bool true`, getTerminalPlistPath()]); 221: if (addCode !== 0) { 222: const { 223: code: setCode 224: } = await execFileNoThrow('/usr/libexec/PlistBuddy', ['-c', `Set :'Window Settings':'${profileName}':useOptionAsMetaKey true`, getTerminalPlistPath()]); 225: if (setCode !== 0) { 226: logError(new Error(`Failed to enable Option as Meta key for Terminal.app profile: ${profileName}`)); 227: return false; 228: } 229: } 230: return true; 231: } 232: async function disableAudioBellForProfile(profileName: string): Promise<boolean> { 233: const { 234: code: addCode 235: } = await execFileNoThrow('/usr/libexec/PlistBuddy', ['-c', `Add :'Window Settings':'${profileName}':Bell bool false`, getTerminalPlistPath()]); 236: if (addCode !== 0) { 237: const { 238: code: setCode 239: } = await execFileNoThrow('/usr/libexec/PlistBuddy', ['-c', `Set :'Window Settings':'${profileName}':Bell false`, getTerminalPlistPath()]); 240: if (setCode !== 0) { 241: logError(new Error(`Failed to disable audio bell for Terminal.app profile: ${profileName}`)); 242: return false; 243: } 244: } 245: return true; 246: } 247: async function enableOptionAsMetaForTerminal(theme: ThemeName): Promise<string> { 248: try { 249: const backupPath = await backupTerminalPreferences(); 250: if (!backupPath) { 251: throw new Error('Failed to create backup of Terminal.app preferences, bailing out'); 252: } 253: const { 254: stdout: defaultProfile, 255: code: readCode 256: } = await execFileNoThrow('defaults', ['read', 'com.apple.Terminal', 'Default Window Settings']); 257: if (readCode !== 0 || !defaultProfile.trim()) { 258: throw new Error('Failed to read default Terminal.app profile'); 259: } 260: const { 261: stdout: startupProfile, 262: code: startupCode 263: } = await execFileNoThrow('defaults', ['read', 'com.apple.Terminal', 'Startup Window Settings']); 264: if (startupCode !== 0 || !startupProfile.trim()) { 265: throw new Error('Failed to read startup Terminal.app profile'); 266: } 267: let wasAnyProfileUpdated = false; 268: const defaultProfileName = defaultProfile.trim(); 269: const optionAsMetaEnabled = await enableOptionAsMetaForProfile(defaultProfileName); 270: const audioBellDisabled = await disableAudioBellForProfile(defaultProfileName); 271: if (optionAsMetaEnabled || audioBellDisabled) { 272: wasAnyProfileUpdated = true; 273: } 274: const startupProfileName = startupProfile.trim(); 275: if (startupProfileName !== defaultProfileName) { 276: const startupOptionAsMetaEnabled = await enableOptionAsMetaForProfile(startupProfileName); 277: const startupAudioBellDisabled = await disableAudioBellForProfile(startupProfileName); 278: if (startupOptionAsMetaEnabled || startupAudioBellDisabled) { 279: wasAnyProfileUpdated = true; 280: } 281: } 282: if (!wasAnyProfileUpdated) { 283: throw new Error('Failed to enable Option as Meta key or disable audio bell for any Terminal.app profile'); 284: } 285: await execFileNoThrow('killall', ['cfprefsd']); 286: markTerminalSetupComplete(); 287: return `${color('success', theme)(`Configured Terminal.app settings:`)}${EOL}${color('success', theme)('- Enabled "Use Option as Meta key"')}${EOL}${color('success', theme)('- Switched to visual bell')}${EOL}${chalk.dim('Option+Enter will now enter a newline.')}${EOL}${chalk.dim('You must restart Terminal.app for changes to take effect.', theme)}${EOL}`; 288: } catch (error) { 289: logError(error); 290: const restoreResult = await checkAndRestoreTerminalBackup(); 291: const errorMessage = 'Failed to enable Option as Meta key for Terminal.app.'; 292: if (restoreResult.status === 'restored') { 293: throw new Error(`${errorMessage} Your settings have been restored from backup.`); 294: } else if (restoreResult.status === 'failed') { 295: throw new Error(`${errorMessage} Restoring from backup failed, try manually with: defaults import com.apple.Terminal ${restoreResult.backupPath}`); 296: } else { 297: throw new Error(`${errorMessage} No backup was available to restore from.`); 298: } 299: } 300: } 301: async function installBindingsForAlacritty(theme: ThemeName): Promise<string> { 302: const ALACRITTY_KEYBINDING = `[[keyboard.bindings]] 303: key = "Return" 304: mods = "Shift" 305: chars = "\\u001B\\r"`; 306: const configPaths: string[] = []; 307: const xdgConfigHome = process.env.XDG_CONFIG_HOME; 308: if (xdgConfigHome) { 309: configPaths.push(join(xdgConfigHome, 'alacritty', 'alacritty.toml')); 310: } else { 311: configPaths.push(join(homedir(), '.config', 'alacritty', 'alacritty.toml')); 312: } 313: if (platform() === 'win32') { 314: const appData = process.env.APPDATA; 315: if (appData) { 316: configPaths.push(join(appData, 'alacritty', 'alacritty.toml')); 317: } 318: } 319: let configPath: string | null = null; 320: let configContent = ''; 321: let configExists = false; 322: for (const path of configPaths) { 323: try { 324: configContent = await readFile(path, { 325: encoding: 'utf-8' 326: }); 327: configPath = path; 328: configExists = true; 329: break; 330: } catch (e: unknown) { 331: if (!isFsInaccessible(e)) throw e; 332: } 333: } 334: if (!configPath) { 335: configPath = configPaths[0] ?? null; 336: } 337: if (!configPath) { 338: throw new Error('No valid config path found for Alacritty'); 339: } 340: try { 341: if (configExists) { 342: if (configContent.includes('mods = "Shift"') && configContent.includes('key = "Return"')) { 343: return `${color('warning', theme)('Found existing Alacritty Shift+Enter key binding. Remove it to continue.')}${EOL}${chalk.dim(`See ${formatPathLink(configPath)}`)}${EOL}`; 344: } 345: const randomSha = randomBytes(4).toString('hex'); 346: const backupPath = `${configPath}.${randomSha}.bak`; 347: try { 348: await copyFile(configPath, backupPath); 349: } catch { 350: return `${color('warning', theme)('Error backing up existing Alacritty config. Bailing out.')}${EOL}${chalk.dim(`See ${formatPathLink(configPath)}`)}${EOL}${chalk.dim(`Backup path: ${formatPathLink(backupPath)}`)}${EOL}`; 351: } 352: } else { 353: await mkdir(dirname(configPath), { 354: recursive: true 355: }); 356: } 357: let updatedContent = configContent; 358: if (configContent && !configContent.endsWith('\n')) { 359: updatedContent += '\n'; 360: } 361: updatedContent += '\n' + ALACRITTY_KEYBINDING + '\n'; 362: await writeFile(configPath, updatedContent, { 363: encoding: 'utf-8' 364: }); 365: return `${color('success', theme)('Installed Alacritty Shift+Enter key binding')}${EOL}${color('success', theme)('You may need to restart Alacritty for changes to take effect')}${EOL}${chalk.dim(`See ${formatPathLink(configPath)}`)}${EOL}`; 366: } catch (error) { 367: logError(error); 368: throw new Error('Failed to install Alacritty Shift+Enter key binding'); 369: } 370: } 371: async function installBindingsForZed(theme: ThemeName): Promise<string> { 372: const zedDir = join(homedir(), '.config', 'zed'); 373: const keymapPath = join(zedDir, 'keymap.json'); 374: try { 375: await mkdir(zedDir, { 376: recursive: true 377: }); 378: let keymapContent = '[]'; 379: let fileExists = false; 380: try { 381: keymapContent = await readFile(keymapPath, { 382: encoding: 'utf-8' 383: }); 384: fileExists = true; 385: } catch (e: unknown) { 386: if (!isFsInaccessible(e)) throw e; 387: } 388: if (fileExists) { 389: if (keymapContent.includes('shift-enter')) { 390: return `${color('warning', theme)('Found existing Zed Shift+Enter key binding. Remove it to continue.')}${EOL}${chalk.dim(`See ${formatPathLink(keymapPath)}`)}${EOL}`; 391: } 392: const randomSha = randomBytes(4).toString('hex'); 393: const backupPath = `${keymapPath}.${randomSha}.bak`; 394: try { 395: await copyFile(keymapPath, backupPath); 396: } catch { 397: return `${color('warning', theme)('Error backing up existing Zed keymap. Bailing out.')}${EOL}${chalk.dim(`See ${formatPathLink(keymapPath)}`)}${EOL}${chalk.dim(`Backup path: ${formatPathLink(backupPath)}`)}${EOL}`; 398: } 399: } 400: let keymap: Array<{ 401: context?: string; 402: bindings: Record<string, string | string[]>; 403: }>; 404: try { 405: keymap = jsonParse(keymapContent); 406: if (!Array.isArray(keymap)) { 407: keymap = []; 408: } 409: } catch { 410: keymap = []; 411: } 412: keymap.push({ 413: context: 'Terminal', 414: bindings: { 415: 'shift-enter': ['terminal::SendText', '\u001b\r'] 416: } 417: }); 418: await writeFile(keymapPath, jsonStringify(keymap, null, 2) + '\n', { 419: encoding: 'utf-8' 420: }); 421: return `${color('success', theme)('Installed Zed Shift+Enter key binding')}${EOL}${chalk.dim(`See ${formatPathLink(keymapPath)}`)}${EOL}`; 422: } catch (error) { 423: logError(error); 424: throw new Error('Failed to install Zed Shift+Enter key binding'); 425: } 426: }

File: src/commands/theme/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const theme = { 3: type: 'local-jsx', 4: name: 'theme', 5: description: 'Change the theme', 6: load: () => import('./theme.js'), 7: } satisfies Command 8: export default theme

File: src/commands/theme/theme.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import * as React from 'react'; 3: import type { CommandResultDisplay } from '../../commands.js'; 4: import { Pane } from '../../components/design-system/Pane.js'; 5: import { ThemePicker } from '../../components/ThemePicker.js'; 6: import { useTheme } from '../../ink.js'; 7: import type { LocalJSXCommandCall } from '../../types/command.js'; 8: type Props = { 9: onDone: (result?: string, options?: { 10: display?: CommandResultDisplay; 11: }) => void; 12: }; 13: function ThemePickerCommand(t0) { 14: const $ = _c(8); 15: const { 16: onDone 17: } = t0; 18: const [, setTheme] = useTheme(); 19: let t1; 20: if ($[0] !== onDone || $[1] !== setTheme) { 21: t1 = setting => { 22: setTheme(setting); 23: onDone(`Theme set to ${setting}`); 24: }; 25: $[0] = onDone; 26: $[1] = setTheme; 27: $[2] = t1; 28: } else { 29: t1 = $[2]; 30: } 31: let t2; 32: if ($[3] !== onDone) { 33: t2 = () => { 34: onDone("Theme picker dismissed", { 35: display: "system" 36: }); 37: }; 38: $[3] = onDone; 39: $[4] = t2; 40: } else { 41: t2 = $[4]; 42: } 43: let t3; 44: if ($[5] !== t1 || $[6] !== t2) { 45: t3 = <Pane color="permission"><ThemePicker onThemeSelect={t1} onCancel={t2} skipExitHandling={true} /></Pane>; 46: $[5] = t1; 47: $[6] = t2; 48: $[7] = t3; 49: } else { 50: t3 = $[7]; 51: } 52: return t3; 53: } 54: export const call: LocalJSXCommandCall = async (onDone, _context) => { 55: return <ThemePickerCommand onDone={onDone} />; 56: };

File: src/commands/thinkback/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 3: const thinkback = { 4: type: 'local-jsx', 5: name: 'think-back', 6: description: 'Your 2025 Claude Code Year in Review', 7: isEnabled: () => 8: checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_thinkback'), 9: load: () => import('./thinkback.js'), 10: } satisfies Command 11: export default thinkback

File: src/commands/thinkback/thinkback.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { execa } from 'execa'; 3: import { readFile } from 'fs/promises'; 4: import { join } from 'path'; 5: import * as React from 'react'; 6: import { useCallback, useEffect, useState } from 'react'; 7: import type { CommandResultDisplay } from '../../commands.js'; 8: import { Select } from '../../components/CustomSelect/select.js'; 9: import { Dialog } from '../../components/design-system/Dialog.js'; 10: import { Spinner } from '../../components/Spinner.js'; 11: import instances from '../../ink/instances.js'; 12: import { Box, Text } from '../../ink.js'; 13: import { enablePluginOp } from '../../services/plugins/pluginOperations.js'; 14: import { logForDebugging } from '../../utils/debug.js'; 15: import { isENOENT, toError } from '../../utils/errors.js'; 16: import { execFileNoThrow } from '../../utils/execFileNoThrow.js'; 17: import { pathExists } from '../../utils/file.js'; 18: import { logError } from '../../utils/log.js'; 19: import { getPlatform } from '../../utils/platform.js'; 20: import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; 21: import { isPluginInstalled } from '../../utils/plugins/installedPluginsManager.js'; 22: import { addMarketplaceSource, clearMarketplacesCache, loadKnownMarketplacesConfig, refreshMarketplace } from '../../utils/plugins/marketplaceManager.js'; 23: import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js'; 24: import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'; 25: import { installSelectedPlugins } from '../../utils/plugins/pluginStartupCheck.js'; 26: const INTERNAL_MARKETPLACE_NAME = 'claude-code-marketplace'; 27: const INTERNAL_MARKETPLACE_REPO = 'anthropics/claude-code-marketplace'; 28: const OFFICIAL_MARKETPLACE_REPO = 'anthropics/claude-plugins-official'; 29: function getMarketplaceName(): string { 30: return "external" === 'ant' ? INTERNAL_MARKETPLACE_NAME : OFFICIAL_MARKETPLACE_NAME; 31: } 32: function getMarketplaceRepo(): string { 33: return "external" === 'ant' ? INTERNAL_MARKETPLACE_REPO : OFFICIAL_MARKETPLACE_REPO; 34: } 35: function getPluginId(): string { 36: return `thinkback@${getMarketplaceName()}`; 37: } 38: const SKILL_NAME = 'thinkback'; 39: async function getThinkbackSkillDir(): Promise<string | null> { 40: const { 41: enabled 42: } = await loadAllPlugins(); 43: const thinkbackPlugin = enabled.find(p => p.name === 'thinkback' || p.source && p.source.includes(getPluginId())); 44: if (!thinkbackPlugin) { 45: return null; 46: } 47: const skillDir = join(thinkbackPlugin.path, 'skills', SKILL_NAME); 48: if (await pathExists(skillDir)) { 49: return skillDir; 50: } 51: return null; 52: } 53: export async function playAnimation(skillDir: string): Promise<{ 54: success: boolean; 55: message: string; 56: }> { 57: const dataPath = join(skillDir, 'year_in_review.js'); 58: const playerPath = join(skillDir, 'player.js'); 59: try { 60: await readFile(dataPath); 61: } catch (e: unknown) { 62: if (isENOENT(e)) { 63: return { 64: success: false, 65: message: 'No animation found. Run /think-back first to generate one.' 66: }; 67: } 68: logError(e); 69: return { 70: success: false, 71: message: `Could not access animation data: ${toError(e).message}` 72: }; 73: } 74: try { 75: await readFile(playerPath); 76: } catch (e: unknown) { 77: if (isENOENT(e)) { 78: return { 79: success: false, 80: message: 'Player script not found. The player.js file is missing from the thinkback skill.' 81: }; 82: } 83: logError(e); 84: return { 85: success: false, 86: message: `Could not access player script: ${toError(e).message}` 87: }; 88: } 89: const inkInstance = instances.get(process.stdout); 90: if (!inkInstance) { 91: return { 92: success: false, 93: message: 'Failed to access terminal instance' 94: }; 95: } 96: inkInstance.enterAlternateScreen(); 97: try { 98: await execa('node', [playerPath], { 99: stdio: 'inherit', 100: cwd: skillDir, 101: reject: false 102: }); 103: } catch { 104: } finally { 105: inkInstance.exitAlternateScreen(); 106: } 107: const htmlPath = join(skillDir, 'year_in_review.html'); 108: if (await pathExists(htmlPath)) { 109: const platform = getPlatform(); 110: const openCmd = platform === 'macos' ? 'open' : platform === 'windows' ? 'start' : 'xdg-open'; 111: void execFileNoThrow(openCmd, [htmlPath]); 112: } 113: return { 114: success: true, 115: message: 'Year in review animation complete!' 116: }; 117: } 118: type InstallState = { 119: phase: 'checking'; 120: } | { 121: phase: 'installing-marketplace'; 122: } | { 123: phase: 'installing-plugin'; 124: } | { 125: phase: 'enabling-plugin'; 126: } | { 127: phase: 'ready'; 128: } | { 129: phase: 'error'; 130: message: string; 131: }; 132: function ThinkbackInstaller({ 133: onReady, 134: onError 135: }: { 136: onReady: () => void; 137: onError: (message: string) => void; 138: }): React.ReactNode { 139: const [state, setState] = useState<InstallState>({ 140: phase: 'checking' 141: }); 142: const [progressMessage, setProgressMessage] = useState(''); 143: useEffect(() => { 144: async function checkAndInstall(): Promise<void> { 145: try { 146: // Check if marketplace is installed 147: const knownMarketplaces = await loadKnownMarketplacesConfig(); 148: const marketplaceName = getMarketplaceName(); 149: const marketplaceRepo = getMarketplaceRepo(); 150: const pluginId = getPluginId(); 151: const marketplaceInstalled = marketplaceName in knownMarketplaces; 152: // Check if plugin is already installed first 153: const pluginAlreadyInstalled = isPluginInstalled(pluginId); 154: if (!marketplaceInstalled) { 155: // Install the marketplace 156: setState({ 157: phase: 'installing-marketplace' 158: }); 159: logForDebugging(`Installing marketplace ${marketplaceRepo}`); 160: await addMarketplaceSource({ 161: source: 'github', 162: repo: marketplaceRepo 163: }, message => { 164: setProgressMessage(message); 165: }); 166: clearAllCaches(); 167: logForDebugging(`Marketplace ${marketplaceName} installed`); 168: } else if (!pluginAlreadyInstalled) { 169: setState({ 170: phase: 'installing-marketplace' 171: }); 172: setProgressMessage('Updating marketplace…'); 173: logForDebugging(`Refreshing marketplace ${marketplaceName}`); 174: await refreshMarketplace(marketplaceName, message_0 => { 175: setProgressMessage(message_0); 176: }); 177: clearMarketplacesCache(); 178: clearAllCaches(); 179: logForDebugging(`Marketplace ${marketplaceName} refreshed`); 180: } 181: if (!pluginAlreadyInstalled) { 182: setState({ 183: phase: 'installing-plugin' 184: }); 185: logForDebugging(`Installing plugin ${pluginId}`); 186: const result = await installSelectedPlugins([pluginId]); 187: if (result.failed.length > 0) { 188: const errorMsg = result.failed.map(f => `${f.name}: ${f.error}`).join(', '); 189: throw new Error(`Failed to install plugin: ${errorMsg}`); 190: } 191: clearAllCaches(); 192: logForDebugging(`Plugin ${pluginId} installed`); 193: } else { 194: const { 195: disabled 196: } = await loadAllPlugins(); 197: const isDisabled = disabled.some(p => p.name === 'thinkback' || p.source?.includes(pluginId)); 198: if (isDisabled) { 199: setState({ 200: phase: 'enabling-plugin' 201: }); 202: logForDebugging(`Enabling plugin ${pluginId}`); 203: const enableResult = await enablePluginOp(pluginId); 204: if (!enableResult.success) { 205: throw new Error(`Failed to enable plugin: ${enableResult.message}`); 206: } 207: clearAllCaches(); 208: logForDebugging(`Plugin ${pluginId} enabled`); 209: } 210: } 211: setState({ 212: phase: 'ready' 213: }); 214: onReady(); 215: } catch (error) { 216: const err = toError(error); 217: logError(err); 218: setState({ 219: phase: 'error', 220: message: err.message 221: }); 222: onError(err.message); 223: } 224: } 225: void checkAndInstall(); 226: }, [onReady, onError]); 227: if (state.phase === 'error') { 228: return <Box flexDirection="column"> 229: <Text color="error">Error: {state.message}</Text> 230: </Box>; 231: } 232: if (state.phase === 'ready') { 233: return null; 234: } 235: const statusMessage = state.phase === 'checking' ? 'Checking thinkback installation…' : state.phase === 'installing-marketplace' ? 'Installing marketplace…' : state.phase === 'enabling-plugin' ? 'Enabling thinkback plugin…' : 'Installing thinkback plugin…'; 236: return <Box flexDirection="column"> 237: <Box> 238: <Spinner /> 239: <Text>{progressMessage || statusMessage}</Text> 240: </Box> 241: </Box>; 242: } 243: type MenuAction = 'play' | 'edit' | 'fix' | 'regenerate'; 244: type GenerativeAction = Exclude<MenuAction, 'play'>; 245: function ThinkbackMenu(t0) { 246: const $ = _c(19); 247: const { 248: onDone, 249: onAction, 250: skillDir, 251: hasGenerated 252: } = t0; 253: const [hasSelected, setHasSelected] = useState(false); 254: let t1; 255: if ($[0] !== hasGenerated) { 256: t1 = hasGenerated ? [{ 257: label: "Play animation", 258: value: "play" as const, 259: description: "Watch your year in review" 260: }, { 261: label: "Edit content", 262: value: "edit" as const, 263: description: "Modify the animation" 264: }, { 265: label: "Fix errors", 266: value: "fix" as const, 267: description: "Fix validation or rendering issues" 268: }, { 269: label: "Regenerate", 270: value: "regenerate" as const, 271: description: "Create a new animation from scratch" 272: }] : [{ 273: label: "Let's go!", 274: value: "regenerate" as const, 275: description: "Generate your personalized animation" 276: }]; 277: $[0] = hasGenerated; 278: $[1] = t1; 279: } else { 280: t1 = $[1]; 281: } 282: const options = t1; 283: let t2; 284: if ($[2] !== onAction || $[3] !== onDone || $[4] !== skillDir) { 285: t2 = function handleSelect(value) { 286: setHasSelected(true); 287: if (value === "play") { 288: playAnimation(skillDir).then(() => { 289: onDone(undefined, { 290: display: "skip" 291: }); 292: }); 293: } else { 294: onAction(value); 295: } 296: }; 297: $[2] = onAction; 298: $[3] = onDone; 299: $[4] = skillDir; 300: $[5] = t2; 301: } else { 302: t2 = $[5]; 303: } 304: const handleSelect = t2; 305: let t3; 306: if ($[6] !== onDone) { 307: t3 = function handleCancel() { 308: onDone(undefined, { 309: display: "skip" 310: }); 311: }; 312: $[6] = onDone; 313: $[7] = t3; 314: } else { 315: t3 = $[7]; 316: } 317: const handleCancel = t3; 318: if (hasSelected) { 319: return null; 320: } 321: let t4; 322: if ($[8] !== hasGenerated) { 323: t4 = !hasGenerated && <Box flexDirection="column"><Text>Relive your year of coding with Claude.</Text><Text dimColor={true}>{"We'll create a personalized ASCII animation celebrating your journey."}</Text></Box>; 324: $[8] = hasGenerated; 325: $[9] = t4; 326: } else { 327: t4 = $[9]; 328: } 329: let t5; 330: if ($[10] !== handleSelect || $[11] !== options) { 331: t5 = <Select options={options} onChange={handleSelect} visibleOptionCount={5} />; 332: $[10] = handleSelect; 333: $[11] = options; 334: $[12] = t5; 335: } else { 336: t5 = $[12]; 337: } 338: let t6; 339: if ($[13] !== t4 || $[14] !== t5) { 340: t6 = <Box flexDirection="column" gap={1}>{t4}{t5}</Box>; 341: $[13] = t4; 342: $[14] = t5; 343: $[15] = t6; 344: } else { 345: t6 = $[15]; 346: } 347: let t7; 348: if ($[16] !== handleCancel || $[17] !== t6) { 349: t7 = <Dialog title="Think Back on 2025 with Claude Code" subtitle="Generate your 2025 Claude Code Think Back (takes a few minutes to run)" onCancel={handleCancel} color="claude">{t6}</Dialog>; 350: $[16] = handleCancel; 351: $[17] = t6; 352: $[18] = t7; 353: } else { 354: t7 = $[18]; 355: } 356: return t7; 357: } 358: const EDIT_PROMPT = 'Use the Skill tool to invoke the "thinkback" skill with mode=edit to modify my existing Claude Code year in review animation. Ask me what I want to change. When the animation is ready, tell the user to run /think-back again to play it.'; 359: const FIX_PROMPT = 'Use the Skill tool to invoke the "thinkback" skill with mode=fix to fix validation or rendering errors in my existing Claude Code year in review animation. Run the validator, identify errors, and fix them. When the animation is ready, tell the user to run /think-back again to play it.'; 360: const REGENERATE_PROMPT = 'Use the Skill tool to invoke the "thinkback" skill with mode=regenerate to create a completely new Claude Code year in review animation from scratch. Delete the existing animation and start fresh. When the animation is ready, tell the user to run /think-back again to play it.'; 361: function ThinkbackFlow(t0) { 362: const $ = _c(27); 363: const { 364: onDone 365: } = t0; 366: const [installComplete, setInstallComplete] = useState(false); 367: const [installError, setInstallError] = useState(null); 368: const [skillDir, setSkillDir] = useState(null); 369: const [hasGenerated, setHasGenerated] = useState(null); 370: let t1; 371: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 372: t1 = function handleReady() { 373: setInstallComplete(true); 374: }; 375: $[0] = t1; 376: } else { 377: t1 = $[0]; 378: } 379: const handleReady = t1; 380: let t2; 381: if ($[1] !== onDone) { 382: t2 = message => { 383: setInstallError(message); 384: onDone(`Error with thinkback: ${message}. Try running /plugin to manually install the think-back plugin.`, { 385: display: "system" 386: }); 387: }; 388: $[1] = onDone; 389: $[2] = t2; 390: } else { 391: t2 = $[2]; 392: } 393: const handleError = t2; 394: let t3; 395: let t4; 396: if ($[3] !== handleError || $[4] !== installComplete || $[5] !== installError || $[6] !== skillDir) { 397: t3 = () => { 398: if (installComplete && !skillDir && !installError) { 399: getThinkbackSkillDir().then(dir => { 400: if (dir) { 401: logForDebugging(`Thinkback skill directory: ${dir}`); 402: setSkillDir(dir); 403: } else { 404: handleError("Could not find thinkback skill directory"); 405: } 406: }); 407: } 408: }; 409: t4 = [installComplete, skillDir, installError, handleError]; 410: $[3] = handleError; 411: $[4] = installComplete; 412: $[5] = installError; 413: $[6] = skillDir; 414: $[7] = t3; 415: $[8] = t4; 416: } else { 417: t3 = $[7]; 418: t4 = $[8]; 419: } 420: useEffect(t3, t4); 421: let t5; 422: let t6; 423: if ($[9] !== skillDir) { 424: t5 = () => { 425: if (!skillDir) { 426: return; 427: } 428: const dataPath = join(skillDir, "year_in_review.js"); 429: pathExists(dataPath).then(exists => { 430: logForDebugging(`Checking for ${dataPath}: ${exists ? "found" : "not found"}`); 431: setHasGenerated(exists); 432: }); 433: }; 434: t6 = [skillDir]; 435: $[9] = skillDir; 436: $[10] = t5; 437: $[11] = t6; 438: } else { 439: t5 = $[10]; 440: t6 = $[11]; 441: } 442: useEffect(t5, t6); 443: let t7; 444: if ($[12] !== onDone) { 445: t7 = function handleAction(action) { 446: const prompts = { 447: edit: EDIT_PROMPT, 448: fix: FIX_PROMPT, 449: regenerate: REGENERATE_PROMPT 450: }; 451: onDone(prompts[action], { 452: display: "user", 453: shouldQuery: true 454: }); 455: }; 456: $[12] = onDone; 457: $[13] = t7; 458: } else { 459: t7 = $[13]; 460: } 461: const handleAction = t7; 462: if (installError) { 463: let t8; 464: if ($[14] !== installError) { 465: t8 = <Text color="error">Error: {installError}</Text>; 466: $[14] = installError; 467: $[15] = t8; 468: } else { 469: t8 = $[15]; 470: } 471: let t9; 472: if ($[16] === Symbol.for("react.memo_cache_sentinel")) { 473: t9 = <Text dimColor={true}>Try running /plugin to manually install the think-back plugin.</Text>; 474: $[16] = t9; 475: } else { 476: t9 = $[16]; 477: } 478: let t10; 479: if ($[17] !== t8) { 480: t10 = <Box flexDirection="column">{t8}{t9}</Box>; 481: $[17] = t8; 482: $[18] = t10; 483: } else { 484: t10 = $[18]; 485: } 486: return t10; 487: } 488: if (!installComplete) { 489: let t8; 490: if ($[19] !== handleError) { 491: t8 = <ThinkbackInstaller onReady={handleReady} onError={handleError} />; 492: $[19] = handleError; 493: $[20] = t8; 494: } else { 495: t8 = $[20]; 496: } 497: return t8; 498: } 499: if (!skillDir || hasGenerated === null) { 500: let t8; 501: if ($[21] === Symbol.for("react.memo_cache_sentinel")) { 502: t8 = <Box><Spinner /><Text>Loading thinkback skill…</Text></Box>; 503: $[21] = t8; 504: } else { 505: t8 = $[21]; 506: } 507: return t8; 508: } 509: let t8; 510: if ($[22] !== handleAction || $[23] !== hasGenerated || $[24] !== onDone || $[25] !== skillDir) { 511: t8 = <ThinkbackMenu onDone={onDone} onAction={handleAction} skillDir={skillDir} hasGenerated={hasGenerated} />; 512: $[22] = handleAction; 513: $[23] = hasGenerated; 514: $[24] = onDone; 515: $[25] = skillDir; 516: $[26] = t8; 517: } else { 518: t8 = $[26]; 519: } 520: return t8; 521: } 522: export async function call(onDone: (result?: string, options?: { 523: display?: CommandResultDisplay; 524: shouldQuery?: boolean; 525: }) => void): Promise<React.ReactNode> { 526: return <ThinkbackFlow onDone={onDone} />; 527: }

File: src/commands/thinkback-play/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 3: const thinkbackPlay = { 4: type: 'local', 5: name: 'thinkback-play', 6: description: 'Play the thinkback animation', 7: isEnabled: () => 8: checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_thinkback'), 9: isHidden: true, 10: supportsNonInteractive: false, 11: load: () => import('./thinkback-play.js'), 12: } satisfies Command 13: export default thinkbackPlay

File: src/commands/thinkback-play/thinkback-play.ts

typescript 1: import { join } from 'path' 2: import type { LocalCommandResult } from '../../commands.js' 3: import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js' 4: import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js' 5: import { playAnimation } from '../thinkback/thinkback.js' 6: const INTERNAL_MARKETPLACE_NAME = 'claude-code-marketplace' 7: const SKILL_NAME = 'thinkback' 8: function getPluginId(): string { 9: const marketplaceName = 10: process.env.USER_TYPE === 'ant' 11: ? INTERNAL_MARKETPLACE_NAME 12: : OFFICIAL_MARKETPLACE_NAME 13: return `thinkback@${marketplaceName}` 14: } 15: export async function call(): Promise<LocalCommandResult> { 16: const v2Data = loadInstalledPluginsV2() 17: const pluginId = getPluginId() 18: const installations = v2Data.plugins[pluginId] 19: if (!installations || installations.length === 0) { 20: return { 21: type: 'text' as const, 22: value: 23: 'Thinkback plugin not installed. Run /think-back first to install it.', 24: } 25: } 26: const firstInstall = installations[0] 27: if (!firstInstall?.installPath) { 28: return { 29: type: 'text' as const, 30: value: 'Thinkback plugin installation path not found.', 31: } 32: } 33: const skillDir = join(firstInstall.installPath, 'skills', SKILL_NAME) 34: const result = await playAnimation(skillDir) 35: return { type: 'text' as const, value: result.message } 36: }

File: src/commands/upgrade/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { getSubscriptionType } from '../../utils/auth.js' 3: import { isEnvTruthy } from '../../utils/envUtils.js' 4: const upgrade = { 5: type: 'local-jsx', 6: name: 'upgrade', 7: description: 'Upgrade to Max for higher rate limits and more Opus', 8: availability: ['claude-ai'], 9: isEnabled: () => 10: !isEnvTruthy(process.env.DISABLE_UPGRADE_COMMAND) && 11: getSubscriptionType() !== 'enterprise', 12: load: () => import('./upgrade.js'), 13: } satisfies Command 14: export default upgrade

File: src/commands/upgrade/upgrade.tsx

typescript 1: import * as React from 'react'; 2: import type { LocalJSXCommandContext } from '../../commands.js'; 3: import { getOauthProfileFromOauthToken } from '../../services/oauth/getOauthProfile.js'; 4: import type { LocalJSXCommandOnDone } from '../../types/command.js'; 5: import { getClaudeAIOAuthTokens, isClaudeAISubscriber } from '../../utils/auth.js'; 6: import { openBrowser } from '../../utils/browser.js'; 7: import { logError } from '../../utils/log.js'; 8: import { Login } from '../login/login.js'; 9: export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode | null> { 10: try { 11: if (isClaudeAISubscriber()) { 12: const tokens = getClaudeAIOAuthTokens(); 13: let isMax20x = false; 14: if (tokens?.subscriptionType && tokens?.rateLimitTier) { 15: isMax20x = tokens.subscriptionType === 'max' && tokens.rateLimitTier === 'default_claude_max_20x'; 16: } else if (tokens?.accessToken) { 17: const profile = await getOauthProfileFromOauthToken(tokens.accessToken); 18: isMax20x = profile?.organization?.organization_type === 'claude_max' && profile?.organization?.rate_limit_tier === 'default_claude_max_20x'; 19: } 20: if (isMax20x) { 21: setTimeout(onDone, 0, 'You are already on the highest Max subscription plan. For additional usage, run /login to switch to an API usage-billed account.'); 22: return null; 23: } 24: } 25: const url = 'https://claude.ai/upgrade/max'; 26: await openBrowser(url); 27: return <Login startingMessage={'Starting new login following /upgrade. Exit with Ctrl-C to use existing account.'} onDone={success => { 28: context.onChangeAPIKey(); 29: onDone(success ? 'Login successful' : 'Login interrupted'); 30: }} />; 31: } catch (error) { 32: logError(error as Error); 33: setTimeout(onDone, 0, 'Failed to open browser. Please visit https://claude.ai/upgrade/max to upgrade.'); 34: } 35: return null; 36: }

File: src/commands/usage/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: export default { 3: type: 'local-jsx', 4: name: 'usage', 5: description: 'Show plan usage limits', 6: availability: ['claude-ai'], 7: load: () => import('./usage.js'), 8: } satisfies Command

File: src/commands/usage/usage.tsx

typescript 1: import * as React from 'react'; 2: import { Settings } from '../../components/Settings/Settings.js'; 3: import type { LocalJSXCommandCall } from '../../types/command.js'; 4: export const call: LocalJSXCommandCall = async (onDone, context) => { 5: return <Settings onClose={onDone} context={context} defaultTab="Usage" />; 6: };

File: src/commands/vim/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: const command = { 3: name: 'vim', 4: description: 'Toggle between Vim and Normal editing modes', 5: supportsNonInteractive: false, 6: type: 'local', 7: load: () => import('./vim.js'), 8: } satisfies Command 9: export default command

File: src/commands/vim/vim.ts

typescript 1: import { 2: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3: logEvent, 4: } from '../../services/analytics/index.js' 5: import type { LocalCommandCall } from '../../types/command.js' 6: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 7: export const call: LocalCommandCall = async () => { 8: const config = getGlobalConfig() 9: let currentMode = config.editorMode || 'normal' 10: if (currentMode === 'emacs') { 11: currentMode = 'normal' 12: } 13: const newMode = currentMode === 'normal' ? 'vim' : 'normal' 14: saveGlobalConfig(current => ({ 15: ...current, 16: editorMode: newMode, 17: })) 18: logEvent('tengu_editor_mode_changed', { 19: mode: newMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 20: source: 21: 'command' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 22: }) 23: return { 24: type: 'text', 25: value: `Editor mode set to ${newMode}. ${ 26: newMode === 'vim' 27: ? 'Use Escape key to toggle between INSERT and NORMAL modes.' 28: : 'Using standard (readline) keyboard bindings.' 29: }`, 30: } 31: }

File: src/commands/voice/index.ts

typescript 1: import type { Command } from '../../commands.js' 2: import { 3: isVoiceGrowthBookEnabled, 4: isVoiceModeEnabled, 5: } from '../../voice/voiceModeEnabled.js' 6: const voice = { 7: type: 'local', 8: name: 'voice', 9: description: 'Toggle voice mode', 10: availability: ['claude-ai'], 11: isEnabled: () => isVoiceGrowthBookEnabled(), 12: get isHidden() { 13: return !isVoiceModeEnabled() 14: }, 15: supportsNonInteractive: false, 16: load: () => import('./voice.js'), 17: } satisfies Command 18: export default voice

File: src/commands/voice/voice.ts

typescript 1: import { normalizeLanguageForSTT } from '../../hooks/useVoice.js' 2: import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js' 3: import { logEvent } from '../../services/analytics/index.js' 4: import type { LocalCommandCall } from '../../types/command.js' 5: import { isAnthropicAuthEnabled } from '../../utils/auth.js' 6: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 7: import { settingsChangeDetector } from '../../utils/settings/changeDetector.js' 8: import { 9: getInitialSettings, 10: updateSettingsForSource, 11: } from '../../utils/settings/settings.js' 12: import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js' 13: const LANG_HINT_MAX_SHOWS = 2 14: export const call: LocalCommandCall = async () => { 15: if (!isVoiceModeEnabled()) { 16: if (!isAnthropicAuthEnabled()) { 17: return { 18: type: 'text' as const, 19: value: 20: 'Voice mode requires a Claude.ai account. Please run /login to sign in.', 21: } 22: } 23: return { 24: type: 'text' as const, 25: value: 'Voice mode is not available.', 26: } 27: } 28: const currentSettings = getInitialSettings() 29: const isCurrentlyEnabled = currentSettings.voiceEnabled === true 30: if (isCurrentlyEnabled) { 31: const result = updateSettingsForSource('userSettings', { 32: voiceEnabled: false, 33: }) 34: if (result.error) { 35: return { 36: type: 'text' as const, 37: value: 38: 'Failed to update settings. Check your settings file for syntax errors.', 39: } 40: } 41: settingsChangeDetector.notifyChange('userSettings') 42: logEvent('tengu_voice_toggled', { enabled: false }) 43: return { 44: type: 'text' as const, 45: value: 'Voice mode disabled.', 46: } 47: } 48: const { isVoiceStreamAvailable } = await import( 49: '../../services/voiceStreamSTT.js' 50: ) 51: const { checkRecordingAvailability } = await import('../../services/voice.js') 52: const recording = await checkRecordingAvailability() 53: if (!recording.available) { 54: return { 55: type: 'text' as const, 56: value: 57: recording.reason ?? 'Voice mode is not available in this environment.', 58: } 59: } 60: if (!isVoiceStreamAvailable()) { 61: return { 62: type: 'text' as const, 63: value: 64: 'Voice mode requires a Claude.ai account. Please run /login to sign in.', 65: } 66: } 67: const { checkVoiceDependencies, requestMicrophonePermission } = await import( 68: '../../services/voice.js' 69: ) 70: const deps = await checkVoiceDependencies() 71: if (!deps.available) { 72: const hint = deps.installCommand 73: ? `\nInstall audio recording tools? Run: ${deps.installCommand}` 74: : '\nInstall SoX manually for audio recording.' 75: return { 76: type: 'text' as const, 77: value: `No audio recording tool found.${hint}`, 78: } 79: } 80: if (!(await requestMicrophonePermission())) { 81: let guidance: string 82: if (process.platform === 'win32') { 83: guidance = 'Settings \u2192 Privacy \u2192 Microphone' 84: } else if (process.platform === 'linux') { 85: guidance = "your system's audio settings" 86: } else { 87: guidance = 'System Settings \u2192 Privacy & Security \u2192 Microphone' 88: } 89: return { 90: type: 'text' as const, 91: value: `Microphone access is denied. To enable it, go to ${guidance}, then run /voice again.`, 92: } 93: } 94: const result = updateSettingsForSource('userSettings', { voiceEnabled: true }) 95: if (result.error) { 96: return { 97: type: 'text' as const, 98: value: 99: 'Failed to update settings. Check your settings file for syntax errors.', 100: } 101: } 102: settingsChangeDetector.notifyChange('userSettings') 103: logEvent('tengu_voice_toggled', { enabled: true }) 104: const key = getShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') 105: const stt = normalizeLanguageForSTT(currentSettings.language) 106: const cfg = getGlobalConfig() 107: const langChanged = cfg.voiceLangHintLastLanguage !== stt.code 108: const priorCount = langChanged ? 0 : (cfg.voiceLangHintShownCount ?? 0) 109: const showHint = !stt.fellBackFrom && priorCount < LANG_HINT_MAX_SHOWS 110: let langNote = '' 111: if (stt.fellBackFrom) { 112: langNote = ` Note: "${stt.fellBackFrom}" is not a supported dictation language; using English. Change it via /config.` 113: } else if (showHint) { 114: langNote = ` Dictation language: ${stt.code} (/config to change).` 115: } 116: if (langChanged || showHint) { 117: saveGlobalConfig(prev => ({ 118: ...prev, 119: voiceLangHintShownCount: priorCount + (showHint ? 1 : 0), 120: voiceLangHintLastLanguage: stt.code, 121: })) 122: } 123: return { 124: type: 'text' as const, 125: value: `Voice mode enabled. Hold ${key} to record.${langNote}`, 126: } 127: }

File: src/commands/advisor.ts

typescript 1: import type { Command } from '../commands.js' 2: import type { LocalCommandCall } from '../types/command.js' 3: import { 4: canUserConfigureAdvisor, 5: isValidAdvisorModel, 6: modelSupportsAdvisor, 7: } from '../utils/advisor.js' 8: import { 9: getDefaultMainLoopModelSetting, 10: normalizeModelStringForAPI, 11: parseUserSpecifiedModel, 12: } from '../utils/model/model.js' 13: import { validateModel } from '../utils/model/validateModel.js' 14: import { updateSettingsForSource } from '../utils/settings/settings.js' 15: const call: LocalCommandCall = async (args, context) => { 16: const arg = args.trim().toLowerCase() 17: const baseModel = parseUserSpecifiedModel( 18: context.getAppState().mainLoopModel ?? getDefaultMainLoopModelSetting(), 19: ) 20: if (!arg) { 21: const current = context.getAppState().advisorModel 22: if (!current) { 23: return { 24: type: 'text', 25: value: 26: 'Advisor: not set\nUse "/advisor <model>" to enable (e.g. "/advisor opus").', 27: } 28: } 29: if (!modelSupportsAdvisor(baseModel)) { 30: return { 31: type: 'text', 32: value: `Advisor: ${current} (inactive)\nThe current model (${baseModel}) does not support advisors.`, 33: } 34: } 35: return { 36: type: 'text', 37: value: `Advisor: ${current}\nUse "/advisor unset" to disable or "/advisor <model>" to change.`, 38: } 39: } 40: if (arg === 'unset' || arg === 'off') { 41: const prev = context.getAppState().advisorModel 42: context.setAppState(s => { 43: if (s.advisorModel === undefined) return s 44: return { ...s, advisorModel: undefined } 45: }) 46: updateSettingsForSource('userSettings', { advisorModel: undefined }) 47: return { 48: type: 'text', 49: value: prev 50: ? `Advisor disabled (was ${prev}).` 51: : 'Advisor already unset.', 52: } 53: } 54: const normalizedModel = normalizeModelStringForAPI(arg) 55: const resolvedModel = parseUserSpecifiedModel(arg) 56: const { valid, error } = await validateModel(resolvedModel) 57: if (!valid) { 58: return { 59: type: 'text', 60: value: error 61: ? `Invalid advisor model: ${error}` 62: : `Unknown model: ${arg} (${resolvedModel})`, 63: } 64: } 65: if (!isValidAdvisorModel(resolvedModel)) { 66: return { 67: type: 'text', 68: value: `The model ${arg} (${resolvedModel}) cannot be used as an advisor`, 69: } 70: } 71: context.setAppState(s => { 72: if (s.advisorModel === normalizedModel) return s 73: return { ...s, advisorModel: normalizedModel } 74: }) 75: updateSettingsForSource('userSettings', { advisorModel: normalizedModel }) 76: if (!modelSupportsAdvisor(baseModel)) { 77: return { 78: type: 'text', 79: value: `Advisor set to ${normalizedModel}.\nNote: Your current model (${baseModel}) does not support advisors. Switch to a supported model to use the advisor.`, 80: } 81: } 82: return { 83: type: 'text', 84: value: `Advisor set to ${normalizedModel}.`, 85: } 86: } 87: const advisor = { 88: type: 'local', 89: name: 'advisor', 90: description: 'Configure the advisor model', 91: argumentHint: '[<model>|off]', 92: isEnabled: () => canUserConfigureAdvisor(), 93: get isHidden() { 94: return !canUserConfigureAdvisor() 95: }, 96: supportsNonInteractive: true, 97: load: () => Promise.resolve({ call }), 98: } satisfies Command 99: export default advisor

File: src/commands/bridge-kick.ts

typescript 1: import { getBridgeDebugHandle } from '../bridge/bridgeDebug.js' 2: import type { Command } from '../commands.js' 3: import type { LocalCommandCall } from '../types/command.js' 4: const USAGE = `/bridge-kick <subcommand> 5: close <code> fire ws_closed with the given code (e.g. 1002) 6: poll <status> [type] next poll throws BridgeFatalError(status, type) 7: poll transient next poll throws axios-style rejection (5xx/net) 8: register fail [N] next N registers transient-fail (default 1) 9: register fatal next register 403s (terminal) 10: reconnect-session fail next POST /bridge/reconnect fails 11: heartbeat <status> next heartbeat throws BridgeFatalError(status) 12: reconnect call reconnectEnvironmentWithSession directly 13: status print bridge state` 14: const call: LocalCommandCall = async args => { 15: const h = getBridgeDebugHandle() 16: if (!h) { 17: return { 18: type: 'text', 19: value: 20: 'No bridge debug handle registered. Remote Control must be connected (USER_TYPE=ant).', 21: } 22: } 23: const [sub, a, b] = args.trim().split(/\s+/) 24: switch (sub) { 25: case 'close': { 26: const code = Number(a) 27: if (!Number.isFinite(code)) { 28: return { type: 'text', value: `close: need a numeric code\n${USAGE}` } 29: } 30: h.fireClose(code) 31: return { 32: type: 'text', 33: value: `Fired transport close(${code}). Watch debug.log for [bridge:repl] recovery.`, 34: } 35: } 36: case 'poll': { 37: if (a === 'transient') { 38: h.injectFault({ 39: method: 'pollForWork', 40: kind: 'transient', 41: status: 503, 42: count: 1, 43: }) 44: h.wakePollLoop() 45: return { 46: type: 'text', 47: value: 48: 'Next poll will throw a transient (axios rejection). Poll loop woken.', 49: } 50: } 51: const status = Number(a) 52: if (!Number.isFinite(status)) { 53: return { 54: type: 'text', 55: value: `poll: need 'transient' or a status code\n${USAGE}`, 56: } 57: } 58: const errorType = 59: b ?? (status === 404 ? 'not_found_error' : 'authentication_error') 60: h.injectFault({ 61: method: 'pollForWork', 62: kind: 'fatal', 63: status, 64: errorType, 65: count: 1, 66: }) 67: h.wakePollLoop() 68: return { 69: type: 'text', 70: value: `Next poll will throw BridgeFatalError(${status}, ${errorType}). Poll loop woken.`, 71: } 72: } 73: case 'register': { 74: if (a === 'fatal') { 75: h.injectFault({ 76: method: 'registerBridgeEnvironment', 77: kind: 'fatal', 78: status: 403, 79: errorType: 'permission_error', 80: count: 1, 81: }) 82: return { 83: type: 'text', 84: value: 85: 'Next registerBridgeEnvironment will 403. Trigger with close/reconnect.', 86: } 87: } 88: const n = Number(b) || 1 89: h.injectFault({ 90: method: 'registerBridgeEnvironment', 91: kind: 'transient', 92: status: 503, 93: count: n, 94: }) 95: return { 96: type: 'text', 97: value: `Next ${n} registerBridgeEnvironment call(s) will transient-fail. Trigger with close/reconnect.`, 98: } 99: } 100: case 'reconnect-session': { 101: h.injectFault({ 102: method: 'reconnectSession', 103: kind: 'fatal', 104: status: 404, 105: errorType: 'not_found_error', 106: count: 2, 107: }) 108: return { 109: type: 'text', 110: value: 111: 'Next 2 POST /bridge/reconnect calls will 404. doReconnect Strategy 1 falls through to Strategy 2.', 112: } 113: } 114: case 'heartbeat': { 115: const status = Number(a) || 401 116: h.injectFault({ 117: method: 'heartbeatWork', 118: kind: 'fatal', 119: status, 120: errorType: status === 401 ? 'authentication_error' : 'not_found_error', 121: count: 1, 122: }) 123: return { 124: type: 'text', 125: value: `Next heartbeat will ${status}. Watch for onHeartbeatFatal → work-state teardown.`, 126: } 127: } 128: case 'reconnect': { 129: h.forceReconnect() 130: return { 131: type: 'text', 132: value: 'Called reconnectEnvironmentWithSession(). Watch debug.log.', 133: } 134: } 135: case 'status': { 136: return { type: 'text', value: h.describe() } 137: } 138: default: 139: return { type: 'text', value: USAGE } 140: } 141: } 142: const bridgeKick = { 143: type: 'local', 144: name: 'bridge-kick', 145: description: 'Inject bridge failure states for manual recovery testing', 146: isEnabled: () => process.env.USER_TYPE === 'ant', 147: supportsNonInteractive: false, 148: load: () => Promise.resolve({ call }), 149: } satisfies Command 150: export default bridgeKick

File: src/commands/brief.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { z } from 'zod/v4' 3: import { getKairosActive, setUserMsgOptIn } from '../bootstrap/state.js' 4: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 5: import { 6: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 7: logEvent, 8: } from '../services/analytics/index.js' 9: import type { ToolUseContext } from '../Tool.js' 10: import { isBriefEntitled } from '../tools/BriefTool/BriefTool.js' 11: import { BRIEF_TOOL_NAME } from '../tools/BriefTool/prompt.js' 12: import type { 13: Command, 14: LocalJSXCommandContext, 15: LocalJSXCommandOnDone, 16: } from '../types/command.js' 17: import { lazySchema } from '../utils/lazySchema.js' 18: const briefConfigSchema = lazySchema(() => 19: z.object({ 20: enable_slash_command: z.boolean(), 21: }), 22: ) 23: type BriefConfig = z.infer<ReturnType<typeof briefConfigSchema>> 24: const DEFAULT_BRIEF_CONFIG: BriefConfig = { 25: enable_slash_command: false, 26: } 27: function getBriefConfig(): BriefConfig { 28: const raw = getFeatureValue_CACHED_MAY_BE_STALE<unknown>( 29: 'tengu_kairos_brief_config', 30: DEFAULT_BRIEF_CONFIG, 31: ) 32: const parsed = briefConfigSchema().safeParse(raw) 33: return parsed.success ? parsed.data : DEFAULT_BRIEF_CONFIG 34: } 35: const brief = { 36: type: 'local-jsx', 37: name: 'brief', 38: description: 'Toggle brief-only mode', 39: isEnabled: () => { 40: if (feature('KAIROS') || feature('KAIROS_BRIEF')) { 41: return getBriefConfig().enable_slash_command 42: } 43: return false 44: }, 45: immediate: true, 46: load: () => 47: Promise.resolve({ 48: async call( 49: onDone: LocalJSXCommandOnDone, 50: context: ToolUseContext & LocalJSXCommandContext, 51: ): Promise<React.ReactNode> { 52: const current = context.getAppState().isBriefOnly 53: const newState = !current 54: if (newState && !isBriefEntitled()) { 55: logEvent('tengu_brief_mode_toggled', { 56: enabled: false, 57: gated: true, 58: source: 59: 'slash_command' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 60: }) 61: onDone('Brief tool is not enabled for your account', { 62: display: 'system', 63: }) 64: return null 65: } 66: setUserMsgOptIn(newState) 67: context.setAppState(prev => { 68: if (prev.isBriefOnly === newState) return prev 69: return { ...prev, isBriefOnly: newState } 70: }) 71: logEvent('tengu_brief_mode_toggled', { 72: enabled: newState, 73: gated: false, 74: source: 75: 'slash_command' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 76: }) 77: const metaMessages = getKairosActive() 78: ? undefined 79: : [ 80: `<system-reminder>\n${ 81: newState 82: ? `Brief mode is now enabled. Use the ${BRIEF_TOOL_NAME} tool for all user-facing output — plain text outside it is hidden from the user's view.` 83: : `Brief mode is now disabled. The ${BRIEF_TOOL_NAME} tool is no longer available — reply with plain text.` 84: }\n</system-reminder>`, 85: ] 86: onDone( 87: newState ? 'Brief-only mode enabled' : 'Brief-only mode disabled', 88: { display: 'system', metaMessages }, 89: ) 90: return null 91: }, 92: }), 93: } satisfies Command 94: export default brief

File: src/commands/commit-push-pr.ts

typescript 1: import type { Command } from '../commands.js' 2: import { 3: getAttributionTexts, 4: getEnhancedPRAttribution, 5: } from '../utils/attribution.js' 6: import { getDefaultBranch } from '../utils/git.js' 7: import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js' 8: import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js' 9: const ALLOWED_TOOLS = [ 10: 'Bash(git checkout --branch:*)', 11: 'Bash(git checkout -b:*)', 12: 'Bash(git add:*)', 13: 'Bash(git status:*)', 14: 'Bash(git push:*)', 15: 'Bash(git commit:*)', 16: 'Bash(gh pr create:*)', 17: 'Bash(gh pr edit:*)', 18: 'Bash(gh pr view:*)', 19: 'Bash(gh pr merge:*)', 20: 'ToolSearch', 21: 'mcp__slack__send_message', 22: 'mcp__claude_ai_Slack__slack_send_message', 23: ] 24: function getPromptContent( 25: defaultBranch: string, 26: prAttribution?: string, 27: ): string { 28: const { commit: commitAttribution, pr: defaultPrAttribution } = 29: getAttributionTexts() 30: const effectivePrAttribution = prAttribution ?? defaultPrAttribution 31: const safeUser = process.env.SAFEUSER || '' 32: const username = process.env.USER || '' 33: let prefix = '' 34: let reviewerArg = ' and `--reviewer anthropics/claude-code`' 35: let addReviewerArg = ' (and add `--add-reviewer anthropics/claude-code`)' 36: let changelogSection = ` 37: ## Changelog 38: <!-- CHANGELOG:START --> 39: [If this PR contains user-facing changes, add a changelog entry here. Otherwise, remove this section.] 40: <!-- CHANGELOG:END -->` 41: let slackStep = ` 42: 5. After creating/updating the PR, check if the user's CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.` 43: if (process.env.USER_TYPE === 'ant' && isUndercover()) { 44: prefix = getUndercoverInstructions() + '\n' 45: reviewerArg = '' 46: addReviewerArg = '' 47: changelogSection = '' 48: slackStep = '' 49: } 50: return `${prefix}## Context 51: - \`SAFEUSER\`: ${safeUser} 52: - \`whoami\`: ${username} 53: - \`git status\`: !\`git status\` 54: - \`git diff HEAD\`: !\`git diff HEAD\` 55: - \`git branch --show-current\`: !\`git branch --show-current\` 56: - \`git diff ${defaultBranch}...HEAD\`: !\`git diff ${defaultBranch}...HEAD\` 57: - \`gh pr view --json number 2>/dev/null || true\`: !\`gh pr view --json number 2>/dev/null || true\` 58: ## Git Safety Protocol 59: - NEVER update the git config 60: - NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them 61: - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it 62: - NEVER run force push to main/master, warn the user if they request it 63: - Do not commit files that likely contain secrets (.env, credentials.json, etc) 64: - Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported 65: ## Your task 66: Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request from the git diff ${defaultBranch}...HEAD output above). 67: Based on the above changes: 68: 1. Create a new branch if on ${defaultBranch} (use SAFEUSER from context above for the branch name prefix, falling back to whoami if SAFEUSER is empty, e.g., \`username/feature-name\`) 69: 2. Create a single commit with an appropriate message using heredoc syntax${commitAttribution ? `, ending with the attribution text shown in the example below` : ''}: 70: \`\`\` 71: git commit -m "$(cat <<'EOF' 72: Commit message here.${commitAttribution ? `\n\n${commitAttribution}` : ''} 73: EOF 74: )" 75: \`\`\` 76: 3. Push the branch to origin 77: 4. If a PR already exists for this branch (check the gh pr view output above), update the PR title and body using \`gh pr edit\` to reflect the current diff${addReviewerArg}. Otherwise, create a pull request using \`gh pr create\` with heredoc syntax for the body${reviewerArg}. 78: - IMPORTANT: Keep PR titles short (under 70 characters). Use the body for details. 79: \`\`\` 80: gh pr create --title "Short, descriptive title" --body "$(cat <<'EOF' 81: ## Summary 82: <1-3 bullet points> 83: ## Test plan 84: [Bulleted markdown checklist of TODOs for testing the pull request...]${changelogSection}${effectivePrAttribution ? `\n\n${effectivePrAttribution}` : ''} 85: EOF 86: )" 87: \`\`\` 88: You have the capability to call multiple tools in a single response. You MUST do all of the above in a single message.${slackStep} 89: Return the PR URL when you're done, so the user can see it.` 90: } 91: const command = { 92: type: 'prompt', 93: name: 'commit-push-pr', 94: description: 'Commit, push, and open a PR', 95: allowedTools: ALLOWED_TOOLS, 96: get contentLength() { 97: // Use 'main' as estimate for content length calculation 98: return getPromptContent('main').length 99: }, 100: progressMessage: 'creating commit and PR', 101: source: 'builtin', 102: async getPromptForCommand(args, context) { 103: // Get default branch and enhanced PR attribution 104: const [defaultBranch, prAttribution] = await Promise.all([ 105: getDefaultBranch(), 106: getEnhancedPRAttribution(context.getAppState), 107: ]) 108: let promptContent = getPromptContent(defaultBranch, prAttribution) 109: // Append user instructions if args provided 110: const trimmedArgs = args?.trim() 111: if (trimmedArgs) { 112: promptContent += `\n\n## Additional instructions from user\n\n${trimmedArgs}` 113: } 114: const finalContent = await executeShellCommandsInPrompt( 115: promptContent, 116: { 117: ...context, 118: getAppState() { 119: const appState = context.getAppState() 120: return { 121: ...appState, 122: toolPermissionContext: { 123: ...appState.toolPermissionContext, 124: alwaysAllowRules: { 125: ...appState.toolPermissionContext.alwaysAllowRules, 126: command: ALLOWED_TOOLS, 127: }, 128: }, 129: } 130: }, 131: }, 132: '/commit-push-pr', 133: ) 134: return [{ type: 'text', text: finalContent }] 135: }, 136: } satisfies Command 137: export default command

File: src/commands/commit.ts

typescript 1: import type { Command } from '../commands.js' 2: import { getAttributionTexts } from '../utils/attribution.js' 3: import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js' 4: import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js' 5: const ALLOWED_TOOLS = [ 6: 'Bash(git add:*)', 7: 'Bash(git status:*)', 8: 'Bash(git commit:*)', 9: ] 10: function getPromptContent(): string { 11: const { commit: commitAttribution } = getAttributionTexts() 12: let prefix = '' 13: if (process.env.USER_TYPE === 'ant' && isUndercover()) { 14: prefix = getUndercoverInstructions() + '\n' 15: } 16: return `${prefix}## Context 17: - Current git status: !\`git status\` 18: - Current git diff (staged and unstaged changes): !\`git diff HEAD\` 19: - Current branch: !\`git branch --show-current\` 20: - Recent commits: !\`git log --oneline -10\` 21: ## Git Safety Protocol 22: - NEVER update the git config 23: - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it 24: - CRITICAL: ALWAYS create NEW commits. NEVER use git commit --amend, unless the user explicitly requests it 25: - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files 26: - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit 27: - Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported 28: ## Your task 29: Based on the above changes, create a single git commit: 30: 1. Analyze all staged changes and draft a commit message: 31: - Look at the recent commits above to follow this repository's commit message style 32: - Summarize the nature of the changes (new feature, enhancement, bug fix, refactoring, test, docs, etc.) 33: - Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.) 34: - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what" 35: 2. Stage relevant files and create the commit using HEREDOC syntax: 36: \`\`\` 37: git commit -m "$(cat <<'EOF' 38: Commit message here.${commitAttribution ? `\n\n${commitAttribution}` : ''} 39: EOF 40: )" 41: \`\`\` 42: You have the capability to call multiple tools in a single response. Stage and create the commit using a single message. Do not use any other tools or do anything else. Do not send any other text or messages besides these tool calls.` 43: } 44: const command = { 45: type: 'prompt', 46: name: 'commit', 47: description: 'Create a git commit', 48: allowedTools: ALLOWED_TOOLS, 49: contentLength: 0, 50: progressMessage: 'creating commit', 51: source: 'builtin', 52: async getPromptForCommand(_args, context) { 53: const promptContent = getPromptContent() 54: const finalContent = await executeShellCommandsInPrompt( 55: promptContent, 56: { 57: ...context, 58: getAppState() { 59: const appState = context.getAppState() 60: return { 61: ...appState, 62: toolPermissionContext: { 63: ...appState.toolPermissionContext, 64: alwaysAllowRules: { 65: ...appState.toolPermissionContext.alwaysAllowRules, 66: command: ALLOWED_TOOLS, 67: }, 68: }, 69: } 70: }, 71: }, 72: '/commit', 73: ) 74: return [{ type: 'text', text: finalContent }] 75: }, 76: } satisfies Command 77: export default command

File: src/commands/createMovedToPluginCommand.ts

typescript 1: import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js' 2: import type { Command } from '../commands.js' 3: import type { ToolUseContext } from '../Tool.js' 4: type Options = { 5: name: string 6: description: string 7: progressMessage: string 8: pluginName: string 9: pluginCommand: string 10: getPromptWhileMarketplaceIsPrivate: ( 11: args: string, 12: context: ToolUseContext, 13: ) => Promise<ContentBlockParam[]> 14: } 15: export function createMovedToPluginCommand({ 16: name, 17: description, 18: progressMessage, 19: pluginName, 20: pluginCommand, 21: getPromptWhileMarketplaceIsPrivate, 22: }: Options): Command { 23: return { 24: type: 'prompt', 25: name, 26: description, 27: progressMessage, 28: contentLength: 0, 29: userFacingName() { 30: return name 31: }, 32: source: 'builtin', 33: async getPromptForCommand( 34: args: string, 35: context: ToolUseContext, 36: ): Promise<ContentBlockParam[]> { 37: if (process.env.USER_TYPE === 'ant') { 38: return [ 39: { 40: type: 'text', 41: text: `This command has been moved to a plugin. Tell the user: 42: 1. To install the plugin, run: 43: claude plugin install ${pluginName}@claude-code-marketplace 44: 2. After installation, use /${pluginName}:${pluginCommand} to run this command 45: 3. For more information, see: https://github.com/anthropics/claude-code-marketplace/blob/main/${pluginName}/README.md 46: Do not attempt to run the command. Simply inform the user about the plugin installation.`, 47: }, 48: ] 49: } 50: return getPromptWhileMarketplaceIsPrivate(args, context) 51: }, 52: } 53: }

File: src/commands/init-verifiers.ts

typescript 1: import type { Command } from '../commands.js' 2: const command = { 3: type: 'prompt', 4: name: 'init-verifiers', 5: description: 6: 'Create verifier skill(s) for automated verification of code changes', 7: contentLength: 0, 8: progressMessage: 'analyzing your project and creating verifier skills', 9: source: 'builtin', 10: async getPromptForCommand() { 11: return [ 12: { 13: type: 'text', 14: text: `Use the TodoWrite tool to track your progress through this multi-step task. 15: ## Goal 16: Create one or more verifier skills that can be used by the Verify agent to automatically verify code changes in this project or folder. You may create multiple verifiers if the project has different verification needs (e.g., both web UI and API endpoints). 17: **Do NOT create verifiers for unit tests or typechecking.** Those are already handled by the standard build/test workflow and don't need dedicated verifier skills. Focus on functional verification: web UI (Playwright), CLI (Tmux), and API (HTTP) verifiers. 18: ## Phase 1: Auto-Detection 19: Analyze the project to detect what's in different subdirectories. The project may contain multiple sub-projects or areas that need different verification approaches (e.g., a web frontend, an API backend, and shared libraries all in one repo). 20: 1. **Scan top-level directories** to identify distinct project areas: 21: - Look for separate package.json, Cargo.toml, pyproject.toml, go.mod in subdirectories 22: - Identify distinct application types in different folders 23: 2. **For each area, detect:** 24: a. **Project type and stack** 25: - Primary language(s) and frameworks 26: - Package managers (npm, yarn, pnpm, pip, cargo, etc.) 27: b. **Application type** 28: - Web app (React, Next.js, Vue, etc.) → suggest Playwright-based verifier 29: - CLI tool → suggest Tmux-based verifier 30: - API service (Express, FastAPI, etc.) → suggest HTTP-based verifier 31: c. **Existing verification tools** 32: - Test frameworks (Jest, Vitest, pytest, etc.) 33: - E2E tools (Playwright, Cypress, etc.) 34: - Dev server scripts in package.json 35: d. **Dev server configuration** 36: - How to start the dev server 37: - What URL it runs on 38: - What text indicates it's ready 39: 3. **Installed verification packages** (for web apps) 40: - Check if Playwright is installed (look in package.json dependencies/devDependencies) 41: - Check MCP configuration (.mcp.json) for browser automation tools: 42: - Playwright MCP server 43: - Chrome DevTools MCP server 44: - Claude Chrome Extension MCP (browser-use via Claude's Chrome extension) 45: - For Python projects, check for playwright, pytest-playwright 46: ## Phase 2: Verification Tool Setup 47: Based on what was detected in Phase 1, help the user set up appropriate verification tools. 48: ### For Web Applications 49: 1. **If browser automation tools are already installed/configured**, ask the user which one they want to use: 50: - Use AskUserQuestion to present the detected options 51: - Example: "I found Playwright and Chrome DevTools MCP configured. Which would you like to use for verification?" 52: 2. **If NO browser automation tools are detected**, ask if they want to install/configure one: 53: - Use AskUserQuestion: "No browser automation tools detected. Would you like to set one up for UI verification?" 54: - Options to offer: 55: - **Playwright** (Recommended) - Full browser automation library, works headless, great for CI 56: - **Chrome DevTools MCP** - Uses Chrome DevTools Protocol via MCP 57: - **Claude Chrome Extension** - Uses the Claude Chrome extension for browser interaction (requires the extension installed in Chrome) 58: - **None** - Skip browser automation (will use basic HTTP checks only) 59: 3. **If user chooses to install Playwright**, run the appropriate command based on package manager: 60: - For npm: \`npm install -D @playwright/test && npx playwright install\` 61: - For yarn: \`yarn add -D @playwright/test && yarn playwright install\` 62: - For pnpm: \`pnpm add -D @playwright/test && pnpm exec playwright install\` 63: - For bun: \`bun add -D @playwright/test && bun playwright install\` 64: 4. **If user chooses Chrome DevTools MCP or Claude Chrome Extension**: 65: - These require MCP server configuration rather than package installation 66: - Ask if they want you to add the MCP server configuration to .mcp.json 67: - For Claude Chrome Extension, inform them they need the extension installed from the Chrome Web Store 68: 5. **MCP Server Setup** (if applicable): 69: - If user selected an MCP-based option, configure the appropriate entry in .mcp.json 70: - Update the verifier skill's allowed-tools to use the appropriate mcp__* tools 71: ### For CLI Tools 72: 1. Check if asciinema is available (run \`which asciinema\`) 73: 2. If not available, inform the user that asciinema can help record verification sessions but is optional 74: 3. Tmux is typically system-installed, just verify it's available 75: ### For API Services 76: 1. Check if HTTP testing tools are available: 77: - curl (usually system-installed) 78: - httpie (\`http\` command) 79: 2. No installation typically needed 80: ## Phase 3: Interactive Q&A 81: Based on the areas detected in Phase 1, you may need to create multiple verifiers. For each distinct area, use the AskUserQuestion tool to confirm: 82: 1. **Verifier name** - Based on detection, suggest a name but let user choose: 83: If there is only ONE project area, use the simple format: 84: - "verifier-playwright" for web UI testing 85: - "verifier-cli" for CLI/terminal testing 86: - "verifier-api" for HTTP API testing 87: If there are MULTIPLE project areas, use the format \`verifier-<project>-<type>\`: 88: - "verifier-frontend-playwright" for the frontend web UI 89: - "verifier-backend-api" for the backend API 90: - "verifier-admin-playwright" for an admin dashboard 91: The \`<project>\` portion should be a short identifier for the subdirectory or project area (e.g., the folder name or package name). 92: Custom names are allowed but MUST include "verifier" in the name — the Verify agent discovers skills by looking for "verifier" in the folder name. 93: 2. **Project-specific questions** based on type: 94: For web apps (playwright): 95: - Dev server command (e.g., "npm run dev") 96: - Dev server URL (e.g., "http://localhost:3000") 97: - Ready signal (text that appears when server is ready) 98: For CLI tools: 99: - Entry point command (e.g., "node ./cli.js" or "./target/debug/myapp") 100: - Whether to record with asciinema 101: For APIs: 102: - API server command 103: - Base URL 104: 3. **Authentication & Login** (for web apps and APIs): 105: Use AskUserQuestion to ask: "Does your app require authentication/login to access the pages or endpoints being verified?" 106: - **No authentication needed** - App is publicly accessible, no login required 107: - **Yes, login required** - App requires authentication before verification can proceed 108: - **Some pages require auth** - Mix of public and authenticated routes 109: If the user selects login required (or partial), ask follow-up questions: 110: - **Login method**: How does a user log in? 111: - Form-based login (username/password on a login page) 112: - API token/key (passed as header or query param) 113: - OAuth/SSO (redirect-based flow) 114: - Other (let user describe) 115: - **Test credentials**: What credentials should the verifier use? 116: - Ask for the login URL (e.g., "/login", "http://localhost:3000/auth") 117: - Ask for test username/email and password, or API key 118: - Note: Suggest the user use environment variables for secrets (e.g., \`TEST_USER\`, \`TEST_PASSWORD\`) rather than hardcoding 119: - **Post-login indicator**: How to confirm login succeeded? 120: - URL redirect (e.g., redirects to "/dashboard") 121: - Element appears (e.g., "Welcome" text, user avatar) 122: - Cookie/token is set 123: ## Phase 4: Generate Verifier Skill 124: **All verifier skills are created in the project root's \`.claude/skills/\` directory.** This ensures they are automatically loaded when Claude runs in the project. 125: Write the skill file to \`.claude/skills/<verifier-name>/SKILL.md\`. 126: ### Skill Template Structure 127: \`\`\`markdown 128: --- 129: name: <verifier-name> 130: description: <description based on type> 131: allowed-tools: 132: # Tools appropriate for the verifier type 133: --- 134: # <Verifier Title> 135: You are a verification executor. You receive a verification plan and execute it EXACTLY as written. 136: ## Project Context 137: <Project-specific details from detection> 138: ## Setup Instructions 139: <How to start any required services> 140: ## Authentication 141: <If auth is required, include step-by-step login instructions here> 142: <Include login URL, credential env vars, and post-login verification> 143: <If no auth needed, omit this section> 144: ## Reporting 145: Report PASS or FAIL for each step using the format specified in the verification plan. 146: ## Cleanup 147: After verification: 148: 1. Stop any dev servers started 149: 2. Close any browser sessions 150: 3. Report final summary 151: ## Self-Update 152: If verification fails because this skill's instructions are outdated (dev server command/port/ready-signal changed, etc.) — not because the feature under test is broken — or if the user corrects you mid-run, use AskUserQuestion to confirm and then Edit this SKILL.md with a minimal targeted fix. 153: \`\`\` 154: ### Allowed Tools by Type 155: **verifier-playwright**: 156: \`\`\`yaml 157: allowed-tools: 158: - Bash(npm:*) 159: - Bash(yarn:*) 160: - Bash(pnpm:*) 161: - Bash(bun:*) 162: - mcp__playwright__* 163: - Read 164: - Glob 165: - Grep 166: \`\`\` 167: **verifier-cli**: 168: \`\`\`yaml 169: allowed-tools: 170: - Tmux 171: - Bash(asciinema:*) 172: - Read 173: - Glob 174: - Grep 175: \`\`\` 176: **verifier-api**: 177: \`\`\`yaml 178: allowed-tools: 179: - Bash(curl:*) 180: - Bash(http:*) 181: - Bash(npm:*) 182: - Bash(yarn:*) 183: - Read 184: - Glob 185: - Grep 186: \`\`\` 187: ## Phase 5: Confirm Creation 188: After writing the skill file(s), inform the user: 189: 1. Where each skill was created (always in \`.claude/skills/\`) 190: 2. How the Verify agent will discover them — the folder name must contain "verifier" (case-insensitive) for automatic discovery 191: 3. That they can edit the skills to customize them 192: 4. That they can run /init-verifiers again to add more verifiers for other areas 193: 5. That the verifier will offer to self-update if it detects its own instructions are outdated (wrong dev server command, changed ready signal, etc.) 194: `, 195: }, 196: ] 197: }, 198: } satisfies Command 199: export default command

File: src/commands/init.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { Command } from '../commands.js' 3: import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js' 4: import { isEnvTruthy } from '../utils/envUtils.js' 5: const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository. 6: What to add: 7: 1. Commands that will be commonly used, such as how to build, lint, and run tests. Include the necessary commands to develop in this codebase, such as how to run a single test. 8: 2. High-level code architecture and structure so that future instances can be productive more quickly. Focus on the "big picture" architecture that requires reading multiple files to understand. 9: Usage notes: 10: - If there's already a CLAUDE.md, suggest improvements to it. 11: - When you make the initial CLAUDE.md, do not repeat yourself and do not include obvious instructions like "Provide helpful error messages to users", "Write unit tests for all new utilities", "Never include sensitive information (API keys, tokens) in code or commits". 12: - Avoid listing every component or file structure that can be easily discovered. 13: - Don't include generic development practices. 14: - If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include the important parts. 15: - If there is a README.md, make sure to include the important parts. 16: - Do not make up information such as "Common Development Tasks", "Tips for Development", "Support and Documentation" unless this is expressly included in other files that you read. 17: - Be sure to prefix the file with the following text: 18: \`\`\` 19: # CLAUDE.md 20: This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 21: \`\`\`` 22: const NEW_INIT_PROMPT = `Set up a minimal CLAUDE.md (and optionally skills and hooks) for this repo. CLAUDE.md is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it. 23: ## Phase 1: Ask what to set up 24: Use AskUserQuestion to find out what the user wants: 25: - "Which CLAUDE.md files should /init set up?" 26: Options: "Project CLAUDE.md" | "Personal CLAUDE.local.md" | "Both project + personal" 27: Description for project: "Team-shared instructions checked into source control — architecture, coding standards, common workflows." 28: Description for personal: "Your private preferences for this project (gitignored, not shared) — your role, sandbox URLs, preferred test data, workflow quirks." 29: - "Also set up skills and hooks?" 30: Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just CLAUDE.md" 31: Description for skills: "On-demand capabilities you or Claude invoke with \`/skill-name\` — good for repeatable workflows and reference knowledge." 32: Description for hooks: "Deterministic shell commands that run on tool events (e.g., format after every edit). Claude can't skip them." 33: ## Phase 2: Explore the codebase 34: Launch a subagent to survey the codebase, and ask it to read key files to understand the project: manifest files (package.json, Cargo.toml, pyproject.toml, go.mod, pom.xml, etc.), README, Makefile/build configs, CI config, existing CLAUDE.md, .claude/rules/, AGENTS.md, .cursor/rules or .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules, .mcp.json. 35: Detect: 36: - Build, test, and lint commands (especially non-standard ones) 37: - Languages, frameworks, and package manager 38: - Project structure (monorepo with workspaces, multi-module, or single project) 39: - Code style rules that differ from language defaults 40: - Non-obvious gotchas, required env vars, or workflow quirks 41: - Existing .claude/skills/ and .claude/rules/ directories 42: - Formatter configuration (prettier, biome, ruff, black, gofmt, rustfmt, or a unified format script like \`npm run format\` / \`make fmt\`) 43: - Git worktree usage: run \`git worktree list\` to check if this repo has multiple worktrees (only relevant if the user wants a personal CLAUDE.local.md) 44: Note what you could NOT figure out from code alone — these become interview questions. 45: ## Phase 3: Fill in the gaps 46: Use AskUserQuestion to gather what you still need to write good CLAUDE.md files and skills. Ask only things the code can't answer. 47: If the user chose project CLAUDE.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices. 48: If the user chose personal CLAUDE.local.md or both: ask about them, not the codebase. Do not mark any options as "recommended" — this is about their personal preferences, not best practices. Examples of questions: 49: - What's their role on the team? (e.g., "backend engineer", "data scientist", "new hire onboarding") 50: - How familiar are they with this codebase and its languages/frameworks? (so Claude can calibrate explanation depth) 51: - Do they have personal sandbox URLs, test accounts, API key paths, or local setup details Claude should know? 52: - Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project CLAUDE.md — that would check a personal reference into the team-shared file. 53: - Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end") 54: **Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a \`/verify\` skill if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**: 55: - **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file. 56: - **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys. 57: - **CLAUDE.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs". 58: **Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or a CLAUDE.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes a CLAUDE.md note. Never propose an artifact type the user didn't opt into. 59: **Show the proposal via AskUserQuestion's \`preview\` field, not as a separate text message** — the dialog overlays your output, so preceding text is hidden. The \`preview\` field renders markdown in a side-panel (like plan mode); the \`question\` field is plain-text-only. Structure it as: 60: - \`question\`: short and plain, e.g. "Does this proposal look right?" 61: - Each option gets a \`preview\` with the full proposal as markdown. The "Looks good — proceed" option's preview shows everything; per-item-drop options' previews show what remains after that drop. 62: - **Keep previews compact — the preview box truncates with no scrolling.** One line per item, no blank lines between items, no header. Example preview content: 63: • **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse 64: • **/verify skill** (on-demand) — \`make lint && make typecheck && make test\` 65: • **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done" 66: - Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all. 67: **Build the preference queue** from the accepted proposal. Each entry: {type: hook|skill|note, description, target file, any Phase-2-sourced details like the actual test/format command}. Phases 4-7 consume this queue. 68: ## Phase 4: Write CLAUDE.md (if user chose project or both) 69: Write a minimal CLAUDE.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it. 70: **Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5. 71: Include: 72: - Build/test/lint commands Claude can't guess (non-standard scripts, flags, or sequences) 73: - Code style rules that DIFFER from language defaults (e.g., "prefer type over interface") 74: - Testing instructions and quirks (e.g., "run single test with: pytest -k 'test_name'") 75: - Repo etiquette (branch naming, PR conventions, commit style) 76: - Required env vars or setup steps 77: - Non-obvious gotchas or architectural decisions 78: - Important parts from existing AI coding tool configs if they exist (AGENTS.md, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules) 79: Exclude: 80: - File-by-file structure or component lists (Claude can discover these by reading the codebase) 81: - Standard language conventions Claude already knows 82: - Generic advice ("write clean code", "handle errors") 83: - Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating CLAUDE.md 84: - Information that changes frequently — reference the source with \`@path/to/import\` so Claude always reads the current version 85: - Long tutorials or walkthroughs (move to a separate file and reference with \`@path/to/import\`, or put in a skill) 86: - Commands obvious from manifest files (e.g., standard "npm test", "cargo test", "pytest") 87: Be specific: "Use 2-space indentation in TypeScript" is better than "Format code properly." 88: Do not repeat yourself and do not make up sections like "Common Development Tasks" or "Tips for Development" — only include information expressly found in files you read. 89: Prefix the file with: 90: \`\`\` 91: # CLAUDE.md 92: This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 93: \`\`\` 94: If CLAUDE.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite. 95: For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside CLAUDE.md and can be scoped to specific file paths using \`paths\` frontmatter. 96: For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory CLAUDE.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants. 97: ## Phase 5: Write CLAUDE.local.md (if user chose personal or both) 98: Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside CLAUDE.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private. 99: **Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.local.md** (personal-level notes) — add each as a concise line. If the user chose personal-only in Phase 1, this is the sole consumer of note entries. 100: Include: 101: - The user's role and familiarity with the codebase (so Claude can calibrate explanations) 102: - Personal sandbox URLs, test accounts, or local setup details 103: - Personal workflow or communication preferences 104: Keep it short — only include what would make Claude's responses noticeably better for this user. 105: If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project CLAUDE.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically. 106: If CLAUDE.local.md already exists: read it, propose specific additions, and do not silently overwrite. 107: ## Phase 6: Suggest and create skills (if user chose "Skills + hooks" or "Skills only") 108: Skills add capabilities Claude can use on demand without bloating every session. 109: **First, consume \`skill\` entries from the Phase 3 preference queue.** Each queued skill preference becomes a SKILL.md tailored to what the user described. For each: 110: - Name it from the preference (e.g., "verify-deep", "session-report", "deploy-sandbox") 111: - Write the body using the user's own words from the interview plus whatever Phase 2 found (test commands, report format, deploy target). If the preference maps to an existing bundled skill (e.g., \`/verify\`), write a project skill that adds the user's specific constraints on top — tell the user the bundled one still exists and theirs is additive. 112: - Ask a quick follow-up if the preference is underspecified (e.g., "which test command should verify-deep run?") 113: **Then suggest additional skills** beyond the queue when you find: 114: - Reference knowledge for specific tasks (conventions, patterns, style guides for a subsystem) 115: - Repeatable workflows the user would want to trigger directly (deploy, fix an issue, release process, verify changes) 116: For each suggested skill, provide: name, one-line purpose, and why it fits this repo. 117: If \`.claude/skills/\` already exists with skills, review them first. Do not overwrite existing skills — only propose new ones that complement what is already there. 118: Create each skill at \`.claude/skills/<skill-name>/SKILL.md\`: 119: \`\`\`yaml 120: --- 121: name: <skill-name> 122: description: <what the skill does and when to use it> 123: --- 124: <Instructions for Claude> 125: \`\`\` 126: Both the user (\`/<skill-name>\`) and Claude can invoke skills by default. For workflows with side effects (e.g., \`/deploy\`, \`/fix-issue 123\`), add \`disable-model-invocation: true\` so only the user can trigger it, and use \`$ARGUMENTS\` to accept input. 127: ## Phase 7: Suggest additional optimizations 128: Tell the user you're going to suggest a few additional optimizations now that CLAUDE.md and skills (if chosen) are in place. 129: Check the environment and ask about each gap you find (use AskUserQuestion): 130: - **GitHub CLI**: Run \`which gh\` (or \`where gh\` on Windows). If it's missing AND the project uses GitHub (check \`git remote -v\` for github.com), ask the user if they want to install it. Explain that the GitHub CLI lets Claude help with commits, pull requests, issues, and code review directly. 131: - **Linting**: If Phase 2 found no lint config (no .eslintrc, ruff.toml, .golangci.yml, etc. for the project's language), ask the user if they want Claude to set up linting for this codebase. Explain that linting catches issues early and gives Claude fast feedback on its own edits. 132: - **Proposal-sourced hooks** (if user chose "Skills + hooks" or "Hooks only"): Consume \`hook\` entries from the Phase 3 preference queue. If Phase 2 found a formatter and the queue has no formatting hook, offer format-on-edit as a fallback. If the user chose "Neither" or "Skills only" in Phase 1, skip this bullet entirely. 133: For each hook preference (from the queue or the formatter fallback): 134: 1. Target file: default based on the Phase 1 CLAUDE.md choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook. 135: 2. Pick the event and matcher from the preference: 136: - "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\` 137: - "when Claude finishes" / "before I review" → \`Stop\` event (fires at the end of every turn — including read-only ones) 138: - "before running bash" → \`PreToolUse\` with matcher \`Bash\` 139: - "before committing" (literal git-commit gate) → **not a hooks.json hook.** Matchers can't filter Bash by command content, so there's no way to target only \`git commit\`. Route this to a git pre-commit hook (\`.git/hooks/pre-commit\`, husky, pre-commit framework) instead — offer to write one. If the user actually means "before I review and commit Claude's output", that's \`Stop\` — probe to disambiguate. 140: Probe if the preference is ambiguous. 141: 3. **Load the hook reference** (once per \`/init\` run, before the first hook): invoke the Skill tool with \`skill: 'update-config'\` and args starting with \`[hooks-only]\` followed by a one-line summary of what you're building — e.g., \`[hooks-only] Constructing a PostToolUse/Write|Edit format hook for .claude/settings.json using ruff\`. This loads the hooks schema and verification flow into context. Subsequent hooks reuse it — don't re-invoke. 142: 4. Follow the skill's **"Constructing a Hook"** flow: dedup check → construct for THIS project → pipe-test raw → wrap → write JSON → \`jq -e\` validate → live-proof (for \`Pre|PostToolUse\` on triggerable matchers) → cleanup → handoff. Target file and event/matcher come from steps 1–2 above. 143: Act on each "yes" before moving on. 144: ## Phase 8: Summary and next steps 145: Recap what was set up — which files were written and the key points included in each. Remind the user these files are a starting point: they should review and tweak them, and can run \`/init\` again anytime to re-scan. 146: Then tell the user that you'll be introducing a few more suggestions for optimizing their codebase and Claude Code setup based on what you found. Present these as a single, well-formatted to-do list where every item is relevant to this repo. Put the most impactful items first. 147: When building the list, work through these checks and include only what applies: 148: - If frontend code was detected (React, Vue, Svelte, etc.): \`/plugin install frontend-design@claude-plugins-official\` gives Claude design principles and component patterns so it produces polished UI; \`/plugin install playwright@claude-plugins-official\` lets Claude launch a real browser, screenshot what it built, and fix visual bugs itself. 149: - If you found gaps in Phase 7 (missing GitHub CLI, missing linting) and the user said no: list them here with a one-line reason why each helps. 150: - If tests are missing or sparse: suggest setting up a test framework so Claude can verify its own changes. 151: - To help you create skills and optimize existing skills using evals, Claude Code has an official skill-creator plugin you can install. Install it with \`/plugin install skill-creator@claude-plugins-official\`, then run \`/skill-creator <skill-name>\` to create new skills or refine any existing skill. (Always include this one.) 152: - Browse official plugins with \`/plugin\` — these bundle skills, agents, hooks, and MCP servers that you may find helpful. You can also create your own custom plugins to share them with others. (Always include this one.)` 153: const command = { 154: type: 'prompt', 155: name: 'init', 156: get description() { 157: return feature('NEW_INIT') && 158: (process.env.USER_TYPE === 'ant' || 159: isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT)) 160: ? 'Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation' 161: : 'Initialize a new CLAUDE.md file with codebase documentation' 162: }, 163: contentLength: 0, 164: progressMessage: 'analyzing your codebase', 165: source: 'builtin', 166: async getPromptForCommand() { 167: maybeMarkProjectOnboardingComplete() 168: return [ 169: { 170: type: 'text', 171: text: 172: feature('NEW_INIT') && 173: (process.env.USER_TYPE === 'ant' || 174: isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT)) 175: ? NEW_INIT_PROMPT 176: : OLD_INIT_PROMPT, 177: }, 178: ] 179: }, 180: } satisfies Command 181: export default command

File: src/commands/insights.ts

typescript 1: import { execFileSync } from 'child_process' 2: import { diffLines } from 'diff' 3: import { constants as fsConstants } from 'fs' 4: import { 5: copyFile, 6: mkdir, 7: mkdtemp, 8: readdir, 9: readFile, 10: rm, 11: unlink, 12: writeFile, 13: } from 'fs/promises' 14: import { tmpdir } from 'os' 15: import { extname, join } from 'path' 16: import type { Command } from '../commands.js' 17: import { queryWithModel } from '../services/api/claude.js' 18: import { 19: AGENT_TOOL_NAME, 20: LEGACY_AGENT_TOOL_NAME, 21: } from '../tools/AgentTool/constants.js' 22: import type { LogOption } from '../types/logs.js' 23: import { getClaudeConfigHomeDir } from '../utils/envUtils.js' 24: import { toError } from '../utils/errors.js' 25: import { execFileNoThrow } from '../utils/execFileNoThrow.js' 26: import { logError } from '../utils/log.js' 27: import { extractTextContent } from '../utils/messages.js' 28: import { getDefaultOpusModel } from '../utils/model/model.js' 29: import { 30: getProjectsDir, 31: getSessionFilesWithMtime, 32: getSessionIdFromLog, 33: loadAllLogsFromSessionFile, 34: } from '../utils/sessionStorage.js' 35: import { jsonParse, jsonStringify } from '../utils/slowOperations.js' 36: import { countCharInString } from '../utils/stringUtils.js' 37: import { asSystemPrompt } from '../utils/systemPromptType.js' 38: import { escapeXmlAttr as escapeHtml } from '../utils/xml.js' 39: function getAnalysisModel(): string { 40: return getDefaultOpusModel() 41: } 42: function getInsightsModel(): string { 43: return getDefaultOpusModel() 44: } 45: type RemoteHostInfo = { 46: name: string 47: sessionCount: number 48: } 49: const getRunningRemoteHosts: () => Promise<string[]> = 50: process.env.USER_TYPE === 'ant' 51: ? async () => { 52: const { stdout, code } = await execFileNoThrow( 53: 'coder', 54: ['list', '-o', 'json'], 55: { timeout: 30000 }, 56: ) 57: if (code !== 0) return [] 58: try { 59: const workspaces = jsonParse(stdout) as Array<{ 60: name: string 61: latest_build?: { status?: string } 62: }> 63: return workspaces 64: .filter(w => w.latest_build?.status === 'running') 65: .map(w => w.name) 66: } catch { 67: return [] 68: } 69: } 70: : async () => [] 71: const getRemoteHostSessionCount: (hs: string) => Promise<number> = 72: process.env.USER_TYPE === 'ant' 73: ? async (homespace: string) => { 74: const { stdout, code } = await execFileNoThrow( 75: 'ssh', 76: [ 77: `${homespace}.coder`, 78: 'find /root/.claude/projects -name "*.jsonl" 2>/dev/null | wc -l', 79: ], 80: { timeout: 30000 }, 81: ) 82: if (code !== 0) return 0 83: return parseInt(stdout.trim(), 10) || 0 84: } 85: : async () => 0 86: const collectFromRemoteHost: ( 87: hs: string, 88: destDir: string, 89: ) => Promise<{ copied: number; skipped: number }> = 90: process.env.USER_TYPE === 'ant' 91: ? async (homespace: string, destDir: string) => { 92: const result = { copied: 0, skipped: 0 } 93: const tempDir = await mkdtemp(join(tmpdir(), 'claude-hs-')) 94: try { 95: const scpResult = await execFileNoThrow( 96: 'scp', 97: ['-rq', `${homespace}.coder:/root/.claude/projects/`, tempDir], 98: { timeout: 300000 }, 99: ) 100: if (scpResult.code !== 0) { 101: return result 102: } 103: const projectsDir = join(tempDir, 'projects') 104: let projectDirents: Awaited<ReturnType<typeof readdir>> 105: try { 106: projectDirents = await readdir(projectsDir, { withFileTypes: true }) 107: } catch { 108: return result 109: } 110: await Promise.all( 111: projectDirents.map(async dirent => { 112: const projectName = dirent.name 113: const projectPath = join(projectsDir, projectName) 114: if (!dirent.isDirectory()) return 115: const destProjectName = `${projectName}__${homespace}` 116: const destProjectPath = join(destDir, destProjectName) 117: try { 118: await mkdir(destProjectPath, { recursive: true }) 119: } catch { 120: } 121: let files: Awaited<ReturnType<typeof readdir>> 122: try { 123: files = await readdir(projectPath, { withFileTypes: true }) 124: } catch { 125: return 126: } 127: await Promise.all( 128: files.map(async fileDirent => { 129: const fileName = fileDirent.name 130: if (!fileName.endsWith('.jsonl')) return 131: const srcFile = join(projectPath, fileName) 132: const destFile = join(destProjectPath, fileName) 133: try { 134: await copyFile(srcFile, destFile, fsConstants.COPYFILE_EXCL) 135: result.copied++ 136: } catch { 137: result.skipped++ 138: } 139: }), 140: ) 141: }), 142: ) 143: } finally { 144: try { 145: await rm(tempDir, { recursive: true, force: true }) 146: } catch { 147: } 148: } 149: return result 150: } 151: : async () => ({ copied: 0, skipped: 0 }) 152: const collectAllRemoteHostData: (destDir: string) => Promise<{ 153: hosts: RemoteHostInfo[] 154: totalCopied: number 155: totalSkipped: number 156: }> = 157: process.env.USER_TYPE === 'ant' 158: ? async (destDir: string) => { 159: const rHosts = await getRunningRemoteHosts() 160: const result: RemoteHostInfo[] = [] 161: let totalCopied = 0 162: let totalSkipped = 0 163: const hostResults = await Promise.all( 164: rHosts.map(async hs => { 165: const sessionCount = await getRemoteHostSessionCount(hs) 166: if (sessionCount > 0) { 167: const { copied, skipped } = await collectFromRemoteHost( 168: hs, 169: destDir, 170: ) 171: return { name: hs, sessionCount, copied, skipped } 172: } 173: return { name: hs, sessionCount, copied: 0, skipped: 0 } 174: }), 175: ) 176: for (const hr of hostResults) { 177: result.push({ name: hr.name, sessionCount: hr.sessionCount }) 178: totalCopied += hr.copied 179: totalSkipped += hr.skipped 180: } 181: return { hosts: result, totalCopied, totalSkipped } 182: } 183: : async () => ({ hosts: [], totalCopied: 0, totalSkipped: 0 }) 184: type SessionMeta = { 185: session_id: string 186: project_path: string 187: start_time: string 188: duration_minutes: number 189: user_message_count: number 190: assistant_message_count: number 191: tool_counts: Record<string, number> 192: languages: Record<string, number> 193: git_commits: number 194: git_pushes: number 195: input_tokens: number 196: output_tokens: number 197: first_prompt: string 198: summary?: string 199: user_interruptions: number 200: user_response_times: number[] 201: tool_errors: number 202: tool_error_categories: Record<string, number> 203: uses_task_agent: boolean 204: uses_mcp: boolean 205: uses_web_search: boolean 206: uses_web_fetch: boolean 207: lines_added: number 208: lines_removed: number 209: files_modified: number 210: message_hours: number[] 211: user_message_timestamps: string[] 212: } 213: type SessionFacets = { 214: session_id: string 215: underlying_goal: string 216: goal_categories: Record<string, number> 217: outcome: string 218: user_satisfaction_counts: Record<string, number> 219: claude_helpfulness: string 220: session_type: string 221: friction_counts: Record<string, number> 222: friction_detail: string 223: primary_success: string 224: brief_summary: string 225: user_instructions_to_claude?: string[] 226: } 227: type AggregatedData = { 228: total_sessions: number 229: total_sessions_scanned?: number 230: sessions_with_facets: number 231: date_range: { start: string; end: string } 232: total_messages: number 233: total_duration_hours: number 234: total_input_tokens: number 235: total_output_tokens: number 236: tool_counts: Record<string, number> 237: languages: Record<string, number> 238: git_commits: number 239: git_pushes: number 240: projects: Record<string, number> 241: goal_categories: Record<string, number> 242: outcomes: Record<string, number> 243: satisfaction: Record<string, number> 244: helpfulness: Record<string, number> 245: session_types: Record<string, number> 246: friction: Record<string, number> 247: success: Record<string, number> 248: session_summaries: Array<{ 249: id: string 250: date: string 251: summary: string 252: goal?: string 253: }> 254: total_interruptions: number 255: total_tool_errors: number 256: tool_error_categories: Record<string, number> 257: user_response_times: number[] 258: median_response_time: number 259: avg_response_time: number 260: sessions_using_task_agent: number 261: sessions_using_mcp: number 262: sessions_using_web_search: number 263: sessions_using_web_fetch: number 264: total_lines_added: number 265: total_lines_removed: number 266: total_files_modified: number 267: days_active: number 268: messages_per_day: number 269: message_hours: number[] 270: multi_clauding: { 271: overlap_events: number 272: sessions_involved: number 273: user_messages_during: number 274: } 275: } 276: const EXTENSION_TO_LANGUAGE: Record<string, string> = { 277: '.ts': 'TypeScript', 278: '.tsx': 'TypeScript', 279: '.js': 'JavaScript', 280: '.jsx': 'JavaScript', 281: '.py': 'Python', 282: '.rb': 'Ruby', 283: '.go': 'Go', 284: '.rs': 'Rust', 285: '.java': 'Java', 286: '.md': 'Markdown', 287: '.json': 'JSON', 288: '.yaml': 'YAML', 289: '.yml': 'YAML', 290: '.sh': 'Shell', 291: '.css': 'CSS', 292: '.html': 'HTML', 293: } 294: const LABEL_MAP: Record<string, string> = { 295: debug_investigate: 'Debug/Investigate', 296: implement_feature: 'Implement Feature', 297: fix_bug: 'Fix Bug', 298: write_script_tool: 'Write Script/Tool', 299: refactor_code: 'Refactor Code', 300: configure_system: 'Configure System', 301: create_pr_commit: 'Create PR/Commit', 302: analyze_data: 'Analyze Data', 303: understand_codebase: 'Understand Codebase', 304: write_tests: 'Write Tests', 305: write_docs: 'Write Docs', 306: deploy_infra: 'Deploy/Infra', 307: warmup_minimal: 'Cache Warmup', 308: fast_accurate_search: 'Fast/Accurate Search', 309: correct_code_edits: 'Correct Code Edits', 310: good_explanations: 'Good Explanations', 311: proactive_help: 'Proactive Help', 312: multi_file_changes: 'Multi-file Changes', 313: handled_complexity: 'Multi-file Changes', 314: good_debugging: 'Good Debugging', 315: misunderstood_request: 'Misunderstood Request', 316: wrong_approach: 'Wrong Approach', 317: buggy_code: 'Buggy Code', 318: user_rejected_action: 'User Rejected Action', 319: claude_got_blocked: 'Claude Got Blocked', 320: user_stopped_early: 'User Stopped Early', 321: wrong_file_or_location: 'Wrong File/Location', 322: excessive_changes: 'Excessive Changes', 323: slow_or_verbose: 'Slow/Verbose', 324: tool_failed: 'Tool Failed', 325: user_unclear: 'User Unclear', 326: external_issue: 'External Issue', 327: frustrated: 'Frustrated', 328: dissatisfied: 'Dissatisfied', 329: likely_satisfied: 'Likely Satisfied', 330: satisfied: 'Satisfied', 331: happy: 'Happy', 332: unsure: 'Unsure', 333: neutral: 'Neutral', 334: delighted: 'Delighted', 335: single_task: 'Single Task', 336: multi_task: 'Multi Task', 337: iterative_refinement: 'Iterative Refinement', 338: exploration: 'Exploration', 339: quick_question: 'Quick Question', 340: fully_achieved: 'Fully Achieved', 341: mostly_achieved: 'Mostly Achieved', 342: partially_achieved: 'Partially Achieved', 343: not_achieved: 'Not Achieved', 344: unclear_from_transcript: 'Unclear', 345: unhelpful: 'Unhelpful', 346: slightly_helpful: 'Slightly Helpful', 347: moderately_helpful: 'Moderately Helpful', 348: very_helpful: 'Very Helpful', 349: essential: 'Essential', 350: } 351: function getDataDir(): string { 352: return join(getClaudeConfigHomeDir(), 'usage-data') 353: } 354: function getFacetsDir(): string { 355: return join(getDataDir(), 'facets') 356: } 357: function getSessionMetaDir(): string { 358: return join(getDataDir(), 'session-meta') 359: } 360: const FACET_EXTRACTION_PROMPT = `Analyze this Claude Code session and extract structured facets. 361: CRITICAL GUIDELINES: 362: 1. **goal_categories**: Count ONLY what the USER explicitly asked for. 363: - DO NOT count Claude's autonomous codebase exploration 364: - DO NOT count work Claude decided to do on its own 365: - ONLY count when user says "can you...", "please...", "I need...", "let's..." 366: 2. **user_satisfaction_counts**: Base ONLY on explicit user signals. 367: - "Yay!", "great!", "perfect!" → happy 368: - "thanks", "looks good", "that works" → satisfied 369: - "ok, now let's..." (continuing without complaint) → likely_satisfied 370: - "that's not right", "try again" → dissatisfied 371: - "this is broken", "I give up" → frustrated 372: 3. **friction_counts**: Be specific about what went wrong. 373: - misunderstood_request: Claude interpreted incorrectly 374: - wrong_approach: Right goal, wrong solution method 375: - buggy_code: Code didn't work correctly 376: - user_rejected_action: User said no/stop to a tool call 377: - excessive_changes: Over-engineered or changed too much 378: 4. If very short or just warmup, use warmup_minimal for goal_category 379: SESSION: 380: ` 381: function getLanguageFromPath(filePath: string): string | null { 382: const ext = extname(filePath).toLowerCase() 383: return EXTENSION_TO_LANGUAGE[ext] || null 384: } 385: function extractToolStats(log: LogOption): { 386: toolCounts: Record<string, number> 387: languages: Record<string, number> 388: gitCommits: number 389: gitPushes: number 390: inputTokens: number 391: outputTokens: number 392: userInterruptions: number 393: userResponseTimes: number[] 394: toolErrors: number 395: toolErrorCategories: Record<string, number> 396: usesTaskAgent: boolean 397: usesMcp: boolean 398: usesWebSearch: boolean 399: usesWebFetch: boolean 400: linesAdded: number 401: linesRemoved: number 402: filesModified: Set<string> 403: messageHours: number[] 404: userMessageTimestamps: string[] 405: } { 406: const toolCounts: Record<string, number> = {} 407: const languages: Record<string, number> = {} 408: let gitCommits = 0 409: let gitPushes = 0 410: let inputTokens = 0 411: let outputTokens = 0 412: let userInterruptions = 0 413: const userResponseTimes: number[] = [] 414: let toolErrors = 0 415: const toolErrorCategories: Record<string, number> = {} 416: let usesTaskAgent = false 417: let linesAdded = 0 418: let linesRemoved = 0 419: const filesModified = new Set<string>() 420: const messageHours: number[] = [] 421: const userMessageTimestamps: string[] = [] 422: let usesMcp = false 423: let usesWebSearch = false 424: let usesWebFetch = false 425: let lastAssistantTimestamp: string | null = null 426: for (const msg of log.messages) { 427: const msgTimestamp = (msg as { timestamp?: string }).timestamp 428: if (msg.type === 'assistant' && msg.message) { 429: if (msgTimestamp) { 430: lastAssistantTimestamp = msgTimestamp 431: } 432: const usage = ( 433: msg.message as { 434: usage?: { input_tokens?: number; output_tokens?: number } 435: } 436: ).usage 437: if (usage) { 438: inputTokens += usage.input_tokens || 0 439: outputTokens += usage.output_tokens || 0 440: } 441: const content = msg.message.content 442: if (Array.isArray(content)) { 443: for (const block of content) { 444: if (block.type === 'tool_use' && 'name' in block) { 445: const toolName = block.name as string 446: toolCounts[toolName] = (toolCounts[toolName] || 0) + 1 447: if ( 448: toolName === AGENT_TOOL_NAME || 449: toolName === LEGACY_AGENT_TOOL_NAME 450: ) 451: usesTaskAgent = true 452: if (toolName.startsWith('mcp__')) usesMcp = true 453: if (toolName === 'WebSearch') usesWebSearch = true 454: if (toolName === 'WebFetch') usesWebFetch = true 455: const input = (block as { input?: Record<string, unknown> }).input 456: if (input) { 457: const filePath = (input.file_path as string) || '' 458: if (filePath) { 459: const lang = getLanguageFromPath(filePath) 460: if (lang) { 461: languages[lang] = (languages[lang] || 0) + 1 462: } 463: // Track files modified by Edit/Write tools 464: if (toolName === 'Edit' || toolName === 'Write') { 465: filesModified.add(filePath) 466: } 467: } 468: if (toolName === 'Edit') { 469: const oldString = (input.old_string as string) || '' 470: const newString = (input.new_string as string) || '' 471: for (const change of diffLines(oldString, newString)) { 472: if (change.added) linesAdded += change.count || 0 473: if (change.removed) linesRemoved += change.count || 0 474: } 475: } 476: // Track lines from Write tool (all added) 477: if (toolName === 'Write') { 478: const writeContent = (input.content as string) || '' 479: if (writeContent) { 480: linesAdded += countCharInString(writeContent, '\n') + 1 481: } 482: } 483: const command = (input.command as string) || '' 484: if (command.includes('git commit')) gitCommits++ 485: if (command.includes('git push')) gitPushes++ 486: } 487: } 488: } 489: } 490: } 491: if (msg.type === 'user' && msg.message) { 492: const content = msg.message.content 493: let isHumanMessage = false 494: if (typeof content === 'string' && content.trim()) { 495: isHumanMessage = true 496: } else if (Array.isArray(content)) { 497: for (const block of content) { 498: if (block.type === 'text' && 'text' in block) { 499: isHumanMessage = true 500: break 501: } 502: } 503: } 504: if (isHumanMessage) { 505: if (msgTimestamp) { 506: try { 507: const msgDate = new Date(msgTimestamp) 508: const hour = msgDate.getHours() 509: messageHours.push(hour) 510: userMessageTimestamps.push(msgTimestamp) 511: } catch { 512: } 513: } 514: if (lastAssistantTimestamp && msgTimestamp) { 515: const assistantTime = new Date(lastAssistantTimestamp).getTime() 516: const userTime = new Date(msgTimestamp).getTime() 517: const responseTimeSec = (userTime - assistantTime) / 1000 518: if (responseTimeSec > 2 && responseTimeSec < 3600) { 519: userResponseTimes.push(responseTimeSec) 520: } 521: } 522: } 523: if (Array.isArray(content)) { 524: for (const block of content) { 525: if (block.type === 'tool_result' && 'content' in block) { 526: const isError = (block as { is_error?: boolean }).is_error 527: if (isError) { 528: toolErrors++ 529: const resultContent = (block as { content?: string }).content 530: let category = 'Other' 531: if (typeof resultContent === 'string') { 532: const lowerContent = resultContent.toLowerCase() 533: if (lowerContent.includes('exit code')) { 534: category = 'Command Failed' 535: } else if ( 536: lowerContent.includes('rejected') || 537: lowerContent.includes("doesn't want") 538: ) { 539: category = 'User Rejected' 540: } else if ( 541: lowerContent.includes('string to replace not found') || 542: lowerContent.includes('no changes') 543: ) { 544: category = 'Edit Failed' 545: } else if (lowerContent.includes('modified since read')) { 546: category = 'File Changed' 547: } else if ( 548: lowerContent.includes('exceeds maximum') || 549: lowerContent.includes('too large') 550: ) { 551: category = 'File Too Large' 552: } else if ( 553: lowerContent.includes('file not found') || 554: lowerContent.includes('does not exist') 555: ) { 556: category = 'File Not Found' 557: } 558: } 559: toolErrorCategories[category] = 560: (toolErrorCategories[category] || 0) + 1 561: } 562: } 563: } 564: } 565: if (typeof content === 'string') { 566: if (content.includes('[Request interrupted by user')) { 567: userInterruptions++ 568: } 569: } else if (Array.isArray(content)) { 570: for (const block of content) { 571: if ( 572: block.type === 'text' && 573: 'text' in block && 574: (block.text as string).includes('[Request interrupted by user') 575: ) { 576: userInterruptions++ 577: break 578: } 579: } 580: } 581: } 582: } 583: return { 584: toolCounts, 585: languages, 586: gitCommits, 587: gitPushes, 588: inputTokens, 589: outputTokens, 590: userInterruptions, 591: userResponseTimes, 592: toolErrors, 593: toolErrorCategories, 594: usesTaskAgent, 595: usesMcp, 596: usesWebSearch, 597: usesWebFetch, 598: linesAdded, 599: linesRemoved, 600: filesModified, 601: messageHours, 602: userMessageTimestamps, 603: } 604: } 605: function hasValidDates(log: LogOption): boolean { 606: return ( 607: !Number.isNaN(log.created.getTime()) && 608: !Number.isNaN(log.modified.getTime()) 609: ) 610: } 611: function logToSessionMeta(log: LogOption): SessionMeta { 612: const stats = extractToolStats(log) 613: const sessionId = getSessionIdFromLog(log) || 'unknown' 614: const startTime = log.created.toISOString() 615: const durationMinutes = Math.round( 616: (log.modified.getTime() - log.created.getTime()) / 1000 / 60, 617: ) 618: let userMessageCount = 0 619: let assistantMessageCount = 0 620: for (const msg of log.messages) { 621: if (msg.type === 'assistant') assistantMessageCount++ 622: if (msg.type === 'user' && msg.message) { 623: const content = msg.message.content 624: let isHumanMessage = false 625: if (typeof content === 'string' && content.trim()) { 626: isHumanMessage = true 627: } else if (Array.isArray(content)) { 628: for (const block of content) { 629: if (block.type === 'text' && 'text' in block) { 630: isHumanMessage = true 631: break 632: } 633: } 634: } 635: if (isHumanMessage) { 636: userMessageCount++ 637: } 638: } 639: } 640: return { 641: session_id: sessionId, 642: project_path: log.projectPath || '', 643: start_time: startTime, 644: duration_minutes: durationMinutes, 645: user_message_count: userMessageCount, 646: assistant_message_count: assistantMessageCount, 647: tool_counts: stats.toolCounts, 648: languages: stats.languages, 649: git_commits: stats.gitCommits, 650: git_pushes: stats.gitPushes, 651: input_tokens: stats.inputTokens, 652: output_tokens: stats.outputTokens, 653: first_prompt: log.firstPrompt || '', 654: summary: log.summary, 655: // New stats 656: user_interruptions: stats.userInterruptions, 657: user_response_times: stats.userResponseTimes, 658: tool_errors: stats.toolErrors, 659: tool_error_categories: stats.toolErrorCategories, 660: uses_task_agent: stats.usesTaskAgent, 661: uses_mcp: stats.usesMcp, 662: uses_web_search: stats.usesWebSearch, 663: uses_web_fetch: stats.usesWebFetch, 664: // Additional stats 665: lines_added: stats.linesAdded, 666: lines_removed: stats.linesRemoved, 667: files_modified: stats.filesModified.size, 668: message_hours: stats.messageHours, 669: user_message_timestamps: stats.userMessageTimestamps, 670: } 671: } 672: /** 673: * Deduplicate conversation branches within the same session. 674: * 675: * When a session file has multiple leaf messages (from retries or branching), 676: * loadAllLogsFromSessionFile produces one LogOption per leaf. Each branch 677: * shares the same root message, so its duration overlaps with sibling 678: * branches. This keeps only the branch with the most user messages 679: * (tie-break by longest duration) per session_id. 680: */ 681: export function deduplicateSessionBranches( 682: entries: Array<{ log: LogOption; meta: SessionMeta }>, 683: ): Array<{ log: LogOption; meta: SessionMeta }> { 684: const bestBySession = new Map<string, { log: LogOption; meta: SessionMeta }>() 685: for (const entry of entries) { 686: const id = entry.meta.session_id 687: const existing = bestBySession.get(id) 688: if ( 689: !existing || 690: entry.meta.user_message_count > existing.meta.user_message_count || 691: (entry.meta.user_message_count === existing.meta.user_message_count && 692: entry.meta.duration_minutes > existing.meta.duration_minutes) 693: ) { 694: bestBySession.set(id, entry) 695: } 696: } 697: return [...bestBySession.values()] 698: } 699: function formatTranscriptForFacets(log: LogOption): string { 700: const lines: string[] = [] 701: const meta = logToSessionMeta(log) 702: lines.push(`Session: ${meta.session_id.slice(0, 8)}`) 703: lines.push(`Date: ${meta.start_time}`) 704: lines.push(`Project: ${meta.project_path}`) 705: lines.push(`Duration: ${meta.duration_minutes} min`) 706: lines.push('') 707: for (const msg of log.messages) { 708: if (msg.type === 'user' && msg.message) { 709: const content = msg.message.content 710: if (typeof content === 'string') { 711: lines.push(`[User]: ${content.slice(0, 500)}`) 712: } else if (Array.isArray(content)) { 713: for (const block of content) { 714: if (block.type === 'text' && 'text' in block) { 715: lines.push(`[User]: ${(block.text as string).slice(0, 500)}`) 716: } 717: } 718: } 719: } else if (msg.type === 'assistant' && msg.message) { 720: const content = msg.message.content 721: if (Array.isArray(content)) { 722: for (const block of content) { 723: if (block.type === 'text' && 'text' in block) { 724: lines.push(`[Assistant]: ${(block.text as string).slice(0, 300)}`) 725: } else if (block.type === 'tool_use' && 'name' in block) { 726: lines.push(`[Tool: ${block.name}]`) 727: } 728: } 729: } 730: } 731: } 732: return lines.join('\n') 733: } 734: const SUMMARIZE_CHUNK_PROMPT = `Summarize this portion of a Claude Code session transcript. Focus on: 735: 1. What the user asked for 736: 2. What Claude did (tools used, files modified) 737: 3. Any friction or issues 738: 4. The outcome 739: Keep it concise - 3-5 sentences. Preserve specific details like file names, error messages, and user feedback. 740: TRANSCRIPT CHUNK: 741: ` 742: async function summarizeTranscriptChunk(chunk: string): Promise<string> { 743: try { 744: const result = await queryWithModel({ 745: systemPrompt: asSystemPrompt([]), 746: userPrompt: SUMMARIZE_CHUNK_PROMPT + chunk, 747: signal: new AbortController().signal, 748: options: { 749: model: getAnalysisModel(), 750: querySource: 'insights', 751: agents: [], 752: isNonInteractiveSession: true, 753: hasAppendSystemPrompt: false, 754: mcpTools: [], 755: maxOutputTokensOverride: 500, 756: }, 757: }) 758: const text = extractTextContent(result.message.content) 759: return text || chunk.slice(0, 2000) 760: } catch { 761: return chunk.slice(0, 2000) 762: } 763: } 764: async function formatTranscriptWithSummarization( 765: log: LogOption, 766: ): Promise<string> { 767: const fullTranscript = formatTranscriptForFacets(log) 768: if (fullTranscript.length <= 30000) { 769: return fullTranscript 770: } 771: const CHUNK_SIZE = 25000 772: const chunks: string[] = [] 773: for (let i = 0; i < fullTranscript.length; i += CHUNK_SIZE) { 774: chunks.push(fullTranscript.slice(i, i + CHUNK_SIZE)) 775: } 776: const summaries = await Promise.all(chunks.map(summarizeTranscriptChunk)) 777: const meta = logToSessionMeta(log) 778: const header = [ 779: `Session: ${meta.session_id.slice(0, 8)}`, 780: `Date: ${meta.start_time}`, 781: `Project: ${meta.project_path}`, 782: `Duration: ${meta.duration_minutes} min`, 783: `[Long session - ${chunks.length} parts summarized]`, 784: '', 785: ].join('\n') 786: return header + summaries.join('\n\n---\n\n') 787: } 788: async function loadCachedFacets( 789: sessionId: string, 790: ): Promise<SessionFacets | null> { 791: const facetPath = join(getFacetsDir(), `${sessionId}.json`) 792: try { 793: const content = await readFile(facetPath, { encoding: 'utf-8' }) 794: const parsed: unknown = jsonParse(content) 795: if (!isValidSessionFacets(parsed)) { 796: try { 797: await unlink(facetPath) 798: } catch { 799: } 800: return null 801: } 802: return parsed 803: } catch { 804: return null 805: } 806: } 807: async function saveFacets(facets: SessionFacets): Promise<void> { 808: try { 809: await mkdir(getFacetsDir(), { recursive: true }) 810: } catch { 811: } 812: const facetPath = join(getFacetsDir(), `${facets.session_id}.json`) 813: await writeFile(facetPath, jsonStringify(facets, null, 2), { 814: encoding: 'utf-8', 815: mode: 0o600, 816: }) 817: } 818: async function loadCachedSessionMeta( 819: sessionId: string, 820: ): Promise<SessionMeta | null> { 821: const metaPath = join(getSessionMetaDir(), `${sessionId}.json`) 822: try { 823: const content = await readFile(metaPath, { encoding: 'utf-8' }) 824: return jsonParse(content) 825: } catch { 826: return null 827: } 828: } 829: async function saveSessionMeta(meta: SessionMeta): Promise<void> { 830: try { 831: await mkdir(getSessionMetaDir(), { recursive: true }) 832: } catch { 833: } 834: const metaPath = join(getSessionMetaDir(), `${meta.session_id}.json`) 835: await writeFile(metaPath, jsonStringify(meta, null, 2), { 836: encoding: 'utf-8', 837: mode: 0o600, 838: }) 839: } 840: async function extractFacetsFromAPI( 841: log: LogOption, 842: sessionId: string, 843: ): Promise<SessionFacets | null> { 844: try { 845: const transcript = await formatTranscriptWithSummarization(log) 846: const jsonPrompt = `${FACET_EXTRACTION_PROMPT}${transcript} 847: RESPOND WITH ONLY A VALID JSON OBJECT matching this schema: 848: { 849: "underlying_goal": "What the user fundamentally wanted to achieve", 850: "goal_categories": {"category_name": count, ...}, 851: "outcome": "fully_achieved|mostly_achieved|partially_achieved|not_achieved|unclear_from_transcript", 852: "user_satisfaction_counts": {"level": count, ...}, 853: "claude_helpfulness": "unhelpful|slightly_helpful|moderately_helpful|very_helpful|essential", 854: "session_type": "single_task|multi_task|iterative_refinement|exploration|quick_question", 855: "friction_counts": {"friction_type": count, ...}, 856: "friction_detail": "One sentence describing friction or empty", 857: "primary_success": "none|fast_accurate_search|correct_code_edits|good_explanations|proactive_help|multi_file_changes|good_debugging", 858: "brief_summary": "One sentence: what user wanted and whether they got it" 859: }` 860: const result = await queryWithModel({ 861: systemPrompt: asSystemPrompt([]), 862: userPrompt: jsonPrompt, 863: signal: new AbortController().signal, 864: options: { 865: model: getAnalysisModel(), 866: querySource: 'insights', 867: agents: [], 868: isNonInteractiveSession: true, 869: hasAppendSystemPrompt: false, 870: mcpTools: [], 871: maxOutputTokensOverride: 4096, 872: }, 873: }) 874: const text = extractTextContent(result.message.content) 875: const jsonMatch = text.match(/\{[\s\S]*\}/) 876: if (!jsonMatch) return null 877: const parsed: unknown = jsonParse(jsonMatch[0]) 878: if (!isValidSessionFacets(parsed)) return null 879: const facets: SessionFacets = { ...parsed, session_id: sessionId } 880: return facets 881: } catch (err) { 882: logError(new Error(`Facet extraction failed: ${toError(err).message}`)) 883: return null 884: } 885: } 886: export function detectMultiClauding( 887: sessions: Array<{ 888: session_id: string 889: user_message_timestamps: string[] 890: }>, 891: ): { 892: overlap_events: number 893: sessions_involved: number 894: user_messages_during: number 895: } { 896: const OVERLAP_WINDOW_MS = 30 * 60000 897: const allSessionMessages: Array<{ ts: number; sessionId: string }> = [] 898: for (const session of sessions) { 899: for (const timestamp of session.user_message_timestamps) { 900: try { 901: const ts = new Date(timestamp).getTime() 902: allSessionMessages.push({ ts, sessionId: session.session_id }) 903: } catch { 904: } 905: } 906: } 907: allSessionMessages.sort((a, b) => a.ts - b.ts) 908: const multiClaudeSessionPairs = new Set<string>() 909: const messagesDuringMulticlaude = new Set<string>() 910: let windowStart = 0 911: const sessionLastIndex = new Map<string, number>() 912: for (let i = 0; i < allSessionMessages.length; i++) { 913: const msg = allSessionMessages[i]! 914: while ( 915: windowStart < i && 916: msg.ts - allSessionMessages[windowStart]!.ts > OVERLAP_WINDOW_MS 917: ) { 918: const expiring = allSessionMessages[windowStart]! 919: if (sessionLastIndex.get(expiring.sessionId) === windowStart) { 920: sessionLastIndex.delete(expiring.sessionId) 921: } 922: windowStart++ 923: } 924: const prevIndex = sessionLastIndex.get(msg.sessionId) 925: if (prevIndex !== undefined) { 926: for (let j = prevIndex + 1; j < i; j++) { 927: const between = allSessionMessages[j]! 928: if (between.sessionId !== msg.sessionId) { 929: const pair = [msg.sessionId, between.sessionId].sort().join(':') 930: multiClaudeSessionPairs.add(pair) 931: messagesDuringMulticlaude.add( 932: `${allSessionMessages[prevIndex]!.ts}:${msg.sessionId}`, 933: ) 934: messagesDuringMulticlaude.add(`${between.ts}:${between.sessionId}`) 935: messagesDuringMulticlaude.add(`${msg.ts}:${msg.sessionId}`) 936: break 937: } 938: } 939: } 940: sessionLastIndex.set(msg.sessionId, i) 941: } 942: const sessionsWithOverlaps = new Set<string>() 943: for (const pair of multiClaudeSessionPairs) { 944: const [s1, s2] = pair.split(':') 945: if (s1) sessionsWithOverlaps.add(s1) 946: if (s2) sessionsWithOverlaps.add(s2) 947: } 948: return { 949: overlap_events: multiClaudeSessionPairs.size, 950: sessions_involved: sessionsWithOverlaps.size, 951: user_messages_during: messagesDuringMulticlaude.size, 952: } 953: } 954: function aggregateData( 955: sessions: SessionMeta[], 956: facets: Map<string, SessionFacets>, 957: ): AggregatedData { 958: const result: AggregatedData = { 959: total_sessions: sessions.length, 960: sessions_with_facets: facets.size, 961: date_range: { start: '', end: '' }, 962: total_messages: 0, 963: total_duration_hours: 0, 964: total_input_tokens: 0, 965: total_output_tokens: 0, 966: tool_counts: {}, 967: languages: {}, 968: git_commits: 0, 969: git_pushes: 0, 970: projects: {}, 971: goal_categories: {}, 972: outcomes: {}, 973: satisfaction: {}, 974: helpfulness: {}, 975: session_types: {}, 976: friction: {}, 977: success: {}, 978: session_summaries: [], 979: // New stats 980: total_interruptions: 0, 981: total_tool_errors: 0, 982: tool_error_categories: {}, 983: user_response_times: [], 984: median_response_time: 0, 985: avg_response_time: 0, 986: sessions_using_task_agent: 0, 987: sessions_using_mcp: 0, 988: sessions_using_web_search: 0, 989: sessions_using_web_fetch: 0, 990: // Additional stats 991: total_lines_added: 0, 992: total_lines_removed: 0, 993: total_files_modified: 0, 994: days_active: 0, 995: messages_per_day: 0, 996: message_hours: [], 997: // Multi-clauding stats (matching Python reference) 998: multi_clauding: { 999: overlap_events: 0, 1000: sessions_involved: 0, 1001: user_messages_during: 0, 1002: }, 1003: } 1004: const dates: string[] = [] 1005: const allResponseTimes: number[] = [] 1006: const allMessageHours: number[] = [] 1007: for (const session of sessions) { 1008: dates.push(session.start_time) 1009: result.total_messages += session.user_message_count 1010: result.total_duration_hours += session.duration_minutes / 60 1011: result.total_input_tokens += session.input_tokens 1012: result.total_output_tokens += session.output_tokens 1013: result.git_commits += session.git_commits 1014: result.git_pushes += session.git_pushes 1015: // New stats aggregation 1016: result.total_interruptions += session.user_interruptions 1017: result.total_tool_errors += session.tool_errors 1018: for (const [cat, count] of Object.entries(session.tool_error_categories)) { 1019: result.tool_error_categories[cat] = 1020: (result.tool_error_categories[cat] || 0) + count 1021: } 1022: allResponseTimes.push(...session.user_response_times) 1023: if (session.uses_task_agent) result.sessions_using_task_agent++ 1024: if (session.uses_mcp) result.sessions_using_mcp++ 1025: if (session.uses_web_search) result.sessions_using_web_search++ 1026: if (session.uses_web_fetch) result.sessions_using_web_fetch++ 1027: // Additional stats aggregation 1028: result.total_lines_added += session.lines_added 1029: result.total_lines_removed += session.lines_removed 1030: result.total_files_modified += session.files_modified 1031: allMessageHours.push(...session.message_hours) 1032: for (const [tool, count] of Object.entries(session.tool_counts)) { 1033: result.tool_counts[tool] = (result.tool_counts[tool] || 0) + count 1034: } 1035: for (const [lang, count] of Object.entries(session.languages)) { 1036: result.languages[lang] = (result.languages[lang] || 0) + count 1037: } 1038: if (session.project_path) { 1039: result.projects[session.project_path] = 1040: (result.projects[session.project_path] || 0) + 1 1041: } 1042: const sessionFacets = facets.get(session.session_id) 1043: if (sessionFacets) { 1044: // Goal categories 1045: for (const [cat, count] of safeEntries(sessionFacets.goal_categories)) { 1046: if (count > 0) { 1047: result.goal_categories[cat] = 1048: (result.goal_categories[cat] || 0) + count 1049: } 1050: } 1051: // Outcomes 1052: result.outcomes[sessionFacets.outcome] = 1053: (result.outcomes[sessionFacets.outcome] || 0) + 1 1054: // Satisfaction counts 1055: for (const [level, count] of safeEntries( 1056: sessionFacets.user_satisfaction_counts, 1057: )) { 1058: if (count > 0) { 1059: result.satisfaction[level] = (result.satisfaction[level] || 0) + count 1060: } 1061: } 1062: // Helpfulness 1063: result.helpfulness[sessionFacets.claude_helpfulness] = 1064: (result.helpfulness[sessionFacets.claude_helpfulness] || 0) + 1 1065: // Session types 1066: result.session_types[sessionFacets.session_type] = 1067: (result.session_types[sessionFacets.session_type] || 0) + 1 1068: // Friction counts 1069: for (const [type, count] of safeEntries(sessionFacets.friction_counts)) { 1070: if (count > 0) { 1071: result.friction[type] = (result.friction[type] || 0) + count 1072: } 1073: } 1074: // Success factors 1075: if (sessionFacets.primary_success !== 'none') { 1076: result.success[sessionFacets.primary_success] = 1077: (result.success[sessionFacets.primary_success] || 0) + 1 1078: } 1079: } 1080: if (result.session_summaries.length < 50) { 1081: result.session_summaries.push({ 1082: id: session.session_id.slice(0, 8), 1083: date: session.start_time.split('T')[0] || '', 1084: summary: session.summary || session.first_prompt.slice(0, 100), 1085: goal: sessionFacets?.underlying_goal, 1086: }) 1087: } 1088: } 1089: dates.sort() 1090: result.date_range.start = dates[0]?.split('T')[0] || '' 1091: result.date_range.end = dates[dates.length - 1]?.split('T')[0] || '' 1092: // Calculate response time stats 1093: result.user_response_times = allResponseTimes 1094: if (allResponseTimes.length > 0) { 1095: const sorted = [...allResponseTimes].sort((a, b) => a - b) 1096: result.median_response_time = sorted[Math.floor(sorted.length / 2)] || 0 1097: result.avg_response_time = 1098: allResponseTimes.reduce((a, b) => a + b, 0) / allResponseTimes.length 1099: } 1100: // Calculate days active and messages per day 1101: const uniqueDays = new Set(dates.map(d => d.split('T')[0])) 1102: result.days_active = uniqueDays.size 1103: result.messages_per_day = 1104: result.days_active > 0 1105: ? Math.round((result.total_messages / result.days_active) * 10) / 10 1106: : 0 1107: result.message_hours = allMessageHours 1108: result.multi_clauding = detectMultiClauding(sessions) 1109: return result 1110: } 1111: type InsightSection = { 1112: name: string 1113: prompt: string 1114: maxTokens: number 1115: } 1116: const INSIGHT_SECTIONS: InsightSection[] = [ 1117: { 1118: name: 'project_areas', 1119: prompt: `Analyze this Claude Code usage data and identify project areas. 1120: RESPOND WITH ONLY A VALID JSON OBJECT: 1121: { 1122: "areas": [ 1123: {"name": "Area name", "session_count": N, "description": "2-3 sentences about what was worked on and how Claude Code was used."} 1124: ] 1125: } 1126: Include 4-5 areas. Skip internal CC operations.`, 1127: maxTokens: 8192, 1128: }, 1129: { 1130: name: 'interaction_style', 1131: prompt: `Analyze this Claude Code usage data and describe the user's interaction style. 1132: RESPOND WITH ONLY A VALID JSON OBJECT: 1133: { 1134: "narrative": "2-3 paragraphs analyzing HOW the user interacts with Claude Code. Use second person 'you'. Describe patterns: iterate quickly vs detailed upfront specs? Interrupt often or let Claude run? Include specific examples. Use **bold** for key insights.", 1135: "key_pattern": "One sentence summary of most distinctive interaction style" 1136: }`, 1137: maxTokens: 8192, 1138: }, 1139: { 1140: name: 'what_works', 1141: prompt: `Analyze this Claude Code usage data and identify what's working well for this user. Use second person ("you"). 1142: RESPOND WITH ONLY A VALID JSON OBJECT: 1143: { 1144: "intro": "1 sentence of context", 1145: "impressive_workflows": [ 1146: {"title": "Short title (3-6 words)", "description": "2-3 sentences describing the impressive workflow or approach. Use 'you' not 'the user'."} 1147: ] 1148: } 1149: Include 3 impressive workflows.`, 1150: maxTokens: 8192, 1151: }, 1152: { 1153: name: 'friction_analysis', 1154: prompt: `Analyze this Claude Code usage data and identify friction points for this user. Use second person ("you"). 1155: RESPOND WITH ONLY A VALID JSON OBJECT: 1156: { 1157: "intro": "1 sentence summarizing friction patterns", 1158: "categories": [ 1159: {"category": "Concrete category name", "description": "1-2 sentences explaining this category and what could be done differently. Use 'you' not 'the user'.", "examples": ["Specific example with consequence", "Another example"]} 1160: ] 1161: } 1162: Include 3 friction categories with 2 examples each.`, 1163: maxTokens: 8192, 1164: }, 1165: { 1166: name: 'suggestions', 1167: prompt: `Analyze this Claude Code usage data and suggest improvements. 1168: ## CC FEATURES REFERENCE (pick from these for features_to_try): 1169: 1. **MCP Servers**: Connect Claude to external tools, databases, and APIs via Model Context Protocol. 1170: - How to use: Run \`claude mcp add <server-name> -- <command>\` 1171: - Good for: database queries, Slack integration, GitHub issue lookup, connecting to internal APIs 1172: 2. **Custom Skills**: Reusable prompts you define as markdown files that run with a single /command. 1173: - How to use: Create \`.claude/skills/commit/SKILL.md\` with instructions. Then type \`/commit\` to run it. 1174: - Good for: repetitive workflows - /commit, /review, /test, /deploy, /pr, or complex multi-step workflows 1175: 3. **Hooks**: Shell commands that auto-run at specific lifecycle events. 1176: - How to use: Add to \`.claude/settings.json\` under "hooks" key. 1177: - Good for: auto-formatting code, running type checks, enforcing conventions 1178: 4. **Headless Mode**: Run Claude non-interactively from scripts and CI/CD. 1179: - How to use: \`claude -p "fix lint errors" --allowedTools "Edit,Read,Bash"\` 1180: - Good for: CI/CD integration, batch code fixes, automated reviews 1181: 5. **Task Agents**: Claude spawns focused sub-agents for complex exploration or parallel work. 1182: - How to use: Claude auto-invokes when helpful, or ask "use an agent to explore X" 1183: - Good for: codebase exploration, understanding complex systems 1184: RESPOND WITH ONLY A VALID JSON OBJECT: 1185: { 1186: "claude_md_additions": [ 1187: {"addition": "A specific line or block to add to CLAUDE.md based on workflow patterns. E.g., 'Always run tests after modifying auth-related files'", "why": "1 sentence explaining why this would help based on actual sessions", "prompt_scaffold": "Instructions for where to add this in CLAUDE.md. E.g., 'Add under ## Testing section'"} 1188: ], 1189: "features_to_try": [ 1190: {"feature": "Feature name from CC FEATURES REFERENCE above", "one_liner": "What it does", "why_for_you": "Why this would help YOU based on your sessions", "example_code": "Actual command or config to copy"} 1191: ], 1192: "usage_patterns": [ 1193: {"title": "Short title", "suggestion": "1-2 sentence summary", "detail": "3-4 sentences explaining how this applies to YOUR work", "copyable_prompt": "A specific prompt to copy and try"} 1194: ] 1195: } 1196: IMPORTANT for claude_md_additions: PRIORITIZE instructions that appear MULTIPLE TIMES in the user data. If user told Claude the same thing in 2+ sessions (e.g., 'always run tests', 'use TypeScript'), that's a PRIME candidate - they shouldn't have to repeat themselves. 1197: IMPORTANT for features_to_try: Pick 2-3 from the CC FEATURES REFERENCE above. Include 2-3 items for each category.`, 1198: maxTokens: 8192, 1199: }, 1200: { 1201: name: 'on_the_horizon', 1202: prompt: `Analyze this Claude Code usage data and identify future opportunities. 1203: RESPOND WITH ONLY A VALID JSON OBJECT: 1204: { 1205: "intro": "1 sentence about evolving AI-assisted development", 1206: "opportunities": [ 1207: {"title": "Short title (4-8 words)", "whats_possible": "2-3 ambitious sentences about autonomous workflows", "how_to_try": "1-2 sentences mentioning relevant tooling", "copyable_prompt": "Detailed prompt to try"} 1208: ] 1209: } 1210: Include 3 opportunities. Think BIG - autonomous workflows, parallel agents, iterating against tests.`, 1211: maxTokens: 8192, 1212: }, 1213: ...(process.env.USER_TYPE === 'ant' 1214: ? [ 1215: { 1216: name: 'cc_team_improvements', 1217: prompt: `Analyze this Claude Code usage data and suggest product improvements for the CC team. 1218: RESPOND WITH ONLY A VALID JSON OBJECT: 1219: { 1220: "improvements": [ 1221: {"title": "Product/tooling improvement", "detail": "3-4 sentences describing the improvement", "evidence": "3-4 sentences with specific session examples"} 1222: ] 1223: } 1224: Include 2-3 improvements based on friction patterns observed.`, 1225: maxTokens: 8192, 1226: }, 1227: { 1228: name: 'model_behavior_improvements', 1229: prompt: `Analyze this Claude Code usage data and suggest model behavior improvements. 1230: RESPOND WITH ONLY A VALID JSON OBJECT: 1231: { 1232: "improvements": [ 1233: {"title": "Model behavior change", "detail": "3-4 sentences describing what the model should do differently", "evidence": "3-4 sentences with specific examples"} 1234: ] 1235: } 1236: Include 2-3 improvements based on friction patterns observed.`, 1237: maxTokens: 8192, 1238: }, 1239: ] 1240: : []), 1241: { 1242: name: 'fun_ending', 1243: prompt: `Analyze this Claude Code usage data and find a memorable moment. 1244: RESPOND WITH ONLY A VALID JSON OBJECT: 1245: { 1246: "headline": "A memorable QUALITATIVE moment from the transcripts - not a statistic. Something human, funny, or surprising.", 1247: "detail": "Brief context about when/where this happened" 1248: } 1249: Find something genuinely interesting or amusing from the session summaries.`, 1250: maxTokens: 8192, 1251: }, 1252: ] 1253: type InsightResults = { 1254: at_a_glance?: { 1255: whats_working?: string 1256: whats_hindering?: string 1257: quick_wins?: string 1258: ambitious_workflows?: string 1259: } 1260: project_areas?: { 1261: areas?: Array<{ name: string; session_count: number; description: string }> 1262: } 1263: interaction_style?: { 1264: narrative?: string 1265: key_pattern?: string 1266: } 1267: what_works?: { 1268: intro?: string 1269: impressive_workflows?: Array<{ title: string; description: string }> 1270: } 1271: friction_analysis?: { 1272: intro?: string 1273: categories?: Array<{ 1274: category: string 1275: description: string 1276: examples?: string[] 1277: }> 1278: } 1279: suggestions?: { 1280: claude_md_additions?: Array<{ 1281: addition: string 1282: why: string 1283: where?: string 1284: prompt_scaffold?: string 1285: }> 1286: features_to_try?: Array<{ 1287: feature: string 1288: one_liner: string 1289: why_for_you: string 1290: example_code?: string 1291: }> 1292: usage_patterns?: Array<{ 1293: title: string 1294: suggestion: string 1295: detail?: string 1296: copyable_prompt?: string 1297: }> 1298: } 1299: on_the_horizon?: { 1300: intro?: string 1301: opportunities?: Array<{ 1302: title: string 1303: whats_possible: string 1304: how_to_try?: string 1305: copyable_prompt?: string 1306: }> 1307: } 1308: cc_team_improvements?: { 1309: improvements?: Array<{ 1310: title: string 1311: detail: string 1312: evidence?: string 1313: }> 1314: } 1315: model_behavior_improvements?: { 1316: improvements?: Array<{ 1317: title: string 1318: detail: string 1319: evidence?: string 1320: }> 1321: } 1322: fun_ending?: { 1323: headline?: string 1324: detail?: string 1325: } 1326: } 1327: async function generateSectionInsight( 1328: section: InsightSection, 1329: dataContext: string, 1330: ): Promise<{ name: string; result: unknown }> { 1331: try { 1332: const result = await queryWithModel({ 1333: systemPrompt: asSystemPrompt([]), 1334: userPrompt: section.prompt + '\n\nDATA:\n' + dataContext, 1335: signal: new AbortController().signal, 1336: options: { 1337: model: getInsightsModel(), 1338: querySource: 'insights', 1339: agents: [], 1340: isNonInteractiveSession: true, 1341: hasAppendSystemPrompt: false, 1342: mcpTools: [], 1343: maxOutputTokensOverride: section.maxTokens, 1344: }, 1345: }) 1346: const text = extractTextContent(result.message.content) 1347: if (text) { 1348: const jsonMatch = text.match(/\{[\s\S]*\}/) 1349: if (jsonMatch) { 1350: try { 1351: return { name: section.name, result: jsonParse(jsonMatch[0]) } 1352: } catch { 1353: return { name: section.name, result: null } 1354: } 1355: } 1356: } 1357: return { name: section.name, result: null } 1358: } catch (err) { 1359: logError(new Error(`${section.name} failed: ${toError(err).message}`)) 1360: return { name: section.name, result: null } 1361: } 1362: } 1363: async function generateParallelInsights( 1364: data: AggregatedData, 1365: facets: Map<string, SessionFacets>, 1366: ): Promise<InsightResults> { 1367: const facetSummaries = Array.from(facets.values()) 1368: .slice(0, 50) 1369: .map(f => `- ${f.brief_summary} (${f.outcome}, ${f.claude_helpfulness})`) 1370: .join('\n') 1371: const frictionDetails = Array.from(facets.values()) 1372: .filter(f => f.friction_detail) 1373: .slice(0, 20) 1374: .map(f => `- ${f.friction_detail}`) 1375: .join('\n') 1376: const userInstructions = Array.from(facets.values()) 1377: .flatMap(f => f.user_instructions_to_claude || []) 1378: .slice(0, 15) 1379: .map(i => `- ${i}`) 1380: .join('\n') 1381: const dataContext = jsonStringify( 1382: { 1383: sessions: data.total_sessions, 1384: analyzed: data.sessions_with_facets, 1385: date_range: data.date_range, 1386: messages: data.total_messages, 1387: hours: Math.round(data.total_duration_hours), 1388: commits: data.git_commits, 1389: top_tools: Object.entries(data.tool_counts) 1390: .sort((a, b) => b[1] - a[1]) 1391: .slice(0, 8), 1392: top_goals: Object.entries(data.goal_categories) 1393: .sort((a, b) => b[1] - a[1]) 1394: .slice(0, 8), 1395: outcomes: data.outcomes, 1396: satisfaction: data.satisfaction, 1397: friction: data.friction, 1398: success: data.success, 1399: languages: data.languages, 1400: }, 1401: null, 1402: 2, 1403: ) 1404: const fullContext = 1405: dataContext + 1406: '\n\nSESSION SUMMARIES:\n' + 1407: facetSummaries + 1408: '\n\nFRICTION DETAILS:\n' + 1409: frictionDetails + 1410: '\n\nUSER INSTRUCTIONS TO CLAUDE:\n' + 1411: (userInstructions || 'None captured') 1412: const results = await Promise.all( 1413: INSIGHT_SECTIONS.map(section => 1414: generateSectionInsight(section, fullContext), 1415: ), 1416: ) 1417: const insights: InsightResults = {} 1418: for (const { name, result } of results) { 1419: if (result) { 1420: ;(insights as Record<string, unknown>)[name] = result 1421: } 1422: } 1423: const projectAreasText = 1424: ( 1425: insights.project_areas as { 1426: areas?: Array<{ name: string; description: string }> 1427: } 1428: )?.areas 1429: ?.map(a => `- ${a.name}: ${a.description}`) 1430: .join('\n') || '' 1431: const bigWinsText = 1432: ( 1433: insights.what_works as { 1434: impressive_workflows?: Array<{ title: string; description: string }> 1435: } 1436: )?.impressive_workflows 1437: ?.map(w => `- ${w.title}: ${w.description}`) 1438: .join('\n') || '' 1439: const frictionText = 1440: ( 1441: insights.friction_analysis as { 1442: categories?: Array<{ category: string; description: string }> 1443: } 1444: )?.categories 1445: ?.map(c => `- ${c.category}: ${c.description}`) 1446: .join('\n') || '' 1447: const featuresText = 1448: ( 1449: insights.suggestions as { 1450: features_to_try?: Array<{ feature: string; one_liner: string }> 1451: } 1452: )?.features_to_try 1453: ?.map(f => `- ${f.feature}: ${f.one_liner}`) 1454: .join('\n') || '' 1455: const patternsText = 1456: ( 1457: insights.suggestions as { 1458: usage_patterns?: Array<{ title: string; suggestion: string }> 1459: } 1460: )?.usage_patterns 1461: ?.map(p => `- ${p.title}: ${p.suggestion}`) 1462: .join('\n') || '' 1463: const horizonText = 1464: ( 1465: insights.on_the_horizon as { 1466: opportunities?: Array<{ title: string; whats_possible: string }> 1467: } 1468: )?.opportunities 1469: ?.map(o => `- ${o.title}: ${o.whats_possible}`) 1470: .join('\n') || '' 1471: // Now generate "At a Glance" with access to other sections' outputs 1472: const atAGlancePrompt = `You're writing an "At a Glance" summary for a Claude Code usage insights report for Claude Code users. The goal is to help them understand their usage and improve how they can use Claude better, especially as models improve. 1473: Use this 4-part structure: 1474: 1. **What's working** - What is the user's unique style of interacting with Claude and what are some impactful things they've done? You can include one or two details, but keep it high level since things might not be fresh in the user's memory. Don't be fluffy or overly complimentary. Also, don't focus on the tool calls they use. 1475: 2. **What's hindering you** - Split into (a) Claude's fault (misunderstandings, wrong approaches, bugs) and (b) user-side friction (not providing enough context, environment issues -- ideally more general than just one project). Be honest but constructive. 1476: 3. **Quick wins to try** - Specific Claude Code features they could try from the examples below, or a workflow technique if you think it's really compelling. (Avoid stuff like "Ask Claude to confirm before taking actions" or "Type out more context up front" which are less compelling.) 1477: 4. **Ambitious workflows for better models** - As we move to much more capable models over the next 3-6 months, what should they prepare for? What workflows that seem impossible now will become possible? Draw from the appropriate section below. 1478: Keep each section to 2-3 not-too-long sentences. Don't overwhelm the user. Don't mention specific numerical stats or underlined_categories from the session data below. Use a coaching tone. 1479: RESPOND WITH ONLY A VALID JSON OBJECT: 1480: { 1481: "whats_working": "(refer to instructions above)", 1482: "whats_hindering": "(refer to instructions above)", 1483: "quick_wins": "(refer to instructions above)", 1484: "ambitious_workflows": "(refer to instructions above)" 1485: } 1486: SESSION DATA: 1487: ${fullContext} 1488: ## Project Areas (what user works on) 1489: ${projectAreasText} 1490: ## Big Wins (impressive accomplishments) 1491: ${bigWinsText} 1492: ## Friction Categories (where things go wrong) 1493: ${frictionText} 1494: ## Features to Try 1495: ${featuresText} 1496: ## Usage Patterns to Adopt 1497: ${patternsText} 1498: ## On the Horizon (ambitious workflows for better models) 1499: ${horizonText}` 1500: const atAGlanceSection: InsightSection = { 1501: name: 'at_a_glance', 1502: prompt: atAGlancePrompt, 1503: maxTokens: 8192, 1504: } 1505: const atAGlanceResult = await generateSectionInsight(atAGlanceSection, '') 1506: if (atAGlanceResult.result) { 1507: insights.at_a_glance = atAGlanceResult.result as { 1508: whats_working?: string 1509: whats_hindering?: string 1510: quick_wins?: string 1511: ambitious_workflows?: string 1512: } 1513: } 1514: return insights 1515: } 1516: // Escape HTML but render **bold** as <strong> 1517: function escapeHtmlWithBold(text: string): string { 1518: const escaped = escapeHtml(text) 1519: return escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') 1520: } 1521: // Fixed orderings for specific charts (matching Python reference) 1522: const SATISFACTION_ORDER = [ 1523: 'frustrated', 1524: 'dissatisfied', 1525: 'likely_satisfied', 1526: 'satisfied', 1527: 'happy', 1528: 'unsure', 1529: ] 1530: const OUTCOME_ORDER = [ 1531: 'not_achieved', 1532: 'partially_achieved', 1533: 'mostly_achieved', 1534: 'fully_achieved', 1535: 'unclear_from_transcript', 1536: ] 1537: function generateBarChart( 1538: data: Record<string, number>, 1539: color: string, 1540: maxItems = 6, 1541: fixedOrder?: string[], 1542: ): string { 1543: let entries: [string, number][] 1544: if (fixedOrder) { 1545: entries = fixedOrder 1546: .filter(key => key in data && (data[key] ?? 0) > 0) 1547: .map(key => [key, data[key] ?? 0] as [string, number]) 1548: } else { 1549: entries = Object.entries(data) 1550: .sort((a, b) => b[1] - a[1]) 1551: .slice(0, maxItems) 1552: } 1553: if (entries.length === 0) return '<p class="empty">No data</p>' 1554: const maxVal = Math.max(...entries.map(e => e[1])) 1555: return entries 1556: .map(([label, count]) => { 1557: const pct = (count / maxVal) * 100 1558: const cleanLabel = 1559: LABEL_MAP[label] || 1560: label.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) 1561: return `<div class="bar-row"> 1562: <div class="bar-label">${escapeHtml(cleanLabel)}</div> 1563: <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div> 1564: <div class="bar-value">${count}</div> 1565: </div>` 1566: }) 1567: .join('\n') 1568: } 1569: function generateResponseTimeHistogram(times: number[]): string { 1570: if (times.length === 0) return '<p class="empty">No response time data</p>' 1571: const buckets: Record<string, number> = { 1572: '2-10s': 0, 1573: '10-30s': 0, 1574: '30s-1m': 0, 1575: '1-2m': 0, 1576: '2-5m': 0, 1577: '5-15m': 0, 1578: '>15m': 0, 1579: } 1580: for (const t of times) { 1581: if (t < 10) buckets['2-10s'] = (buckets['2-10s'] ?? 0) + 1 1582: else if (t < 30) buckets['10-30s'] = (buckets['10-30s'] ?? 0) + 1 1583: else if (t < 60) buckets['30s-1m'] = (buckets['30s-1m'] ?? 0) + 1 1584: else if (t < 120) buckets['1-2m'] = (buckets['1-2m'] ?? 0) + 1 1585: else if (t < 300) buckets['2-5m'] = (buckets['2-5m'] ?? 0) + 1 1586: else if (t < 900) buckets['5-15m'] = (buckets['5-15m'] ?? 0) + 1 1587: else buckets['>15m'] = (buckets['>15m'] ?? 0) + 1 1588: } 1589: const maxVal = Math.max(...Object.values(buckets)) 1590: if (maxVal === 0) return '<p class="empty">No response time data</p>' 1591: return Object.entries(buckets) 1592: .map(([label, count]) => { 1593: const pct = (count / maxVal) * 100 1594: return `<div class="bar-row"> 1595: <div class="bar-label">${label}</div> 1596: <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:#6366f1"></div></div> 1597: <div class="bar-value">${count}</div> 1598: </div>` 1599: }) 1600: .join('\n') 1601: } 1602: function generateTimeOfDayChart(messageHours: number[]): string { 1603: if (messageHours.length === 0) return '<p class="empty">No time data</p>' 1604: const periods = [ 1605: { label: 'Morning (6-12)', range: [6, 7, 8, 9, 10, 11] }, 1606: { label: 'Afternoon (12-18)', range: [12, 13, 14, 15, 16, 17] }, 1607: { label: 'Evening (18-24)', range: [18, 19, 20, 21, 22, 23] }, 1608: { label: 'Night (0-6)', range: [0, 1, 2, 3, 4, 5] }, 1609: ] 1610: const hourCounts: Record<number, number> = {} 1611: for (const h of messageHours) { 1612: hourCounts[h] = (hourCounts[h] || 0) + 1 1613: } 1614: const periodCounts = periods.map(p => ({ 1615: label: p.label, 1616: count: p.range.reduce((sum, h) => sum + (hourCounts[h] || 0), 0), 1617: })) 1618: const maxVal = Math.max(...periodCounts.map(p => p.count)) || 1 1619: const barsHtml = periodCounts 1620: .map( 1621: p => ` 1622: <div class="bar-row"> 1623: <div class="bar-label">${p.label}</div> 1624: <div class="bar-track"><div class="bar-fill" style="width:${(p.count / maxVal) * 100}%;background:#8b5cf6"></div></div> 1625: <div class="bar-value">${p.count}</div> 1626: </div>`, 1627: ) 1628: .join('\n') 1629: return `<div id="hour-histogram">${barsHtml}</div>` 1630: } 1631: function getHourCountsJson(messageHours: number[]): string { 1632: const hourCounts: Record<number, number> = {} 1633: for (const h of messageHours) { 1634: hourCounts[h] = (hourCounts[h] || 0) + 1 1635: } 1636: return jsonStringify(hourCounts) 1637: } 1638: function generateHtmlReport( 1639: data: AggregatedData, 1640: insights: InsightResults, 1641: ): string { 1642: const markdownToHtml = (md: string): string => { 1643: if (!md) return '' 1644: return md 1645: .split('\n\n') 1646: .map(p => { 1647: let html = escapeHtml(p) 1648: html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') 1649: html = html.replace(/^- /gm, '• ') 1650: html = html.replace(/\n/g, '<br>') 1651: return `<p>${html}</p>` 1652: }) 1653: .join('\n') 1654: } 1655: const atAGlance = insights.at_a_glance 1656: const atAGlanceHtml = atAGlance 1657: ? ` 1658: <div class="at-a-glance"> 1659: <div class="glance-title">At a Glance</div> 1660: <div class="glance-sections"> 1661: ${atAGlance.whats_working ? `<div class="glance-section"><strong>What's working:</strong> ${escapeHtmlWithBold(atAGlance.whats_working)} <a href="#section-wins" class="see-more">Impressive Things You Did →</a></div>` : ''} 1662: ${atAGlance.whats_hindering ? `<div class="glance-section"><strong>What's hindering you:</strong> ${escapeHtmlWithBold(atAGlance.whats_hindering)} <a href="#section-friction" class="see-more">Where Things Go Wrong →</a></div>` : ''} 1663: ${atAGlance.quick_wins ? `<div class="glance-section"><strong>Quick wins to try:</strong> ${escapeHtmlWithBold(atAGlance.quick_wins)} <a href="#section-features" class="see-more">Features to Try →</a></div>` : ''} 1664: ${atAGlance.ambitious_workflows ? `<div class="glance-section"><strong>Ambitious workflows:</strong> ${escapeHtmlWithBold(atAGlance.ambitious_workflows)} <a href="#section-horizon" class="see-more">On the Horizon →</a></div>` : ''} 1665: </div> 1666: </div> 1667: ` 1668: : '' 1669: // Build project areas section 1670: const projectAreas = insights.project_areas?.areas || [] 1671: const projectAreasHtml = 1672: projectAreas.length > 0 1673: ? ` 1674: <h2 id="section-work">What You Work On</h2> 1675: <div class="project-areas"> 1676: ${projectAreas 1677: .map( 1678: area => ` 1679: <div class="project-area"> 1680: <div class="area-header"> 1681: <span class="area-name">${escapeHtml(area.name)}</span> 1682: <span class="area-count">~${area.session_count} sessions</span> 1683: </div> 1684: <div class="area-desc">${escapeHtml(area.description)}</div> 1685: </div> 1686: `, 1687: ) 1688: .join('')} 1689: </div> 1690: ` 1691: : '' 1692: // Build interaction style section 1693: const interactionStyle = insights.interaction_style 1694: const interactionHtml = interactionStyle?.narrative 1695: ? ` 1696: <h2 id="section-usage">How You Use Claude Code</h2> 1697: <div class="narrative"> 1698: ${markdownToHtml(interactionStyle.narrative)} 1699: ${interactionStyle.key_pattern ? `<div class="key-insight"><strong>Key pattern:</strong> ${escapeHtml(interactionStyle.key_pattern)}</div>` : ''} 1700: </div> 1701: ` 1702: : '' 1703: // Build what works section 1704: const whatWorks = insights.what_works 1705: const whatWorksHtml = 1706: whatWorks?.impressive_workflows && whatWorks.impressive_workflows.length > 0 1707: ? ` 1708: <h2 id="section-wins">Impressive Things You Did</h2> 1709: ${whatWorks.intro ? `<p class="section-intro">${escapeHtml(whatWorks.intro)}</p>` : ''} 1710: <div class="big-wins"> 1711: ${whatWorks.impressive_workflows 1712: .map( 1713: wf => ` 1714: <div class="big-win"> 1715: <div class="big-win-title">${escapeHtml(wf.title || '')}</div> 1716: <div class="big-win-desc">${escapeHtml(wf.description || '')}</div> 1717: </div> 1718: `, 1719: ) 1720: .join('')} 1721: </div> 1722: ` 1723: : '' 1724: // Build friction section 1725: const frictionAnalysis = insights.friction_analysis 1726: const frictionHtml = 1727: frictionAnalysis?.categories && frictionAnalysis.categories.length > 0 1728: ? ` 1729: <h2 id="section-friction">Where Things Go Wrong</h2> 1730: ${frictionAnalysis.intro ? `<p class="section-intro">${escapeHtml(frictionAnalysis.intro)}</p>` : ''} 1731: <div class="friction-categories"> 1732: ${frictionAnalysis.categories 1733: .map( 1734: cat => ` 1735: <div class="friction-category"> 1736: <div class="friction-title">${escapeHtml(cat.category || '')}</div> 1737: <div class="friction-desc">${escapeHtml(cat.description || '')}</div> 1738: ${cat.examples ? `<ul class="friction-examples">${cat.examples.map(ex => `<li>${escapeHtml(ex)}</li>`).join('')}</ul>` : ''} 1739: </div> 1740: `, 1741: ) 1742: .join('')} 1743: </div> 1744: ` 1745: : '' 1746: // Build suggestions section 1747: const suggestions = insights.suggestions 1748: const suggestionsHtml = suggestions 1749: ? ` 1750: ${ 1751: suggestions.claude_md_additions && 1752: suggestions.claude_md_additions.length > 0 1753: ? ` 1754: <h2 id="section-features">Existing CC Features to Try</h2> 1755: <div class="claude-md-section"> 1756: <h3>Suggested CLAUDE.md Additions</h3> 1757: <p style="font-size: 12px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code to add it to your CLAUDE.md.</p> 1758: <div class="claude-md-actions"> 1759: <button class="copy-all-btn" onclick="copyAllCheckedClaudeMd()">Copy All Checked</button> 1760: </div> 1761: ${suggestions.claude_md_additions 1762: .map( 1763: (add, i) => ` 1764: <div class="claude-md-item"> 1765: <input type="checkbox" id="cmd-${i}" class="cmd-checkbox" checked data-text="${escapeHtml(add.prompt_scaffold || add.where || 'Add to CLAUDE.md')}\\n\\n${escapeHtml(add.addition)}"> 1766: <label for="cmd-${i}"> 1767: <code class="cmd-code">${escapeHtml(add.addition)}</code> 1768: <button class="copy-btn" onclick="copyCmdItem(${i})">Copy</button> 1769: </label> 1770: <div class="cmd-why">${escapeHtml(add.why)}</div> 1771: </div> 1772: `, 1773: ) 1774: .join('')} 1775: </div> 1776: ` 1777: : '' 1778: } 1779: ${ 1780: suggestions.features_to_try && suggestions.features_to_try.length > 0 1781: ? ` 1782: <p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code and it'll set it up for you.</p> 1783: <div class="features-section"> 1784: ${suggestions.features_to_try 1785: .map( 1786: feat => ` 1787: <div class="feature-card"> 1788: <div class="feature-title">${escapeHtml(feat.feature || '')}</div> 1789: <div class="feature-oneliner">${escapeHtml(feat.one_liner || '')}</div> 1790: <div class="feature-why"><strong>Why for you:</strong> ${escapeHtml(feat.why_for_you || '')}</div> 1791: ${ 1792: feat.example_code 1793: ? ` 1794: <div class="feature-examples"> 1795: <div class="feature-example"> 1796: <div class="example-code-row"> 1797: <code class="example-code">${escapeHtml(feat.example_code)}</code> 1798: <button class="copy-btn" onclick="copyText(this)">Copy</button> 1799: </div> 1800: </div> 1801: </div> 1802: ` 1803: : '' 1804: } 1805: </div> 1806: `, 1807: ) 1808: .join('')} 1809: </div> 1810: ` 1811: : '' 1812: } 1813: ${ 1814: suggestions.usage_patterns && suggestions.usage_patterns.length > 0 1815: ? ` 1816: <h2 id="section-patterns">New Ways to Use Claude Code</h2> 1817: <p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code and it'll walk you through it.</p> 1818: <div class="patterns-section"> 1819: ${suggestions.usage_patterns 1820: .map( 1821: pat => ` 1822: <div class="pattern-card"> 1823: <div class="pattern-title">${escapeHtml(pat.title || '')}</div> 1824: <div class="pattern-summary">${escapeHtml(pat.suggestion || '')}</div> 1825: ${pat.detail ? `<div class="pattern-detail">${escapeHtml(pat.detail)}</div>` : ''} 1826: ${ 1827: pat.copyable_prompt 1828: ? ` 1829: <div class="copyable-prompt-section"> 1830: <div class="prompt-label">Paste into Claude Code:</div> 1831: <div class="copyable-prompt-row"> 1832: <code class="copyable-prompt">${escapeHtml(pat.copyable_prompt)}</code> 1833: <button class="copy-btn" onclick="copyText(this)">Copy</button> 1834: </div> 1835: </div> 1836: ` 1837: : '' 1838: } 1839: </div> 1840: `, 1841: ) 1842: .join('')} 1843: </div> 1844: ` 1845: : '' 1846: } 1847: ` 1848: : '' 1849: // Build On the Horizon section 1850: const horizonData = insights.on_the_horizon 1851: const horizonHtml = 1852: horizonData?.opportunities && horizonData.opportunities.length > 0 1853: ? ` 1854: <h2 id="section-horizon">On the Horizon</h2> 1855: ${horizonData.intro ? `<p class="section-intro">${escapeHtml(horizonData.intro)}</p>` : ''} 1856: <div class="horizon-section"> 1857: ${horizonData.opportunities 1858: .map( 1859: opp => ` 1860: <div class="horizon-card"> 1861: <div class="horizon-title">${escapeHtml(opp.title || '')}</div> 1862: <div class="horizon-possible">${escapeHtml(opp.whats_possible || '')}</div> 1863: ${opp.how_to_try ? `<div class="horizon-tip"><strong>Getting started:</strong> ${escapeHtml(opp.how_to_try)}</div>` : ''} 1864: ${opp.copyable_prompt ? `<div class="pattern-prompt"><div class="prompt-label">Paste into Claude Code:</div><code>${escapeHtml(opp.copyable_prompt)}</code><button class="copy-btn" onclick="copyText(this)">Copy</button></div>` : ''} 1865: </div> 1866: `, 1867: ) 1868: .join('')} 1869: </div> 1870: ` 1871: : '' 1872: // Build Team Feedback section (collapsible, ant-only) 1873: const ccImprovements = 1874: process.env.USER_TYPE === 'ant' 1875: ? insights.cc_team_improvements?.improvements || [] 1876: : [] 1877: const modelImprovements = 1878: process.env.USER_TYPE === 'ant' 1879: ? insights.model_behavior_improvements?.improvements || [] 1880: : [] 1881: const teamFeedbackHtml = 1882: ccImprovements.length > 0 || modelImprovements.length > 0 1883: ? ` 1884: <h2 id="section-feedback" class="feedback-header">Closing the Loop: Feedback for Other Teams</h2> 1885: <p class="feedback-intro">Suggestions for the CC product and model teams based on your usage patterns. Click to expand.</p> 1886: ${ 1887: ccImprovements.length > 0 1888: ? ` 1889: <div class="collapsible-section"> 1890: <div class="collapsible-header" onclick="toggleCollapsible(this)"> 1891: <span class="collapsible-arrow">▶</span> 1892: <h3>Product Improvements for CC Team</h3> 1893: </div> 1894: <div class="collapsible-content"> 1895: <div class="suggestions-section"> 1896: ${ccImprovements 1897: .map( 1898: imp => ` 1899: <div class="feedback-card team-card"> 1900: <div class="feedback-title">${escapeHtml(imp.title || '')}</div> 1901: <div class="feedback-detail">${escapeHtml(imp.detail || '')}</div> 1902: ${imp.evidence ? `<div class="feedback-evidence"><em>Evidence:</em> ${escapeHtml(imp.evidence)}</div>` : ''} 1903: </div> 1904: `, 1905: ) 1906: .join('')} 1907: </div> 1908: </div> 1909: </div> 1910: ` 1911: : '' 1912: } 1913: ${ 1914: modelImprovements.length > 0 1915: ? ` 1916: <div class="collapsible-section"> 1917: <div class="collapsible-header" onclick="toggleCollapsible(this)"> 1918: <span class="collapsible-arrow">▶</span> 1919: <h3>Model Behavior Improvements</h3> 1920: </div> 1921: <div class="collapsible-content"> 1922: <div class="suggestions-section"> 1923: ${modelImprovements 1924: .map( 1925: imp => ` 1926: <div class="feedback-card model-card"> 1927: <div class="feedback-title">${escapeHtml(imp.title || '')}</div> 1928: <div class="feedback-detail">${escapeHtml(imp.detail || '')}</div> 1929: ${imp.evidence ? `<div class="feedback-evidence"><em>Evidence:</em> ${escapeHtml(imp.evidence)}</div>` : ''} 1930: </div> 1931: `, 1932: ) 1933: .join('')} 1934: </div> 1935: </div> 1936: </div> 1937: ` 1938: : '' 1939: } 1940: ` 1941: : '' 1942: // Build Fun Ending section 1943: const funEnding = insights.fun_ending 1944: const funEndingHtml = funEnding?.headline 1945: ? ` 1946: <div class="fun-ending"> 1947: <div class="fun-headline">"${escapeHtml(funEnding.headline)}"</div> 1948: ${funEnding.detail ? `<div class="fun-detail">${escapeHtml(funEnding.detail)}</div>` : ''} 1949: </div> 1950: ` 1951: : '' 1952: const css = ` 1953: * { box-sizing: border-box; margin: 0; padding: 0; } 1954: body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: #f8fafc; color: #334155; line-height: 1.65; padding: 48px 24px; } 1955: .container { max-width: 800px; margin: 0 auto; } 1956: h1 { font-size: 32px; font-weight: 700; color: #0f172a; margin-bottom: 8px; } 1957: h2 { font-size: 20px; font-weight: 600; color: #0f172a; margin-top: 48px; margin-bottom: 16px; } 1958: .subtitle { color: #64748b; font-size: 15px; margin-bottom: 32px; } 1959: .nav-toc { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 32px 0; padding: 16px; background: white; border-radius: 8px; border: 1px solid #e2e8f0; } 1960: .nav-toc a { font-size: 12px; color: #64748b; text-decoration: none; padding: 6px 12px; border-radius: 6px; background: #f1f5f9; transition: all 0.15s; } 1961: .nav-toc a:hover { background: #e2e8f0; color: #334155; } 1962: .stats-row { display: flex; gap: 24px; margin-bottom: 40px; padding: 20px 0; border-top: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0; flex-wrap: wrap; } 1963: .stat { text-align: center; } 1964: .stat-value { font-size: 24px; font-weight: 700; color: #0f172a; } 1965: .stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; } 1966: .at-a-glance { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid #f59e0b; border-radius: 12px; padding: 20px 24px; margin-bottom: 32px; } 1967: .glance-title { font-size: 16px; font-weight: 700; color: #92400e; margin-bottom: 16px; } 1968: .glance-sections { display: flex; flex-direction: column; gap: 12px; } 1969: .glance-section { font-size: 14px; color: #78350f; line-height: 1.6; } 1970: .glance-section strong { color: #92400e; } 1971: .see-more { color: #b45309; text-decoration: none; font-size: 13px; white-space: nowrap; } 1972: .see-more:hover { text-decoration: underline; } 1973: .project-areas { display: flex; flex-direction: column; gap: 12px; margin-bottom: 32px; } 1974: .project-area { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; } 1975: .area-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } 1976: .area-name { font-weight: 600; font-size: 15px; color: #0f172a; } 1977: .area-count { font-size: 12px; color: #64748b; background: #f1f5f9; padding: 2px 8px; border-radius: 4px; } 1978: .area-desc { font-size: 14px; color: #475569; line-height: 1.5; } 1979: .narrative { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin-bottom: 24px; } 1980: .narrative p { margin-bottom: 12px; font-size: 14px; color: #475569; line-height: 1.7; } 1981: .key-insight { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 12px 16px; margin-top: 12px; font-size: 14px; color: #166534; } 1982: .section-intro { font-size: 14px; color: #64748b; margin-bottom: 16px; } 1983: .big-wins { display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; } 1984: .big-win { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 16px; } 1985: .big-win-title { font-weight: 600; font-size: 15px; color: #166534; margin-bottom: 8px; } 1986: .big-win-desc { font-size: 14px; color: #15803d; line-height: 1.5; } 1987: .friction-categories { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; } 1988: .friction-category { background: #fef2f2; border: 1px solid #fca5a5; border-radius: 8px; padding: 16px; } 1989: .friction-title { font-weight: 600; font-size: 15px; color: #991b1b; margin-bottom: 6px; } 1990: .friction-desc { font-size: 13px; color: #7f1d1d; margin-bottom: 10px; } 1991: .friction-examples { margin: 0 0 0 20px; font-size: 13px; color: #334155; } 1992: .friction-examples li { margin-bottom: 4px; } 1993: .claude-md-section { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 16px; margin-bottom: 20px; } 1994: .claude-md-section h3 { font-size: 14px; font-weight: 600; color: #1e40af; margin: 0 0 12px 0; } 1995: .claude-md-actions { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #dbeafe; } 1996: .copy-all-btn { background: #2563eb; color: white; border: none; border-radius: 4px; padding: 6px 12px; font-size: 12px; cursor: pointer; font-weight: 500; transition: all 0.2s; } 1997: .copy-all-btn:hover { background: #1d4ed8; } 1998: .copy-all-btn.copied { background: #16a34a; } 1999: .claude-md-item { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; padding: 10px 0; border-bottom: 1px solid #dbeafe; } 2000: .claude-md-item:last-child { border-bottom: none; } 2001: .cmd-checkbox { margin-top: 2px; } 2002: .cmd-code { background: white; padding: 8px 12px; border-radius: 4px; font-size: 12px; color: #1e40af; border: 1px solid #bfdbfe; font-family: monospace; display: block; white-space: pre-wrap; word-break: break-word; flex: 1; } 2003: .cmd-why { font-size: 12px; color: #64748b; width: 100%; padding-left: 24px; margin-top: 4px; } 2004: .features-section, .patterns-section { display: flex; flex-direction: column; gap: 12px; margin: 16px 0; } 2005: .feature-card { background: #f0fdf4; border: 1px solid #86efac; border-radius: 8px; padding: 16px; } 2006: .pattern-card { background: #f0f9ff; border: 1px solid #7dd3fc; border-radius: 8px; padding: 16px; } 2007: .feature-title, .pattern-title { font-weight: 600; font-size: 15px; color: #0f172a; margin-bottom: 6px; } 2008: .feature-oneliner { font-size: 14px; color: #475569; margin-bottom: 8px; } 2009: .pattern-summary { font-size: 14px; color: #475569; margin-bottom: 8px; } 2010: .feature-why, .pattern-detail { font-size: 13px; color: #334155; line-height: 1.5; } 2011: .feature-examples { margin-top: 12px; } 2012: .feature-example { padding: 8px 0; border-top: 1px solid #d1fae5; } 2013: .feature-example:first-child { border-top: none; } 2014: .example-desc { font-size: 13px; color: #334155; margin-bottom: 6px; } 2015: .example-code-row { display: flex; align-items: flex-start; gap: 8px; } 2016: .example-code { flex: 1; background: #f1f5f9; padding: 8px 12px; border-radius: 4px; font-family: monospace; font-size: 12px; color: #334155; overflow-x: auto; white-space: pre-wrap; } 2017: .copyable-prompt-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e2e8f0; } 2018: .copyable-prompt-row { display: flex; align-items: flex-start; gap: 8px; } 2019: .copyable-prompt { flex: 1; background: #f8fafc; padding: 10px 12px; border-radius: 4px; font-family: monospace; font-size: 12px; color: #334155; border: 1px solid #e2e8f0; white-space: pre-wrap; line-height: 1.5; } 2020: .feature-code { background: #f8fafc; padding: 12px; border-radius: 6px; margin-top: 12px; border: 1px solid #e2e8f0; display: flex; align-items: flex-start; gap: 8px; } 2021: .feature-code code { flex: 1; font-family: monospace; font-size: 12px; color: #334155; white-space: pre-wrap; } 2022: .pattern-prompt { background: #f8fafc; padding: 12px; border-radius: 6px; margin-top: 12px; border: 1px solid #e2e8f0; } 2023: .pattern-prompt code { font-family: monospace; font-size: 12px; color: #334155; display: block; white-space: pre-wrap; margin-bottom: 8px; } 2024: .prompt-label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: #64748b; margin-bottom: 6px; } 2025: .copy-btn { background: #e2e8f0; border: none; border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; color: #475569; flex-shrink: 0; } 2026: .copy-btn:hover { background: #cbd5e1; } 2027: .charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 24px 0; } 2028: .chart-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; } 2029: .chart-title { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; margin-bottom: 12px; } 2030: .bar-row { display: flex; align-items: center; margin-bottom: 6px; } 2031: .bar-label { width: 100px; font-size: 11px; color: #475569; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 2032: .bar-track { flex: 1; height: 6px; background: #f1f5f9; border-radius: 3px; margin: 0 8px; } 2033: .bar-fill { height: 100%; border-radius: 3px; } 2034: .bar-value { width: 28px; font-size: 11px; font-weight: 500; color: #64748b; text-align: right; } 2035: .empty { color: #94a3b8; font-size: 13px; } 2036: .horizon-section { display: flex; flex-direction: column; gap: 16px; } 2037: .horizon-card { background: linear-gradient(135deg, #faf5ff 0%, #f5f3ff 100%); border: 1px solid #c4b5fd; border-radius: 8px; padding: 16px; } 2038: .horizon-title { font-weight: 600; font-size: 15px; color: #5b21b6; margin-bottom: 8px; } 2039: .horizon-possible { font-size: 14px; color: #334155; margin-bottom: 10px; line-height: 1.5; } 2040: .horizon-tip { font-size: 13px; color: #6b21a8; background: rgba(255,255,255,0.6); padding: 8px 12px; border-radius: 4px; } 2041: .feedback-header { margin-top: 48px; color: #64748b; font-size: 16px; } 2042: .feedback-intro { font-size: 13px; color: #94a3b8; margin-bottom: 16px; } 2043: .feedback-section { margin-top: 16px; } 2044: .feedback-section h3 { font-size: 14px; font-weight: 600; color: #475569; margin-bottom: 12px; } 2045: .feedback-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 12px; } 2046: .feedback-card.team-card { background: #eff6ff; border-color: #bfdbfe; } 2047: .feedback-card.model-card { background: #faf5ff; border-color: #e9d5ff; } 2048: .feedback-title { font-weight: 600; font-size: 14px; color: #0f172a; margin-bottom: 6px; } 2049: .feedback-detail { font-size: 13px; color: #475569; line-height: 1.5; } 2050: .feedback-evidence { font-size: 12px; color: #64748b; margin-top: 8px; } 2051: .fun-ending { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid #fbbf24; border-radius: 12px; padding: 24px; margin-top: 40px; text-align: center; } 2052: .fun-headline { font-size: 18px; font-weight: 600; color: #78350f; margin-bottom: 8px; } 2053: .fun-detail { font-size: 14px; color: #92400e; } 2054: .collapsible-section { margin-top: 16px; } 2055: .collapsible-header { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px 0; border-bottom: 1px solid #e2e8f0; } 2056: .collapsible-header h3 { margin: 0; font-size: 14px; font-weight: 600; color: #475569; } 2057: .collapsible-arrow { font-size: 12px; color: #94a3b8; transition: transform 0.2s; } 2058: .collapsible-content { display: none; padding-top: 16px; } 2059: .collapsible-content.open { display: block; } 2060: .collapsible-header.open .collapsible-arrow { transform: rotate(90deg); } 2061: @media (max-width: 640px) { .charts-row { grid-template-columns: 1fr; } .stats-row { justify-content: center; } } 2062: ` 2063: const hourCountsJson = getHourCountsJson(data.message_hours) 2064: const js = ` 2065: function toggleCollapsible(header) { 2066: header.classList.toggle('open'); 2067: const content = header.nextElementSibling; 2068: content.classList.toggle('open'); 2069: } 2070: function copyText(btn) { 2071: const code = btn.previousElementSibling; 2072: navigator.clipboard.writeText(code.textContent).then(() => { 2073: btn.textContent = 'Copied!'; 2074: setTimeout(() => { btn.textContent = 'Copy'; }, 2000); 2075: }); 2076: } 2077: function copyCmdItem(idx) { 2078: const checkbox = document.getElementById('cmd-' + idx); 2079: if (checkbox) { 2080: const text = checkbox.dataset.text; 2081: navigator.clipboard.writeText(text).then(() => { 2082: const btn = checkbox.nextElementSibling.querySelector('.copy-btn'); 2083: if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); } 2084: }); 2085: } 2086: } 2087: function copyAllCheckedClaudeMd() { 2088: const checkboxes = document.querySelectorAll('.cmd-checkbox:checked'); 2089: const texts = []; 2090: checkboxes.forEach(cb => { 2091: if (cb.dataset.text) { texts.push(cb.dataset.text); } 2092: }); 2093: const combined = texts.join('\\n'); 2094: const btn = document.querySelector('.copy-all-btn'); 2095: if (btn) { 2096: navigator.clipboard.writeText(combined).then(() => { 2097: btn.textContent = 'Copied ' + texts.length + ' items!'; 2098: btn.classList.add('copied'); 2099: setTimeout(() => { btn.textContent = 'Copy All Checked'; btn.classList.remove('copied'); }, 2000); 2100: }); 2101: } 2102: } 2103: const rawHourCounts = ${hourCountsJson}; 2104: function updateHourHistogram(offsetFromPT) { 2105: const periods = [ 2106: { label: "Morning (6-12)", range: [6,7,8,9,10,11] }, 2107: { label: "Afternoon (12-18)", range: [12,13,14,15,16,17] }, 2108: { label: "Evening (18-24)", range: [18,19,20,21,22,23] }, 2109: { label: "Night (0-6)", range: [0,1,2,3,4,5] } 2110: ]; 2111: const adjustedCounts = {}; 2112: for (const [hour, count] of Object.entries(rawHourCounts)) { 2113: const newHour = (parseInt(hour) + offsetFromPT + 24) % 24; 2114: adjustedCounts[newHour] = (adjustedCounts[newHour] || 0) + count; 2115: } 2116: const periodCounts = periods.map(p => ({ 2117: label: p.label, 2118: count: p.range.reduce((sum, h) => sum + (adjustedCounts[h] || 0), 0) 2119: })); 2120: const maxCount = Math.max(...periodCounts.map(p => p.count)) || 1; 2121: const container = document.getElementById('hour-histogram'); 2122: container.textContent = ''; 2123: periodCounts.forEach(p => { 2124: const row = document.createElement('div'); 2125: row.className = 'bar-row'; 2126: const label = document.createElement('div'); 2127: label.className = 'bar-label'; 2128: label.textContent = p.label; 2129: const track = document.createElement('div'); 2130: track.className = 'bar-track'; 2131: const fill = document.createElement('div'); 2132: fill.className = 'bar-fill'; 2133: fill.style.width = (p.count / maxCount) * 100 + '%'; 2134: fill.style.background = '#8b5cf6'; 2135: track.appendChild(fill); 2136: const value = document.createElement('div'); 2137: value.className = 'bar-value'; 2138: value.textContent = p.count; 2139: row.appendChild(label); 2140: row.appendChild(track); 2141: row.appendChild(value); 2142: container.appendChild(row); 2143: }); 2144: } 2145: document.getElementById('timezone-select').addEventListener('change', function() { 2146: const customInput = document.getElementById('custom-offset'); 2147: if (this.value === 'custom') { 2148: customInput.style.display = 'inline-block'; 2149: customInput.focus(); 2150: } else { 2151: customInput.style.display = 'none'; 2152: updateHourHistogram(parseInt(this.value)); 2153: } 2154: }); 2155: document.getElementById('custom-offset').addEventListener('change', function() { 2156: const offset = parseInt(this.value) + 8; 2157: updateHourHistogram(offset); 2158: }); 2159: ` 2160: return `<!DOCTYPE html> 2161: <html> 2162: <head> 2163: <meta charset="utf-8"> 2164: <title>Claude Code Insights</title> 2165: <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> 2166: <style>${css}</style> 2167: </head> 2168: <body> 2169: <div class="container"> 2170: <h1>Claude Code Insights</h1> 2171: <p class="subtitle">${data.total_messages.toLocaleString()} messages across ${data.total_sessions} sessions${data.total_sessions_scanned && data.total_sessions_scanned > data.total_sessions ? ` (${data.total_sessions_scanned.toLocaleString()} total)` : ''} | ${data.date_range.start} to ${data.date_range.end}</p> 2172: ${atAGlanceHtml} 2173: <nav class="nav-toc"> 2174: <a href="#section-work">What You Work On</a> 2175: <a href="#section-usage">How You Use CC</a> 2176: <a href="#section-wins">Impressive Things</a> 2177: <a href="#section-friction">Where Things Go Wrong</a> 2178: <a href="#section-features">Features to Try</a> 2179: <a href="#section-patterns">New Usage Patterns</a> 2180: <a href="#section-horizon">On the Horizon</a> 2181: <a href="#section-feedback">Team Feedback</a> 2182: </nav> 2183: <div class="stats-row"> 2184: <div class="stat"><div class="stat-value">${data.total_messages.toLocaleString()}</div><div class="stat-label">Messages</div></div> 2185: <div class="stat"><div class="stat-value">+${data.total_lines_added.toLocaleString()}/-${data.total_lines_removed.toLocaleString()}</div><div class="stat-label">Lines</div></div> 2186: <div class="stat"><div class="stat-value">${data.total_files_modified}</div><div class="stat-label">Files</div></div> 2187: <div class="stat"><div class="stat-value">${data.days_active}</div><div class="stat-label">Days</div></div> 2188: <div class="stat"><div class="stat-value">${data.messages_per_day}</div><div class="stat-label">Msgs/Day</div></div> 2189: </div> 2190: ${projectAreasHtml} 2191: <div class="charts-row"> 2192: <div class="chart-card"> 2193: <div class="chart-title">What You Wanted</div> 2194: ${generateBarChart(data.goal_categories, '#2563eb')} 2195: </div> 2196: <div class="chart-card"> 2197: <div class="chart-title">Top Tools Used</div> 2198: ${generateBarChart(data.tool_counts, '#0891b2')} 2199: </div> 2200: </div> 2201: <div class="charts-row"> 2202: <div class="chart-card"> 2203: <div class="chart-title">Languages</div> 2204: ${generateBarChart(data.languages, '#10b981')} 2205: </div> 2206: <div class="chart-card"> 2207: <div class="chart-title">Session Types</div> 2208: ${generateBarChart(data.session_types || {}, '#8b5cf6')} 2209: </div> 2210: </div> 2211: ${interactionHtml} 2212: <!-- Response Time Distribution --> 2213: <div class="chart-card" style="margin: 24px 0;"> 2214: <div class="chart-title">User Response Time Distribution</div> 2215: ${generateResponseTimeHistogram(data.user_response_times)} 2216: <div style="font-size: 12px; color: #64748b; margin-top: 8px;"> 2217: Median: ${data.median_response_time.toFixed(1)}s &bull; Average: ${data.avg_response_time.toFixed(1)}s 2218: </div> 2219: </div> 2220: <!-- Multi-clauding Section (matching Python reference) --> 2221: <div class="chart-card" style="margin: 24px 0;"> 2222: <div class="chart-title">Multi-Clauding (Parallel Sessions)</div> 2223: ${ 2224: data.multi_clauding.overlap_events === 0 2225: ? ` 2226: <p style="font-size: 14px; color: #64748b; padding: 8px 0;"> 2227: No parallel session usage detected. You typically work with one Claude Code session at a time. 2228: </p> 2229: ` 2230: : ` 2231: <div style="display: flex; gap: 24px; margin: 12px 0;"> 2232: <div style="text-align: center;"> 2233: <div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.multi_clauding.overlap_events}</div> 2234: <div style="font-size: 11px; color: #64748b; text-transform: uppercase;">Overlap Events</div> 2235: </div> 2236: <div style="text-align: center;"> 2237: <div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.multi_clauding.sessions_involved}</div> 2238: <div style="font-size: 11px; color: #64748b; text-transform: uppercase;">Sessions Involved</div> 2239: </div> 2240: <div style="text-align: center;"> 2241: <div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.total_messages > 0 ? Math.round((100 * data.multi_clauding.user_messages_during) / data.total_messages) : 0}%</div> 2242: <div style="font-size: 11px; color: #64748b; text-transform: uppercase;">Of Messages</div> 2243: </div> 2244: </div> 2245: <p style="font-size: 13px; color: #475569; margin-top: 12px;"> 2246: You run multiple Claude Code sessions simultaneously. Multi-clauding is detected when sessions 2247: overlap in time, suggesting parallel workflows. 2248: </p> 2249: ` 2250: } 2251: </div> 2252: <!-- Time of Day & Tool Errors --> 2253: <div class="charts-row"> 2254: <div class="chart-card"> 2255: <div class="chart-title" style="display: flex; align-items: center; gap: 12px;"> 2256: User Messages by Time of Day 2257: <select id="timezone-select" style="font-size: 12px; padding: 4px 8px; border-radius: 4px; border: 1px solid #e2e8f0;"> 2258: <option value="0">PT (UTC-8)</option> 2259: <option value="3">ET (UTC-5)</option> 2260: <option value="8">London (UTC)</option> 2261: <option value="9">CET (UTC+1)</option> 2262: <option value="17">Tokyo (UTC+9)</option> 2263: <option value="custom">Custom offset...</option> 2264: </select> 2265: <input type="number" id="custom-offset" placeholder="UTC offset" style="display: none; width: 80px; font-size: 12px; padding: 4px; border-radius: 4px; border: 1px solid #e2e8f0;"> 2266: </div> 2267: ${generateTimeOfDayChart(data.message_hours)} 2268: </div> 2269: <div class="chart-card"> 2270: <div class="chart-title">Tool Errors Encountered</div> 2271: ${Object.keys(data.tool_error_categories).length > 0 ? generateBarChart(data.tool_error_categories, '#dc2626') : '<p class="empty">No tool errors</p>'} 2272: </div> 2273: </div> 2274: ${whatWorksHtml} 2275: <div class="charts-row"> 2276: <div class="chart-card"> 2277: <div class="chart-title">What Helped Most (Claude's Capabilities)</div> 2278: ${generateBarChart(data.success, '#16a34a')} 2279: </div> 2280: <div class="chart-card"> 2281: <div class="chart-title">Outcomes</div> 2282: ${generateBarChart(data.outcomes, '#8b5cf6', 6, OUTCOME_ORDER)} 2283: </div> 2284: </div> 2285: ${frictionHtml} 2286: <div class="charts-row"> 2287: <div class="chart-card"> 2288: <div class="chart-title">Primary Friction Types</div> 2289: ${generateBarChart(data.friction, '#dc2626')} 2290: </div> 2291: <div class="chart-card"> 2292: <div class="chart-title">Inferred Satisfaction (model-estimated)</div> 2293: ${generateBarChart(data.satisfaction, '#eab308', 6, SATISFACTION_ORDER)} 2294: </div> 2295: </div> 2296: ${suggestionsHtml} 2297: ${horizonHtml} 2298: ${funEndingHtml} 2299: ${teamFeedbackHtml} 2300: </div> 2301: <script>${js}</script> 2302: </body> 2303: </html>` 2304: } 2305: // ============================================================================ 2306: // Export Types & Functions 2307: // ============================================================================ 2308: /** 2309: * Structured export format for claudescope consumption 2310: */ 2311: export type InsightsExport = { 2312: metadata: { 2313: username: string 2314: generated_at: string 2315: claude_code_version: string 2316: date_range: { start: string; end: string } 2317: session_count: number 2318: remote_hosts_collected?: string[] 2319: } 2320: aggregated_data: AggregatedData 2321: insights: InsightResults 2322: facets_summary?: { 2323: total: number 2324: goal_categories: Record<string, number> 2325: outcomes: Record<string, number> 2326: satisfaction: Record<string, number> 2327: friction: Record<string, number> 2328: } 2329: } 2330: /** 2331: * Build export data from already-computed values. 2332: * Used by background upload to S3. 2333: */ 2334: export function buildExportData( 2335: data: AggregatedData, 2336: insights: InsightResults, 2337: facets: Map<string, SessionFacets>, 2338: remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number }, 2339: ): InsightsExport { 2340: const version = typeof MACRO !== 'undefined' ? MACRO.VERSION : 'unknown' 2341: const remote_hosts_collected = remoteStats?.hosts 2342: .filter(h => h.sessionCount > 0) 2343: .map(h => h.name) 2344: const facets_summary = { 2345: total: facets.size, 2346: goal_categories: {} as Record<string, number>, 2347: outcomes: {} as Record<string, number>, 2348: satisfaction: {} as Record<string, number>, 2349: friction: {} as Record<string, number>, 2350: } 2351: for (const f of facets.values()) { 2352: for (const [cat, count] of safeEntries(f.goal_categories)) { 2353: if (count > 0) { 2354: facets_summary.goal_categories[cat] = 2355: (facets_summary.goal_categories[cat] || 0) + count 2356: } 2357: } 2358: facets_summary.outcomes[f.outcome] = 2359: (facets_summary.outcomes[f.outcome] || 0) + 1 2360: for (const [level, count] of safeEntries(f.user_satisfaction_counts)) { 2361: if (count > 0) { 2362: facets_summary.satisfaction[level] = 2363: (facets_summary.satisfaction[level] || 0) + count 2364: } 2365: } 2366: for (const [type, count] of safeEntries(f.friction_counts)) { 2367: if (count > 0) { 2368: facets_summary.friction[type] = 2369: (facets_summary.friction[type] || 0) + count 2370: } 2371: } 2372: } 2373: return { 2374: metadata: { 2375: username: process.env.SAFEUSER || process.env.USER || 'unknown', 2376: generated_at: new Date().toISOString(), 2377: claude_code_version: version, 2378: date_range: data.date_range, 2379: session_count: data.total_sessions, 2380: ...(remote_hosts_collected && 2381: remote_hosts_collected.length > 0 && { 2382: remote_hosts_collected, 2383: }), 2384: }, 2385: aggregated_data: data, 2386: insights, 2387: facets_summary, 2388: } 2389: } 2390: // ============================================================================ 2391: // Lite Session Scanning 2392: // ============================================================================ 2393: type LiteSessionInfo = { 2394: sessionId: string 2395: path: string 2396: mtime: number 2397: size: number 2398: } 2399: /** 2400: * Scans all project directories using filesystem metadata only (no JSONL parsing). 2401: * Returns a list of session file info sorted by mtime descending. 2402: * Yields to the event loop between project directories to keep the UI responsive. 2403: */ 2404: async function scanAllSessions(): Promise<LiteSessionInfo[]> { 2405: const projectsDir = getProjectsDir() 2406: let dirents: Awaited<ReturnType<typeof readdir>> 2407: try { 2408: dirents = await readdir(projectsDir, { withFileTypes: true }) 2409: } catch { 2410: return [] 2411: } 2412: const projectDirs = dirents 2413: .filter(dirent => dirent.isDirectory()) 2414: .map(dirent => join(projectsDir, dirent.name)) 2415: const allSessions: LiteSessionInfo[] = [] 2416: for (let i = 0; i < projectDirs.length; i++) { 2417: const sessionFiles = await getSessionFilesWithMtime(projectDirs[i]!) 2418: for (const [sessionId, fileInfo] of sessionFiles) { 2419: allSessions.push({ 2420: sessionId, 2421: path: fileInfo.path, 2422: mtime: fileInfo.mtime, 2423: size: fileInfo.size, 2424: }) 2425: } 2426: // Yield to event loop every 10 project directories 2427: if (i % 10 === 9) { 2428: await new Promise<void>(resolve => setImmediate(resolve)) 2429: } 2430: } 2431: // Sort by mtime descending (most recent first) 2432: allSessions.sort((a, b) => b.mtime - a.mtime) 2433: return allSessions 2434: } 2435: // ============================================================================ 2436: // Main Function 2437: // ============================================================================ 2438: export async function generateUsageReport(options?: { 2439: collectRemote?: boolean 2440: }): Promise<{ 2441: insights: InsightResults 2442: htmlPath: string 2443: data: AggregatedData 2444: remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number } 2445: facets: Map<string, SessionFacets> 2446: }> { 2447: let remoteStats: { hosts: RemoteHostInfo[]; totalCopied: number } | undefined 2448: // Optionally collect data from remote hosts first (ant-only) 2449: if (process.env.USER_TYPE === 'ant' && options?.collectRemote) { 2450: const destDir = join(getClaudeConfigHomeDir(), 'projects') 2451: const { hosts, totalCopied } = await collectAllRemoteHostData(destDir) 2452: remoteStats = { hosts, totalCopied } 2453: } 2454: // Phase 1: Lite scan — filesystem metadata only (no JSONL parsing) 2455: const allScannedSessions = await scanAllSessions() 2456: const totalSessionsScanned = allScannedSessions.length 2457: // Phase 2: Load SessionMeta — use cache where available, parse only uncached 2458: // Read cached metas in parallel batches to avoid blocking the event loop 2459: const META_BATCH_SIZE = 50 2460: const MAX_SESSIONS_TO_LOAD = 200 2461: let allMetas: SessionMeta[] = [] 2462: const uncachedSessions: LiteSessionInfo[] = [] 2463: for (let i = 0; i < allScannedSessions.length; i += META_BATCH_SIZE) { 2464: const batch = allScannedSessions.slice(i, i + META_BATCH_SIZE) 2465: const results = await Promise.all( 2466: batch.map(async sessionInfo => ({ 2467: sessionInfo, 2468: cached: await loadCachedSessionMeta(sessionInfo.sessionId), 2469: })), 2470: ) 2471: for (const { sessionInfo, cached } of results) { 2472: if (cached) { 2473: allMetas.push(cached) 2474: } else if (uncachedSessions.length < MAX_SESSIONS_TO_LOAD) { 2475: uncachedSessions.push(sessionInfo) 2476: } 2477: } 2478: } 2479: // Load full message data only for uncached sessions and compute SessionMeta 2480: const logsForFacets = new Map<string, LogOption>() 2481: // Filter out /insights meta-sessions (facet extraction API calls get logged as sessions) 2482: const isMetaSession = (log: LogOption): boolean => { 2483: for (const msg of log.messages.slice(0, 5)) { 2484: if (msg.type === 'user' && msg.message) { 2485: const content = msg.message.content 2486: if (typeof content === 'string') { 2487: if ( 2488: content.includes('RESPOND WITH ONLY A VALID JSON OBJECT') || 2489: content.includes('record_facets') 2490: ) { 2491: return true 2492: } 2493: } 2494: } 2495: } 2496: return false 2497: } 2498: // Load uncached sessions in batches to yield to event loop between batches 2499: const LOAD_BATCH_SIZE = 10 2500: for (let i = 0; i < uncachedSessions.length; i += LOAD_BATCH_SIZE) { 2501: const batch = uncachedSessions.slice(i, i + LOAD_BATCH_SIZE) 2502: const batchResults = await Promise.all( 2503: batch.map(async sessionInfo => { 2504: try { 2505: return await loadAllLogsFromSessionFile(sessionInfo.path) 2506: } catch { 2507: return [] 2508: } 2509: }), 2510: ) 2511: // Collect metas synchronously, then save them in parallel (independent writes) 2512: const metasToSave: SessionMeta[] = [] 2513: for (const logs of batchResults) { 2514: for (const log of logs) { 2515: if (isMetaSession(log) || !hasValidDates(log)) continue 2516: const meta = logToSessionMeta(log) 2517: allMetas.push(meta) 2518: metasToSave.push(meta) 2519: // Keep the log around for potential facet extraction 2520: logsForFacets.set(meta.session_id, log) 2521: } 2522: } 2523: await Promise.all(metasToSave.map(meta => saveSessionMeta(meta))) 2524: } 2525: // Deduplicate session branches (keep the one with most user messages per session_id) 2526: // This prevents inflated totals when a session has multiple conversation branches 2527: const bestBySession = new Map<string, SessionMeta>() 2528: for (const meta of allMetas) { 2529: const existing = bestBySession.get(meta.session_id) 2530: if ( 2531: !existing || 2532: meta.user_message_count > existing.user_message_count || 2533: (meta.user_message_count === existing.user_message_count && 2534: meta.duration_minutes > existing.duration_minutes) 2535: ) { 2536: bestBySession.set(meta.session_id, meta) 2537: } 2538: } 2539: // Replace allMetas with deduplicated list and remove unused logs from logsForFacets 2540: const keptSessionIds = new Set(bestBySession.keys()) 2541: allMetas = [...bestBySession.values()] 2542: for (const sessionId of logsForFacets.keys()) { 2543: if (!keptSessionIds.has(sessionId)) { 2544: logsForFacets.delete(sessionId) 2545: } 2546: } 2547: // Sort all metas by start_time descending (most recent first) 2548: allMetas.sort((a, b) => b.start_time.localeCompare(a.start_time)) 2549: // Pre-filter obviously minimal sessions to save API calls 2550: // (matching Python's substantive filtering concept) 2551: const isSubstantiveSession = (meta: SessionMeta): boolean => { 2552: // Skip sessions with very few user messages 2553: if (meta.user_message_count < 2) return false 2554: // Skip very short sessions (< 1 minute) 2555: if (meta.duration_minutes < 1) return false 2556: return true 2557: } 2558: const substantiveMetas = allMetas.filter(isSubstantiveSession) 2559: // Phase 3: Facet extraction — only for sessions without cached facets 2560: const facets = new Map<string, SessionFacets>() 2561: const toExtract: Array<{ log: LogOption; sessionId: string }> = [] 2562: const MAX_FACET_EXTRACTIONS = 50 2563: // Load cached facets for all substantive sessions in parallel 2564: const cachedFacetResults = await Promise.all( 2565: substantiveMetas.map(async meta => ({ 2566: sessionId: meta.session_id, 2567: cached: await loadCachedFacets(meta.session_id), 2568: })), 2569: ) 2570: for (const { sessionId, cached } of cachedFacetResults) { 2571: if (cached) { 2572: facets.set(sessionId, cached) 2573: } else { 2574: const log = logsForFacets.get(sessionId) 2575: if (log && toExtract.length < MAX_FACET_EXTRACTIONS) { 2576: toExtract.push({ log, sessionId }) 2577: } 2578: } 2579: } 2580: // Extract facets for sessions that need them (50 concurrent) 2581: const CONCURRENCY = 50 2582: for (let i = 0; i < toExtract.length; i += CONCURRENCY) { 2583: const batch = toExtract.slice(i, i + CONCURRENCY) 2584: const results = await Promise.all( 2585: batch.map(async ({ log, sessionId }) => { 2586: const newFacets = await extractFacetsFromAPI(log, sessionId) 2587: return { sessionId, newFacets } 2588: }), 2589: ) 2590: // Collect facets synchronously, save in parallel (independent writes) 2591: const facetsToSave: SessionFacets[] = [] 2592: for (const { sessionId, newFacets } of results) { 2593: if (newFacets) { 2594: facets.set(sessionId, newFacets) 2595: facetsToSave.push(newFacets) 2596: } 2597: } 2598: await Promise.all(facetsToSave.map(f => saveFacets(f))) 2599: } 2600: // Filter out warmup/minimal sessions (matching Python's is_minimal) 2601: // A session is minimal if warmup_minimal is the ONLY goal category 2602: const isMinimalSession = (sessionId: string): boolean => { 2603: const sessionFacets = facets.get(sessionId) 2604: if (!sessionFacets) return false 2605: const cats = sessionFacets.goal_categories 2606: const catKeys = safeKeys(cats).filter(k => (cats[k] ?? 0) > 0) 2607: return catKeys.length === 1 && catKeys[0] === 'warmup_minimal' 2608: } 2609: const substantiveSessions = substantiveMetas.filter( 2610: s => !isMinimalSession(s.session_id), 2611: ) 2612: const substantiveFacets = new Map<string, SessionFacets>() 2613: for (const [sessionId, f] of facets) { 2614: if (!isMinimalSession(sessionId)) { 2615: substantiveFacets.set(sessionId, f) 2616: } 2617: } 2618: const aggregated = aggregateData(substantiveSessions, substantiveFacets) 2619: aggregated.total_sessions_scanned = totalSessionsScanned 2620: // Generate parallel insights from Claude (6 sections) 2621: const insights = await generateParallelInsights(aggregated, facets) 2622: // Generate HTML report 2623: const htmlReport = generateHtmlReport(aggregated, insights) 2624: // Save reports 2625: try { 2626: await mkdir(getDataDir(), { recursive: true }) 2627: } catch { 2628: // Directory may already exist 2629: } 2630: const htmlPath = join(getDataDir(), 'report.html') 2631: await writeFile(htmlPath, htmlReport, { 2632: encoding: 'utf-8', 2633: mode: 0o600, 2634: }) 2635: return { 2636: insights, 2637: htmlPath, 2638: data: aggregated, 2639: remoteStats, 2640: facets: substantiveFacets, 2641: } 2642: } 2643: function safeEntries<V>( 2644: obj: Record<string, V> | undefined | null, 2645: ): [string, V][] { 2646: return obj ? Object.entries(obj) : [] 2647: } 2648: function safeKeys(obj: Record<string, unknown> | undefined | null): string[] { 2649: return obj ? Object.keys(obj) : [] 2650: } 2651: // ============================================================================ 2652: // Command Definition 2653: // ============================================================================ 2654: const usageReport: Command = { 2655: type: 'prompt', 2656: name: 'insights', 2657: description: 'Generate a report analyzing your Claude Code sessions', 2658: contentLength: 0, // Dynamic content 2659: progressMessage: 'analyzing your sessions', 2660: source: 'builtin', 2661: async getPromptForCommand(args) { 2662: let collectRemote = false 2663: let remoteHosts: string[] = [] 2664: let hasRemoteHosts = false 2665: if (process.env.USER_TYPE === 'ant') { 2666: // Parse --homespaces flag 2667: collectRemote = args?.includes('--homespaces') ?? false 2668: // Check for available remote hosts 2669: remoteHosts = await getRunningRemoteHosts() 2670: hasRemoteHosts = remoteHosts.length > 0 2671: // Show collection message if collecting 2672: if (collectRemote && hasRemoteHosts) { 2673: // biome-ignore lint/suspicious/noConsole: intentional 2674: console.error( 2675: `Collecting sessions from ${remoteHosts.length} homespace(s): ${remoteHosts.join(', ')}...`, 2676: ) 2677: } 2678: } 2679: const { insights, htmlPath, data, remoteStats } = await generateUsageReport( 2680: { collectRemote }, 2681: ) 2682: let reportUrl = `file: 2683: let uploadHint = '' 2684: if (process.env.USER_TYPE === 'ant') { 2685: const timestamp = new Date() 2686: .toISOString() 2687: .replace(/[-:]/g, '') 2688: .replace('T', '_') 2689: .slice(0, 15) 2690: const username = process.env.SAFEUSER || process.env.USER || 'unknown' 2691: const filename = `${username}_insights_${timestamp}.html` 2692: const s3Path = `s3://anthropic-serve/atamkin/cc-user-reports/${filename}` 2693: const s3Url = `https://s3-frontend.infra.ant.dev/anthropic-serve/atamkin/cc-user-reports/${filename}` 2694: reportUrl = s3Url 2695: try { 2696: execFileSync('ff', ['cp', htmlPath, s3Path], { 2697: timeout: 60000, 2698: stdio: 'pipe', 2699: }) 2700: } catch { 2701: reportUrl = `file://${htmlPath}` 2702: uploadHint = `\nAutomatic upload failed. Are you on the boron namespace? Try \`use-bo\` and ensure you've run \`sso\`. 2703: To share, run: ff cp ${htmlPath} ${s3Path} 2704: Then access at: ${s3Url}` 2705: } 2706: } 2707: const sessionLabel = 2708: data.total_sessions_scanned && 2709: data.total_sessions_scanned > data.total_sessions 2710: ? `${data.total_sessions_scanned.toLocaleString()} sessions total · ${data.total_sessions} analyzed` 2711: : `${data.total_sessions} sessions` 2712: const stats = [ 2713: sessionLabel, 2714: `${data.total_messages.toLocaleString()} messages`, 2715: `${Math.round(data.total_duration_hours)}h`, 2716: `${data.git_commits} commits`, 2717: ].join(' · ') 2718: let remoteInfo = '' 2719: if (process.env.USER_TYPE === 'ant') { 2720: if (remoteStats && remoteStats.totalCopied > 0) { 2721: const hsNames = remoteStats.hosts 2722: .filter(h => h.sessionCount > 0) 2723: .map(h => h.name) 2724: .join(', ') 2725: remoteInfo = `\n_Collected ${remoteStats.totalCopied} new sessions from: ${hsNames}_\n` 2726: } else if (!collectRemote && hasRemoteHosts) { 2727: remoteInfo = `\n_Tip: Run \`/insights --homespaces\` to include sessions from your ${remoteHosts.length} running homespace(s)_\n` 2728: } 2729: } 2730: const atAGlance = insights.at_a_glance 2731: const summaryText = atAGlance 2732: ? `## At a Glance 2733: ${atAGlance.whats_working ? `**What's working:** ${atAGlance.whats_working} See _Impressive Things You Did_.` : ''} 2734: ${atAGlance.whats_hindering ? `**What's hindering you:** ${atAGlance.whats_hindering} See _Where Things Go Wrong_.` : ''} 2735: ${atAGlance.quick_wins ? `**Quick wins to try:** ${atAGlance.quick_wins} See _Features to Try_.` : ''} 2736: ${atAGlance.ambitious_workflows ? `**Ambitious workflows:** ${atAGlance.ambitious_workflows} See _On the Horizon_.` : ''}` 2737: : '_No insights generated_' 2738: const header = `# Claude Code Insights 2739: ${stats} 2740: ${data.date_range.start} to ${data.date_range.end} 2741: ${remoteInfo} 2742: ` 2743: const userSummary = `${header}${summaryText} 2744: Your full shareable insights report is ready: ${reportUrl}${uploadHint}` 2745: return [ 2746: { 2747: type: 'text', 2748: text: `The user just ran /insights to generate a usage report analyzing their Claude Code sessions. 2749: Here is the full insights data: 2750: ${jsonStringify(insights, null, 2)} 2751: Report URL: ${reportUrl} 2752: HTML file: ${htmlPath} 2753: Facets directory: ${getFacetsDir()} 2754: Here is what the user sees: 2755: ${userSummary} 2756: Now output the following message exactly: 2757: <message> 2758: Your shareable insights report is ready: 2759: ${reportUrl}${uploadHint} 2760: Want to dig into any section or try one of the suggestions? 2761: </message>`, 2762: }, 2763: ] 2764: }, 2765: } 2766: function isValidSessionFacets(obj: unknown): obj is SessionFacets { 2767: if (!obj || typeof obj !== 'object') return false 2768: const o = obj as Record<string, unknown> 2769: return ( 2770: typeof o.underlying_goal === 'string' && 2771: typeof o.outcome === 'string' && 2772: typeof o.brief_summary === 'string' && 2773: o.goal_categories !== null && 2774: typeof o.goal_categories === 'object' && 2775: o.user_satisfaction_counts !== null && 2776: typeof o.user_satisfaction_counts === 'object' && 2777: o.friction_counts !== null && 2778: typeof o.friction_counts === 'object' 2779: ) 2780: } 2781: export default usageReport

File: src/commands/install.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { homedir } from 'node:os'; 3: import { join } from 'node:path'; 4: import React, { useEffect, useState } from 'react'; 5: import type { CommandResultDisplay } from 'src/commands.js'; 6: import { logEvent } from 'src/services/analytics/index.js'; 7: import { StatusIcon } from '../components/design-system/StatusIcon.js'; 8: import { Box, render, Text } from '../ink.js'; 9: import { logForDebugging } from '../utils/debug.js'; 10: import { env } from '../utils/env.js'; 11: import { errorMessage } from '../utils/errors.js'; 12: import { checkInstall, cleanupNpmInstallations, cleanupShellAliases, installLatest } from '../utils/nativeInstaller/index.js'; 13: import { getInitialSettings, updateSettingsForSource } from '../utils/settings/settings.js'; 14: interface InstallProps { 15: onDone: (result: string, options?: { 16: display?: CommandResultDisplay; 17: }) => void; 18: force?: boolean; 19: target?: string; 20: } 21: type InstallState = { 22: type: 'checking'; 23: } | { 24: type: 'cleaning-npm'; 25: } | { 26: type: 'installing'; 27: version: string; 28: } | { 29: type: 'setting-up'; 30: } | { 31: type: 'set-up'; 32: messages: string[]; 33: } | { 34: type: 'success'; 35: version: string; 36: setupMessages?: string[]; 37: } | { 38: type: 'error'; 39: message: string; 40: warnings?: string[]; 41: }; 42: function getInstallationPath(): string { 43: const isWindows = env.platform === 'win32'; 44: const homeDir = homedir(); 45: if (isWindows) { 46: const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe'); 47: return windowsPath.replace(/\//g, '\\'); 48: } 49: return '~/.local/bin/claude'; 50: } 51: function SetupNotes(t0) { 52: const $ = _c(5); 53: const { 54: messages 55: } = t0; 56: if (messages.length === 0) { 57: return null; 58: } 59: let t1; 60: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 61: t1 = <Box><Text color="warning"><StatusIcon status="warning" withSpace={true} />Setup notes:</Text></Box>; 62: $[0] = t1; 63: } else { 64: t1 = $[0]; 65: } 66: let t2; 67: if ($[1] !== messages) { 68: t2 = messages.map(_temp); 69: $[1] = messages; 70: $[2] = t2; 71: } else { 72: t2 = $[2]; 73: } 74: let t3; 75: if ($[3] !== t2) { 76: t3 = <Box flexDirection="column" gap={0} marginBottom={1}>{t1}{t2}</Box>; 77: $[3] = t2; 78: $[4] = t3; 79: } else { 80: t3 = $[4]; 81: } 82: return t3; 83: } 84: function _temp(message, index) { 85: return <Box key={index} marginLeft={2}><Text dimColor={true}>• {message}</Text></Box>; 86: } 87: function Install({ 88: onDone, 89: force, 90: target 91: }: InstallProps): React.ReactNode { 92: const [state, setState] = useState<InstallState>({ 93: type: 'checking' 94: }); 95: useEffect(() => { 96: async function run() { 97: try { 98: logForDebugging(`Install: Starting installation process (force=${force}, target=${target})`); 99: const channelOrVersion = target || getInitialSettings()?.autoUpdatesChannel || 'latest'; 100: setState({ 101: type: 'installing', 102: version: channelOrVersion 103: }); 104: logForDebugging(`Install: Calling installLatest(channelOrVersion=${channelOrVersion}, forceReinstall=${force})`); 105: const result = await installLatest(channelOrVersion, force); 106: logForDebugging(`Install: installLatest returned version=${result.latestVersion}, wasUpdated=${result.wasUpdated}, lockFailed=${result.lockFailed}`); 107: if (result.lockFailed) { 108: throw new Error('Could not install - another process is currently installing Claude. Please try again in a moment.'); 109: } 110: if (!result.latestVersion) { 111: logForDebugging('Install: Failed to retrieve version information during install', { 112: level: 'error' 113: }); 114: } 115: if (!result.wasUpdated) { 116: logForDebugging('Install: Already up to date'); 117: } 118: setState({ 119: type: 'setting-up' 120: }); 121: const setupMessages = await checkInstall(true); 122: logForDebugging(`Install: Setup launcher completed with ${setupMessages.length} messages`); 123: if (setupMessages.length > 0) { 124: setupMessages.forEach(msg => logForDebugging(`Install: Setup message: ${msg.message}`)); 125: } 126: logForDebugging('Install: Cleaning up npm installations after successful install'); 127: const { 128: removed, 129: errors, 130: warnings 131: } = await cleanupNpmInstallations(); 132: if (removed > 0) { 133: logForDebugging(`Cleaned up ${removed} npm installation(s)`); 134: } 135: if (errors.length > 0) { 136: logForDebugging(`Cleanup errors: ${errors.join(', ')}`); 137: } 138: const aliasMessages = await cleanupShellAliases(); 139: if (aliasMessages.length > 0) { 140: logForDebugging(`Shell alias cleanup: ${aliasMessages.map(m => m.message).join('; ')}`); 141: } 142: logEvent('tengu_claude_install_command', { 143: has_version: result.latestVersion ? 1 : 0, 144: forced: force ? 1 : 0 145: }); 146: if (target === 'latest' || target === 'stable') { 147: updateSettingsForSource('userSettings', { 148: autoUpdatesChannel: target 149: }); 150: logForDebugging(`Install: Saved autoUpdatesChannel=${target} to user settings`); 151: } 152: const allWarnings = [...warnings, ...aliasMessages.map(m_0 => m_0.message)]; 153: if (setupMessages.length > 0) { 154: setState({ 155: type: 'set-up', 156: messages: setupMessages.map(m_1 => m_1.message) 157: }); 158: setTimeout(setState, 2000, { 159: type: 'success' as const, 160: version: result.latestVersion || 'current', 161: setupMessages: [...setupMessages.map(m_2 => m_2.message), ...allWarnings] 162: }); 163: } else { 164: logForDebugging('Install: Shell PATH already configured'); 165: setState({ 166: type: 'success', 167: version: result.latestVersion || 'current', 168: setupMessages: allWarnings.length > 0 ? allWarnings : undefined 169: }); 170: } 171: } catch (error) { 172: logForDebugging(`Install command failed: ${error}`, { 173: level: 'error' 174: }); 175: setState({ 176: type: 'error', 177: message: errorMessage(error) 178: }); 179: } 180: } 181: void run(); 182: }, [force, target]); 183: useEffect(() => { 184: if (state.type === 'success') { 185: setTimeout(onDone, 2000, 'Claude Code installation completed successfully', { 186: display: 'system' as const 187: }); 188: } else if (state.type === 'error') { 189: setTimeout(onDone, 3000, 'Claude Code installation failed', { 190: display: 'system' as const 191: }); 192: } 193: }, [state, onDone]); 194: return <Box flexDirection="column" marginTop={1}> 195: {state.type === 'checking' && <Text color="claude">Checking installation status...</Text>} 196: {state.type === 'cleaning-npm' && <Text color="warning">Cleaning up old npm installations...</Text>} 197: {state.type === 'installing' && <Text color="claude"> 198: Installing Claude Code native build {state.version}... 199: </Text>} 200: {state.type === 'setting-up' && <Text color="claude">Setting up launcher and shell integration...</Text>} 201: {state.type === 'set-up' && <SetupNotes messages={state.messages} />} 202: {state.type === 'success' && <Box flexDirection="column" gap={1}> 203: <Box> 204: <StatusIcon status="success" withSpace /> 205: <Text color="success" bold> 206: Claude Code successfully installed! 207: </Text> 208: </Box> 209: <Box marginLeft={2} flexDirection="column" gap={1}> 210: {state.version !== 'current' && <Box> 211: <Text dimColor>Version: </Text> 212: <Text color="claude">{state.version}</Text> 213: </Box>} 214: <Box> 215: <Text dimColor>Location: </Text> 216: <Text color="text">{getInstallationPath()}</Text> 217: </Box> 218: </Box> 219: <Box marginLeft={2} flexDirection="column" gap={1}> 220: <Box marginTop={1}> 221: <Text dimColor>Next: Run </Text> 222: <Text color="claude" bold> 223: claude --help 224: </Text> 225: <Text dimColor> to get started</Text> 226: </Box> 227: </Box> 228: {state.setupMessages && <SetupNotes messages={state.setupMessages} />} 229: </Box>} 230: {state.type === 'error' && <Box flexDirection="column" gap={1}> 231: <Box> 232: <StatusIcon status="error" withSpace /> 233: <Text color="error">Installation failed</Text> 234: </Box> 235: <Text color="error">{state.message}</Text> 236: <Box marginTop={1}> 237: <Text dimColor>Try running with --force to override checks</Text> 238: </Box> 239: </Box>} 240: </Box>; 241: } 242: export const install = { 243: type: 'local-jsx' as const, 244: name: 'install', 245: description: 'Install Claude Code native build', 246: argumentHint: '[options]', 247: async call(onDone: (result: string, options?: { 248: display?: CommandResultDisplay; 249: }) => void, _context: unknown, args: string[]) { 250: const force = args.includes('--force'); 251: const nonFlagArgs = args.filter(arg => !arg.startsWith('--')); 252: const target = nonFlagArgs[0]; 253: const { 254: unmount 255: } = await render(<Install onDone={(result, options) => { 256: unmount(); 257: onDone(result, options); 258: }} force={force} target={target} />); 259: } 260: };

File: src/commands/review.ts

typescript 1: import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js' 2: import type { Command } from '../commands.js' 3: import { isUltrareviewEnabled } from './review/ultrareviewEnabled.js' 4: const CCR_TERMS_URL = 'https://code.claude.com/docs/en/claude-code-on-the-web' 5: const LOCAL_REVIEW_PROMPT = (args: string) => ` 6: You are an expert code reviewer. Follow these steps: 7: 1. If no PR number is provided in the args, run \`gh pr list\` to show open PRs 8: 2. If a PR number is provided, run \`gh pr view <number>\` to get PR details 9: 3. Run \`gh pr diff <number>\` to get the diff 10: 4. Analyze the changes and provide a thorough code review that includes: 11: - Overview of what the PR does 12: - Analysis of code quality and style 13: - Specific suggestions for improvements 14: - Any potential issues or risks 15: Keep your review concise but thorough. Focus on: 16: - Code correctness 17: - Following project conventions 18: - Performance implications 19: - Test coverage 20: - Security considerations 21: Format your review with clear sections and bullet points. 22: PR number: ${args} 23: ` 24: const review: Command = { 25: type: 'prompt', 26: name: 'review', 27: description: 'Review a pull request', 28: progressMessage: 'reviewing pull request', 29: contentLength: 0, 30: source: 'builtin', 31: async getPromptForCommand(args): Promise<ContentBlockParam[]> { 32: return [{ type: 'text', text: LOCAL_REVIEW_PROMPT(args) }] 33: }, 34: } 35: const ultrareview: Command = { 36: type: 'local-jsx', 37: name: 'ultrareview', 38: description: `~10–20 min · Finds and verifies bugs in your branch. Runs in Claude Code on the web. See ${CCR_TERMS_URL}`, 39: isEnabled: () => isUltrareviewEnabled(), 40: load: () => import('./review/ultrareviewCommand.js'), 41: } 42: export default review 43: export { ultrareview }

File: src/commands/security-review.ts

typescript 1: import { parseFrontmatter } from '../utils/frontmatterParser.js' 2: import { parseSlashCommandToolsFromFrontmatter } from '../utils/markdownConfigLoader.js' 3: import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js' 4: import { createMovedToPluginCommand } from './createMovedToPluginCommand.js' 5: const SECURITY_REVIEW_MARKDOWN = `--- 6: allowed-tools: Bash(git diff:*), Bash(git status:*), Bash(git log:*), Bash(git show:*), Bash(git remote show:*), Read, Glob, Grep, LS, Task 7: description: Complete a security review of the pending changes on the current branch 8: --- 9: You are a senior security engineer conducting a focused security review of the changes on this branch. 10: GIT STATUS: 11: \`\`\` 12: !\`git status\` 13: \`\`\` 14: FILES MODIFIED: 15: \`\`\` 16: !\`git diff --name-only origin/HEAD...\` 17: \`\`\` 18: COMMITS: 19: \`\`\` 20: !\`git log --no-decorate origin/HEAD...\` 21: \`\`\` 22: DIFF CONTENT: 23: \`\`\` 24: !\`git diff origin/HEAD...\` 25: \`\`\` 26: Review the complete diff above. This contains all code changes in the PR. 27: OBJECTIVE: 28: Perform a security-focused code review to identify HIGH-CONFIDENCE security vulnerabilities that could have real exploitation potential. This is not a general code review - focus ONLY on security implications newly added by this PR. Do not comment on existing security concerns. 29: CRITICAL INSTRUCTIONS: 30: 1. MINIMIZE FALSE POSITIVES: Only flag issues where you're >80% confident of actual exploitability 31: 2. AVOID NOISE: Skip theoretical issues, style concerns, or low-impact findings 32: 3. FOCUS ON IMPACT: Prioritize vulnerabilities that could lead to unauthorized access, data breaches, or system compromise 33: 4. EXCLUSIONS: Do NOT report the following issue types: 34: - Denial of Service (DOS) vulnerabilities, even if they allow service disruption 35: - Secrets or sensitive data stored on disk (these are handled by other processes) 36: - Rate limiting or resource exhaustion issues 37: SECURITY CATEGORIES TO EXAMINE: 38: **Input Validation Vulnerabilities:** 39: - SQL injection via unsanitized user input 40: - Command injection in system calls or subprocesses 41: - XXE injection in XML parsing 42: - Template injection in templating engines 43: - NoSQL injection in database queries 44: - Path traversal in file operations 45: **Authentication & Authorization Issues:** 46: - Authentication bypass logic 47: - Privilege escalation paths 48: - Session management flaws 49: - JWT token vulnerabilities 50: - Authorization logic bypasses 51: **Crypto & Secrets Management:** 52: - Hardcoded API keys, passwords, or tokens 53: - Weak cryptographic algorithms or implementations 54: - Improper key storage or management 55: - Cryptographic randomness issues 56: - Certificate validation bypasses 57: **Injection & Code Execution:** 58: - Remote code execution via deseralization 59: - Pickle injection in Python 60: - YAML deserialization vulnerabilities 61: - Eval injection in dynamic code execution 62: - XSS vulnerabilities in web applications (reflected, stored, DOM-based) 63: **Data Exposure:** 64: - Sensitive data logging or storage 65: - PII handling violations 66: - API endpoint data leakage 67: - Debug information exposure 68: Additional notes: 69: - Even if something is only exploitable from the local network, it can still be a HIGH severity issue 70: ANALYSIS METHODOLOGY: 71: Phase 1 - Repository Context Research (Use file search tools): 72: - Identify existing security frameworks and libraries in use 73: - Look for established secure coding patterns in the codebase 74: - Examine existing sanitization and validation patterns 75: - Understand the project's security model and threat model 76: Phase 2 - Comparative Analysis: 77: - Compare new code changes against existing security patterns 78: - Identify deviations from established secure practices 79: - Look for inconsistent security implementations 80: - Flag code that introduces new attack surfaces 81: Phase 3 - Vulnerability Assessment: 82: - Examine each modified file for security implications 83: - Trace data flow from user inputs to sensitive operations 84: - Look for privilege boundaries being crossed unsafely 85: - Identify injection points and unsafe deserialization 86: REQUIRED OUTPUT FORMAT: 87: You MUST output your findings in markdown. The markdown output should contain the file, line number, severity, category (e.g. \`sql_injection\` or \`xss\`), description, exploit scenario, and fix recommendation. 88: For example: 89: # Vuln 1: XSS: \`foo.py:42\` 90: * Severity: High 91: * Description: User input from \`username\` parameter is directly interpolated into HTML without escaping, allowing reflected XSS attacks 92: * Exploit Scenario: Attacker crafts URL like /bar?q=<script>alert(document.cookie)</script> to execute JavaScript in victim's browser, enabling session hijacking or data theft 93: * Recommendation: Use Flask's escape() function or Jinja2 templates with auto-escaping enabled for all user inputs rendered in HTML 94: SEVERITY GUIDELINES: 95: - **HIGH**: Directly exploitable vulnerabilities leading to RCE, data breach, or authentication bypass 96: - **MEDIUM**: Vulnerabilities requiring specific conditions but with significant impact 97: - **LOW**: Defense-in-depth issues or lower-impact vulnerabilities 98: CONFIDENCE SCORING: 99: - 0.9-1.0: Certain exploit path identified, tested if possible 100: - 0.8-0.9: Clear vulnerability pattern with known exploitation methods 101: - 0.7-0.8: Suspicious pattern requiring specific conditions to exploit 102: - Below 0.7: Don't report (too speculative) 103: FINAL REMINDER: 104: Focus on HIGH and MEDIUM findings only. Better to miss some theoretical issues than flood the report with false positives. Each finding should be something a security engineer would confidently raise in a PR review. 105: FALSE POSITIVE FILTERING: 106: > You do not need to run commands to reproduce the vulnerability, just read the code to determine if it is a real vulnerability. Do not use the bash tool or write to any files. 107: > 108: > HARD EXCLUSIONS - Automatically exclude findings matching these patterns: 109: > 1. Denial of Service (DOS) vulnerabilities or resource exhaustion attacks. 110: > 2. Secrets or credentials stored on disk if they are otherwise secured. 111: > 3. Rate limiting concerns or service overload scenarios. 112: > 4. Memory consumption or CPU exhaustion issues. 113: > 5. Lack of input validation on non-security-critical fields without proven security impact. 114: > 6. Input sanitization concerns for GitHub Action workflows unless they are clearly triggerable via untrusted input. 115: > 7. A lack of hardening measures. Code is not expected to implement all security best practices, only flag concrete vulnerabilities. 116: > 8. Race conditions or timing attacks that are theoretical rather than practical issues. Only report a race condition if it is concretely problematic. 117: > 9. Vulnerabilities related to outdated third-party libraries. These are managed separately and should not be reported here. 118: > 10. Memory safety issues such as buffer overflows or use-after-free-vulnerabilities are impossible in rust. Do not report memory safety issues in rust or any other memory safe languages. 119: > 11. Files that are only unit tests or only used as part of running tests. 120: > 12. Log spoofing concerns. Outputting un-sanitized user input to logs is not a vulnerability. 121: > 13. SSRF vulnerabilities that only control the path. SSRF is only a concern if it can control the host or protocol. 122: > 14. Including user-controlled content in AI system prompts is not a vulnerability. 123: > 15. Regex injection. Injecting untrusted content into a regex is not a vulnerability. 124: > 16. Regex DOS concerns. 125: > 16. Insecure documentation. Do not report any findings in documentation files such as markdown files. 126: > 17. A lack of audit logs is not a vulnerability. 127: > 128: > PRECEDENTS - 129: > 1. Logging high value secrets in plaintext is a vulnerability. Logging URLs is assumed to be safe. 130: > 2. UUIDs can be assumed to be unguessable and do not need to be validated. 131: > 3. Environment variables and CLI flags are trusted values. Attackers are generally not able to modify them in a secure environment. Any attack that relies on controlling an environment variable is invalid. 132: > 4. Resource management issues such as memory or file descriptor leaks are not valid. 133: > 5. Subtle or low impact web vulnerabilities such as tabnabbing, XS-Leaks, prototype pollution, and open redirects should not be reported unless they are extremely high confidence. 134: > 6. React and Angular are generally secure against XSS. These frameworks do not need to sanitize or escape user input unless it is using dangerouslySetInnerHTML, bypassSecurityTrustHtml, or similar methods. Do not report XSS vulnerabilities in React or Angular components or tsx files unless they are using unsafe methods. 135: > 7. Most vulnerabilities in github action workflows are not exploitable in practice. Before validating a github action workflow vulnerability ensure it is concrete and has a very specific attack path. 136: > 8. A lack of permission checking or authentication in client-side JS/TS code is not a vulnerability. Client-side code is not trusted and does not need to implement these checks, they are handled on the server-side. The same applies to all flows that send untrusted data to the backend, the backend is responsible for validating and sanitizing all inputs. 137: > 9. Only include MEDIUM findings if they are obvious and concrete issues. 138: > 10. Most vulnerabilities in ipython notebooks (*.ipynb files) are not exploitable in practice. Before validating a notebook vulnerability ensure it is concrete and has a very specific attack path where untrusted input can trigger the vulnerability. 139: > 11. Logging non-PII data is not a vulnerability even if the data may be sensitive. Only report logging vulnerabilities if they expose sensitive information such as secrets, passwords, or personally identifiable information (PII). 140: > 12. Command injection vulnerabilities in shell scripts are generally not exploitable in practice since shell scripts generally do not run with untrusted user input. Only report command injection vulnerabilities in shell scripts if they are concrete and have a very specific attack path for untrusted input. 141: > 142: > SIGNAL QUALITY CRITERIA - For remaining findings, assess: 143: > 1. Is there a concrete, exploitable vulnerability with a clear attack path? 144: > 2. Does this represent a real security risk vs theoretical best practice? 145: > 3. Are there specific code locations and reproduction steps? 146: > 4. Would this finding be actionable for a security team? 147: > 148: > For each finding, assign a confidence score from 1-10: 149: > - 1-3: Low confidence, likely false positive or noise 150: > - 4-6: Medium confidence, needs investigation 151: > - 7-10: High confidence, likely true vulnerability 152: START ANALYSIS: 153: Begin your analysis now. Do this in 3 steps: 154: 1. Use a sub-task to identify vulnerabilities. Use the repository exploration tools to understand the codebase context, then analyze the PR changes for security implications. In the prompt for this sub-task, include all of the above. 155: 2. Then for each vulnerability identified by the above sub-task, create a new sub-task to filter out false-positives. Launch these sub-tasks as parallel sub-tasks. In the prompt for these sub-tasks, include everything in the "FALSE POSITIVE FILTERING" instructions. 156: 3. Filter out any vulnerabilities where the sub-task reported a confidence less than 8. 157: Your final reply must contain the markdown report and nothing else.` 158: export default createMovedToPluginCommand({ 159: name: 'security-review', 160: description: 161: 'Complete a security review of the pending changes on the current branch', 162: progressMessage: 'analyzing code changes for security risks', 163: pluginName: 'security-review', 164: pluginCommand: 'security-review', 165: async getPromptWhileMarketplaceIsPrivate(_args, context) { 166: const parsed = parseFrontmatter(SECURITY_REVIEW_MARKDOWN) 167: const allowedTools = parseSlashCommandToolsFromFrontmatter( 168: parsed.frontmatter['allowed-tools'], 169: ) 170: const processedContent = await executeShellCommandsInPrompt( 171: parsed.content, 172: { 173: ...context, 174: getAppState() { 175: const appState = context.getAppState() 176: return { 177: ...appState, 178: toolPermissionContext: { 179: ...appState.toolPermissionContext, 180: alwaysAllowRules: { 181: ...appState.toolPermissionContext.alwaysAllowRules, 182: command: allowedTools, 183: }, 184: }, 185: } 186: }, 187: }, 188: 'security-review', 189: ) 190: return [ 191: { 192: type: 'text', 193: text: processedContent, 194: }, 195: ] 196: }, 197: })

File: src/commands/statusline.tsx

typescript 1: import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; 2: import type { Command } from '../commands.js'; 3: import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js'; 4: const statusline = { 5: type: 'prompt', 6: description: "Set up Claude Code's status line UI", 7: contentLength: 0, 8: aliases: [], 9: name: 'statusline', 10: progressMessage: 'setting up statusLine', 11: allowedTools: [AGENT_TOOL_NAME, 'Read(~/**)', 'Edit(~/.claude/settings.json)'], 12: source: 'builtin', 13: disableNonInteractive: true, 14: async getPromptForCommand(args): Promise<ContentBlockParam[]> { 15: const prompt = args.trim() || 'Configure my statusLine from my shell PS1 configuration'; 16: return [{ 17: type: 'text', 18: text: `Create an ${AGENT_TOOL_NAME} with subagent_type "statusline-setup" and the prompt "${prompt}"` 19: }]; 20: } 21: } satisfies Command; 22: export default statusline;

File: src/commands/ultraplan.tsx

typescript 1: import { readFileSync } from 'fs'; 2: import { REMOTE_CONTROL_DISCONNECTED_MSG } from '../bridge/types.js'; 3: import type { Command } from '../commands.js'; 4: import { DIAMOND_OPEN } from '../constants/figures.js'; 5: import { getRemoteSessionUrl } from '../constants/product.js'; 6: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; 7: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js'; 8: import type { AppState } from '../state/AppStateStore.js'; 9: import { checkRemoteAgentEligibility, formatPreconditionError, RemoteAgentTask, type RemoteAgentTaskState, registerRemoteAgentTask } from '../tasks/RemoteAgentTask/RemoteAgentTask.js'; 10: import type { LocalJSXCommandCall } from '../types/command.js'; 11: import { logForDebugging } from '../utils/debug.js'; 12: import { errorMessage } from '../utils/errors.js'; 13: import { logError } from '../utils/log.js'; 14: import { enqueuePendingNotification } from '../utils/messageQueueManager.js'; 15: import { ALL_MODEL_CONFIGS } from '../utils/model/configs.js'; 16: import { updateTaskState } from '../utils/task/framework.js'; 17: import { archiveRemoteSession, teleportToRemote } from '../utils/teleport.js'; 18: import { pollForApprovedExitPlanMode, UltraplanPollError } from '../utils/ultraplan/ccrSession.js'; 19: const ULTRAPLAN_TIMEOUT_MS = 30 * 60 * 1000; 20: export const CCR_TERMS_URL = 'https://code.claude.com/docs/en/claude-code-on-the-web'; 21: function getUltraplanModel(): string { 22: return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus46.firstParty); 23: } 24: const _rawPrompt = require('../utils/ultraplan/prompt.txt'); 25: const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPrompt : _rawPrompt.default).trimEnd(); 26: const ULTRAPLAN_INSTRUCTIONS: string = "external" === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE ? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd() : DEFAULT_INSTRUCTIONS; 27: export function buildUltraplanPrompt(blurb: string, seedPlan?: string): string { 28: const parts: string[] = []; 29: if (seedPlan) { 30: parts.push('Here is a draft plan to refine:', '', seedPlan, ''); 31: } 32: parts.push(ULTRAPLAN_INSTRUCTIONS); 33: if (blurb) { 34: parts.push('', blurb); 35: } 36: return parts.join('\n'); 37: } 38: function startDetachedPoll(taskId: string, sessionId: string, url: string, getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void): void { 39: const started = Date.now(); 40: let failed = false; 41: void (async () => { 42: try { 43: const { 44: plan, 45: rejectCount, 46: executionTarget 47: } = await pollForApprovedExitPlanMode(sessionId, ULTRAPLAN_TIMEOUT_MS, phase => { 48: if (phase === 'needs_input') logEvent('tengu_ultraplan_awaiting_input', {}); 49: updateTaskState<RemoteAgentTaskState>(taskId, setAppState, t => { 50: if (t.status !== 'running') return t; 51: const next = phase === 'running' ? undefined : phase; 52: return t.ultraplanPhase === next ? t : { 53: ...t, 54: ultraplanPhase: next 55: }; 56: }); 57: }, () => getAppState().tasks?.[taskId]?.status !== 'running'); 58: logEvent('tengu_ultraplan_approved', { 59: duration_ms: Date.now() - started, 60: plan_length: plan.length, 61: reject_count: rejectCount, 62: execution_target: executionTarget as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 63: }); 64: if (executionTarget === 'remote') { 65: const task = getAppState().tasks?.[taskId]; 66: if (task?.status !== 'running') return; 67: updateTaskState<RemoteAgentTaskState>(taskId, setAppState, t => t.status !== 'running' ? t : { 68: ...t, 69: status: 'completed', 70: endTime: Date.now() 71: }); 72: setAppState(prev => prev.ultraplanSessionUrl === url ? { 73: ...prev, 74: ultraplanSessionUrl: undefined 75: } : prev); 76: enqueuePendingNotification({ 77: value: [`Ultraplan approved — executing in Claude Code on the web. Follow along at: ${url}`, '', 'Results will land as a pull request when the remote session finishes. There is nothing to do here.'].join('\n'), 78: mode: 'task-notification' 79: }); 80: } else { 81: setAppState(prev => { 82: const task = prev.tasks?.[taskId]; 83: if (!task || task.status !== 'running') return prev; 84: return { 85: ...prev, 86: ultraplanPendingChoice: { 87: plan, 88: sessionId, 89: taskId 90: } 91: }; 92: }); 93: } 94: } catch (e) { 95: const task = getAppState().tasks?.[taskId]; 96: if (task?.status !== 'running') return; 97: failed = true; 98: logEvent('tengu_ultraplan_failed', { 99: duration_ms: Date.now() - started, 100: reason: (e instanceof UltraplanPollError ? e.reason : 'network_or_unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 101: reject_count: e instanceof UltraplanPollError ? e.rejectCount : undefined 102: }); 103: enqueuePendingNotification({ 104: value: `Ultraplan failed: ${errorMessage(e)}\n\nSession: ${url}`, 105: mode: 'task-notification' 106: }); 107: void archiveRemoteSession(sessionId).catch(e => logForDebugging(`ultraplan archive failed: ${String(e)}`)); 108: setAppState(prev => 109: prev.ultraplanSessionUrl === url ? { 110: ...prev, 111: ultraplanSessionUrl: undefined 112: } : prev); 113: } finally { 114: if (failed) { 115: updateTaskState<RemoteAgentTaskState>(taskId, setAppState, t => t.status !== 'running' ? t : { 116: ...t, 117: status: 'failed', 118: endTime: Date.now() 119: }); 120: } 121: } 122: })(); 123: } 124: function buildLaunchMessage(disconnectedBridge?: boolean): string { 125: const prefix = disconnectedBridge ? `${REMOTE_CONTROL_DISCONNECTED_MSG} ` : ''; 126: return `${DIAMOND_OPEN} ultraplan\n${prefix}Starting Claude Code on the web…`; 127: } 128: function buildSessionReadyMessage(url: string): string { 129: return `${DIAMOND_OPEN} ultraplan · Monitor progress in Claude Code on the web ${url}\nYou can continue working — when the ${DIAMOND_OPEN} fills, press ↓ to view results`; 130: } 131: function buildAlreadyActiveMessage(url: string | undefined): string { 132: return url ? `ultraplan: already polling. Open ${url} to check status, or wait for the plan to land here.` : 'ultraplan: already launching. Please wait for the session to start.'; 133: } 134: /** 135: * Stop a running ultraplan: archive the remote session (halts it but keeps the 136: * URL viewable), kill the local task entry (clears the pill), and clear 137: * ultraplanSessionUrl (re-arms the keyword trigger). startDetachedPoll's 138: * shouldStop callback sees the killed status on its next tick and throws; 139: * the catch block early-returns when status !== 'running'. 140: */ 141: export async function stopUltraplan(taskId: string, sessionId: string, setAppState: (f: (prev: AppState) => AppState) => void): Promise<void> { 142: await RemoteAgentTask.kill(taskId, setAppState); 143: setAppState(prev => prev.ultraplanSessionUrl || prev.ultraplanPendingChoice || prev.ultraplanLaunching ? { 144: ...prev, 145: ultraplanSessionUrl: undefined, 146: ultraplanPendingChoice: undefined, 147: ultraplanLaunching: undefined 148: } : prev); 149: const url = getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL); 150: enqueuePendingNotification({ 151: value: `Ultraplan stopped.\n\nSession: ${url}`, 152: mode: 'task-notification' 153: }); 154: enqueuePendingNotification({ 155: value: 'The user stopped the ultraplan session above. Do not respond to the stop notification — wait for their next message.', 156: mode: 'task-notification', 157: isMeta: true 158: }); 159: } 160: export async function launchUltraplan(opts: { 161: blurb: string; 162: seedPlan?: string; 163: getAppState: () => AppState; 164: setAppState: (f: (prev: AppState) => AppState) => void; 165: signal: AbortSignal; 166: disconnectedBridge?: boolean; 167: onSessionReady?: (msg: string) => void; 168: }): Promise<string> { 169: const { 170: blurb, 171: seedPlan, 172: getAppState, 173: setAppState, 174: signal, 175: disconnectedBridge, 176: onSessionReady 177: } = opts; 178: const { 179: ultraplanSessionUrl: active, 180: ultraplanLaunching 181: } = getAppState(); 182: if (active || ultraplanLaunching) { 183: logEvent('tengu_ultraplan_create_failed', { 184: reason: (active ? 'already_polling' : 'already_launching') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 185: }); 186: return buildAlreadyActiveMessage(active); 187: } 188: if (!blurb && !seedPlan) { 189: return [ 190: 'Usage: /ultraplan \\<prompt\\>, or include "ultraplan" anywhere', 'in your prompt', '', 'Advanced multi-agent plan mode with our most powerful model', '(Opus). Runs in Claude Code on the web. When the plan is ready,', 'you can execute it in the web session or send it back here.', 'Terminal stays free while the remote plans.', 'Requires /login.', '', `Terms: ${CCR_TERMS_URL}`].join('\n'); 191: } 192: setAppState(prev => prev.ultraplanLaunching ? prev : { 193: ...prev, 194: ultraplanLaunching: true 195: }); 196: void launchDetached({ 197: blurb, 198: seedPlan, 199: getAppState, 200: setAppState, 201: signal, 202: onSessionReady 203: }); 204: return buildLaunchMessage(disconnectedBridge); 205: } 206: async function launchDetached(opts: { 207: blurb: string; 208: seedPlan?: string; 209: getAppState: () => AppState; 210: setAppState: (f: (prev: AppState) => AppState) => void; 211: signal: AbortSignal; 212: onSessionReady?: (msg: string) => void; 213: }): Promise<void> { 214: const { 215: blurb, 216: seedPlan, 217: getAppState, 218: setAppState, 219: signal, 220: onSessionReady 221: } = opts; 222: let sessionId: string | undefined; 223: try { 224: const model = getUltraplanModel(); 225: const eligibility = await checkRemoteAgentEligibility(); 226: if (!eligibility.eligible) { 227: logEvent('tengu_ultraplan_create_failed', { 228: reason: 'precondition' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 229: precondition_errors: eligibility.errors.map(e => e.type).join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 230: }); 231: const reasons = eligibility.errors.map(formatPreconditionError).join('\n'); 232: enqueuePendingNotification({ 233: value: `ultraplan: cannot launch remote session —\n${reasons}`, 234: mode: 'task-notification' 235: }); 236: return; 237: } 238: const prompt = buildUltraplanPrompt(blurb, seedPlan); 239: let bundleFailMsg: string | undefined; 240: const session = await teleportToRemote({ 241: initialMessage: prompt, 242: description: blurb || 'Refine local plan', 243: model, 244: permissionMode: 'plan', 245: ultraplan: true, 246: signal, 247: useDefaultEnvironment: true, 248: onBundleFail: msg => { 249: bundleFailMsg = msg; 250: } 251: }); 252: if (!session) { 253: logEvent('tengu_ultraplan_create_failed', { 254: reason: (bundleFailMsg ? 'bundle_fail' : 'teleport_null') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 255: }); 256: enqueuePendingNotification({ 257: value: `ultraplan: session creation failed${bundleFailMsg ? ` — ${bundleFailMsg}` : ''}. See --debug for details.`, 258: mode: 'task-notification' 259: }); 260: return; 261: } 262: sessionId = session.id; 263: const url = getRemoteSessionUrl(session.id, process.env.SESSION_INGRESS_URL); 264: setAppState(prev => ({ 265: ...prev, 266: ultraplanSessionUrl: url, 267: ultraplanLaunching: undefined 268: })); 269: onSessionReady?.(buildSessionReadyMessage(url)); 270: logEvent('tengu_ultraplan_launched', { 271: has_seed_plan: Boolean(seedPlan), 272: model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 273: }); 274: const { 275: taskId 276: } = registerRemoteAgentTask({ 277: remoteTaskType: 'ultraplan', 278: session: { 279: id: session.id, 280: title: blurb || 'Ultraplan' 281: }, 282: command: blurb, 283: context: { 284: abortController: new AbortController(), 285: getAppState, 286: setAppState 287: }, 288: isUltraplan: true 289: }); 290: startDetachedPoll(taskId, session.id, url, getAppState, setAppState); 291: } catch (e) { 292: logError(e); 293: logEvent('tengu_ultraplan_create_failed', { 294: reason: 'unexpected_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 295: }); 296: enqueuePendingNotification({ 297: value: `ultraplan: unexpected error — ${errorMessage(e)}`, 298: mode: 'task-notification' 299: }); 300: if (sessionId) { 301: void archiveRemoteSession(sessionId).catch(err => logForDebugging('ultraplan: failed to archive orphaned session', err)); 302: setAppState(prev => prev.ultraplanSessionUrl ? { 303: ...prev, 304: ultraplanSessionUrl: undefined 305: } : prev); 306: } 307: } finally { 308: setAppState(prev => prev.ultraplanLaunching ? { 309: ...prev, 310: ultraplanLaunching: undefined 311: } : prev); 312: } 313: } 314: const call: LocalJSXCommandCall = async (onDone, context, args) => { 315: const blurb = args.trim(); 316: if (!blurb) { 317: const msg = await launchUltraplan({ 318: blurb, 319: getAppState: context.getAppState, 320: setAppState: context.setAppState, 321: signal: context.abortController.signal 322: }); 323: onDone(msg, { 324: display: 'system' 325: }); 326: return null; 327: } 328: const { 329: ultraplanSessionUrl: active, 330: ultraplanLaunching 331: } = context.getAppState(); 332: if (active || ultraplanLaunching) { 333: logEvent('tengu_ultraplan_create_failed', { 334: reason: (active ? 'already_polling' : 'already_launching') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 335: }); 336: onDone(buildAlreadyActiveMessage(active), { 337: display: 'system' 338: }); 339: return null; 340: } 341: context.setAppState(prev => ({ 342: ...prev, 343: ultraplanLaunchPending: { 344: blurb 345: } 346: })); 347: onDone(undefined, { 348: display: 'skip' 349: }); 350: return null; 351: }; 352: export default { 353: type: 'local-jsx', 354: name: 'ultraplan', 355: description: `~10–30 min · Claude Code on the web drafts an advanced plan you can edit and approve. See ${CCR_TERMS_URL}`, 356: argumentHint: '<prompt>', 357: isEnabled: () => "external" === 'ant', 358: load: () => Promise.resolve({ 359: call 360: }) 361: } satisfies Command;

File: src/commands/version.ts

typescript 1: import type { Command, LocalCommandCall } from '../types/command.js' 2: const call: LocalCommandCall = async () => { 3: return { 4: type: 'text', 5: value: MACRO.BUILD_TIME 6: ? `${MACRO.VERSION} (built ${MACRO.BUILD_TIME})` 7: : MACRO.VERSION, 8: } 9: } 10: const version = { 11: type: 'local', 12: name: 'version', 13: description: 14: 'Print the version this session is running (not what autoupdate downloaded)', 15: isEnabled: () => process.env.USER_TYPE === 'ant', 16: supportsNonInteractive: true, 17: load: () => Promise.resolve({ call }), 18: } satisfies Command 19: export default version

File: src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode } from 'react'; 3: import { Box } from '../../../../ink.js'; 4: import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; 5: import type { AgentColorName } from '../../../../tools/AgentTool/agentColorManager.js'; 6: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 7: import { Byline } from '../../../design-system/Byline.js'; 8: import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; 9: import { useWizard } from '../../../wizard/index.js'; 10: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 11: import { ColorPicker } from '../../ColorPicker.js'; 12: import type { AgentWizardData } from '../types.js'; 13: export function ColorStep() { 14: const $ = _c(14); 15: const { 16: goNext, 17: goBack, 18: updateWizardData, 19: wizardData 20: } = useWizard(); 21: let t0; 22: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 23: t0 = { 24: context: "Confirmation" 25: }; 26: $[0] = t0; 27: } else { 28: t0 = $[0]; 29: } 30: useKeybinding("confirm:no", goBack, t0); 31: let t1; 32: if ($[1] !== goNext || $[2] !== updateWizardData || $[3] !== wizardData.agentType || $[4] !== wizardData.location || $[5] !== wizardData.selectedModel || $[6] !== wizardData.selectedTools || $[7] !== wizardData.systemPrompt || $[8] !== wizardData.whenToUse) { 33: t1 = color => { 34: updateWizardData({ 35: selectedColor: color, 36: finalAgent: { 37: agentType: wizardData.agentType, 38: whenToUse: wizardData.whenToUse, 39: getSystemPrompt: () => wizardData.systemPrompt, 40: tools: wizardData.selectedTools, 41: ...(wizardData.selectedModel ? { 42: model: wizardData.selectedModel 43: } : {}), 44: ...(color ? { 45: color: color as AgentColorName 46: } : {}), 47: source: wizardData.location 48: } 49: }); 50: goNext(); 51: }; 52: $[1] = goNext; 53: $[2] = updateWizardData; 54: $[3] = wizardData.agentType; 55: $[4] = wizardData.location; 56: $[5] = wizardData.selectedModel; 57: $[6] = wizardData.selectedTools; 58: $[7] = wizardData.systemPrompt; 59: $[8] = wizardData.whenToUse; 60: $[9] = t1; 61: } else { 62: t1 = $[9]; 63: } 64: const handleConfirm = t1; 65: let t2; 66: if ($[10] === Symbol.for("react.memo_cache_sentinel")) { 67: t2 = <Byline><KeyboardShortcutHint shortcut={"\u2191\u2193"} action="navigate" /><KeyboardShortcutHint shortcut="Enter" action="select" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" /></Byline>; 68: $[10] = t2; 69: } else { 70: t2 = $[10]; 71: } 72: const t3 = wizardData.agentType || "agent"; 73: let t4; 74: if ($[11] !== handleConfirm || $[12] !== t3) { 75: t4 = <WizardDialogLayout subtitle="Choose background color" footerText={t2}><Box><ColorPicker agentName={t3} currentColor="automatic" onConfirm={handleConfirm} /></Box></WizardDialogLayout>; 76: $[11] = handleConfirm; 77: $[12] = t3; 78: $[13] = t4; 79: } else { 80: t4 = $[13]; 81: } 82: return t4; 83: }

File: src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode } from 'react'; 3: import type { KeyboardEvent } from '../../../../ink/events/keyboard-event.js'; 4: import { Box, Text } from '../../../../ink.js'; 5: import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; 6: import { isAutoMemoryEnabled } from '../../../../memdir/paths.js'; 7: import type { Tools } from '../../../../Tool.js'; 8: import { getMemoryScopeDisplay } from '../../../../tools/AgentTool/agentMemory.js'; 9: import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; 10: import { truncateToWidth } from '../../../../utils/format.js'; 11: import { getAgentModelDisplay } from '../../../../utils/model/agent.js'; 12: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 13: import { Byline } from '../../../design-system/Byline.js'; 14: import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; 15: import { useWizard } from '../../../wizard/index.js'; 16: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 17: import { getNewRelativeAgentFilePath } from '../../agentFileUtils.js'; 18: import { validateAgent } from '../../validateAgent.js'; 19: import type { AgentWizardData } from '../types.js'; 20: type Props = { 21: tools: Tools; 22: existingAgents: AgentDefinition[]; 23: onSave: () => void; 24: onSaveAndEdit: () => void; 25: error?: string | null; 26: }; 27: export function ConfirmStep(t0) { 28: const $ = _c(88); 29: const { 30: tools, 31: existingAgents, 32: onSave, 33: onSaveAndEdit, 34: error 35: } = t0; 36: const { 37: goBack, 38: wizardData 39: } = useWizard(); 40: let t1; 41: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 42: t1 = { 43: context: "Confirmation" 44: }; 45: $[0] = t1; 46: } else { 47: t1 = $[0]; 48: } 49: useKeybinding("confirm:no", goBack, t1); 50: let t2; 51: if ($[1] !== onSave || $[2] !== onSaveAndEdit) { 52: t2 = e => { 53: if (e.key === "s" || e.key === "return") { 54: e.preventDefault(); 55: onSave(); 56: } else { 57: if (e.key === "e") { 58: e.preventDefault(); 59: onSaveAndEdit(); 60: } 61: } 62: }; 63: $[1] = onSave; 64: $[2] = onSaveAndEdit; 65: $[3] = t2; 66: } else { 67: t2 = $[3]; 68: } 69: const handleKeyDown = t2; 70: const agent = wizardData.finalAgent; 71: let T0; 72: let T1; 73: let t10; 74: let t11; 75: let t12; 76: let t13; 77: let t14; 78: let t15; 79: let t16; 80: let t17; 81: let t18; 82: let t19; 83: let t3; 84: let t4; 85: let t5; 86: let t6; 87: let t7; 88: let t8; 89: let t9; 90: if ($[4] !== agent || $[5] !== existingAgents || $[6] !== handleKeyDown || $[7] !== tools || $[8] !== wizardData.location) { 91: const validation = validateAgent(agent, tools, existingAgents); 92: let t20; 93: if ($[28] !== agent) { 94: t20 = truncateToWidth(agent.getSystemPrompt(), 240); 95: $[28] = agent; 96: $[29] = t20; 97: } else { 98: t20 = $[29]; 99: } 100: const systemPromptPreview = t20; 101: let t21; 102: if ($[30] !== agent.whenToUse) { 103: t21 = truncateToWidth(agent.whenToUse, 240); 104: $[30] = agent.whenToUse; 105: $[31] = t21; 106: } else { 107: t21 = $[31]; 108: } 109: const whenToUsePreview = t21; 110: const getToolsDisplay = _temp; 111: let t22; 112: if ($[32] !== agent.memory) { 113: t22 = isAutoMemoryEnabled() ? <Text><Text bold={true}>Memory</Text>: {getMemoryScopeDisplay(agent.memory)}</Text> : null; 114: $[32] = agent.memory; 115: $[33] = t22; 116: } else { 117: t22 = $[33]; 118: } 119: const memoryDisplayElement = t22; 120: T1 = WizardDialogLayout; 121: t18 = "Confirm and save"; 122: if ($[34] === Symbol.for("react.memo_cache_sentinel")) { 123: t19 = <Byline><KeyboardShortcutHint shortcut="s/Enter" action="save" /><KeyboardShortcutHint shortcut="e" action="edit in your editor" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline>; 124: $[34] = t19; 125: } else { 126: t19 = $[34]; 127: } 128: T0 = Box; 129: t3 = "column"; 130: t4 = 0; 131: t5 = true; 132: t6 = handleKeyDown; 133: let t23; 134: if ($[35] === Symbol.for("react.memo_cache_sentinel")) { 135: t23 = <Text bold={true}>Name</Text>; 136: $[35] = t23; 137: } else { 138: t23 = $[35]; 139: } 140: if ($[36] !== agent.agentType) { 141: t7 = <Text>{t23}: {agent.agentType}</Text>; 142: $[36] = agent.agentType; 143: $[37] = t7; 144: } else { 145: t7 = $[37]; 146: } 147: let t24; 148: if ($[38] === Symbol.for("react.memo_cache_sentinel")) { 149: t24 = <Text bold={true}>Location</Text>; 150: $[38] = t24; 151: } else { 152: t24 = $[38]; 153: } 154: let t25; 155: if ($[39] !== agent.agentType || $[40] !== wizardData.location) { 156: t25 = getNewRelativeAgentFilePath({ 157: source: wizardData.location, 158: agentType: agent.agentType 159: }); 160: $[39] = agent.agentType; 161: $[40] = wizardData.location; 162: $[41] = t25; 163: } else { 164: t25 = $[41]; 165: } 166: if ($[42] !== t25) { 167: t8 = <Text>{t24}:{" "}{t25}</Text>; 168: $[42] = t25; 169: $[43] = t8; 170: } else { 171: t8 = $[43]; 172: } 173: let t26; 174: if ($[44] === Symbol.for("react.memo_cache_sentinel")) { 175: t26 = <Text bold={true}>Tools</Text>; 176: $[44] = t26; 177: } else { 178: t26 = $[44]; 179: } 180: let t27; 181: if ($[45] !== agent.tools) { 182: t27 = getToolsDisplay(agent.tools); 183: $[45] = agent.tools; 184: $[46] = t27; 185: } else { 186: t27 = $[46]; 187: } 188: if ($[47] !== t27) { 189: t9 = <Text>{t26}: {t27}</Text>; 190: $[47] = t27; 191: $[48] = t9; 192: } else { 193: t9 = $[48]; 194: } 195: let t28; 196: if ($[49] === Symbol.for("react.memo_cache_sentinel")) { 197: t28 = <Text bold={true}>Model</Text>; 198: $[49] = t28; 199: } else { 200: t28 = $[49]; 201: } 202: let t29; 203: if ($[50] !== agent.model) { 204: t29 = getAgentModelDisplay(agent.model); 205: $[50] = agent.model; 206: $[51] = t29; 207: } else { 208: t29 = $[51]; 209: } 210: if ($[52] !== t29) { 211: t10 = <Text>{t28}: {t29}</Text>; 212: $[52] = t29; 213: $[53] = t10; 214: } else { 215: t10 = $[53]; 216: } 217: t11 = memoryDisplayElement; 218: if ($[54] === Symbol.for("react.memo_cache_sentinel")) { 219: t12 = <Box marginTop={1}><Text><Text bold={true}>Description</Text> (tells Claude when to use this agent):</Text></Box>; 220: $[54] = t12; 221: } else { 222: t12 = $[54]; 223: } 224: if ($[55] !== whenToUsePreview) { 225: t13 = <Box marginLeft={2} marginTop={1}><Text>{whenToUsePreview}</Text></Box>; 226: $[55] = whenToUsePreview; 227: $[56] = t13; 228: } else { 229: t13 = $[56]; 230: } 231: if ($[57] === Symbol.for("react.memo_cache_sentinel")) { 232: t14 = <Box marginTop={1}><Text><Text bold={true}>System prompt</Text>:</Text></Box>; 233: $[57] = t14; 234: } else { 235: t14 = $[57]; 236: } 237: if ($[58] !== systemPromptPreview) { 238: t15 = <Box marginLeft={2} marginTop={1}><Text>{systemPromptPreview}</Text></Box>; 239: $[58] = systemPromptPreview; 240: $[59] = t15; 241: } else { 242: t15 = $[59]; 243: } 244: t16 = validation.warnings.length > 0 && <Box marginTop={1} flexDirection="column"><Text color="warning">Warnings:</Text>{validation.warnings.map(_temp2)}</Box>; 245: t17 = validation.errors.length > 0 && <Box marginTop={1} flexDirection="column"><Text color="error">Errors:</Text>{validation.errors.map(_temp3)}</Box>; 246: $[4] = agent; 247: $[5] = existingAgents; 248: $[6] = handleKeyDown; 249: $[7] = tools; 250: $[8] = wizardData.location; 251: $[9] = T0; 252: $[10] = T1; 253: $[11] = t10; 254: $[12] = t11; 255: $[13] = t12; 256: $[14] = t13; 257: $[15] = t14; 258: $[16] = t15; 259: $[17] = t16; 260: $[18] = t17; 261: $[19] = t18; 262: $[20] = t19; 263: $[21] = t3; 264: $[22] = t4; 265: $[23] = t5; 266: $[24] = t6; 267: $[25] = t7; 268: $[26] = t8; 269: $[27] = t9; 270: } else { 271: T0 = $[9]; 272: T1 = $[10]; 273: t10 = $[11]; 274: t11 = $[12]; 275: t12 = $[13]; 276: t13 = $[14]; 277: t14 = $[15]; 278: t15 = $[16]; 279: t16 = $[17]; 280: t17 = $[18]; 281: t18 = $[19]; 282: t19 = $[20]; 283: t3 = $[21]; 284: t4 = $[22]; 285: t5 = $[23]; 286: t6 = $[24]; 287: t7 = $[25]; 288: t8 = $[26]; 289: t9 = $[27]; 290: } 291: let t20; 292: if ($[60] !== error) { 293: t20 = error && <Box marginTop={1}><Text color="error">{error}</Text></Box>; 294: $[60] = error; 295: $[61] = t20; 296: } else { 297: t20 = $[61]; 298: } 299: let t21; 300: if ($[62] === Symbol.for("react.memo_cache_sentinel")) { 301: t21 = <Text bold={true}>s</Text>; 302: $[62] = t21; 303: } else { 304: t21 = $[62]; 305: } 306: let t22; 307: if ($[63] === Symbol.for("react.memo_cache_sentinel")) { 308: t22 = <Text bold={true}>Enter</Text>; 309: $[63] = t22; 310: } else { 311: t22 = $[63]; 312: } 313: let t23; 314: if ($[64] === Symbol.for("react.memo_cache_sentinel")) { 315: t23 = <Box marginTop={2}><Text color="success">Press {t21} or {t22} to save,{" "}<Text bold={true}>e</Text> to save and edit</Text></Box>; 316: $[64] = t23; 317: } else { 318: t23 = $[64]; 319: } 320: let t24; 321: if ($[65] !== T0 || $[66] !== t10 || $[67] !== t11 || $[68] !== t12 || $[69] !== t13 || $[70] !== t14 || $[71] !== t15 || $[72] !== t16 || $[73] !== t17 || $[74] !== t20 || $[75] !== t3 || $[76] !== t4 || $[77] !== t5 || $[78] !== t6 || $[79] !== t7 || $[80] !== t8 || $[81] !== t9) { 322: t24 = <T0 flexDirection={t3} tabIndex={t4} autoFocus={t5} onKeyDown={t6}>{t7}{t8}{t9}{t10}{t11}{t12}{t13}{t14}{t15}{t16}{t17}{t20}{t23}</T0>; 323: $[65] = T0; 324: $[66] = t10; 325: $[67] = t11; 326: $[68] = t12; 327: $[69] = t13; 328: $[70] = t14; 329: $[71] = t15; 330: $[72] = t16; 331: $[73] = t17; 332: $[74] = t20; 333: $[75] = t3; 334: $[76] = t4; 335: $[77] = t5; 336: $[78] = t6; 337: $[79] = t7; 338: $[80] = t8; 339: $[81] = t9; 340: $[82] = t24; 341: } else { 342: t24 = $[82]; 343: } 344: let t25; 345: if ($[83] !== T1 || $[84] !== t18 || $[85] !== t19 || $[86] !== t24) { 346: t25 = <T1 subtitle={t18} footerText={t19}>{t24}</T1>; 347: $[83] = T1; 348: $[84] = t18; 349: $[85] = t19; 350: $[86] = t24; 351: $[87] = t25; 352: } else { 353: t25 = $[87]; 354: } 355: return t25; 356: } 357: function _temp3(err, i_0) { 358: return <Text key={i_0} color="error">{" "}• {err}</Text>; 359: } 360: function _temp2(warning, i) { 361: return <Text key={i} dimColor={true}>{" "}• {warning}</Text>; 362: } 363: function _temp(toolNames) { 364: if (toolNames === undefined) { 365: return "All tools"; 366: } 367: if (toolNames.length === 0) { 368: return "None"; 369: } 370: if (toolNames.length === 1) { 371: return toolNames[0] || "None"; 372: } 373: if (toolNames.length === 2) { 374: return toolNames.join(" and "); 375: } 376: return `${toolNames.slice(0, -1).join(", ")}, and ${toolNames[toolNames.length - 1]}`; 377: }

File: src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx

typescript 1: import chalk from 'chalk'; 2: import React, { type ReactNode, useCallback, useState } from 'react'; 3: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; 4: import { useSetAppState } from 'src/state/AppState.js'; 5: import type { Tools } from '../../../../Tool.js'; 6: import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; 7: import { getActiveAgentsFromList } from '../../../../tools/AgentTool/loadAgentsDir.js'; 8: import { editFileInEditor } from '../../../../utils/promptEditor.js'; 9: import { useWizard } from '../../../wizard/index.js'; 10: import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js'; 11: import type { AgentWizardData } from '../types.js'; 12: import { ConfirmStep } from './ConfirmStep.js'; 13: type Props = { 14: tools: Tools; 15: existingAgents: AgentDefinition[]; 16: onComplete: (message: string) => void; 17: }; 18: export function ConfirmStepWrapper({ 19: tools, 20: existingAgents, 21: onComplete 22: }: Props): ReactNode { 23: const { 24: wizardData 25: } = useWizard<AgentWizardData>(); 26: const [saveError, setSaveError] = useState<string | null>(null); 27: const setAppState = useSetAppState(); 28: const saveAgent = useCallback(async (openInEditor: boolean): Promise<void> => { 29: if (!wizardData?.finalAgent) return; 30: try { 31: await saveAgentToFile(wizardData.location!, wizardData.finalAgent.agentType, wizardData.finalAgent.whenToUse, wizardData.finalAgent.tools, wizardData.finalAgent.getSystemPrompt(), true, wizardData.finalAgent.color, wizardData.finalAgent.model, wizardData.finalAgent.memory); 32: setAppState(state => { 33: if (!wizardData.finalAgent) return state; 34: const allAgents = state.agentDefinitions.allAgents.concat(wizardData.finalAgent); 35: return { 36: ...state, 37: agentDefinitions: { 38: ...state.agentDefinitions, 39: activeAgents: getActiveAgentsFromList(allAgents), 40: allAgents 41: } 42: }; 43: }); 44: if (openInEditor) { 45: const filePath = getNewAgentFilePath({ 46: source: wizardData.location!, 47: agentType: wizardData.finalAgent.agentType 48: }); 49: await editFileInEditor(filePath); 50: } 51: logEvent('tengu_agent_created', { 52: agent_type: wizardData.finalAgent.agentType, 53: generation_method: wizardData.wasGenerated ? 'generated' : 'manual', 54: source: wizardData.location!, 55: tool_count: wizardData.finalAgent.tools?.length ?? 'all', 56: has_custom_model: !!wizardData.finalAgent.model, 57: has_custom_color: !!wizardData.finalAgent.color, 58: has_memory: !!wizardData.finalAgent.memory, 59: memory_scope: wizardData.finalAgent.memory ?? 'none', 60: ...(openInEditor ? { 61: opened_in_editor: true 62: } : {}) 63: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS); 64: const message = openInEditor ? `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)} and opened in editor. ` + `If you made edits, restart to load the latest version.` : `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)}`; 65: onComplete(message); 66: } catch (err) { 67: setSaveError(err instanceof Error ? err.message : 'Failed to save agent'); 68: } 69: }, [wizardData, onComplete, setAppState]); 70: const handleSave = useCallback(() => saveAgent(false), [saveAgent]); 71: const handleSaveAndEdit = useCallback(() => saveAgent(true), [saveAgent]); 72: return <ConfirmStep tools={tools} existingAgents={existingAgents} onSave={handleSave} onSaveAndEdit={handleSaveAndEdit} error={saveError} />; 73: }

File: src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode, useCallback, useState } from 'react'; 3: import { Box, Text } from '../../../../ink.js'; 4: import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; 5: import { editPromptInEditor } from '../../../../utils/promptEditor.js'; 6: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 7: import { Byline } from '../../../design-system/Byline.js'; 8: import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; 9: import TextInput from '../../../TextInput.js'; 10: import { useWizard } from '../../../wizard/index.js'; 11: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 12: import type { AgentWizardData } from '../types.js'; 13: export function DescriptionStep() { 14: const $ = _c(18); 15: const { 16: goNext, 17: goBack, 18: updateWizardData, 19: wizardData 20: } = useWizard(); 21: const [whenToUse, setWhenToUse] = useState(wizardData.whenToUse || ""); 22: const [cursorOffset, setCursorOffset] = useState(whenToUse.length); 23: const [error, setError] = useState(null); 24: let t0; 25: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 26: t0 = { 27: context: "Settings" 28: }; 29: $[0] = t0; 30: } else { 31: t0 = $[0]; 32: } 33: useKeybinding("confirm:no", goBack, t0); 34: let t1; 35: if ($[1] !== whenToUse) { 36: t1 = async () => { 37: const result = await editPromptInEditor(whenToUse); 38: if (result.content !== null) { 39: setWhenToUse(result.content); 40: setCursorOffset(result.content.length); 41: } 42: }; 43: $[1] = whenToUse; 44: $[2] = t1; 45: } else { 46: t1 = $[2]; 47: } 48: const handleExternalEditor = t1; 49: let t2; 50: if ($[3] === Symbol.for("react.memo_cache_sentinel")) { 51: t2 = { 52: context: "Chat" 53: }; 54: $[3] = t2; 55: } else { 56: t2 = $[3]; 57: } 58: useKeybinding("chat:externalEditor", handleExternalEditor, t2); 59: let t3; 60: if ($[4] !== goNext || $[5] !== updateWizardData) { 61: t3 = value => { 62: const trimmedValue = value.trim(); 63: if (!trimmedValue) { 64: setError("Description is required"); 65: return; 66: } 67: setError(null); 68: updateWizardData({ 69: whenToUse: trimmedValue 70: }); 71: goNext(); 72: }; 73: $[4] = goNext; 74: $[5] = updateWizardData; 75: $[6] = t3; 76: } else { 77: t3 = $[6]; 78: } 79: const handleSubmit = t3; 80: let t4; 81: if ($[7] === Symbol.for("react.memo_cache_sentinel")) { 82: t4 = <Byline><KeyboardShortcutHint shortcut="Type" action="enter text" /><KeyboardShortcutHint shortcut="Enter" action="continue" /><ConfigurableShortcutHint action="chat:externalEditor" context="Chat" fallback="ctrl+g" description="open in editor" /><ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="go back" /></Byline>; 83: $[7] = t4; 84: } else { 85: t4 = $[7]; 86: } 87: let t5; 88: if ($[8] === Symbol.for("react.memo_cache_sentinel")) { 89: t5 = <Text>When should Claude use this agent?</Text>; 90: $[8] = t5; 91: } else { 92: t5 = $[8]; 93: } 94: let t6; 95: if ($[9] !== cursorOffset || $[10] !== handleSubmit || $[11] !== whenToUse) { 96: t6 = <Box marginTop={1}><TextInput value={whenToUse} onChange={setWhenToUse} onSubmit={handleSubmit} placeholder="e.g., use this agent after you're done writing code..." columns={80} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} focus={true} showCursor={true} /></Box>; 97: $[9] = cursorOffset; 98: $[10] = handleSubmit; 99: $[11] = whenToUse; 100: $[12] = t6; 101: } else { 102: t6 = $[12]; 103: } 104: let t7; 105: if ($[13] !== error) { 106: t7 = error && <Box marginTop={1}><Text color="error">{error}</Text></Box>; 107: $[13] = error; 108: $[14] = t7; 109: } else { 110: t7 = $[14]; 111: } 112: let t8; 113: if ($[15] !== t6 || $[16] !== t7) { 114: t8 = <WizardDialogLayout subtitle="Description (tell Claude when to use this agent)" footerText={t4}><Box flexDirection="column">{t5}{t6}{t7}</Box></WizardDialogLayout>; 115: $[15] = t6; 116: $[16] = t7; 117: $[17] = t8; 118: } else { 119: t8 = $[17]; 120: } 121: return t8; 122: }

File: src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx

typescript 1: import { APIUserAbortError } from '@anthropic-ai/sdk'; 2: import React, { type ReactNode, useCallback, useRef, useState } from 'react'; 3: import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js'; 4: import { Box, Text } from '../../../../ink.js'; 5: import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; 6: import { createAbortController } from '../../../../utils/abortController.js'; 7: import { editPromptInEditor } from '../../../../utils/promptEditor.js'; 8: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 9: import { Byline } from '../../../design-system/Byline.js'; 10: import { Spinner } from '../../../Spinner.js'; 11: import TextInput from '../../../TextInput.js'; 12: import { useWizard } from '../../../wizard/index.js'; 13: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 14: import { generateAgent } from '../../generateAgent.js'; 15: import type { AgentWizardData } from '../types.js'; 16: export function GenerateStep(): ReactNode { 17: const { 18: updateWizardData, 19: goBack, 20: goToStep, 21: wizardData 22: } = useWizard<AgentWizardData>(); 23: const [prompt, setPrompt] = useState(wizardData.generationPrompt || ''); 24: const [isGenerating, setIsGenerating] = useState(false); 25: const [error, setError] = useState<string | null>(null); 26: const [cursorOffset, setCursorOffset] = useState(prompt.length); 27: const model = useMainLoopModel(); 28: const abortControllerRef = useRef<AbortController | null>(null); 29: // Cancel generation when escape pressed during generation 30: const handleCancelGeneration = useCallback(() => { 31: if (abortControllerRef.current) { 32: abortControllerRef.current.abort(); 33: abortControllerRef.current = null; 34: setIsGenerating(false); 35: setError('Generation cancelled'); 36: } 37: }, []); 38: useKeybinding('confirm:no', handleCancelGeneration, { 39: context: 'Settings', 40: isActive: isGenerating 41: }); 42: const handleExternalEditor = useCallback(async () => { 43: const result = await editPromptInEditor(prompt); 44: if (result.content !== null) { 45: setPrompt(result.content); 46: setCursorOffset(result.content.length); 47: } 48: }, [prompt]); 49: useKeybinding('chat:externalEditor', handleExternalEditor, { 50: context: 'Chat', 51: isActive: !isGenerating 52: }); 53: const handleGoBack = useCallback(() => { 54: updateWizardData({ 55: generationPrompt: '', 56: agentType: '', 57: systemPrompt: '', 58: whenToUse: '', 59: generatedAgent: undefined, 60: wasGenerated: false 61: }); 62: setPrompt(''); 63: setError(null); 64: goBack(); 65: }, [updateWizardData, goBack]); 66: // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in prompt input) 67: useKeybinding('confirm:no', handleGoBack, { 68: context: 'Settings', 69: isActive: !isGenerating 70: }); 71: const handleGenerate = async (): Promise<void> => { 72: const trimmedPrompt = prompt.trim(); 73: if (!trimmedPrompt) { 74: setError('Please describe what the agent should do'); 75: return; 76: } 77: setError(null); 78: setIsGenerating(true); 79: updateWizardData({ 80: generationPrompt: trimmedPrompt, 81: isGenerating: true 82: }); 83: const controller = createAbortController(); 84: abortControllerRef.current = controller; 85: try { 86: const generated = await generateAgent(trimmedPrompt, model, [], controller.signal); 87: updateWizardData({ 88: agentType: generated.identifier, 89: whenToUse: generated.whenToUse, 90: systemPrompt: generated.systemPrompt, 91: generatedAgent: generated, 92: isGenerating: false, 93: wasGenerated: true 94: }); 95: goToStep(6); 96: } catch (err) { 97: if (err instanceof APIUserAbortError) { 98: } else if (err instanceof Error && !err.message.includes('No assistant message found')) { 99: setError(err.message || 'Failed to generate agent'); 100: } 101: updateWizardData({ 102: isGenerating: false 103: }); 104: } finally { 105: setIsGenerating(false); 106: abortControllerRef.current = null; 107: } 108: }; 109: const subtitle = 'Describe what this agent should do and when it should be used (be comprehensive for best results)'; 110: if (isGenerating) { 111: return <WizardDialogLayout subtitle={subtitle} footerText={<ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="cancel" />}> 112: <Box flexDirection="row" alignItems="center"> 113: <Spinner /> 114: <Text color="suggestion"> Generating agent from description...</Text> 115: </Box> 116: </WizardDialogLayout>; 117: } 118: return <WizardDialogLayout subtitle={subtitle} footerText={<Byline> 119: <ConfigurableShortcutHint action="confirm:yes" context="Confirmation" fallback="Enter" description="submit" /> 120: <ConfigurableShortcutHint action="chat:externalEditor" context="Chat" fallback="ctrl+g" description="open in editor" /> 121: <ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="go back" /> 122: </Byline>}> 123: <Box flexDirection="column"> 124: {error && <Box marginBottom={1}> 125: <Text color="error">{error}</Text> 126: </Box>} 127: <TextInput value={prompt} onChange={setPrompt} onSubmit={handleGenerate} placeholder="e.g., Help me write unit tests for my code..." columns={80} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} focus showCursor /> 128: </Box> 129: </WizardDialogLayout>; 130: }

File: src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode } from 'react'; 3: import { Box } from '../../../../ink.js'; 4: import type { SettingSource } from '../../../../utils/settings/constants.js'; 5: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 6: import { Select } from '../../../CustomSelect/select.js'; 7: import { Byline } from '../../../design-system/Byline.js'; 8: import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; 9: import { useWizard } from '../../../wizard/index.js'; 10: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 11: import type { AgentWizardData } from '../types.js'; 12: export function LocationStep() { 13: const $ = _c(11); 14: const { 15: goNext, 16: updateWizardData, 17: cancel 18: } = useWizard(); 19: let t0; 20: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 21: t0 = { 22: label: "Project (.claude/agents/)", 23: value: "projectSettings" as SettingSource 24: }; 25: $[0] = t0; 26: } else { 27: t0 = $[0]; 28: } 29: let t1; 30: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 31: t1 = [t0, { 32: label: "Personal (~/.claude/agents/)", 33: value: "userSettings" as SettingSource 34: }]; 35: $[1] = t1; 36: } else { 37: t1 = $[1]; 38: } 39: const locationOptions = t1; 40: let t2; 41: if ($[2] === Symbol.for("react.memo_cache_sentinel")) { 42: t2 = <Byline><KeyboardShortcutHint shortcut={"\u2191\u2193"} action="navigate" /><KeyboardShortcutHint shortcut="Enter" action="select" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline>; 43: $[2] = t2; 44: } else { 45: t2 = $[2]; 46: } 47: let t3; 48: if ($[3] !== goNext || $[4] !== updateWizardData) { 49: t3 = value => { 50: updateWizardData({ 51: location: value as SettingSource 52: }); 53: goNext(); 54: }; 55: $[3] = goNext; 56: $[4] = updateWizardData; 57: $[5] = t3; 58: } else { 59: t3 = $[5]; 60: } 61: let t4; 62: if ($[6] !== cancel) { 63: t4 = () => cancel(); 64: $[6] = cancel; 65: $[7] = t4; 66: } else { 67: t4 = $[7]; 68: } 69: let t5; 70: if ($[8] !== t3 || $[9] !== t4) { 71: t5 = <WizardDialogLayout subtitle="Choose location" footerText={t2}><Box><Select key="location-select" options={locationOptions} onChange={t3} onCancel={t4} /></Box></WizardDialogLayout>; 72: $[8] = t3; 73: $[9] = t4; 74: $[10] = t5; 75: } else { 76: t5 = $[10]; 77: } 78: return t5; 79: }

File: src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode } from 'react'; 3: import { Box } from '../../../../ink.js'; 4: import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; 5: import { isAutoMemoryEnabled } from '../../../../memdir/paths.js'; 6: import { type AgentMemoryScope, loadAgentMemoryPrompt } from '../../../../tools/AgentTool/agentMemory.js'; 7: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 8: import { Select } from '../../../CustomSelect/select.js'; 9: import { Byline } from '../../../design-system/Byline.js'; 10: import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; 11: import { useWizard } from '../../../wizard/index.js'; 12: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 13: import type { AgentWizardData } from '../types.js'; 14: type MemoryOption = { 15: label: string; 16: value: AgentMemoryScope | 'none'; 17: }; 18: export function MemoryStep() { 19: const $ = _c(13); 20: const { 21: goNext, 22: goBack, 23: updateWizardData, 24: wizardData 25: } = useWizard(); 26: let t0; 27: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 28: t0 = { 29: context: "Confirmation" 30: }; 31: $[0] = t0; 32: } else { 33: t0 = $[0]; 34: } 35: useKeybinding("confirm:no", goBack, t0); 36: const isUserScope = wizardData.location === "userSettings"; 37: let t1; 38: if ($[1] !== isUserScope) { 39: t1 = isUserScope ? [{ 40: label: "User scope (~/.claude/agent-memory/) (Recommended)", 41: value: "user" 42: }, { 43: label: "None (no persistent memory)", 44: value: "none" 45: }, { 46: label: "Project scope (.claude/agent-memory/)", 47: value: "project" 48: }, { 49: label: "Local scope (.claude/agent-memory-local/)", 50: value: "local" 51: }] : [{ 52: label: "Project scope (.claude/agent-memory/) (Recommended)", 53: value: "project" 54: }, { 55: label: "None (no persistent memory)", 56: value: "none" 57: }, { 58: label: "User scope (~/.claude/agent-memory/)", 59: value: "user" 60: }, { 61: label: "Local scope (.claude/agent-memory-local/)", 62: value: "local" 63: }]; 64: $[1] = isUserScope; 65: $[2] = t1; 66: } else { 67: t1 = $[2]; 68: } 69: const memoryOptions = t1; 70: let t2; 71: if ($[3] !== goNext || $[4] !== updateWizardData || $[5] !== wizardData.finalAgent || $[6] !== wizardData.systemPrompt) { 72: t2 = value => { 73: const memory = value === "none" ? undefined : value as AgentMemoryScope; 74: const agentType = wizardData.finalAgent?.agentType; 75: updateWizardData({ 76: selectedMemory: memory, 77: finalAgent: wizardData.finalAgent ? { 78: ...wizardData.finalAgent, 79: memory, 80: getSystemPrompt: isAutoMemoryEnabled() && memory && agentType ? () => wizardData.systemPrompt + "\n\n" + loadAgentMemoryPrompt(agentType, memory) : () => wizardData.systemPrompt 81: } : undefined 82: }); 83: goNext(); 84: }; 85: $[3] = goNext; 86: $[4] = updateWizardData; 87: $[5] = wizardData.finalAgent; 88: $[6] = wizardData.systemPrompt; 89: $[7] = t2; 90: } else { 91: t2 = $[7]; 92: } 93: const handleSelect = t2; 94: let t3; 95: if ($[8] === Symbol.for("react.memo_cache_sentinel")) { 96: t3 = <Byline><KeyboardShortcutHint shortcut={"\u2191\u2193"} action="navigate" /><KeyboardShortcutHint shortcut="Enter" action="select" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" /></Byline>; 97: $[8] = t3; 98: } else { 99: t3 = $[8]; 100: } 101: let t4; 102: if ($[9] !== goBack || $[10] !== handleSelect || $[11] !== memoryOptions) { 103: t4 = <WizardDialogLayout subtitle="Configure agent memory" footerText={t3}><Box><Select key="memory-select" options={memoryOptions} onChange={handleSelect} onCancel={goBack} /></Box></WizardDialogLayout>; 104: $[9] = goBack; 105: $[10] = handleSelect; 106: $[11] = memoryOptions; 107: $[12] = t4; 108: } else { 109: t4 = $[12]; 110: } 111: return t4; 112: }

File: src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode } from 'react'; 3: import { Box } from '../../../../ink.js'; 4: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 5: import { Select } from '../../../CustomSelect/select.js'; 6: import { Byline } from '../../../design-system/Byline.js'; 7: import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; 8: import { useWizard } from '../../../wizard/index.js'; 9: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 10: import type { AgentWizardData } from '../types.js'; 11: export function MethodStep() { 12: const $ = _c(11); 13: const { 14: goNext, 15: goBack, 16: updateWizardData, 17: goToStep 18: } = useWizard(); 19: let t0; 20: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 21: t0 = [{ 22: label: "Generate with Claude (recommended)", 23: value: "generate" 24: }, { 25: label: "Manual configuration", 26: value: "manual" 27: }]; 28: $[0] = t0; 29: } else { 30: t0 = $[0]; 31: } 32: const methodOptions = t0; 33: let t1; 34: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 35: t1 = <Byline><KeyboardShortcutHint shortcut={"\u2191\u2193"} action="navigate" /><KeyboardShortcutHint shortcut="Enter" action="select" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" /></Byline>; 36: $[1] = t1; 37: } else { 38: t1 = $[1]; 39: } 40: let t2; 41: if ($[2] !== goNext || $[3] !== goToStep || $[4] !== updateWizardData) { 42: t2 = value => { 43: const method = value as 'generate' | 'manual'; 44: updateWizardData({ 45: method, 46: wasGenerated: method === "generate" 47: }); 48: if (method === "generate") { 49: goNext(); 50: } else { 51: goToStep(3); 52: } 53: }; 54: $[2] = goNext; 55: $[3] = goToStep; 56: $[4] = updateWizardData; 57: $[5] = t2; 58: } else { 59: t2 = $[5]; 60: } 61: let t3; 62: if ($[6] !== goBack) { 63: t3 = () => goBack(); 64: $[6] = goBack; 65: $[7] = t3; 66: } else { 67: t3 = $[7]; 68: } 69: let t4; 70: if ($[8] !== t2 || $[9] !== t3) { 71: t4 = <WizardDialogLayout subtitle="Creation method" footerText={t1}><Box><Select key="method-select" options={methodOptions} onChange={t2} onCancel={t3} /></Box></WizardDialogLayout>; 72: $[8] = t2; 73: $[9] = t3; 74: $[10] = t4; 75: } else { 76: t4 = $[10]; 77: } 78: return t4; 79: }

File: src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode } from 'react'; 3: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 4: import { Byline } from '../../../design-system/Byline.js'; 5: import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; 6: import { useWizard } from '../../../wizard/index.js'; 7: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 8: import { ModelSelector } from '../../ModelSelector.js'; 9: import type { AgentWizardData } from '../types.js'; 10: export function ModelStep() { 11: const $ = _c(8); 12: const { 13: goNext, 14: goBack, 15: updateWizardData, 16: wizardData 17: } = useWizard(); 18: let t0; 19: if ($[0] !== goNext || $[1] !== updateWizardData) { 20: t0 = model => { 21: updateWizardData({ 22: selectedModel: model 23: }); 24: goNext(); 25: }; 26: $[0] = goNext; 27: $[1] = updateWizardData; 28: $[2] = t0; 29: } else { 30: t0 = $[2]; 31: } 32: const handleComplete = t0; 33: let t1; 34: if ($[3] === Symbol.for("react.memo_cache_sentinel")) { 35: t1 = <Byline><KeyboardShortcutHint shortcut={"\u2191\u2193"} action="navigate" /><KeyboardShortcutHint shortcut="Enter" action="select" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" /></Byline>; 36: $[3] = t1; 37: } else { 38: t1 = $[3]; 39: } 40: let t2; 41: if ($[4] !== goBack || $[5] !== handleComplete || $[6] !== wizardData.selectedModel) { 42: t2 = <WizardDialogLayout subtitle="Select model" footerText={t1}><ModelSelector initialModel={wizardData.selectedModel} onComplete={handleComplete} onCancel={goBack} /></WizardDialogLayout>; 43: $[4] = goBack; 44: $[5] = handleComplete; 45: $[6] = wizardData.selectedModel; 46: $[7] = t2; 47: } else { 48: t2 = $[7]; 49: } 50: return t2; 51: }

File: src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode, useCallback, useState } from 'react'; 3: import { Box, Text } from '../../../../ink.js'; 4: import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; 5: import { editPromptInEditor } from '../../../../utils/promptEditor.js'; 6: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 7: import { Byline } from '../../../design-system/Byline.js'; 8: import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; 9: import TextInput from '../../../TextInput.js'; 10: import { useWizard } from '../../../wizard/index.js'; 11: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 12: import type { AgentWizardData } from '../types.js'; 13: export function PromptStep() { 14: const $ = _c(20); 15: const { 16: goNext, 17: goBack, 18: updateWizardData, 19: wizardData 20: } = useWizard(); 21: const [systemPrompt, setSystemPrompt] = useState(wizardData.systemPrompt || ""); 22: const [cursorOffset, setCursorOffset] = useState(systemPrompt.length); 23: const [error, setError] = useState(null); 24: let t0; 25: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 26: t0 = { 27: context: "Settings" 28: }; 29: $[0] = t0; 30: } else { 31: t0 = $[0]; 32: } 33: useKeybinding("confirm:no", goBack, t0); 34: let t1; 35: if ($[1] !== systemPrompt) { 36: t1 = async () => { 37: const result = await editPromptInEditor(systemPrompt); 38: if (result.content !== null) { 39: setSystemPrompt(result.content); 40: setCursorOffset(result.content.length); 41: } 42: }; 43: $[1] = systemPrompt; 44: $[2] = t1; 45: } else { 46: t1 = $[2]; 47: } 48: const handleExternalEditor = t1; 49: let t2; 50: if ($[3] === Symbol.for("react.memo_cache_sentinel")) { 51: t2 = { 52: context: "Chat" 53: }; 54: $[3] = t2; 55: } else { 56: t2 = $[3]; 57: } 58: useKeybinding("chat:externalEditor", handleExternalEditor, t2); 59: let t3; 60: if ($[4] !== goNext || $[5] !== systemPrompt || $[6] !== updateWizardData) { 61: t3 = () => { 62: const trimmedPrompt = systemPrompt.trim(); 63: if (!trimmedPrompt) { 64: setError("System prompt is required"); 65: return; 66: } 67: setError(null); 68: updateWizardData({ 69: systemPrompt: trimmedPrompt 70: }); 71: goNext(); 72: }; 73: $[4] = goNext; 74: $[5] = systemPrompt; 75: $[6] = updateWizardData; 76: $[7] = t3; 77: } else { 78: t3 = $[7]; 79: } 80: const handleSubmit = t3; 81: let t4; 82: if ($[8] === Symbol.for("react.memo_cache_sentinel")) { 83: t4 = <Byline><KeyboardShortcutHint shortcut="Type" action="enter text" /><KeyboardShortcutHint shortcut="Enter" action="continue" /><ConfigurableShortcutHint action="chat:externalEditor" context="Chat" fallback="ctrl+g" description="open in editor" /><ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="go back" /></Byline>; 84: $[8] = t4; 85: } else { 86: t4 = $[8]; 87: } 88: let t5; 89: let t6; 90: if ($[9] === Symbol.for("react.memo_cache_sentinel")) { 91: t5 = <Text>Enter the system prompt for your agent:</Text>; 92: t6 = <Text dimColor={true}>Be comprehensive for best results</Text>; 93: $[9] = t5; 94: $[10] = t6; 95: } else { 96: t5 = $[9]; 97: t6 = $[10]; 98: } 99: let t7; 100: if ($[11] !== cursorOffset || $[12] !== handleSubmit || $[13] !== systemPrompt) { 101: t7 = <Box marginTop={1}><TextInput value={systemPrompt} onChange={setSystemPrompt} onSubmit={handleSubmit} placeholder="You are a helpful code reviewer who..." columns={80} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} focus={true} showCursor={true} /></Box>; 102: $[11] = cursorOffset; 103: $[12] = handleSubmit; 104: $[13] = systemPrompt; 105: $[14] = t7; 106: } else { 107: t7 = $[14]; 108: } 109: let t8; 110: if ($[15] !== error) { 111: t8 = error && <Box marginTop={1}><Text color="error">{error}</Text></Box>; 112: $[15] = error; 113: $[16] = t8; 114: } else { 115: t8 = $[16]; 116: } 117: let t9; 118: if ($[17] !== t7 || $[18] !== t8) { 119: t9 = <WizardDialogLayout subtitle="System prompt" footerText={t4}><Box flexDirection="column">{t5}{t6}{t7}{t8}</Box></WizardDialogLayout>; 120: $[17] = t7; 121: $[18] = t8; 122: $[19] = t9; 123: } else { 124: t9 = $[19]; 125: } 126: return t9; 127: }

File: src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode } from 'react'; 3: import type { Tools } from '../../../../Tool.js'; 4: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 5: import { Byline } from '../../../design-system/Byline.js'; 6: import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; 7: import { useWizard } from '../../../wizard/index.js'; 8: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 9: import { ToolSelector } from '../../ToolSelector.js'; 10: import type { AgentWizardData } from '../types.js'; 11: type Props = { 12: tools: Tools; 13: }; 14: export function ToolsStep(t0) { 15: const $ = _c(9); 16: const { 17: tools 18: } = t0; 19: const { 20: goNext, 21: goBack, 22: updateWizardData, 23: wizardData 24: } = useWizard(); 25: let t1; 26: if ($[0] !== goNext || $[1] !== updateWizardData) { 27: t1 = selectedTools => { 28: updateWizardData({ 29: selectedTools 30: }); 31: goNext(); 32: }; 33: $[0] = goNext; 34: $[1] = updateWizardData; 35: $[2] = t1; 36: } else { 37: t1 = $[2]; 38: } 39: const handleComplete = t1; 40: const initialTools = wizardData.selectedTools; 41: let t2; 42: if ($[3] === Symbol.for("react.memo_cache_sentinel")) { 43: t2 = <Byline><KeyboardShortcutHint shortcut="Enter" action="toggle selection" /><KeyboardShortcutHint shortcut={"\u2191\u2193"} action="navigate" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" /></Byline>; 44: $[3] = t2; 45: } else { 46: t2 = $[3]; 47: } 48: let t3; 49: if ($[4] !== goBack || $[5] !== handleComplete || $[6] !== initialTools || $[7] !== tools) { 50: t3 = <WizardDialogLayout subtitle="Select tools" footerText={t2}><ToolSelector tools={tools} initialTools={initialTools} onComplete={handleComplete} onCancel={goBack} /></WizardDialogLayout>; 51: $[4] = goBack; 52: $[5] = handleComplete; 53: $[6] = initialTools; 54: $[7] = tools; 55: $[8] = t3; 56: } else { 57: t3 = $[8]; 58: } 59: return t3; 60: }

File: src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode, useState } from 'react'; 3: import { Box, Text } from '../../../../ink.js'; 4: import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; 5: import type { AgentDefinition } from '../../../../tools/AgentTool/loadAgentsDir.js'; 6: import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; 7: import { Byline } from '../../../design-system/Byline.js'; 8: import { KeyboardShortcutHint } from '../../../design-system/KeyboardShortcutHint.js'; 9: import TextInput from '../../../TextInput.js'; 10: import { useWizard } from '../../../wizard/index.js'; 11: import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; 12: import { validateAgentType } from '../../validateAgent.js'; 13: import type { AgentWizardData } from '../types.js'; 14: type Props = { 15: existingAgents: AgentDefinition[]; 16: }; 17: export function TypeStep(_props) { 18: const $ = _c(15); 19: const { 20: goNext, 21: goBack, 22: updateWizardData, 23: wizardData 24: } = useWizard(); 25: const [agentType, setAgentType] = useState(wizardData.agentType || ""); 26: const [error, setError] = useState(null); 27: const [cursorOffset, setCursorOffset] = useState(agentType.length); 28: let t0; 29: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 30: t0 = { 31: context: "Settings" 32: }; 33: $[0] = t0; 34: } else { 35: t0 = $[0]; 36: } 37: useKeybinding("confirm:no", goBack, t0); 38: let t1; 39: if ($[1] !== goNext || $[2] !== updateWizardData) { 40: t1 = value => { 41: const trimmedValue = value.trim(); 42: const validationError = validateAgentType(trimmedValue); 43: if (validationError) { 44: setError(validationError); 45: return; 46: } 47: setError(null); 48: updateWizardData({ 49: agentType: trimmedValue 50: }); 51: goNext(); 52: }; 53: $[1] = goNext; 54: $[2] = updateWizardData; 55: $[3] = t1; 56: } else { 57: t1 = $[3]; 58: } 59: const handleSubmit = t1; 60: let t2; 61: if ($[4] === Symbol.for("react.memo_cache_sentinel")) { 62: t2 = <Byline><KeyboardShortcutHint shortcut="Type" action="enter text" /><KeyboardShortcutHint shortcut="Enter" action="continue" /><ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="go back" /></Byline>; 63: $[4] = t2; 64: } else { 65: t2 = $[4]; 66: } 67: let t3; 68: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 69: t3 = <Text>Enter a unique identifier for your agent:</Text>; 70: $[5] = t3; 71: } else { 72: t3 = $[5]; 73: } 74: let t4; 75: if ($[6] !== agentType || $[7] !== cursorOffset || $[8] !== handleSubmit) { 76: t4 = <Box marginTop={1}><TextInput value={agentType} onChange={setAgentType} onSubmit={handleSubmit} placeholder="e.g., test-runner, tech-lead, etc" columns={60} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} focus={true} showCursor={true} /></Box>; 77: $[6] = agentType; 78: $[7] = cursorOffset; 79: $[8] = handleSubmit; 80: $[9] = t4; 81: } else { 82: t4 = $[9]; 83: } 84: let t5; 85: if ($[10] !== error) { 86: t5 = error && <Box marginTop={1}><Text color="error">{error}</Text></Box>; 87: $[10] = error; 88: $[11] = t5; 89: } else { 90: t5 = $[11]; 91: } 92: let t6; 93: if ($[12] !== t4 || $[13] !== t5) { 94: t6 = <WizardDialogLayout subtitle="Agent type (identifier)" footerText={t2}><Box flexDirection="column">{t3}{t4}{t5}</Box></WizardDialogLayout>; 95: $[12] = t4; 96: $[13] = t5; 97: $[14] = t6; 98: } else { 99: t6 = $[14]; 100: } 101: return t6; 102: }

File: src/components/agents/new-agent-creation/CreateAgentWizard.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { type ReactNode } from 'react'; 3: import { isAutoMemoryEnabled } from '../../../memdir/paths.js'; 4: import type { Tools } from '../../../Tool.js'; 5: import type { AgentDefinition } from '../../../tools/AgentTool/loadAgentsDir.js'; 6: import { WizardProvider } from '../../wizard/index.js'; 7: import type { WizardStepComponent } from '../../wizard/types.js'; 8: import type { AgentWizardData } from './types.js'; 9: import { ColorStep } from './wizard-steps/ColorStep.js'; 10: import { ConfirmStepWrapper } from './wizard-steps/ConfirmStepWrapper.js'; 11: import { DescriptionStep } from './wizard-steps/DescriptionStep.js'; 12: import { GenerateStep } from './wizard-steps/GenerateStep.js'; 13: import { LocationStep } from './wizard-steps/LocationStep.js'; 14: import { MemoryStep } from './wizard-steps/MemoryStep.js'; 15: import { MethodStep } from './wizard-steps/MethodStep.js'; 16: import { ModelStep } from './wizard-steps/ModelStep.js'; 17: import { PromptStep } from './wizard-steps/PromptStep.js'; 18: import { ToolsStep } from './wizard-steps/ToolsStep.js'; 19: import { TypeStep } from './wizard-steps/TypeStep.js'; 20: type Props = { 21: tools: Tools; 22: existingAgents: AgentDefinition[]; 23: onComplete: (message: string) => void; 24: onCancel: () => void; 25: }; 26: export function CreateAgentWizard(t0) { 27: const $ = _c(17); 28: const { 29: tools, 30: existingAgents, 31: onComplete, 32: onCancel 33: } = t0; 34: let t1; 35: if ($[0] !== existingAgents) { 36: t1 = () => <TypeStep existingAgents={existingAgents} />; 37: $[0] = existingAgents; 38: $[1] = t1; 39: } else { 40: t1 = $[1]; 41: } 42: let t2; 43: if ($[2] !== tools) { 44: t2 = () => <ToolsStep tools={tools} />; 45: $[2] = tools; 46: $[3] = t2; 47: } else { 48: t2 = $[3]; 49: } 50: let t3; 51: if ($[4] === Symbol.for("react.memo_cache_sentinel")) { 52: t3 = isAutoMemoryEnabled() ? [MemoryStep] : []; 53: $[4] = t3; 54: } else { 55: t3 = $[4]; 56: } 57: let t4; 58: if ($[5] !== existingAgents || $[6] !== onComplete || $[7] !== tools) { 59: t4 = () => <ConfirmStepWrapper tools={tools} existingAgents={existingAgents} onComplete={onComplete} />; 60: $[5] = existingAgents; 61: $[6] = onComplete; 62: $[7] = tools; 63: $[8] = t4; 64: } else { 65: t4 = $[8]; 66: } 67: let t5; 68: if ($[9] !== t1 || $[10] !== t2 || $[11] !== t4) { 69: t5 = [LocationStep, MethodStep, GenerateStep, t1, PromptStep, DescriptionStep, t2, ModelStep, ColorStep, ...t3, t4]; 70: $[9] = t1; 71: $[10] = t2; 72: $[11] = t4; 73: $[12] = t5; 74: } else { 75: t5 = $[12]; 76: } 77: const steps = t5; 78: let t6; 79: if ($[13] === Symbol.for("react.memo_cache_sentinel")) { 80: t6 = {}; 81: $[13] = t6; 82: } else { 83: t6 = $[13]; 84: } 85: let t7; 86: if ($[14] !== onCancel || $[15] !== steps) { 87: t7 = <WizardProvider steps={steps} initialData={t6} onComplete={_temp} onCancel={onCancel} title="Create new agent" showStepCounter={false} />; 88: $[14] = onCancel; 89: $[15] = steps; 90: $[16] = t7; 91: } else { 92: t7 = $[16]; 93: } 94: return t7; 95: } 96: function _temp() {}

File: src/components/agents/AgentDetail.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import * as React from 'react'; 4: import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; 5: import { Box, Text } from '../../ink.js'; 6: import { useKeybinding } from '../../keybindings/useKeybinding.js'; 7: import type { Tools } from '../../Tool.js'; 8: import { getAgentColor } from '../../tools/AgentTool/agentColorManager.js'; 9: import { getMemoryScopeDisplay } from '../../tools/AgentTool/agentMemory.js'; 10: import { resolveAgentTools } from '../../tools/AgentTool/agentToolUtils.js'; 11: import { type AgentDefinition, isBuiltInAgent } from '../../tools/AgentTool/loadAgentsDir.js'; 12: import { getAgentModelDisplay } from '../../utils/model/agent.js'; 13: import { Markdown } from '../Markdown.js'; 14: import { getActualRelativeAgentFilePath } from './agentFileUtils.js'; 15: type Props = { 16: agent: AgentDefinition; 17: tools: Tools; 18: allAgents?: AgentDefinition[]; 19: onBack: () => void; 20: }; 21: export function AgentDetail(t0) { 22: const $ = _c(48); 23: const { 24: agent, 25: tools, 26: onBack 27: } = t0; 28: const resolvedTools = resolveAgentTools(agent, tools, false); 29: let t1; 30: if ($[0] !== agent) { 31: t1 = getActualRelativeAgentFilePath(agent); 32: $[0] = agent; 33: $[1] = t1; 34: } else { 35: t1 = $[1]; 36: } 37: const filePath = t1; 38: let t2; 39: if ($[2] !== agent.agentType) { 40: t2 = getAgentColor(agent.agentType); 41: $[2] = agent.agentType; 42: $[3] = t2; 43: } else { 44: t2 = $[3]; 45: } 46: const backgroundColor = t2; 47: let t3; 48: if ($[4] === Symbol.for("react.memo_cache_sentinel")) { 49: t3 = { 50: context: "Confirmation" 51: }; 52: $[4] = t3; 53: } else { 54: t3 = $[4]; 55: } 56: useKeybinding("confirm:no", onBack, t3); 57: let t4; 58: if ($[5] !== onBack) { 59: t4 = e => { 60: if (e.key === "return") { 61: e.preventDefault(); 62: onBack(); 63: } 64: }; 65: $[5] = onBack; 66: $[6] = t4; 67: } else { 68: t4 = $[6]; 69: } 70: const handleKeyDown = t4; 71: const renderToolsList = function renderToolsList() { 72: if (resolvedTools.hasWildcard) { 73: return <Text>All tools</Text>; 74: } 75: if (!agent.tools || agent.tools.length === 0) { 76: return <Text>None</Text>; 77: } 78: return <>{resolvedTools.validTools.length > 0 && <Text>{resolvedTools.validTools.join(", ")}</Text>}{resolvedTools.invalidTools.length > 0 && <Text color="warning">{figures.warning} Unrecognized:{" "}{resolvedTools.invalidTools.join(", ")}</Text>}</>; 79: }; 80: const T0 = Box; 81: const t5 = "column"; 82: const t6 = 1; 83: const t7 = 0; 84: const t8 = true; 85: let t9; 86: if ($[7] !== filePath) { 87: t9 = <Text dimColor={true}>{filePath}</Text>; 88: $[7] = filePath; 89: $[8] = t9; 90: } else { 91: t9 = $[8]; 92: } 93: let t10; 94: if ($[9] === Symbol.for("react.memo_cache_sentinel")) { 95: t10 = <Text><Text bold={true}>Description</Text> (tells Claude when to use this agent):</Text>; 96: $[9] = t10; 97: } else { 98: t10 = $[9]; 99: } 100: let t11; 101: if ($[10] !== agent.whenToUse) { 102: t11 = <Box flexDirection="column">{t10}<Box marginLeft={2}><Text>{agent.whenToUse}</Text></Box></Box>; 103: $[10] = agent.whenToUse; 104: $[11] = t11; 105: } else { 106: t11 = $[11]; 107: } 108: const T1 = Box; 109: let t12; 110: if ($[12] === Symbol.for("react.memo_cache_sentinel")) { 111: t12 = <Text><Text bold={true}>Tools</Text>:{" "}</Text>; 112: $[12] = t12; 113: } else { 114: t12 = $[12]; 115: } 116: const t13 = renderToolsList(); 117: let t14; 118: if ($[13] !== T1 || $[14] !== t12 || $[15] !== t13) { 119: t14 = <T1>{t12}{t13}</T1>; 120: $[13] = T1; 121: $[14] = t12; 122: $[15] = t13; 123: $[16] = t14; 124: } else { 125: t14 = $[16]; 126: } 127: let t15; 128: if ($[17] === Symbol.for("react.memo_cache_sentinel")) { 129: t15 = <Text bold={true}>Model</Text>; 130: $[17] = t15; 131: } else { 132: t15 = $[17]; 133: } 134: let t16; 135: if ($[18] !== agent.model) { 136: t16 = getAgentModelDisplay(agent.model); 137: $[18] = agent.model; 138: $[19] = t16; 139: } else { 140: t16 = $[19]; 141: } 142: let t17; 143: if ($[20] !== t16) { 144: t17 = <Text>{t15}: {t16}</Text>; 145: $[20] = t16; 146: $[21] = t17; 147: } else { 148: t17 = $[21]; 149: } 150: let t18; 151: if ($[22] !== agent.permissionMode) { 152: t18 = agent.permissionMode && <Text><Text bold={true}>Permission mode</Text>: {agent.permissionMode}</Text>; 153: $[22] = agent.permissionMode; 154: $[23] = t18; 155: } else { 156: t18 = $[23]; 157: } 158: let t19; 159: if ($[24] !== agent.memory) { 160: t19 = agent.memory && <Text><Text bold={true}>Memory</Text>: {getMemoryScopeDisplay(agent.memory)}</Text>; 161: $[24] = agent.memory; 162: $[25] = t19; 163: } else { 164: t19 = $[25]; 165: } 166: let t20; 167: if ($[26] !== agent.hooks) { 168: t20 = agent.hooks && Object.keys(agent.hooks).length > 0 && <Text><Text bold={true}>Hooks</Text>: {Object.keys(agent.hooks).join(", ")}</Text>; 169: $[26] = agent.hooks; 170: $[27] = t20; 171: } else { 172: t20 = $[27]; 173: } 174: let t21; 175: if ($[28] !== agent.skills) { 176: t21 = agent.skills && agent.skills.length > 0 && <Text><Text bold={true}>Skills</Text>:{" "}{agent.skills.length > 10 ? `${agent.skills.length} skills` : agent.skills.join(", ")}</Text>; 177: $[28] = agent.skills; 178: $[29] = t21; 179: } else { 180: t21 = $[29]; 181: } 182: let t22; 183: if ($[30] !== agent.agentType || $[31] !== backgroundColor) { 184: t22 = backgroundColor && <Box><Text><Text bold={true}>Color</Text>:{" "}<Text backgroundColor={backgroundColor} color="inverseText">{" "}{agent.agentType}{" "}</Text></Text></Box>; 185: $[30] = agent.agentType; 186: $[31] = backgroundColor; 187: $[32] = t22; 188: } else { 189: t22 = $[32]; 190: } 191: let t23; 192: if ($[33] !== agent) { 193: t23 = !isBuiltInAgent(agent) && <><Box><Text><Text bold={true}>System prompt</Text>:</Text></Box><Box marginLeft={2} marginRight={2}><Markdown>{agent.getSystemPrompt()}</Markdown></Box></>; 194: $[33] = agent; 195: $[34] = t23; 196: } else { 197: t23 = $[34]; 198: } 199: let t24; 200: if ($[35] !== T0 || $[36] !== handleKeyDown || $[37] !== t11 || $[38] !== t14 || $[39] !== t17 || $[40] !== t18 || $[41] !== t19 || $[42] !== t20 || $[43] !== t21 || $[44] !== t22 || $[45] !== t23 || $[46] !== t9) { 201: t24 = <T0 flexDirection={t5} gap={t6} tabIndex={t7} autoFocus={t8} onKeyDown={handleKeyDown}>{t9}{t11}{t14}{t17}{t18}{t19}{t20}{t21}{t22}{t23}</T0>; 202: $[35] = T0; 203: $[36] = handleKeyDown; 204: $[37] = t11; 205: $[38] = t14; 206: $[39] = t17; 207: $[40] = t18; 208: $[41] = t19; 209: $[42] = t20; 210: $[43] = t21; 211: $[44] = t22; 212: $[45] = t23; 213: $[46] = t9; 214: $[47] = t24; 215: } else { 216: t24 = $[47]; 217: } 218: return t24; 219: }

File: src/components/agents/AgentEditor.tsx

typescript 1: import chalk from 'chalk'; 2: import figures from 'figures'; 3: import * as React from 'react'; 4: import { useCallback, useMemo, useState } from 'react'; 5: import { useSetAppState } from 'src/state/AppState.js'; 6: import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; 7: import { Box, Text } from '../../ink.js'; 8: import { useKeybinding } from '../../keybindings/useKeybinding.js'; 9: import type { Tools } from '../../Tool.js'; 10: import { type AgentColorName, setAgentColor } from '../../tools/AgentTool/agentColorManager.js'; 11: import { type AgentDefinition, getActiveAgentsFromList, isCustomAgent, isPluginAgent } from '../../tools/AgentTool/loadAgentsDir.js'; 12: import { editFileInEditor } from '../../utils/promptEditor.js'; 13: import { getActualAgentFilePath, updateAgentFile } from './agentFileUtils.js'; 14: import { ColorPicker } from './ColorPicker.js'; 15: import { ModelSelector } from './ModelSelector.js'; 16: import { ToolSelector } from './ToolSelector.js'; 17: import { getAgentSourceDisplayName } from './utils.js'; 18: type Props = { 19: agent: AgentDefinition; 20: tools: Tools; 21: onSaved: (message: string) => void; 22: onBack: () => void; 23: }; 24: type EditMode = 'menu' | 'edit-tools' | 'edit-color' | 'edit-model'; 25: type SaveChanges = { 26: tools?: string[]; 27: color?: AgentColorName; 28: model?: string; 29: }; 30: export function AgentEditor({ 31: agent, 32: tools, 33: onSaved, 34: onBack 35: }: Props): React.ReactNode { 36: const setAppState = useSetAppState(); 37: const [editMode, setEditMode] = useState<EditMode>('menu'); 38: const [selectedMenuIndex, setSelectedMenuIndex] = useState(0); 39: const [error, setError] = useState<string | null>(null); 40: const [selectedColor, setSelectedColor] = useState<AgentColorName | undefined>(agent.color as AgentColorName | undefined); 41: const handleOpenInEditor = useCallback(async () => { 42: const filePath = getActualAgentFilePath(agent); 43: const result = await editFileInEditor(filePath); 44: if (result.error) { 45: setError(result.error); 46: } else { 47: onSaved(`Opened ${agent.agentType} in editor. If you made edits, restart to load the latest version.`); 48: } 49: }, [agent, onSaved]); 50: const handleSave = useCallback(async (changes: SaveChanges = {}) => { 51: const { 52: tools: newTools, 53: color: newColor, 54: model: newModel 55: } = changes; 56: const finalColor = newColor ?? selectedColor; 57: const hasToolsChanged = newTools !== undefined; 58: const hasModelChanged = newModel !== undefined; 59: const hasColorChanged = finalColor !== agent.color; 60: if (!hasToolsChanged && !hasModelChanged && !hasColorChanged) { 61: return false; 62: } 63: try { 64: if (!isCustomAgent(agent) && !isPluginAgent(agent)) { 65: return false; 66: } 67: await updateAgentFile(agent, agent.whenToUse, newTools ?? agent.tools, agent.getSystemPrompt(), finalColor, newModel ?? agent.model); 68: if (hasColorChanged && finalColor) { 69: setAgentColor(agent.agentType, finalColor); 70: } 71: setAppState(state => { 72: const allAgents = state.agentDefinitions.allAgents.map(a => a.agentType === agent.agentType ? { 73: ...a, 74: tools: newTools ?? a.tools, 75: color: finalColor, 76: model: newModel ?? a.model 77: } : a); 78: return { 79: ...state, 80: agentDefinitions: { 81: ...state.agentDefinitions, 82: activeAgents: getActiveAgentsFromList(allAgents), 83: allAgents 84: } 85: }; 86: }); 87: onSaved(`Updated agent: ${chalk.bold(agent.agentType)}`); 88: return true; 89: } catch (err) { 90: setError(err instanceof Error ? err.message : 'Failed to save agent'); 91: return false; 92: } 93: }, [agent, selectedColor, onSaved, setAppState]); 94: const menuItems = useMemo(() => [{ 95: label: 'Open in editor', 96: action: handleOpenInEditor 97: }, { 98: label: 'Edit tools', 99: action: () => setEditMode('edit-tools') 100: }, { 101: label: 'Edit model', 102: action: () => setEditMode('edit-model') 103: }, { 104: label: 'Edit color', 105: action: () => setEditMode('edit-color') 106: }], [handleOpenInEditor]); 107: const handleEscape = useCallback(() => { 108: setError(null); 109: if (editMode === 'menu') { 110: onBack(); 111: } else { 112: setEditMode('menu'); 113: } 114: }, [editMode, onBack]); 115: const handleMenuKeyDown = useCallback((e: KeyboardEvent) => { 116: if (e.key === 'up') { 117: e.preventDefault(); 118: setSelectedMenuIndex(index => Math.max(0, index - 1)); 119: } else if (e.key === 'down') { 120: e.preventDefault(); 121: setSelectedMenuIndex(index_0 => Math.min(menuItems.length - 1, index_0 + 1)); 122: } else if (e.key === 'return') { 123: e.preventDefault(); 124: const selectedItem = menuItems[selectedMenuIndex]; 125: if (selectedItem) { 126: void selectedItem.action(); 127: } 128: } 129: }, [menuItems, selectedMenuIndex]); 130: useKeybinding('confirm:no', handleEscape, { 131: context: 'Confirmation' 132: }); 133: const renderMenu = (): React.ReactNode => <Box flexDirection="column" tabIndex={0} autoFocus onKeyDown={handleMenuKeyDown}> 134: <Text dimColor>Source: {getAgentSourceDisplayName(agent.source)}</Text> 135: <Box marginTop={1} flexDirection="column"> 136: {menuItems.map((item, index_1) => <Text key={item.label} color={index_1 === selectedMenuIndex ? 'suggestion' : undefined}> 137: {index_1 === selectedMenuIndex ? `${figures.pointer} ` : ' '} 138: {item.label} 139: </Text>)} 140: </Box> 141: {error && <Box marginTop={1}> 142: <Text color="error">{error}</Text> 143: </Box>} 144: </Box>; 145: switch (editMode) { 146: case 'menu': 147: return renderMenu(); 148: case 'edit-tools': 149: return <ToolSelector tools={tools} initialTools={agent.tools} onComplete={async finalTools => { 150: setEditMode('menu'); 151: await handleSave({ 152: tools: finalTools 153: }); 154: }} />; 155: case 'edit-color': 156: return <ColorPicker agentName={agent.agentType} currentColor={selectedColor || agent.color as AgentColorName || 'automatic'} onConfirm={async color => { 157: setSelectedColor(color); 158: setEditMode('menu'); 159: await handleSave({ 160: color 161: }); 162: }} />; 163: case 'edit-model': 164: return <ModelSelector initialModel={agent.model} onComplete={async model => { 165: setEditMode('menu'); 166: await handleSave({ 167: model 168: }); 169: }} />; 170: default: 171: return null; 172: } 173: }

File: src/components/agents/agentFileUtils.ts

typescript 1: import { mkdir, open, unlink } from 'fs/promises' 2: import { join } from 'path' 3: import type { SettingSource } from 'src/utils/settings/constants.js' 4: import { getManagedFilePath } from 'src/utils/settings/managedPath.js' 5: import type { AgentMemoryScope } from '../../tools/AgentTool/agentMemory.js' 6: import { 7: type AgentDefinition, 8: isBuiltInAgent, 9: isPluginAgent, 10: } from '../../tools/AgentTool/loadAgentsDir.js' 11: import { getCwd } from '../../utils/cwd.js' 12: import type { EffortValue } from '../../utils/effort.js' 13: import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 14: import { getErrnoCode } from '../../utils/errors.js' 15: import { AGENT_PATHS } from './types.js' 16: export function formatAgentAsMarkdown( 17: agentType: string, 18: whenToUse: string, 19: tools: string[] | undefined, 20: systemPrompt: string, 21: color?: string, 22: model?: string, 23: memory?: AgentMemoryScope, 24: effort?: EffortValue, 25: ): string { 26: const escapedWhenToUse = whenToUse 27: .replace(/\\/g, '\\\\') // Escape backslashes first 28: .replace(/"/g, '\\"') // Escape double quotes 29: .replace(/\n/g, '\\\\n') // Escape newlines as \\n so yaml preserves them as \n 30: // Omit tools field entirely when tools is undefined or ['*'] (all tools allowed) 31: const isAllTools = 32: tools === undefined || (tools.length === 1 && tools[0] === '*') 33: const toolsLine = isAllTools ? '' : `\ntools: ${tools.join(', ')}` 34: const modelLine = model ? `\nmodel: ${model}` : '' 35: const effortLine = effort !== undefined ? `\neffort: ${effort}` : '' 36: const colorLine = color ? `\ncolor: ${color}` : '' 37: const memoryLine = memory ? `\nmemory: ${memory}` : '' 38: return `--- 39: name: ${agentType} 40: description: "${escapedWhenToUse}"${toolsLine}${modelLine}${effortLine}${colorLine}${memoryLine} 41: --- 42: ${systemPrompt} 43: ` 44: } 45: /** 46: * Gets the directory path for an agent location 47: */ 48: function getAgentDirectoryPath(location: SettingSource): string { 49: switch (location) { 50: case 'flagSettings': 51: throw new Error(`Cannot get directory path for ${location} agents`) 52: case 'userSettings': 53: return join(getClaudeConfigHomeDir(), AGENT_PATHS.AGENTS_DIR) 54: case 'projectSettings': 55: return join(getCwd(), AGENT_PATHS.FOLDER_NAME, AGENT_PATHS.AGENTS_DIR) 56: case 'policySettings': 57: return join( 58: getManagedFilePath(), 59: AGENT_PATHS.FOLDER_NAME, 60: AGENT_PATHS.AGENTS_DIR, 61: ) 62: case 'localSettings': 63: return join(getCwd(), AGENT_PATHS.FOLDER_NAME, AGENT_PATHS.AGENTS_DIR) 64: } 65: } 66: function getRelativeAgentDirectoryPath(location: SettingSource): string { 67: switch (location) { 68: case 'projectSettings': 69: return join('.', AGENT_PATHS.FOLDER_NAME, AGENT_PATHS.AGENTS_DIR) 70: default: 71: return getAgentDirectoryPath(location) 72: } 73: } 74: export function getNewAgentFilePath(agent: { 75: source: SettingSource 76: agentType: string 77: }): string { 78: const dirPath = getAgentDirectoryPath(agent.source) 79: return join(dirPath, `${agent.agentType}.md`) 80: } 81: export function getActualAgentFilePath(agent: AgentDefinition): string { 82: if (agent.source === 'built-in') { 83: return 'Built-in' 84: } 85: if (agent.source === 'plugin') { 86: throw new Error('Cannot get file path for plugin agents') 87: } 88: const dirPath = getAgentDirectoryPath(agent.source) 89: const filename = agent.filename || agent.agentType 90: return join(dirPath, `${filename}.md`) 91: } 92: export function getNewRelativeAgentFilePath(agent: { 93: source: SettingSource | 'built-in' 94: agentType: string 95: }): string { 96: if (agent.source === 'built-in') { 97: return 'Built-in' 98: } 99: const dirPath = getRelativeAgentDirectoryPath(agent.source) 100: return join(dirPath, `${agent.agentType}.md`) 101: } 102: export function getActualRelativeAgentFilePath(agent: AgentDefinition): string { 103: if (isBuiltInAgent(agent)) { 104: return 'Built-in' 105: } 106: if (isPluginAgent(agent)) { 107: return `Plugin: ${agent.plugin || 'Unknown'}` 108: } 109: if (agent.source === 'flagSettings') { 110: return 'CLI argument' 111: } 112: const dirPath = getRelativeAgentDirectoryPath(agent.source) 113: const filename = agent.filename || agent.agentType 114: return join(dirPath, `${filename}.md`) 115: } 116: async function ensureAgentDirectoryExists( 117: source: SettingSource, 118: ): Promise<string> { 119: const dirPath = getAgentDirectoryPath(source) 120: await mkdir(dirPath, { recursive: true }) 121: return dirPath 122: } 123: export async function saveAgentToFile( 124: source: SettingSource | 'built-in', 125: agentType: string, 126: whenToUse: string, 127: tools: string[] | undefined, 128: systemPrompt: string, 129: checkExists = true, 130: color?: string, 131: model?: string, 132: memory?: AgentMemoryScope, 133: effort?: EffortValue, 134: ): Promise<void> { 135: if (source === 'built-in') { 136: throw new Error('Cannot save built-in agents') 137: } 138: await ensureAgentDirectoryExists(source) 139: const filePath = getNewAgentFilePath({ source, agentType }) 140: const content = formatAgentAsMarkdown( 141: agentType, 142: whenToUse, 143: tools, 144: systemPrompt, 145: color, 146: model, 147: memory, 148: effort, 149: ) 150: try { 151: await writeFileAndFlush(filePath, content, checkExists ? 'wx' : 'w') 152: } catch (e: unknown) { 153: if (getErrnoCode(e) === 'EEXIST') { 154: throw new Error(`Agent file already exists: ${filePath}`) 155: } 156: throw e 157: } 158: } 159: export async function updateAgentFile( 160: agent: AgentDefinition, 161: newWhenToUse: string, 162: newTools: string[] | undefined, 163: newSystemPrompt: string, 164: newColor?: string, 165: newModel?: string, 166: newMemory?: AgentMemoryScope, 167: newEffort?: EffortValue, 168: ): Promise<void> { 169: if (agent.source === 'built-in') { 170: throw new Error('Cannot update built-in agents') 171: } 172: const filePath = getActualAgentFilePath(agent) 173: const content = formatAgentAsMarkdown( 174: agent.agentType, 175: newWhenToUse, 176: newTools, 177: newSystemPrompt, 178: newColor, 179: newModel, 180: newMemory, 181: newEffort, 182: ) 183: await writeFileAndFlush(filePath, content) 184: } 185: export async function deleteAgentFromFile( 186: agent: AgentDefinition, 187: ): Promise<void> { 188: if (agent.source === 'built-in') { 189: throw new Error('Cannot delete built-in agents') 190: } 191: const filePath = getActualAgentFilePath(agent) 192: try { 193: await unlink(filePath) 194: } catch (e: unknown) { 195: const code = getErrnoCode(e) 196: if (code !== 'ENOENT') { 197: throw e 198: } 199: } 200: } 201: async function writeFileAndFlush( 202: filePath: string, 203: content: string, 204: flag: 'w' | 'wx' = 'w', 205: ): Promise<void> { 206: const handle = await open(filePath, flag) 207: try { 208: await handle.writeFile(content, { encoding: 'utf-8' }) 209: await handle.datasync() 210: } finally { 211: await handle.close() 212: } 213: }

File: src/components/agents/AgentNavigationFooter.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import * as React from 'react'; 3: import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; 4: import { Box, Text } from '../../ink.js'; 5: type Props = { 6: instructions?: string; 7: }; 8: export function AgentNavigationFooter(t0) { 9: const $ = _c(2); 10: const { 11: instructions: t1 12: } = t0; 13: const instructions = t1 === undefined ? "Press \u2191\u2193 to navigate \xB7 Enter to select \xB7 Esc to go back" : t1; 14: const exitState = useExitOnCtrlCDWithKeybindings(); 15: const t2 = exitState.pending ? `Press ${exitState.keyName} again to exit` : instructions; 16: let t3; 17: if ($[0] !== t2) { 18: t3 = <Box marginLeft={2}><Text dimColor={true}>{t2}</Text></Box>; 19: $[0] = t2; 20: $[1] = t3; 21: } else { 22: t3 = $[1]; 23: } 24: return t3; 25: }

File: src/components/agents/AgentsList.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import * as React from 'react'; 4: import type { SettingSource } from 'src/utils/settings/constants.js'; 5: import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; 6: import { Box, Text } from '../../ink.js'; 7: import type { ResolvedAgent } from '../../tools/AgentTool/agentDisplay.js'; 8: import { AGENT_SOURCE_GROUPS, compareAgentsByName, getOverrideSourceLabel, resolveAgentModelDisplay } from '../../tools/AgentTool/agentDisplay.js'; 9: import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; 10: import { count } from '../../utils/array.js'; 11: import { Dialog } from '../design-system/Dialog.js'; 12: import { Divider } from '../design-system/Divider.js'; 13: import { getAgentSourceDisplayName } from './utils.js'; 14: type Props = { 15: source: SettingSource | 'all' | 'built-in' | 'plugin'; 16: agents: ResolvedAgent[]; 17: onBack: () => void; 18: onSelect: (agent: AgentDefinition) => void; 19: onCreateNew?: () => void; 20: changes?: string[]; 21: }; 22: export function AgentsList(t0) { 23: const $ = _c(96); 24: const { 25: source, 26: agents, 27: onBack, 28: onSelect, 29: onCreateNew, 30: changes 31: } = t0; 32: const [selectedAgent, setSelectedAgent] = React.useState(null); 33: const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true); 34: let t1; 35: if ($[0] !== agents) { 36: t1 = [...agents].sort(compareAgentsByName); 37: $[0] = agents; 38: $[1] = t1; 39: } else { 40: t1 = $[1]; 41: } 42: const sortedAgents = t1; 43: const getOverrideInfo = _temp; 44: let t2; 45: if ($[2] !== isCreateNewSelected) { 46: t2 = () => <Box><Text color={isCreateNewSelected ? "suggestion" : undefined}>{isCreateNewSelected ? `${figures.pointer} ` : " "}</Text><Text color={isCreateNewSelected ? "suggestion" : undefined}>Create new agent</Text></Box>; 47: $[2] = isCreateNewSelected; 48: $[3] = t2; 49: } else { 50: t2 = $[3]; 51: } 52: const renderCreateNewOption = t2; 53: let t3; 54: if ($[4] !== isCreateNewSelected || $[5] !== selectedAgent?.agentType || $[6] !== selectedAgent?.source) { 55: t3 = agent_0 => { 56: const isBuiltIn = agent_0.source === "built-in"; 57: const isSelected = !isBuiltIn && !isCreateNewSelected && selectedAgent?.agentType === agent_0.agentType && selectedAgent?.source === agent_0.source; 58: const { 59: isOverridden, 60: overriddenBy 61: } = getOverrideInfo(agent_0); 62: const dimmed = isBuiltIn || isOverridden; 63: const textColor = !isBuiltIn && isSelected ? "suggestion" : undefined; 64: const resolvedModel = resolveAgentModelDisplay(agent_0); 65: return <Box key={`${agent_0.agentType}-${agent_0.source}`}><Text dimColor={dimmed && !isSelected} color={textColor}>{isBuiltIn ? "" : isSelected ? `${figures.pointer} ` : " "}</Text><Text dimColor={dimmed && !isSelected} color={textColor}>{agent_0.agentType}</Text>{resolvedModel && <Text dimColor={true} color={textColor}>{" \xB7 "}{resolvedModel}</Text>}{agent_0.memory && <Text dimColor={true} color={textColor}>{" \xB7 "}{agent_0.memory} memory</Text>}{overriddenBy && <Text dimColor={!isSelected} color={isSelected ? "warning" : undefined}>{" "}{figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)}</Text>}</Box>; 66: }; 67: $[4] = isCreateNewSelected; 68: $[5] = selectedAgent?.agentType; 69: $[6] = selectedAgent?.source; 70: $[7] = t3; 71: } else { 72: t3 = $[7]; 73: } 74: const renderAgent = t3; 75: let t4; 76: if ($[8] !== sortedAgents || $[9] !== source) { 77: bb0: { 78: const nonBuiltIn = sortedAgents.filter(_temp2); 79: if (source === "all") { 80: t4 = AGENT_SOURCE_GROUPS.filter(_temp3).flatMap(t5 => { 81: const { 82: source: groupSource 83: } = t5; 84: return nonBuiltIn.filter(a_0 => a_0.source === groupSource); 85: }); 86: break bb0; 87: } 88: t4 = nonBuiltIn; 89: } 90: $[8] = sortedAgents; 91: $[9] = source; 92: $[10] = t4; 93: } else { 94: t4 = $[10]; 95: } 96: const selectableAgentsInOrder = t4; 97: let t5; 98: let t6; 99: if ($[11] !== isCreateNewSelected || $[12] !== onCreateNew || $[13] !== selectableAgentsInOrder || $[14] !== selectedAgent) { 100: t5 = () => { 101: if (!selectedAgent && !isCreateNewSelected && selectableAgentsInOrder.length > 0) { 102: if (onCreateNew) { 103: setIsCreateNewSelected(true); 104: } else { 105: setSelectedAgent(selectableAgentsInOrder[0] || null); 106: } 107: } 108: }; 109: t6 = [selectableAgentsInOrder, selectedAgent, isCreateNewSelected, onCreateNew]; 110: $[11] = isCreateNewSelected; 111: $[12] = onCreateNew; 112: $[13] = selectableAgentsInOrder; 113: $[14] = selectedAgent; 114: $[15] = t5; 115: $[16] = t6; 116: } else { 117: t5 = $[15]; 118: t6 = $[16]; 119: } 120: React.useEffect(t5, t6); 121: let t7; 122: if ($[17] !== isCreateNewSelected || $[18] !== onCreateNew || $[19] !== onSelect || $[20] !== selectableAgentsInOrder || $[21] !== selectedAgent) { 123: t7 = e => { 124: if (e.key === "return") { 125: e.preventDefault(); 126: if (isCreateNewSelected && onCreateNew) { 127: onCreateNew(); 128: } else { 129: if (selectedAgent) { 130: onSelect(selectedAgent); 131: } 132: } 133: return; 134: } 135: if (e.key !== "up" && e.key !== "down") { 136: return; 137: } 138: e.preventDefault(); 139: const hasCreateOption = !!onCreateNew; 140: const totalItems = selectableAgentsInOrder.length + (hasCreateOption ? 1 : 0); 141: if (totalItems === 0) { 142: return; 143: } 144: let currentPosition = 0; 145: if (!isCreateNewSelected && selectedAgent) { 146: const agentIndex = selectableAgentsInOrder.findIndex(a_1 => a_1.agentType === selectedAgent.agentType && a_1.source === selectedAgent.source); 147: if (agentIndex >= 0) { 148: currentPosition = hasCreateOption ? agentIndex + 1 : agentIndex; 149: } 150: } 151: const newPosition = e.key === "up" ? currentPosition === 0 ? totalItems - 1 : currentPosition - 1 : currentPosition === totalItems - 1 ? 0 : currentPosition + 1; 152: if (hasCreateOption && newPosition === 0) { 153: setIsCreateNewSelected(true); 154: setSelectedAgent(null); 155: } else { 156: const agentIndex_0 = hasCreateOption ? newPosition - 1 : newPosition; 157: const newAgent = selectableAgentsInOrder[agentIndex_0]; 158: if (newAgent) { 159: setIsCreateNewSelected(false); 160: setSelectedAgent(newAgent); 161: } 162: } 163: }; 164: $[17] = isCreateNewSelected; 165: $[18] = onCreateNew; 166: $[19] = onSelect; 167: $[20] = selectableAgentsInOrder; 168: $[21] = selectedAgent; 169: $[22] = t7; 170: } else { 171: t7 = $[22]; 172: } 173: const handleKeyDown = t7; 174: let t8; 175: if ($[23] !== renderAgent || $[24] !== sortedAgents) { 176: t8 = t9 => { 177: const title = t9 === undefined ? "Built-in (always available):" : t9; 178: const builtInAgents = sortedAgents.filter(_temp4); 179: return <Box flexDirection="column" marginBottom={1} paddingLeft={2}><Text bold={true} dimColor={true}>{title}</Text>{builtInAgents.map(renderAgent)}</Box>; 180: }; 181: $[23] = renderAgent; 182: $[24] = sortedAgents; 183: $[25] = t8; 184: } else { 185: t8 = $[25]; 186: } 187: const renderBuiltInAgentsSection = t8; 188: let t9; 189: if ($[26] !== renderAgent) { 190: t9 = (title_0, groupAgents) => { 191: if (!groupAgents.length) { 192: return null; 193: } 194: const folderPath = groupAgents[0]?.baseDir; 195: return <Box flexDirection="column" marginBottom={1}><Box paddingLeft={2}><Text bold={true} dimColor={true}>{title_0}</Text>{folderPath && <Text dimColor={true}> ({folderPath})</Text>}</Box>{groupAgents.map(agent_1 => renderAgent(agent_1))}</Box>; 196: }; 197: $[26] = renderAgent; 198: $[27] = t9; 199: } else { 200: t9 = $[27]; 201: } 202: const renderAgentGroup = t9; 203: let t10; 204: if ($[28] !== source) { 205: t10 = getAgentSourceDisplayName(source); 206: $[28] = source; 207: $[29] = t10; 208: } else { 209: t10 = $[29]; 210: } 211: const sourceTitle = t10; 212: let T0; 213: let T1; 214: let t11; 215: let t12; 216: let t13; 217: let t14; 218: let t15; 219: let t16; 220: let t17; 221: let t18; 222: let t19; 223: let t20; 224: let t21; 225: let t22; 226: if ($[30] !== changes || $[31] !== handleKeyDown || $[32] !== onBack || $[33] !== onCreateNew || $[34] !== renderAgent || $[35] !== renderAgentGroup || $[36] !== renderBuiltInAgentsSection || $[37] !== renderCreateNewOption || $[38] !== sortedAgents || $[39] !== source || $[40] !== sourceTitle) { 227: t22 = Symbol.for("react.early_return_sentinel"); 228: bb1: { 229: const builtInAgents_0 = sortedAgents.filter(_temp5); 230: const hasNoAgents = !sortedAgents.length || source !== "built-in" && !sortedAgents.some(_temp6); 231: if (hasNoAgents) { 232: let t23; 233: if ($[55] !== onCreateNew || $[56] !== renderCreateNewOption) { 234: t23 = onCreateNew && <Box>{renderCreateNewOption()}</Box>; 235: $[55] = onCreateNew; 236: $[56] = renderCreateNewOption; 237: $[57] = t23; 238: } else { 239: t23 = $[57]; 240: } 241: let t24; 242: let t25; 243: let t26; 244: if ($[58] === Symbol.for("react.memo_cache_sentinel")) { 245: t24 = <Text dimColor={true}>No agents found. Create specialized subagents that Claude can delegate to.</Text>; 246: t25 = <Text dimColor={true}>Each subagent has its own context window, custom system prompt, and specific tools.</Text>; 247: t26 = <Text dimColor={true}>Try creating: Code Reviewer, Code Simplifier, Security Reviewer, Tech Lead, or UX Reviewer.</Text>; 248: $[58] = t24; 249: $[59] = t25; 250: $[60] = t26; 251: } else { 252: t24 = $[58]; 253: t25 = $[59]; 254: t26 = $[60]; 255: } 256: let t27; 257: if ($[61] !== renderBuiltInAgentsSection || $[62] !== sortedAgents || $[63] !== source) { 258: t27 = source !== "built-in" && sortedAgents.some(_temp7) && <><Divider />{renderBuiltInAgentsSection()}</>; 259: $[61] = renderBuiltInAgentsSection; 260: $[62] = sortedAgents; 261: $[63] = source; 262: $[64] = t27; 263: } else { 264: t27 = $[64]; 265: } 266: let t28; 267: if ($[65] !== handleKeyDown || $[66] !== t23 || $[67] !== t27) { 268: t28 = <Box flexDirection="column" gap={1} tabIndex={0} autoFocus={true} onKeyDown={handleKeyDown}>{t23}{t24}{t25}{t26}{t27}</Box>; 269: $[65] = handleKeyDown; 270: $[66] = t23; 271: $[67] = t27; 272: $[68] = t28; 273: } else { 274: t28 = $[68]; 275: } 276: let t29; 277: if ($[69] !== onBack || $[70] !== sourceTitle || $[71] !== t28) { 278: t29 = <Dialog title={sourceTitle} subtitle="No agents found" onCancel={onBack} hideInputGuide={true}>{t28}</Dialog>; 279: $[69] = onBack; 280: $[70] = sourceTitle; 281: $[71] = t28; 282: $[72] = t29; 283: } else { 284: t29 = $[72]; 285: } 286: t22 = t29; 287: break bb1; 288: } 289: T1 = Dialog; 290: t17 = sourceTitle; 291: let t23; 292: if ($[73] !== sortedAgents) { 293: t23 = count(sortedAgents, _temp8); 294: $[73] = sortedAgents; 295: $[74] = t23; 296: } else { 297: t23 = $[74]; 298: } 299: t18 = `${t23} agents`; 300: t19 = onBack; 301: t20 = true; 302: if ($[75] !== changes) { 303: t21 = changes && changes.length > 0 && <Box marginTop={1}><Text dimColor={true}>{changes[changes.length - 1]}</Text></Box>; 304: $[75] = changes; 305: $[76] = t21; 306: } else { 307: t21 = $[76]; 308: } 309: T0 = Box; 310: t11 = "column"; 311: t12 = 0; 312: t13 = true; 313: t14 = handleKeyDown; 314: if ($[77] !== onCreateNew || $[78] !== renderCreateNewOption) { 315: t15 = onCreateNew && <Box marginBottom={1}>{renderCreateNewOption()}</Box>; 316: $[77] = onCreateNew; 317: $[78] = renderCreateNewOption; 318: $[79] = t15; 319: } else { 320: t15 = $[79]; 321: } 322: t16 = source === "all" ? <>{AGENT_SOURCE_GROUPS.filter(_temp9).map(t24 => { 323: const { 324: label, 325: source: groupSource_0 326: } = t24; 327: return <React.Fragment key={groupSource_0}>{renderAgentGroup(label, sortedAgents.filter(a_7 => a_7.source === groupSource_0))}</React.Fragment>; 328: })}{builtInAgents_0.length > 0 && <Box flexDirection="column" marginBottom={1} paddingLeft={2}><Text dimColor={true}><Text bold={true}>Built-in agents</Text> (always available)</Text>{builtInAgents_0.map(renderAgent)}</Box>}</> : source === "built-in" ? <><Text dimColor={true} italic={true}>Built-in agents are provided by default and cannot be modified.</Text><Box marginTop={1} flexDirection="column">{sortedAgents.map(agent_2 => renderAgent(agent_2))}</Box></> : <>{sortedAgents.filter(_temp0).map(agent_3 => renderAgent(agent_3))}{sortedAgents.some(_temp1) && <><Divider />{renderBuiltInAgentsSection()}</>}</>; 329: } 330: $[30] = changes; 331: $[31] = handleKeyDown; 332: $[32] = onBack; 333: $[33] = onCreateNew; 334: $[34] = renderAgent; 335: $[35] = renderAgentGroup; 336: $[36] = renderBuiltInAgentsSection; 337: $[37] = renderCreateNewOption; 338: $[38] = sortedAgents; 339: $[39] = source; 340: $[40] = sourceTitle; 341: $[41] = T0; 342: $[42] = T1; 343: $[43] = t11; 344: $[44] = t12; 345: $[45] = t13; 346: $[46] = t14; 347: $[47] = t15; 348: $[48] = t16; 349: $[49] = t17; 350: $[50] = t18; 351: $[51] = t19; 352: $[52] = t20; 353: $[53] = t21; 354: $[54] = t22; 355: } else { 356: T0 = $[41]; 357: T1 = $[42]; 358: t11 = $[43]; 359: t12 = $[44]; 360: t13 = $[45]; 361: t14 = $[46]; 362: t15 = $[47]; 363: t16 = $[48]; 364: t17 = $[49]; 365: t18 = $[50]; 366: t19 = $[51]; 367: t20 = $[52]; 368: t21 = $[53]; 369: t22 = $[54]; 370: } 371: if (t22 !== Symbol.for("react.early_return_sentinel")) { 372: return t22; 373: } 374: let t23; 375: if ($[80] !== T0 || $[81] !== t11 || $[82] !== t12 || $[83] !== t13 || $[84] !== t14 || $[85] !== t15 || $[86] !== t16) { 376: t23 = <T0 flexDirection={t11} tabIndex={t12} autoFocus={t13} onKeyDown={t14}>{t15}{t16}</T0>; 377: $[80] = T0; 378: $[81] = t11; 379: $[82] = t12; 380: $[83] = t13; 381: $[84] = t14; 382: $[85] = t15; 383: $[86] = t16; 384: $[87] = t23; 385: } else { 386: t23 = $[87]; 387: } 388: let t24; 389: if ($[88] !== T1 || $[89] !== t17 || $[90] !== t18 || $[91] !== t19 || $[92] !== t20 || $[93] !== t21 || $[94] !== t23) { 390: t24 = <T1 title={t17} subtitle={t18} onCancel={t19} hideInputGuide={t20}>{t21}{t23}</T1>; 391: $[88] = T1; 392: $[89] = t17; 393: $[90] = t18; 394: $[91] = t19; 395: $[92] = t20; 396: $[93] = t21; 397: $[94] = t23; 398: $[95] = t24; 399: } else { 400: t24 = $[95]; 401: } 402: return t24; 403: } 404: function _temp1(a_9) { 405: return a_9.source === "built-in"; 406: } 407: function _temp0(a_8) { 408: return a_8.source !== "built-in"; 409: } 410: function _temp9(g_0) { 411: return g_0.source !== "built-in"; 412: } 413: function _temp8(a_6) { 414: return !a_6.overriddenBy; 415: } 416: function _temp7(a_5) { 417: return a_5.source === "built-in"; 418: } 419: function _temp6(a_4) { 420: return a_4.source !== "built-in"; 421: } 422: function _temp5(a_3) { 423: return a_3.source === "built-in"; 424: } 425: function _temp4(a_2) { 426: return a_2.source === "built-in"; 427: } 428: function _temp3(g) { 429: return g.source !== "built-in"; 430: } 431: function _temp2(a) { 432: return a.source !== "built-in"; 433: } 434: function _temp(agent) { 435: return { 436: isOverridden: !!agent.overriddenBy, 437: overriddenBy: agent.overriddenBy || null 438: }; 439: }

File: src/components/agents/AgentsMenu.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import chalk from 'chalk'; 3: import * as React from 'react'; 4: import { useCallback, useMemo, useState } from 'react'; 5: import type { SettingSource } from 'src/utils/settings/constants.js'; 6: import type { CommandResultDisplay } from '../../commands.js'; 7: import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; 8: import { useMergedTools } from '../../hooks/useMergedTools.js'; 9: import { Box, Text } from '../../ink.js'; 10: import { useAppState, useSetAppState } from '../../state/AppState.js'; 11: import type { Tools } from '../../Tool.js'; 12: import { type ResolvedAgent, resolveAgentOverrides } from '../../tools/AgentTool/agentDisplay.js'; 13: import { type AgentDefinition, getActiveAgentsFromList } from '../../tools/AgentTool/loadAgentsDir.js'; 14: import { toError } from '../../utils/errors.js'; 15: import { logError } from '../../utils/log.js'; 16: import { Select } from '../CustomSelect/select.js'; 17: import { Dialog } from '../design-system/Dialog.js'; 18: import { AgentDetail } from './AgentDetail.js'; 19: import { AgentEditor } from './AgentEditor.js'; 20: import { AgentNavigationFooter } from './AgentNavigationFooter.js'; 21: import { AgentsList } from './AgentsList.js'; 22: import { deleteAgentFromFile } from './agentFileUtils.js'; 23: import { CreateAgentWizard } from './new-agent-creation/CreateAgentWizard.js'; 24: import type { ModeState } from './types.js'; 25: type Props = { 26: tools: Tools; 27: onExit: (result?: string, options?: { 28: display?: CommandResultDisplay; 29: }) => void; 30: }; 31: export function AgentsMenu(t0) { 32: const $ = _c(157); 33: const { 34: tools, 35: onExit 36: } = t0; 37: let t1; 38: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 39: t1 = { 40: mode: "list-agents", 41: source: "all" 42: }; 43: $[0] = t1; 44: } else { 45: t1 = $[0]; 46: } 47: const [modeState, setModeState] = useState(t1); 48: const agentDefinitions = useAppState(_temp); 49: const mcpTools = useAppState(_temp2); 50: const toolPermissionContext = useAppState(_temp3); 51: const setAppState = useSetAppState(); 52: const { 53: allAgents, 54: activeAgents: agents 55: } = agentDefinitions; 56: let t2; 57: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 58: t2 = []; 59: $[1] = t2; 60: } else { 61: t2 = $[1]; 62: } 63: const [changes, setChanges] = useState(t2); 64: const mergedTools = useMergedTools(tools, mcpTools, toolPermissionContext); 65: useExitOnCtrlCDWithKeybindings(); 66: let t3; 67: if ($[2] !== allAgents) { 68: t3 = allAgents.filter(_temp4); 69: $[2] = allAgents; 70: $[3] = t3; 71: } else { 72: t3 = $[3]; 73: } 74: let t4; 75: if ($[4] !== allAgents) { 76: t4 = allAgents.filter(_temp5); 77: $[4] = allAgents; 78: $[5] = t4; 79: } else { 80: t4 = $[5]; 81: } 82: let t5; 83: if ($[6] !== allAgents) { 84: t5 = allAgents.filter(_temp6); 85: $[6] = allAgents; 86: $[7] = t5; 87: } else { 88: t5 = $[7]; 89: } 90: let t6; 91: if ($[8] !== allAgents) { 92: t6 = allAgents.filter(_temp7); 93: $[8] = allAgents; 94: $[9] = t6; 95: } else { 96: t6 = $[9]; 97: } 98: let t7; 99: if ($[10] !== allAgents) { 100: t7 = allAgents.filter(_temp8); 101: $[10] = allAgents; 102: $[11] = t7; 103: } else { 104: t7 = $[11]; 105: } 106: let t8; 107: if ($[12] !== allAgents) { 108: t8 = allAgents.filter(_temp9); 109: $[12] = allAgents; 110: $[13] = t8; 111: } else { 112: t8 = $[13]; 113: } 114: let t9; 115: if ($[14] !== allAgents) { 116: t9 = allAgents.filter(_temp0); 117: $[14] = allAgents; 118: $[15] = t9; 119: } else { 120: t9 = $[15]; 121: } 122: let t10; 123: if ($[16] !== allAgents || $[17] !== t3 || $[18] !== t4 || $[19] !== t5 || $[20] !== t6 || $[21] !== t7 || $[22] !== t8 || $[23] !== t9) { 124: t10 = { 125: "built-in": t3, 126: userSettings: t4, 127: projectSettings: t5, 128: policySettings: t6, 129: localSettings: t7, 130: flagSettings: t8, 131: plugin: t9, 132: all: allAgents 133: }; 134: $[16] = allAgents; 135: $[17] = t3; 136: $[18] = t4; 137: $[19] = t5; 138: $[20] = t6; 139: $[21] = t7; 140: $[22] = t8; 141: $[23] = t9; 142: $[24] = t10; 143: } else { 144: t10 = $[24]; 145: } 146: const agentsBySource = t10; 147: let t11; 148: if ($[25] === Symbol.for("react.memo_cache_sentinel")) { 149: t11 = message => { 150: setChanges(prev => [...prev, message]); 151: setModeState({ 152: mode: "list-agents", 153: source: "all" 154: }); 155: }; 156: $[25] = t11; 157: } else { 158: t11 = $[25]; 159: } 160: const handleAgentCreated = t11; 161: let t12; 162: if ($[26] !== setAppState) { 163: t12 = async agent => { 164: ; 165: try { 166: await deleteAgentFromFile(agent); 167: setAppState(state => { 168: const allAgents_0 = state.agentDefinitions.allAgents.filter(a_6 => !(a_6.agentType === agent.agentType && a_6.source === agent.source)); 169: return { 170: ...state, 171: agentDefinitions: { 172: ...state.agentDefinitions, 173: allAgents: allAgents_0, 174: activeAgents: getActiveAgentsFromList(allAgents_0) 175: } 176: }; 177: }); 178: setChanges(prev_0 => [...prev_0, `Deleted agent: ${chalk.bold(agent.agentType)}`]); 179: setModeState({ 180: mode: "list-agents", 181: source: "all" 182: }); 183: } catch (t13) { 184: const error = t13; 185: logError(toError(error)); 186: } 187: }; 188: $[26] = setAppState; 189: $[27] = t12; 190: } else { 191: t12 = $[27]; 192: } 193: const handleAgentDeleted = t12; 194: switch (modeState.mode) { 195: case "list-agents": 196: { 197: let t13; 198: if ($[28] !== agentsBySource || $[29] !== modeState.source) { 199: t13 = modeState.source === "all" ? [...agentsBySource["built-in"], ...agentsBySource.userSettings, ...agentsBySource.projectSettings, ...agentsBySource.localSettings, ...agentsBySource.policySettings, ...agentsBySource.flagSettings, ...agentsBySource.plugin] : agentsBySource[modeState.source]; 200: $[28] = agentsBySource; 201: $[29] = modeState.source; 202: $[30] = t13; 203: } else { 204: t13 = $[30]; 205: } 206: const agentsToShow = t13; 207: let t14; 208: if ($[31] !== agents || $[32] !== agentsToShow) { 209: t14 = resolveAgentOverrides(agentsToShow, agents); 210: $[31] = agents; 211: $[32] = agentsToShow; 212: $[33] = t14; 213: } else { 214: t14 = $[33]; 215: } 216: const allResolved = t14; 217: const resolvedAgents = allResolved; 218: let t15; 219: if ($[34] !== changes || $[35] !== onExit) { 220: t15 = () => { 221: const exitMessage = changes.length > 0 ? `Agent changes:\n${changes.join("\n")}` : undefined; 222: onExit(exitMessage ?? "Agents dialog dismissed", { 223: display: changes.length === 0 ? "system" : undefined 224: }); 225: }; 226: $[34] = changes; 227: $[35] = onExit; 228: $[36] = t15; 229: } else { 230: t15 = $[36]; 231: } 232: let t16; 233: if ($[37] !== modeState) { 234: t16 = agent_0 => setModeState({ 235: mode: "agent-menu", 236: agent: agent_0, 237: previousMode: modeState 238: }); 239: $[37] = modeState; 240: $[38] = t16; 241: } else { 242: t16 = $[38]; 243: } 244: let t17; 245: if ($[39] === Symbol.for("react.memo_cache_sentinel")) { 246: t17 = () => setModeState({ 247: mode: "create-agent" 248: }); 249: $[39] = t17; 250: } else { 251: t17 = $[39]; 252: } 253: let t18; 254: if ($[40] !== changes || $[41] !== modeState.source || $[42] !== resolvedAgents || $[43] !== t15 || $[44] !== t16) { 255: t18 = <AgentsList source={modeState.source} agents={resolvedAgents} onBack={t15} onSelect={t16} onCreateNew={t17} changes={changes} />; 256: $[40] = changes; 257: $[41] = modeState.source; 258: $[42] = resolvedAgents; 259: $[43] = t15; 260: $[44] = t16; 261: $[45] = t18; 262: } else { 263: t18 = $[45]; 264: } 265: let t19; 266: if ($[46] === Symbol.for("react.memo_cache_sentinel")) { 267: t19 = <AgentNavigationFooter />; 268: $[46] = t19; 269: } else { 270: t19 = $[46]; 271: } 272: let t20; 273: if ($[47] !== t18) { 274: t20 = <>{t18}{t19}</>; 275: $[47] = t18; 276: $[48] = t20; 277: } else { 278: t20 = $[48]; 279: } 280: return t20; 281: } 282: case "create-agent": 283: { 284: let t13; 285: if ($[49] === Symbol.for("react.memo_cache_sentinel")) { 286: t13 = () => setModeState({ 287: mode: "list-agents", 288: source: "all" 289: }); 290: $[49] = t13; 291: } else { 292: t13 = $[49]; 293: } 294: let t14; 295: if ($[50] !== agents || $[51] !== mergedTools) { 296: t14 = <CreateAgentWizard tools={mergedTools} existingAgents={agents} onComplete={handleAgentCreated} onCancel={t13} />; 297: $[50] = agents; 298: $[51] = mergedTools; 299: $[52] = t14; 300: } else { 301: t14 = $[52]; 302: } 303: return t14; 304: } 305: case "agent-menu": 306: { 307: let t13; 308: if ($[53] !== allAgents || $[54] !== modeState.agent.agentType || $[55] !== modeState.agent.source) { 309: let t14; 310: if ($[57] !== modeState.agent.agentType || $[58] !== modeState.agent.source) { 311: t14 = a_9 => a_9.agentType === modeState.agent.agentType && a_9.source === modeState.agent.source; 312: $[57] = modeState.agent.agentType; 313: $[58] = modeState.agent.source; 314: $[59] = t14; 315: } else { 316: t14 = $[59]; 317: } 318: t13 = allAgents.find(t14); 319: $[53] = allAgents; 320: $[54] = modeState.agent.agentType; 321: $[55] = modeState.agent.source; 322: $[56] = t13; 323: } else { 324: t13 = $[56]; 325: } 326: const freshAgent_1 = t13; 327: const agentToUse = freshAgent_1 || modeState.agent; 328: const isEditable = agentToUse.source !== "built-in" && agentToUse.source !== "plugin" && agentToUse.source !== "flagSettings"; 329: let t14; 330: if ($[60] === Symbol.for("react.memo_cache_sentinel")) { 331: t14 = { 332: label: "View agent", 333: value: "view" 334: }; 335: $[60] = t14; 336: } else { 337: t14 = $[60]; 338: } 339: let t15; 340: if ($[61] !== isEditable) { 341: t15 = isEditable ? [{ 342: label: "Edit agent", 343: value: "edit" 344: }, { 345: label: "Delete agent", 346: value: "delete" 347: }] : []; 348: $[61] = isEditable; 349: $[62] = t15; 350: } else { 351: t15 = $[62]; 352: } 353: let t16; 354: if ($[63] === Symbol.for("react.memo_cache_sentinel")) { 355: t16 = { 356: label: "Back", 357: value: "back" 358: }; 359: $[63] = t16; 360: } else { 361: t16 = $[63]; 362: } 363: let t17; 364: if ($[64] !== t15) { 365: t17 = [t14, ...t15, t16]; 366: $[64] = t15; 367: $[65] = t17; 368: } else { 369: t17 = $[65]; 370: } 371: const menuItems = t17; 372: let t18; 373: if ($[66] !== agentToUse || $[67] !== modeState) { 374: t18 = value_0 => { 375: bb129: switch (value_0) { 376: case "view": 377: { 378: setModeState({ 379: mode: "view-agent", 380: agent: agentToUse, 381: previousMode: modeState.previousMode 382: }); 383: break bb129; 384: } 385: case "edit": 386: { 387: setModeState({ 388: mode: "edit-agent", 389: agent: agentToUse, 390: previousMode: modeState 391: }); 392: break bb129; 393: } 394: case "delete": 395: { 396: setModeState({ 397: mode: "delete-confirm", 398: agent: agentToUse, 399: previousMode: modeState 400: }); 401: break bb129; 402: } 403: case "back": 404: { 405: setModeState(modeState.previousMode); 406: } 407: } 408: }; 409: $[66] = agentToUse; 410: $[67] = modeState; 411: $[68] = t18; 412: } else { 413: t18 = $[68]; 414: } 415: const handleMenuSelect = t18; 416: let t19; 417: if ($[69] !== modeState.previousMode) { 418: t19 = () => setModeState(modeState.previousMode); 419: $[69] = modeState.previousMode; 420: $[70] = t19; 421: } else { 422: t19 = $[70]; 423: } 424: let t20; 425: if ($[71] !== modeState.previousMode) { 426: t20 = () => setModeState(modeState.previousMode); 427: $[71] = modeState.previousMode; 428: $[72] = t20; 429: } else { 430: t20 = $[72]; 431: } 432: let t21; 433: if ($[73] !== handleMenuSelect || $[74] !== menuItems || $[75] !== t20) { 434: t21 = <Select options={menuItems} onChange={handleMenuSelect} onCancel={t20} />; 435: $[73] = handleMenuSelect; 436: $[74] = menuItems; 437: $[75] = t20; 438: $[76] = t21; 439: } else { 440: t21 = $[76]; 441: } 442: let t22; 443: if ($[77] !== changes) { 444: t22 = changes.length > 0 && <Box marginTop={1}><Text dimColor={true}>{changes[changes.length - 1]}</Text></Box>; 445: $[77] = changes; 446: $[78] = t22; 447: } else { 448: t22 = $[78]; 449: } 450: let t23; 451: if ($[79] !== t21 || $[80] !== t22) { 452: t23 = <Box flexDirection="column">{t21}{t22}</Box>; 453: $[79] = t21; 454: $[80] = t22; 455: $[81] = t23; 456: } else { 457: t23 = $[81]; 458: } 459: let t24; 460: if ($[82] !== modeState.agent.agentType || $[83] !== t19 || $[84] !== t23) { 461: t24 = <Dialog title={modeState.agent.agentType} onCancel={t19} hideInputGuide={true}>{t23}</Dialog>; 462: $[82] = modeState.agent.agentType; 463: $[83] = t19; 464: $[84] = t23; 465: $[85] = t24; 466: } else { 467: t24 = $[85]; 468: } 469: let t25; 470: if ($[86] === Symbol.for("react.memo_cache_sentinel")) { 471: t25 = <AgentNavigationFooter />; 472: $[86] = t25; 473: } else { 474: t25 = $[86]; 475: } 476: let t26; 477: if ($[87] !== t24) { 478: t26 = <>{t24}{t25}</>; 479: $[87] = t24; 480: $[88] = t26; 481: } else { 482: t26 = $[88]; 483: } 484: return t26; 485: } 486: case "view-agent": 487: { 488: let t13; 489: if ($[89] !== allAgents || $[90] !== modeState.agent) { 490: let t14; 491: if ($[92] !== modeState.agent) { 492: t14 = a_8 => a_8.agentType === modeState.agent.agentType && a_8.source === modeState.agent.source; 493: $[92] = modeState.agent; 494: $[93] = t14; 495: } else { 496: t14 = $[93]; 497: } 498: t13 = allAgents.find(t14); 499: $[89] = allAgents; 500: $[90] = modeState.agent; 501: $[91] = t13; 502: } else { 503: t13 = $[91]; 504: } 505: const freshAgent_0 = t13; 506: const agentToDisplay = freshAgent_0 || modeState.agent; 507: let t14; 508: if ($[94] !== agentToDisplay || $[95] !== modeState.previousMode) { 509: t14 = () => setModeState({ 510: mode: "agent-menu", 511: agent: agentToDisplay, 512: previousMode: modeState.previousMode 513: }); 514: $[94] = agentToDisplay; 515: $[95] = modeState.previousMode; 516: $[96] = t14; 517: } else { 518: t14 = $[96]; 519: } 520: let t15; 521: if ($[97] !== agentToDisplay || $[98] !== modeState.previousMode) { 522: t15 = () => setModeState({ 523: mode: "agent-menu", 524: agent: agentToDisplay, 525: previousMode: modeState.previousMode 526: }); 527: $[97] = agentToDisplay; 528: $[98] = modeState.previousMode; 529: $[99] = t15; 530: } else { 531: t15 = $[99]; 532: } 533: let t16; 534: if ($[100] !== agentToDisplay || $[101] !== allAgents || $[102] !== mergedTools || $[103] !== t15) { 535: t16 = <AgentDetail agent={agentToDisplay} tools={mergedTools} allAgents={allAgents} onBack={t15} />; 536: $[100] = agentToDisplay; 537: $[101] = allAgents; 538: $[102] = mergedTools; 539: $[103] = t15; 540: $[104] = t16; 541: } else { 542: t16 = $[104]; 543: } 544: let t17; 545: if ($[105] !== agentToDisplay.agentType || $[106] !== t14 || $[107] !== t16) { 546: t17 = <Dialog title={agentToDisplay.agentType} onCancel={t14} hideInputGuide={true}>{t16}</Dialog>; 547: $[105] = agentToDisplay.agentType; 548: $[106] = t14; 549: $[107] = t16; 550: $[108] = t17; 551: } else { 552: t17 = $[108]; 553: } 554: let t18; 555: if ($[109] === Symbol.for("react.memo_cache_sentinel")) { 556: t18 = <AgentNavigationFooter instructions="Press Enter or Esc to go back" />; 557: $[109] = t18; 558: } else { 559: t18 = $[109]; 560: } 561: let t19; 562: if ($[110] !== t17) { 563: t19 = <>{t17}{t18}</>; 564: $[110] = t17; 565: $[111] = t19; 566: } else { 567: t19 = $[111]; 568: } 569: return t19; 570: } 571: case "delete-confirm": 572: { 573: let t13; 574: if ($[112] === Symbol.for("react.memo_cache_sentinel")) { 575: t13 = [{ 576: label: "Yes, delete", 577: value: "yes" 578: }, { 579: label: "No, cancel", 580: value: "no" 581: }]; 582: $[112] = t13; 583: } else { 584: t13 = $[112]; 585: } 586: const deleteOptions = t13; 587: let t14; 588: if ($[113] !== modeState) { 589: t14 = () => { 590: if ("previousMode" in modeState) { 591: setModeState(modeState.previousMode); 592: } 593: }; 594: $[113] = modeState; 595: $[114] = t14; 596: } else { 597: t14 = $[114]; 598: } 599: let t15; 600: if ($[115] !== modeState.agent.agentType) { 601: t15 = <Text>Are you sure you want to delete the agent{" "}<Text bold={true}>{modeState.agent.agentType}</Text>?</Text>; 602: $[115] = modeState.agent.agentType; 603: $[116] = t15; 604: } else { 605: t15 = $[116]; 606: } 607: let t16; 608: if ($[117] !== modeState.agent.source) { 609: t16 = <Box marginTop={1}><Text dimColor={true}>Source: {modeState.agent.source}</Text></Box>; 610: $[117] = modeState.agent.source; 611: $[118] = t16; 612: } else { 613: t16 = $[118]; 614: } 615: let t17; 616: if ($[119] !== handleAgentDeleted || $[120] !== modeState) { 617: t17 = value => { 618: if (value === "yes") { 619: handleAgentDeleted(modeState.agent); 620: } else { 621: if ("previousMode" in modeState) { 622: setModeState(modeState.previousMode); 623: } 624: } 625: }; 626: $[119] = handleAgentDeleted; 627: $[120] = modeState; 628: $[121] = t17; 629: } else { 630: t17 = $[121]; 631: } 632: let t18; 633: if ($[122] !== modeState) { 634: t18 = () => { 635: if ("previousMode" in modeState) { 636: setModeState(modeState.previousMode); 637: } 638: }; 639: $[122] = modeState; 640: $[123] = t18; 641: } else { 642: t18 = $[123]; 643: } 644: let t19; 645: if ($[124] !== t17 || $[125] !== t18) { 646: t19 = <Box marginTop={1}><Select options={deleteOptions} onChange={t17} onCancel={t18} /></Box>; 647: $[124] = t17; 648: $[125] = t18; 649: $[126] = t19; 650: } else { 651: t19 = $[126]; 652: } 653: let t20; 654: if ($[127] !== t14 || $[128] !== t15 || $[129] !== t16 || $[130] !== t19) { 655: t20 = <Dialog title="Delete agent" onCancel={t14} color="error">{t15}{t16}{t19}</Dialog>; 656: $[127] = t14; 657: $[128] = t15; 658: $[129] = t16; 659: $[130] = t19; 660: $[131] = t20; 661: } else { 662: t20 = $[131]; 663: } 664: let t21; 665: if ($[132] === Symbol.for("react.memo_cache_sentinel")) { 666: t21 = <AgentNavigationFooter instructions={"Press \u2191\u2193 to navigate, Enter to select, Esc to cancel"} />; 667: $[132] = t21; 668: } else { 669: t21 = $[132]; 670: } 671: let t22; 672: if ($[133] !== t20) { 673: t22 = <>{t20}{t21}</>; 674: $[133] = t20; 675: $[134] = t22; 676: } else { 677: t22 = $[134]; 678: } 679: return t22; 680: } 681: case "edit-agent": 682: { 683: let t13; 684: if ($[135] !== allAgents || $[136] !== modeState.agent) { 685: let t14; 686: if ($[138] !== modeState.agent) { 687: t14 = a_7 => a_7.agentType === modeState.agent.agentType && a_7.source === modeState.agent.source; 688: $[138] = modeState.agent; 689: $[139] = t14; 690: } else { 691: t14 = $[139]; 692: } 693: t13 = allAgents.find(t14); 694: $[135] = allAgents; 695: $[136] = modeState.agent; 696: $[137] = t13; 697: } else { 698: t13 = $[137]; 699: } 700: const freshAgent = t13; 701: const agentToEdit = freshAgent || modeState.agent; 702: const t14 = `Edit agent: ${agentToEdit.agentType}`; 703: let t15; 704: if ($[140] !== modeState.previousMode) { 705: t15 = () => setModeState(modeState.previousMode); 706: $[140] = modeState.previousMode; 707: $[141] = t15; 708: } else { 709: t15 = $[141]; 710: } 711: let t16; 712: let t17; 713: if ($[142] !== modeState.previousMode) { 714: t16 = message_0 => { 715: handleAgentCreated(message_0); 716: setModeState(modeState.previousMode); 717: }; 718: t17 = () => setModeState(modeState.previousMode); 719: $[142] = modeState.previousMode; 720: $[143] = t16; 721: $[144] = t17; 722: } else { 723: t16 = $[143]; 724: t17 = $[144]; 725: } 726: let t18; 727: if ($[145] !== agentToEdit || $[146] !== mergedTools || $[147] !== t16 || $[148] !== t17) { 728: t18 = <AgentEditor agent={agentToEdit} tools={mergedTools} onSaved={t16} onBack={t17} />; 729: $[145] = agentToEdit; 730: $[146] = mergedTools; 731: $[147] = t16; 732: $[148] = t17; 733: $[149] = t18; 734: } else { 735: t18 = $[149]; 736: } 737: let t19; 738: if ($[150] !== t14 || $[151] !== t15 || $[152] !== t18) { 739: t19 = <Dialog title={t14} onCancel={t15} hideInputGuide={true}>{t18}</Dialog>; 740: $[150] = t14; 741: $[151] = t15; 742: $[152] = t18; 743: $[153] = t19; 744: } else { 745: t19 = $[153]; 746: } 747: let t20; 748: if ($[154] === Symbol.for("react.memo_cache_sentinel")) { 749: t20 = <AgentNavigationFooter />; 750: $[154] = t20; 751: } else { 752: t20 = $[154]; 753: } 754: let t21; 755: if ($[155] !== t19) { 756: t21 = <>{t19}{t20}</>; 757: $[155] = t19; 758: $[156] = t21; 759: } else { 760: t21 = $[156]; 761: } 762: return t21; 763: } 764: default: 765: { 766: return null; 767: } 768: } 769: } 770: function _temp0(a_5) { 771: return a_5.source === "plugin"; 772: } 773: function _temp9(a_4) { 774: return a_4.source === "flagSettings"; 775: } 776: function _temp8(a_3) { 777: return a_3.source === "localSettings"; 778: } 779: function _temp7(a_2) { 780: return a_2.source === "policySettings"; 781: } 782: function _temp6(a_1) { 783: return a_1.source === "projectSettings"; 784: } 785: function _temp5(a_0) { 786: return a_0.source === "userSettings"; 787: } 788: function _temp4(a) { 789: return a.source === "built-in"; 790: } 791: function _temp3(s_1) { 792: return s_1.toolPermissionContext; 793: } 794: function _temp2(s_0) { 795: return s_0.mcp.tools; 796: } 797: function _temp(s) { 798: return s.agentDefinitions; 799: }

File: src/components/agents/ColorPicker.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import React, { useState } from 'react'; 4: import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; 5: import { Box, Text } from '../../ink.js'; 6: import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName } from '../../tools/AgentTool/agentColorManager.js'; 7: import { capitalize } from '../../utils/stringUtils.js'; 8: type ColorOption = AgentColorName | 'automatic'; 9: const COLOR_OPTIONS: ColorOption[] = ['automatic', ...AGENT_COLORS]; 10: type Props = { 11: agentName: string; 12: currentColor?: AgentColorName | 'automatic'; 13: onConfirm: (color: AgentColorName | undefined) => void; 14: }; 15: export function ColorPicker(t0) { 16: const $ = _c(17); 17: const { 18: agentName, 19: currentColor: t1, 20: onConfirm 21: } = t0; 22: const currentColor = t1 === undefined ? "automatic" : t1; 23: let t2; 24: if ($[0] !== currentColor) { 25: t2 = COLOR_OPTIONS.findIndex(opt => opt === currentColor); 26: $[0] = currentColor; 27: $[1] = t2; 28: } else { 29: t2 = $[1]; 30: } 31: const [selectedIndex, setSelectedIndex] = useState(Math.max(0, t2)); 32: let t3; 33: if ($[2] !== onConfirm || $[3] !== selectedIndex) { 34: t3 = e => { 35: if (e.key === "up") { 36: e.preventDefault(); 37: setSelectedIndex(_temp); 38: } else { 39: if (e.key === "down") { 40: e.preventDefault(); 41: setSelectedIndex(_temp2); 42: } else { 43: if (e.key === "return") { 44: e.preventDefault(); 45: const selected = COLOR_OPTIONS[selectedIndex]; 46: onConfirm(selected === "automatic" ? undefined : selected); 47: } 48: } 49: } 50: }; 51: $[2] = onConfirm; 52: $[3] = selectedIndex; 53: $[4] = t3; 54: } else { 55: t3 = $[4]; 56: } 57: const handleKeyDown = t3; 58: const selectedValue = COLOR_OPTIONS[selectedIndex]; 59: let t4; 60: if ($[5] !== selectedIndex) { 61: t4 = COLOR_OPTIONS.map((option, index) => { 62: const isSelected = index === selectedIndex; 63: return <Box key={option} flexDirection="row" gap={1}><Text color={isSelected ? "suggestion" : undefined}>{isSelected ? figures.pointer : " "}</Text>{option === "automatic" ? <Text bold={isSelected}>Automatic color</Text> : <Box gap={1}><Text backgroundColor={AGENT_COLOR_TO_THEME_COLOR[option]} color="inverseText">{" "}</Text><Text bold={isSelected}>{capitalize(option)}</Text></Box>}</Box>; 64: }); 65: $[5] = selectedIndex; 66: $[6] = t4; 67: } else { 68: t4 = $[6]; 69: } 70: let t5; 71: if ($[7] !== t4) { 72: t5 = <Box flexDirection="column">{t4}</Box>; 73: $[7] = t4; 74: $[8] = t5; 75: } else { 76: t5 = $[8]; 77: } 78: let t6; 79: if ($[9] === Symbol.for("react.memo_cache_sentinel")) { 80: t6 = <Text>Preview: </Text>; 81: $[9] = t6; 82: } else { 83: t6 = $[9]; 84: } 85: let t7; 86: if ($[10] !== agentName || $[11] !== selectedValue) { 87: t7 = <Box marginTop={1}>{t6}{selectedValue === undefined || selectedValue === "automatic" ? <Text inverse={true} bold={true}>{" "}@{agentName}{" "}</Text> : <Text backgroundColor={AGENT_COLOR_TO_THEME_COLOR[selectedValue]} color="inverseText" bold={true}>{" "}@{agentName}{" "}</Text>}</Box>; 88: $[10] = agentName; 89: $[11] = selectedValue; 90: $[12] = t7; 91: } else { 92: t7 = $[12]; 93: } 94: let t8; 95: if ($[13] !== handleKeyDown || $[14] !== t5 || $[15] !== t7) { 96: t8 = <Box flexDirection="column" gap={1} tabIndex={0} autoFocus={true} onKeyDown={handleKeyDown}>{t5}{t7}</Box>; 97: $[13] = handleKeyDown; 98: $[14] = t5; 99: $[15] = t7; 100: $[16] = t8; 101: } else { 102: t8 = $[16]; 103: } 104: return t8; 105: } 106: function _temp2(prev_0) { 107: return prev_0 < COLOR_OPTIONS.length - 1 ? prev_0 + 1 : 0; 108: } 109: function _temp(prev) { 110: return prev > 0 ? prev - 1 : COLOR_OPTIONS.length - 1; 111: }

File: src/components/agents/generateAgent.ts

typescript 1: import type { ContentBlock } from '@anthropic-ai/sdk/resources/index.mjs' 2: import { getUserContext } from 'src/context.js' 3: import { queryModelWithoutStreaming } from 'src/services/api/claude.js' 4: import { getEmptyToolPermissionContext } from 'src/Tool.js' 5: import { AGENT_TOOL_NAME } from 'src/tools/AgentTool/constants.js' 6: import { prependUserContext } from 'src/utils/api.js' 7: import { 8: createUserMessage, 9: normalizeMessagesForAPI, 10: } from 'src/utils/messages.js' 11: import type { ModelName } from 'src/utils/model/model.js' 12: import { isAutoMemoryEnabled } from '../../memdir/paths.js' 13: import { 14: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 15: logEvent, 16: } from '../../services/analytics/index.js' 17: import { jsonParse } from '../../utils/slowOperations.js' 18: import { asSystemPrompt } from '../../utils/systemPromptType.js' 19: type GeneratedAgent = { 20: identifier: string 21: whenToUse: string 22: systemPrompt: string 23: } 24: const AGENT_CREATION_SYSTEM_PROMPT = `You are an elite AI agent architect specializing in crafting high-performance agent configurations. Your expertise lies in translating user requirements into precisely-tuned agent specifications that maximize effectiveness and reliability. 25: **Important Context**: You may have access to project-specific instructions from CLAUDE.md files and other context that may include coding standards, project structure, and custom requirements. Consider this context when creating agents to ensure they align with the project's established patterns and practices. 26: When a user describes what they want an agent to do, you will: 27: 1. **Extract Core Intent**: Identify the fundamental purpose, key responsibilities, and success criteria for the agent. Look for both explicit requirements and implicit needs. Consider any project-specific context from CLAUDE.md files. For agents that are meant to review code, you should assume that the user is asking to review recently written code and not the whole codebase, unless the user has explicitly instructed you otherwise. 28: 2. **Design Expert Persona**: Create a compelling expert identity that embodies deep domain knowledge relevant to the task. The persona should inspire confidence and guide the agent's decision-making approach. 29: 3. **Architect Comprehensive Instructions**: Develop a system prompt that: 30: - Establishes clear behavioral boundaries and operational parameters 31: - Provides specific methodologies and best practices for task execution 32: - Anticipates edge cases and provides guidance for handling them 33: - Incorporates any specific requirements or preferences mentioned by the user 34: - Defines output format expectations when relevant 35: - Aligns with project-specific coding standards and patterns from CLAUDE.md 36: 4. **Optimize for Performance**: Include: 37: - Decision-making frameworks appropriate to the domain 38: - Quality control mechanisms and self-verification steps 39: - Efficient workflow patterns 40: - Clear escalation or fallback strategies 41: 5. **Create Identifier**: Design a concise, descriptive identifier that: 42: - Uses lowercase letters, numbers, and hyphens only 43: - Is typically 2-4 words joined by hyphens 44: - Clearly indicates the agent's primary function 45: - Is memorable and easy to type 46: - Avoids generic terms like "helper" or "assistant" 47: 6 **Example agent descriptions**: 48: - in the 'whenToUse' field of the JSON object, you should include examples of when this agent should be used. 49: - examples should be of the form: 50: - <example> 51: Context: The user is creating a test-runner agent that should be called after a logical chunk of code is written. 52: user: "Please write a function that checks if a number is prime" 53: assistant: "Here is the relevant function: " 54: <function call omitted for brevity only for this example> 55: <commentary> 56: Since a significant piece of code was written, use the ${AGENT_TOOL_NAME} tool to launch the test-runner agent to run the tests. 57: </commentary> 58: assistant: "Now let me use the test-runner agent to run the tests" 59: </example> 60: - <example> 61: Context: User is creating an agent to respond to the word "hello" with a friendly jok. 62: user: "Hello" 63: assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent to respond with a friendly joke" 64: <commentary> 65: Since the user is greeting, use the greeting-responder agent to respond with a friendly joke. 66: </commentary> 67: </example> 68: - If the user mentioned or implied that the agent should be used proactively, you should include examples of this. 69: - NOTE: Ensure that in the examples, you are making the assistant use the Agent tool and not simply respond directly to the task. 70: Your output must be a valid JSON object with exactly these fields: 71: { 72: "identifier": "A unique, descriptive identifier using lowercase letters, numbers, and hyphens (e.g., 'test-runner', 'api-docs-writer', 'code-formatter')", 73: "whenToUse": "A precise, actionable description starting with 'Use this agent when...' that clearly defines the triggering conditions and use cases. Ensure you include examples as described above.", 74: "systemPrompt": "The complete system prompt that will govern the agent's behavior, written in second person ('You are...', 'You will...') and structured for maximum clarity and effectiveness" 75: } 76: Key principles for your system prompts: 77: - Be specific rather than generic - avoid vague instructions 78: - Include concrete examples when they would clarify behavior 79: - Balance comprehensiveness with clarity - every instruction should add value 80: - Ensure the agent has enough context to handle variations of the core task 81: - Make the agent proactive in seeking clarification when needed 82: - Build in quality assurance and self-correction mechanisms 83: Remember: The agents you create should be autonomous experts capable of handling their designated tasks with minimal additional guidance. Your system prompts are their complete operational manual. 84: ` 85: const AGENT_MEMORY_INSTRUCTIONS = ` 86: 7. **Agent Memory Instructions**: If the user mentions "memory", "remember", "learn", "persist", or similar concepts, OR if the agent would benefit from building up knowledge across conversations (e.g., code reviewers learning patterns, architects learning codebase structure, etc.), include domain-specific memory update instructions in the systemPrompt. 87: Add a section like this to the systemPrompt, tailored to the agent's specific domain: 88: "**Update your agent memory** as you discover [domain-specific items]. This builds up institutional knowledge across conversations. Write concise notes about what you found and where. 89: Examples of what to record: 90: - [domain-specific item 1] 91: - [domain-specific item 2] 92: - [domain-specific item 3]" 93: Examples of domain-specific memory instructions: 94: - For a code-reviewer: "Update your agent memory as you discover code patterns, style conventions, common issues, and architectural decisions in this codebase." 95: - For a test-runner: "Update your agent memory as you discover test patterns, common failure modes, flaky tests, and testing best practices." 96: - For an architect: "Update your agent memory as you discover codepaths, library locations, key architectural decisions, and component relationships." 97: - For a documentation writer: "Update your agent memory as you discover documentation patterns, API structures, and terminology conventions." 98: The memory instructions should be specific to what the agent would naturally learn while performing its core tasks. 99: ` 100: export async function generateAgent( 101: userPrompt: string, 102: model: ModelName, 103: existingIdentifiers: string[], 104: abortSignal: AbortSignal, 105: ): Promise<GeneratedAgent> { 106: const existingList = 107: existingIdentifiers.length > 0 108: ? `\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existingIdentifiers.join(', ')}` 109: : '' 110: const prompt = `Create an agent configuration based on this request: "${userPrompt}".${existingList} 111: Return ONLY the JSON object, no other text.` 112: const userMessage = createUserMessage({ content: prompt }) 113: // Fetch user and system contexts 114: const userContext = await getUserContext() 115: // Prepend user context to messages and append system context to system prompt 116: const messagesWithContext = prependUserContext([userMessage], userContext) 117: // Include memory instructions when the feature is enabled 118: const systemPrompt = isAutoMemoryEnabled() 119: ? AGENT_CREATION_SYSTEM_PROMPT + AGENT_MEMORY_INSTRUCTIONS 120: : AGENT_CREATION_SYSTEM_PROMPT 121: const response = await queryModelWithoutStreaming({ 122: messages: normalizeMessagesForAPI(messagesWithContext), 123: systemPrompt: asSystemPrompt([systemPrompt]), 124: thinkingConfig: { type: 'disabled' as const }, 125: tools: [], 126: signal: abortSignal, 127: options: { 128: getToolPermissionContext: async () => getEmptyToolPermissionContext(), 129: model, 130: toolChoice: undefined, 131: agents: [], 132: isNonInteractiveSession: false, 133: hasAppendSystemPrompt: false, 134: querySource: 'agent_creation', 135: mcpTools: [], 136: }, 137: }) 138: const textBlocks = response.message.content.filter( 139: (block): block is ContentBlock & { type: 'text' } => block.type === 'text', 140: ) 141: const responseText = textBlocks.map(block => block.text).join('\n') 142: let parsed: GeneratedAgent 143: try { 144: parsed = jsonParse(responseText.trim()) 145: } catch { 146: const jsonMatch = responseText.match(/\{[\s\S]*\}/) 147: if (!jsonMatch) { 148: throw new Error('No JSON object found in response') 149: } 150: parsed = jsonParse(jsonMatch[0]) 151: } 152: if (!parsed.identifier || !parsed.whenToUse || !parsed.systemPrompt) { 153: throw new Error('Invalid agent configuration generated') 154: } 155: logEvent('tengu_agent_definition_generated', { 156: agent_identifier: 157: parsed.identifier as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 158: }) 159: return { 160: identifier: parsed.identifier, 161: whenToUse: parsed.whenToUse, 162: systemPrompt: parsed.systemPrompt, 163: } 164: }

File: src/components/agents/ModelSelector.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import * as React from 'react'; 3: import { Box, Text } from '../../ink.js'; 4: import { getAgentModelOptions } from '../../utils/model/agent.js'; 5: import { Select } from '../CustomSelect/select.js'; 6: interface ModelSelectorProps { 7: initialModel?: string; 8: onComplete: (model?: string) => void; 9: onCancel?: () => void; 10: } 11: export function ModelSelector(t0) { 12: const $ = _c(11); 13: const { 14: initialModel, 15: onComplete, 16: onCancel 17: } = t0; 18: let t1; 19: if ($[0] !== initialModel) { 20: bb0: { 21: const base = getAgentModelOptions(); 22: if (initialModel && !base.some(o => o.value === initialModel)) { 23: t1 = [{ 24: value: initialModel, 25: label: initialModel, 26: description: "Current model (custom ID)" 27: }, ...base]; 28: break bb0; 29: } 30: t1 = base; 31: } 32: $[0] = initialModel; 33: $[1] = t1; 34: } else { 35: t1 = $[1]; 36: } 37: const modelOptions = t1; 38: const defaultModel = initialModel ?? "sonnet"; 39: let t2; 40: if ($[2] === Symbol.for("react.memo_cache_sentinel")) { 41: t2 = <Box marginBottom={1}><Text dimColor={true}>Model determines the agent's reasoning capabilities and speed.</Text></Box>; 42: $[2] = t2; 43: } else { 44: t2 = $[2]; 45: } 46: let t3; 47: if ($[3] !== onCancel || $[4] !== onComplete) { 48: t3 = () => onCancel ? onCancel() : onComplete(undefined); 49: $[3] = onCancel; 50: $[4] = onComplete; 51: $[5] = t3; 52: } else { 53: t3 = $[5]; 54: } 55: let t4; 56: if ($[6] !== defaultModel || $[7] !== modelOptions || $[8] !== onComplete || $[9] !== t3) { 57: t4 = <Box flexDirection="column">{t2}<Select options={modelOptions} defaultValue={defaultModel} onChange={onComplete} onCancel={t3} /></Box>; 58: $[6] = defaultModel; 59: $[7] = modelOptions; 60: $[8] = onComplete; 61: $[9] = t3; 62: $[10] = t4; 63: } else { 64: t4 = $[10]; 65: } 66: return t4; 67: }

File: src/components/agents/ToolSelector.tsx

````typescript 1: import { c as _c } from “react/compiler-runtime”; 2: import figures from ‘figures’; 3: import React, { useCallback, useMemo, useState } from ‘react’; 4: import { mcpInfoFromString } from ‘src/services/mcp/mcpStringUtils.js’; 5: import { isMcpTool } from ‘src/services/mcp/utils.js’; 6: import type { Tool, Tools } from ‘src/Tool.js’; 7: import { filterToolsForAgent } from ‘src/tools/AgentTool/agentToolUtils.js’; 8: import { AGENT_TOOL_NAME } from ‘src/tools/AgentTool/constants.js’; 9: import { BashTool } from ‘src/tools/BashTool/BashTool.js’; 10: import { ExitPlanModeV2Tool } from ‘src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js’; 11: import { FileEditTool } from ‘src/tools/FileEditTool/FileEditTool.js’; 12: import { FileReadTool } from ‘src/tools/FileReadTool/FileReadTool.js’; 13: import { FileWriteTool } from ‘src/tools/FileWriteTool/FileWriteTool.js’; 14: import { GlobTool } from ‘src/tools/GlobTool/GlobTool.js’; 15: import { GrepTool } from ‘src/tools/GrepTool/GrepTool.js’; 16: import { ListMcpResourcesTool } from ‘src/tools/ListMcpResourcesTool/ListMcpResourcesTool.js’; 17: import { NotebookEditTool } from ‘src/tools/NotebookEditTool/NotebookEditTool.js’; 18: import { ReadMcpResourceTool } from ‘src/tools/ReadMcpResourceTool/ReadMcpResourceTool.js’; 19: import { TaskOutputTool } from ‘src/tools/TaskOutputTool/TaskOutputTool.js’; 20: import { TaskStopTool } from ‘src/tools/TaskStopTool/TaskStopTool.js’; 21: import { TodoWriteTool } from ‘src/tools/TodoWriteTool/TodoWriteTool.js’; 22: import { TungstenTool } from ‘src/tools/TungstenTool/TungstenTool.js’; 23: import { WebFetchTool } from ‘src/tools/WebFetchTool/WebFetchTool.js’; 24: import { WebSearchTool } from ‘src/tools/WebSearchTool/WebSearchTool.js’; 25: import type { KeyboardEvent } from ‘../../ink/events/keyboard-event.js’; 26: import { Box, Text } from ‘../../ink.js’; 27: import { useKeybinding } from ‘../../keybindings/useKeybinding.js’; 28: import { count } from ‘../../utils/array.js’; 29: import { plural } from ‘../../utils/stringUtils.js’; 30: import { Divider } from ‘../design-system/Divider.js’; 31: type Props = { 32: tools: Tools; 33: initialTools: string[] | undefined; 34: onComplete: (selectedTools: string[] | undefined) => void; 35: onCancel?: () => void; 36: }; 37: type ToolBucket = { 38: name: string; 39: toolNames: Set; 40: isMcp?: boolean; 41: }; 42: type ToolBuckets = { 43: READ_ONLY: ToolBucket; 44: EDIT: ToolBucket; 45: EXECUTION: ToolBucket; 46: MCP: ToolBucket; 47: OTHER: ToolBucket; 48: }; 49: function getToolBuckets(): ToolBuckets { 50: return { 51: READ_ONLY: { 52: name: 'Read-only tools', 53: toolNames: new Set([GlobTool.name, GrepTool.name, ExitPlanModeV2Tool.name, FileReadTool.name, WebFetchTool.name, TodoWriteTool.name, WebSearchTool.name, TaskStopTool.name, TaskOutputTool.name, ListMcpResourcesTool.name, ReadMcpResourceTool.name]) 54: }, 55: EDIT: { 56: name: 'Edit tools', 57: toolNames: new Set([FileEditTool.name, FileWriteTool.name, NotebookEditTool.name]) 58: }, 59: EXECUTION: { 60: name: 'Execution tools', 61: toolNames: new Set([BashTool.name, "external" === 'ant' ? TungstenTool.name : undefined].filter(n => n !== undefined)) 62: }, 63: MCP: { 64: name: 'MCP tools', 65: toolNames: new Set(), 66: isMcp: true 67: }, 68: OTHER: { 69: name: 'Other tools', 70: toolNames: new Set() 71: } 72: }; 73: } 74: function getMcpServerBuckets(tools: Tools): Array<{ 75: serverName: string; 76: tools: Tools; 77: }> { 78: const serverMap = new Map<string, Tool[]>(); 79: tools.forEach(tool => { 80: if (isMcpTool(tool)) { 81: const mcpInfo = mcpInfoFromString(tool.name); 82: if (mcpInfo?.serverName) { 83: const existing = serverMap.get(mcpInfo.serverName) || []; 84: existing.push(tool); 85: serverMap.set(mcpInfo.serverName, existing); 86: } 87: } 88: }); 89: return Array.from(serverMap.entries()).map(([serverName, tools]) => ({ 90: serverName, 91: tools 92: })).sort((a, b) => a.serverName.localeCompare(b.serverName)); 93: } 94: export function ToolSelector(t0) { 95: const $ = _c(69); 96: const { 97: tools, 98: initialTools, 99: onComplete, 100: onCancel 101: } = t0; 102: let t1; 103: if ($[0] !== tools) { 104: t1 = filterToolsForAgent({ 105: tools, 106: isBuiltIn: false, 107: isAsync: false 108: }); 109: $[0] = tools; 110: $[1] = t1; 111: } else { 112: t1 = $[1]; 113: } 114: const customAgentTools = t1; 115: let t2; 116: if ($[2] !== customAgentTools || $[3] !== initialTools) { 117: t2 = !initialTools || initialTools.includes("*") ? customAgentTools.map(_temp) : initialTools; 118: $[2] = customAgentTools; 119: $[3] = initialTools; 120: $[4] = t2; 121: } else { 122: t2 = $[4]; 123: } 124: const expandedInitialTools = t2; 125: const [selectedTools, setSelectedTools] = useState(expandedInitialTools); 126: const [focusIndex, setFocusIndex] = useState(0); 127: const [showIndividualTools, setShowIndividualTools] = useState(false); 128: let t3; 129: if ($[5] !== customAgentTools) { 130: t3 = new Set(customAgentTools.map(_temp2)); 131: $[5] = customAgentTools; 132: $[6] = t3; 133: } else { 134: t3 = $[6]; 135: } 136: const toolNames = t3; 137: let t4; 138: if ($[7] !== selectedTools || $[8] !== toolNames) { 139: let t5; 140: if ($[10] !== toolNames) { 141: t5 = name => toolNames.has(name); 142: $[10] = toolNames; 143: $[11] = t5; 144: } else { 145: t5 = $[11]; 146: } 147: t4 = selectedTools.filter(t5); 148: $[7] = selectedTools; 149: $[8] = toolNames; 150: $[9] = t4; 151: } else { 152: t4 = $[9]; 153: } 154: const validSelectedTools = t4; 155: let t5; 156: if ($[12] !== validSelectedTools) { 157: t5 = new Set(validSelectedTools); 158: $[12] = validSelectedTools; 159: $[13] = t5; 160: } else { 161: t5 = $[13]; 162: } 163: const selectedSet = t5; 164: const isAllSelected = validSelectedTools.length === customAgentTools.length && customAgentTools.length > 0; 165: let t6; 166: if ($[14] === Symbol.for("react.memo_cache_sentinel")) { 167: t6 = toolName => { 168: if (!toolName) { 169: return; 170: } 171: setSelectedTools(current => current.includes(toolName) ? current.filter(t_1 => t_1 !== toolName) : [...current, toolName]); 172: }; 173: $[14] = t6; 174: } else { 175: t6 = $[14]; 176: } 177: const handleToggleTool = t6; 178: let t7; 179: if ($[15] === Symbol.for("react.memo_cache_sentinel")) { 180: t7 = (toolNames_0, select) => { 181: setSelectedTools(current_0 => { 182: if (select) { 183: const toolsToAdd = toolNames_0.filter(t_2 => !current_0.includes(t_2)); 184: return [...current_0, ...toolsToAdd]; 185: } else { 186: return current_0.filter(t_3 => !toolNames_0.includes(t_3)); 187: } 188: }); 189: }; 190: $[15] = t7; 191: } else { 192: t7 = $[15]; 193: } 194: const handleToggleTools = t7; 195: let t8; 196: if ($[16] !== customAgentTools || $[17] !== onComplete || $[18] !== validSelectedTools) { 197: t8 = () => { 198: const allToolNames = customAgentTools.map(_temp3); 199: const areAllToolsSelected = validSelectedTools.length === allToolNames.length && allToolNames.every(name_0 => validSelectedTools.includes(name_0)); 200: const finalTools = areAllToolsSelected ? undefined : validSelectedTools; 201: onComplete(finalTools); 202: }; 203: $[16] = customAgentTools; 204: $[17] = onComplete; 205: $[18] = validSelectedTools; 206: $[19] = t8; 207: } else { 208: t8 = $[19]; 209: } 210: const handleConfirm = t8; 211: let buckets; 212: if ($[20] !== customAgentTools) { 213: const toolBuckets = getToolBuckets(); 214: buckets = { 215: readOnly: [] as Tool[], 216: edit: [] as Tool[], 217: execution: [] as Tool[], 218: mcp: [] as Tool[], 219: other: [] as Tool[] 220: }; 221: customAgentTools.forEach(tool => { 222: if (isMcpTool(tool)) { 223: buckets.mcp.push(tool); 224: } else { 225: if (toolBuckets.READ_ONLY.toolNames.has(tool.name)) { 226: buckets.readOnly.push(tool); 227: } else { 228: if (toolBuckets.EDIT.toolNames.has(tool.name)) { 229: buckets.edit.push(tool); 230: } else { 231: if (toolBuckets.EXECUTION.toolNames.has(tool.name)) { 232: buckets.execution.push(tool); 233: } else { 234: if (tool.name !== AGENT_TOOL_NAME) { 235: buckets.other.push(tool); 236: } 237: } 238: } 239: } 240: } 241: }); 242: $[20] = customAgentTools; 243: $[21] = buckets; 244: } else { 245: buckets = $[21]; 246: } 247: const toolsByBucket = buckets; 248: let t9; 249: if ($[22] !== selectedSet) { 250: t9 = bucketTools => { 251: const selected = count(bucketTools, t_5 => selectedSet.has(t_5.name)); 252: const needsSelection = selected < bucketTools.length; 253: return () => { 254: const toolNames_1 = bucketTools.map(_temp4); 255: handleToggleTools(toolNames_1, needsSelection); 256: }; 257: }; 258: $[22] = selectedSet; 259: $[23] = t9; 260: } else { 261: t9 = $[23]; 262: } 263: const createBucketToggleAction = t9; 264: let navigableItems; 265: if ($[24] !== createBucketToggleAction || $[25] !== customAgentTools || $[26] !== focusIndex || $[27] !== handleConfirm || $[28] !== isAllSelected || $[29] !== selectedSet || $[30] !== showIndividualTools || $[31] !== toolsByBucket.edit || $[32] !== toolsByBucket.execution || $[33] !== toolsByBucket.mcp || $[34] !== toolsByBucket.other || $[35] !== toolsByBucket.readOnly) { 266: navigableItems = []; 267: navigableItems.push({ 268: id: "continue", 269: label: "Continue", 270: action: handleConfirm, 271: isContinue: true 272: }); 273: let t10; 274: if ($[37] !== customAgentTools || $[38] !== isAllSelected) { 275: t10 = () => { 276: const allToolNames_0 = customAgentTools.map(_temp5); 277: handleToggleTools(allToolNames_0, !isAllSelected); 278: }; 279: $[37] = customAgentTools; 280: $[38] = isAllSelected; 281: $[39] = t10; 282: } else { 283: t10 = $[39]; 284: } 285: navigableItems.push({ 286: id: "bucket-all", 287: label: `${isAllSelected ? figures.checkboxOn : figures.checkboxOff} All tools`, 288: action: t10 289: }); 290: const toolBuckets_0 = getToolBuckets(); 291: const bucketConfigs = [{ 292: id: "bucket-readonly", 293: name: toolBuckets_0.READ_ONLY.name, 294: tools: toolsByBucket.readOnly 295: }, { 296: id: "bucket-edit", 297: name: toolBuckets_0.EDIT.name, 298: tools: toolsByBucket.edit 299: }, { 300: id: "bucket-execution", 301: name: toolBuckets_0.EXECUTION.name, 302: tools: toolsByBucket.execution 303: }, { 304: id: "bucket-mcp", 305: name: toolBuckets_0.MCP.name, 306: tools: toolsByBucket.mcp 307: }, { 308: id: "bucket-other", 309: name: toolBuckets_0.OTHER.name, 310: tools: toolsByBucket.other 311: }]; 312: bucketConfigs.forEach(t11 => { 313: const { 314: id, 315: name: name_1, 316: tools: bucketTools_0 317: } = t11; 318: if (bucketTools_0.length === 0) { 319: return; 320: } 321: const selected_0 = count(bucketTools_0, t_8 => selectedSet.has(t_8.name)); 322: const isFullySelected = selected_0 === bucketTools_0.length; 323: navigableItems.push({ 324: id, 325: label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${name_1}`, 326: action: createBucketToggleAction(bucketTools_0) 327: }); 328: }); 329: const toggleButtonIndex = navigableItems.length; 330: let t12;