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'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 "{searchQuery}"</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 "{searchQuery}"</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 • 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;