From fd509086f3b8d61f6c3eaedb0130e18305e3bf28 Mon Sep 17 00:00:00 2001 From: DoSun Date: Thu, 14 May 2026 15:04:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20serve=20=E6=A8=A1=E5=BC=8F=E4=B8=8B?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=BA=8B=E4=BB=B6=E8=A2=AB=20shouldAvoidPerm?= =?UTF-8?q?issionPrompts=20=E5=90=9E=E6=B2=A1=E5=AF=BC=E8=87=B4=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 当 --permission-prompt-tool 指定时不设置 shouldAvoidPermissionPrompts,允许 can_use_tool 控制消息正常发出 - serve 默认权限模式从 acceptEdits 改为 default,确保权限检查链路完整 --- src/main.tsx | 12429 +++++++++++++++------------------- src/server/sessionHandle.ts | 133 +- 2 files changed, 5654 insertions(+), 6908 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index dac06ba00..84efd6f2c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,163 +6,146 @@ // key) in parallel — isRemoteManagedSettingsEligible() otherwise reads them // sequentially via sync spawn inside applySafeConfigEnvironmentVariables() // (~65ms on every macOS startup) -import { profileCheckpoint, profileReport } from "./utils/startupProfiler.js"; +import { profileCheckpoint, profileReport } from './utils/startupProfiler.js'; // eslint-disable-next-line custom-rules/no-top-level-side-effects -profileCheckpoint("main_tsx_entry"); +profileCheckpoint('main_tsx_entry'); -import { startMdmRawRead } from "./utils/settings/mdm/rawRead.js"; +import { startMdmRawRead } from './utils/settings/mdm/rawRead.js'; // eslint-disable-next-line custom-rules/no-top-level-side-effects startMdmRawRead(); -import { - ensureKeychainPrefetchCompleted, - startKeychainPrefetch, -} from "./utils/secureStorage/keychainPrefetch.js"; +import { ensureKeychainPrefetchCompleted, startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js'; // eslint-disable-next-line custom-rules/no-top-level-side-effects startKeychainPrefetch(); -import { feature } from "bun:bundle"; +import { feature } from 'bun:bundle'; +import { Command as CommanderCommand, InvalidArgumentError, Option } from '@commander-js/extra-typings'; +import chalk from 'chalk'; +import { readFileSync } from 'fs'; +import mapValues from 'lodash-es/mapValues.js'; +import pickBy from 'lodash-es/pickBy.js'; +import uniqBy from 'lodash-es/uniqBy.js'; +import React from 'react'; +import { getOauthConfig } from './constants/oauth.js'; +import { getRemoteSessionUrl } from './constants/product.js'; +import { getSystemContext, getUserContext } from './context.js'; +import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js'; +import { addToHistory } from './history.js'; +import type { Root } from '@anthropic/ink'; +import { launchRepl } from './replLauncher.js'; import { - Command as CommanderCommand, - InvalidArgumentError, - Option, -} from '@commander-js/extra-typings' -import chalk from 'chalk' -import { readFileSync } from 'fs' -import mapValues from 'lodash-es/mapValues.js' -import pickBy from 'lodash-es/pickBy.js' -import uniqBy from 'lodash-es/uniqBy.js' -import React from 'react' -import { getOauthConfig } from './constants/oauth.js' -import { getRemoteSessionUrl } from './constants/product.js' -import { getSystemContext, getUserContext } from './context.js' -import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js' -import { addToHistory } from './history.js' -import type { Root } from '@anthropic/ink' -import { launchRepl } from './replLauncher.js' + hasGrowthBookEnvOverride, + initializeGrowthBook, + refreshGrowthBookAfterAuthChange, +} from './services/analytics/growthbook.js'; +import { fetchBootstrapData } from './services/api/bootstrap.js'; import { - hasGrowthBookEnvOverride, - initializeGrowthBook, - refreshGrowthBookAfterAuthChange, -} from "./services/analytics/growthbook.js"; -import { fetchBootstrapData } from "./services/api/bootstrap.js"; + type DownloadResult, + downloadSessionFiles, + type FilesApiConfig, + parseFileSpecs, +} from './services/api/filesApi.js'; +import { prefetchPassesEligibility } from './services/api/referral.js'; +import type { McpSdkServerConfig, McpServerConfig, ScopedMcpServerConfig } from './services/mcp/types.js'; import { - type DownloadResult, - downloadSessionFiles, - type FilesApiConfig, - parseFileSpecs, -} from "./services/api/filesApi.js"; -import { prefetchPassesEligibility } from "./services/api/referral.js"; -import type { - McpSdkServerConfig, - McpServerConfig, - ScopedMcpServerConfig, -} from "./services/mcp/types.js"; + isPolicyAllowed, + loadPolicyLimits, + refreshPolicyLimits, + waitForPolicyLimitsToLoad, +} from './services/policyLimits/index.js'; +import { loadRemoteManagedSettings, refreshRemoteManagedSettings } from './services/remoteManagedSettings/index.js'; +import type { ToolInputJSONSchema } from './Tool.js'; import { - isPolicyAllowed, - loadPolicyLimits, - refreshPolicyLimits, - waitForPolicyLimitsToLoad, -} from "./services/policyLimits/index.js"; + createSyntheticOutputTool, + isSyntheticOutputToolEnabled, +} from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'; +import { getTools } from './tools.js'; import { - loadRemoteManagedSettings, - refreshRemoteManagedSettings, -} from "./services/remoteManagedSettings/index.js"; -import type { ToolInputJSONSchema } from "./Tool.js"; + canUserConfigureAdvisor, + getInitialAdvisorSetting, + isAdvisorEnabled, + isValidAdvisorModel, + modelSupportsAdvisor, +} from './utils/advisor.js'; +import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js'; +import { count, uniq } from './utils/array.js'; +import { installAsciicastRecorder } from './utils/asciicast.js'; import { - createSyntheticOutputTool, - isSyntheticOutputToolEnabled, -} from "@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js"; -import { getTools } from "./tools.js"; + getSubscriptionType, + isClaudeAISubscriber, + prefetchAwsCredentialsAndBedRockInfoIfSafe, + prefetchGcpCredentialsIfSafe, + validateForceLoginOrg, +} from './utils/auth.js'; import { - canUserConfigureAdvisor, - getInitialAdvisorSetting, - isAdvisorEnabled, - isValidAdvisorModel, - modelSupportsAdvisor, -} from "./utils/advisor.js"; -import { isAgentSwarmsEnabled } from "./utils/agentSwarmsEnabled.js"; -import { count, uniq } from "./utils/array.js"; -import { installAsciicastRecorder } from "./utils/asciicast.js"; + checkHasTrustDialogAccepted, + getGlobalConfig, + getRemoteControlAtStartup, + isAutoUpdaterDisabled, + saveGlobalConfig, +} from './utils/config.js'; +import { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js'; +import { getInitialEffortSetting, parseEffortValue } from './utils/effort.js'; import { - getSubscriptionType, - isClaudeAISubscriber, - prefetchAwsCredentialsAndBedRockInfoIfSafe, - prefetchGcpCredentialsIfSafe, - validateForceLoginOrg, -} from "./utils/auth.js"; -import { - checkHasTrustDialogAccepted, - getGlobalConfig, - getRemoteControlAtStartup, - isAutoUpdaterDisabled, - saveGlobalConfig, -} from "./utils/config.js"; -import { seedEarlyInput, stopCapturingEarlyInput } from "./utils/earlyInput.js"; -import { getInitialEffortSetting, parseEffortValue } from "./utils/effort.js"; -import { - getInitialFastModeSetting, - isFastModeEnabled, - prefetchFastModeStatus, - resolveFastModeStatusFromCache, -} from "./utils/fastMode.js"; -import { applyConfigEnvironmentVariables } from "./utils/managedEnv.js"; -import { createSystemMessage, createUserMessage } from "./utils/messages.js"; -import { getPlatform } from "./utils/platform.js"; -import { getBaseRenderOptions } from "./utils/renderOptions.js"; -import { getSessionIngressAuthToken } from "./utils/sessionIngressAuth.js"; -import { settingsChangeDetector } from "./utils/settings/changeDetector.js"; -import { skillChangeDetector } from "./utils/skills/skillChangeDetector.js"; -import { jsonParse, writeFileSync_DEPRECATED } from "./utils/slowOperations.js"; -import { computeInitialTeamContext } from "./utils/swarm/reconnection.js"; -import { initializeWarningHandler } from "./utils/warningHandler.js"; -import { isWorktreeModeEnabled } from "./utils/worktreeModeEnabled.js"; + getInitialFastModeSetting, + isFastModeEnabled, + prefetchFastModeStatus, + resolveFastModeStatusFromCache, +} from './utils/fastMode.js'; +import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'; +import { createSystemMessage, createUserMessage } from './utils/messages.js'; +import { getPlatform } from './utils/platform.js'; +import { getBaseRenderOptions } from './utils/renderOptions.js'; +import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js'; +import { settingsChangeDetector } from './utils/settings/changeDetector.js'; +import { skillChangeDetector } from './utils/skills/skillChangeDetector.js'; +import { jsonParse, writeFileSync_DEPRECATED } from './utils/slowOperations.js'; +import { computeInitialTeamContext } from './utils/swarm/reconnection.js'; +import { initializeWarningHandler } from './utils/warningHandler.js'; +import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.js'; // Lazy require to avoid circular dependency: teammate.ts -> AppState.tsx -> ... -> main.tsx /* eslint-disable @typescript-eslint/no-require-imports */ -const getTeammateUtils = () => - require("./utils/teammate.js") as typeof import("./utils/teammate.js"); +const getTeammateUtils = () => require('./utils/teammate.js') as typeof import('./utils/teammate.js'); const getTeammatePromptAddendum = () => - require("./utils/swarm/teammatePromptAddendum.js") as typeof import("./utils/swarm/teammatePromptAddendum.js"); + require('./utils/swarm/teammatePromptAddendum.js') as typeof import('./utils/swarm/teammatePromptAddendum.js'); const getTeammateModeSnapshot = () => - require("./utils/swarm/backends/teammateModeSnapshot.js") as typeof import("./utils/swarm/backends/teammateModeSnapshot.js"); + require('./utils/swarm/backends/teammateModeSnapshot.js') as typeof import('./utils/swarm/backends/teammateModeSnapshot.js'); /* eslint-enable @typescript-eslint/no-require-imports */ // Dead code elimination: conditional import for COORDINATOR_MODE /* eslint-disable @typescript-eslint/no-require-imports */ -const coordinatorModeModule = feature("COORDINATOR_MODE") - ? (require("./coordinator/coordinatorMode.js") as typeof import("./coordinator/coordinatorMode.js")) - : null; +const coordinatorModeModule = feature('COORDINATOR_MODE') + ? (require('./coordinator/coordinatorMode.js') as typeof import('./coordinator/coordinatorMode.js')) + : null; /* eslint-enable @typescript-eslint/no-require-imports */ // Dead code elimination: conditional import for KAIROS (assistant mode) /* eslint-disable @typescript-eslint/no-require-imports */ -const assistantModule = feature("KAIROS") - ? (require("./assistant/index.js") as typeof import("./assistant/index.js")) - : null; -const kairosGate = feature("KAIROS") - ? (require("./assistant/gate.js") as typeof import("./assistant/gate.js")) - : null; +const assistantModule = feature('KAIROS') + ? (require('./assistant/index.js') as typeof import('./assistant/index.js')) + : null; +const kairosGate = feature('KAIROS') ? (require('./assistant/gate.js') as typeof import('./assistant/gate.js')) : null; -import { relative, resolve } from "path"; -import { isAnalyticsDisabled } from "src/services/analytics/config.js"; -import { getFeatureValue_CACHED_MAY_BE_STALE } from "src/services/analytics/growthbook.js"; +import { relative, resolve } from 'path'; +import { isAnalyticsDisabled } from 'src/services/analytics/config.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { initializeAnalyticsGates } from 'src/services/analytics/sink.js' +} from 'src/services/analytics/index.js'; +import { initializeAnalyticsGates } from 'src/services/analytics/sink.js'; import { - getOriginalCwd, - setAdditionalDirectoriesForClaudeMd, - setIsRemoteMode, - setMainLoopModelOverride, - setMainThreadAgentType, - setTeleportedSessionInfo, -} from "./bootstrap/state.js"; -import { filterCommandsForRemoteMode, getCommands } from "./commands.js"; -import type { StatsStore } from "./context/stats.js"; + getOriginalCwd, + setAdditionalDirectoriesForClaudeMd, + setIsRemoteMode, + setMainLoopModelOverride, + setMainThreadAgentType, + setTeleportedSessionInfo, +} from './bootstrap/state.js'; +import { filterCommandsForRemoteMode, getCommands } from './commands.js'; +import type { StatsStore } from './context/stats.js'; import { launchAssistantInstallWizard, launchAssistantSessionChooser, @@ -171,283 +154,213 @@ import { launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper, -} from './dialogLaunchers.js' -import { SHOW_CURSOR } from '@anthropic/ink' +} from './dialogLaunchers.js'; +import { SHOW_CURSOR } from '@anthropic/ink'; import { - exitWithError, - exitWithMessage, - getRenderContext, - renderAndRun, - showSetupScreens, -} from "./interactiveHelpers.js"; -import { initBuiltinPlugins } from "./plugins/bundled/index.js"; + exitWithError, + exitWithMessage, + getRenderContext, + renderAndRun, + showSetupScreens, +} from './interactiveHelpers.js'; +import { initBuiltinPlugins } from './plugins/bundled/index.js'; /* eslint-enable @typescript-eslint/no-require-imports */ -import { checkQuotaStatus } from "./services/claudeAiLimits.js"; +import { checkQuotaStatus } from './services/claudeAiLimits.js'; +import { getMcpToolsCommandsAndResources, prefetchAllMcpResources } from './services/mcp/client.js'; +import { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } from './services/plugins/pluginCliCommands.js'; +import { initBundledSkills } from './skills/bundled/index.js'; +import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'; import { - getMcpToolsCommandsAndResources, - prefetchAllMcpResources, -} from "./services/mcp/client.js"; + getActiveAgentsFromList, + getAgentDefinitionsWithOverrides, + isBuiltInAgent, + isCustomAgent, + parseAgentsFromJson, +} from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import type { LogOption } from './types/logs.js'; +import type { Message as MessageType } from './types/message.js'; import { - VALID_INSTALLABLE_SCOPES, - VALID_UPDATE_SCOPES, -} from "./services/plugins/pluginCliCommands.js"; -import { initBundledSkills } from "./skills/bundled/index.js"; -import type { AgentColorName } from "@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js"; + CLAUDE_IN_CHROME_SKILL_HINT, + CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER, +} from './utils/claudeInChrome/prompt.js'; import { - getActiveAgentsFromList, - getAgentDefinitionsWithOverrides, - isBuiltInAgent, - isCustomAgent, - parseAgentsFromJson, -} from "@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js"; -import type { LogOption } from "./types/logs.js"; -import type { Message as MessageType } from "./types/message.js"; + setupClaudeInChrome, + shouldAutoEnableClaudeInChrome, + shouldEnableClaudeInChrome, +} from './utils/claudeInChrome/setup.js'; +import { getContextWindowForModel } from './utils/context.js'; +import { loadConversationForResume } from './utils/conversationRecovery.js'; +import { buildDeepLinkBanner } from './utils/deepLink/banner.js'; +import { hasNodeOption, isBareMode, isEnvTruthy, isInProtectedNamespace } from './utils/envUtils.js'; +import { refreshExampleCommands } from './utils/exampleCommands.js'; +import type { FpsMetrics } from './utils/fpsTracker.js'; +import { getWorktreePaths } from './utils/getWorktreePaths.js'; +import { findGitRoot, getBranch, getIsGit, getWorktreeCount } from './utils/git.js'; +import { getGhAuthStatus } from './utils/github/ghAuthStatus.js'; +import { safeParseJSON } from './utils/json.js'; +import { logError } from './utils/log.js'; +import { getModelDeprecationWarning } from './utils/model/deprecation.js'; import { - CLAUDE_IN_CHROME_SKILL_HINT, - CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER, -} from "./utils/claudeInChrome/prompt.js"; + getDefaultMainLoopModel, + getUserSpecifiedModelSetting, + normalizeModelStringForAPI, + parseUserSpecifiedModel, +} from './utils/model/model.js'; +import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; +import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; import { - setupClaudeInChrome, - shouldAutoEnableClaudeInChrome, - shouldEnableClaudeInChrome, -} from "./utils/claudeInChrome/setup.js"; -import { getContextWindowForModel } from "./utils/context.js"; -import { loadConversationForResume } from "./utils/conversationRecovery.js"; -import { buildDeepLinkBanner } from "./utils/deepLink/banner.js"; + getAutoModeEnabledStateIfCached, + initializeToolPermissionContext, + initialPermissionModeFromCLI, + isDefaultPermissionModeAuto, + parseToolListFromCLI, + removeDangerousPermissions, + stripDangerousPermissionsForAutoMode, + verifyAutoModeGateAccess, +} from './utils/permissions/permissionSetup.js'; +import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; +import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js'; +import { getManagedPluginNames } from './utils/plugins/managedPlugins.js'; +import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js'; +import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js'; +import { countFilesRoundedRg } from './utils/ripgrep.js'; +import { processSessionStartHooks, processSetupHooks } from './utils/sessionStart.js'; import { - hasNodeOption, - isBareMode, - isEnvTruthy, - isInProtectedNamespace, -} from "./utils/envUtils.js"; -import { refreshExampleCommands } from "./utils/exampleCommands.js"; -import type { FpsMetrics } from "./utils/fpsTracker.js"; -import { getWorktreePaths } from "./utils/getWorktreePaths.js"; + cacheSessionTitle, + getSessionIdFromLog, + loadTranscriptFromFile, + saveAgentSetting, + saveMode, + searchSessionsByCustomTitle, + sessionIdExists, +} from './utils/sessionStorage.js'; +import { ensureMdmSettingsLoaded } from './utils/settings/mdm/settings.js'; import { - findGitRoot, - getBranch, - getIsGit, - getWorktreeCount, -} from "./utils/git.js"; -import { getGhAuthStatus } from "./utils/github/ghAuthStatus.js"; -import { safeParseJSON } from "./utils/json.js"; -import { logError } from "./utils/log.js"; -import { getModelDeprecationWarning } from "./utils/model/deprecation.js"; -import { - getDefaultMainLoopModel, - getUserSpecifiedModelSetting, - normalizeModelStringForAPI, - parseUserSpecifiedModel, -} from "./utils/model/model.js"; -import { ensureModelStringsInitialized } from "./utils/model/modelStrings.js"; -import { PERMISSION_MODES } from "./utils/permissions/PermissionMode.js"; -import { - getAutoModeEnabledStateIfCached, - initializeToolPermissionContext, - initialPermissionModeFromCLI, - isDefaultPermissionModeAuto, - parseToolListFromCLI, - removeDangerousPermissions, - stripDangerousPermissionsForAutoMode, - verifyAutoModeGateAccess, -} from "./utils/permissions/permissionSetup.js"; -import { cleanupOrphanedPluginVersionsInBackground } from "./utils/plugins/cacheUtils.js"; -import { initializeVersionedPlugins } from "./utils/plugins/installedPluginsManager.js"; -import { getManagedPluginNames } from "./utils/plugins/managedPlugins.js"; -import { getGlobExclusionsForPluginCache } from "./utils/plugins/orphanedPluginFilter.js"; -import { getPluginSeedDirs } from "./utils/plugins/pluginDirectories.js"; -import { countFilesRoundedRg } from "./utils/ripgrep.js"; -import { - processSessionStartHooks, - processSetupHooks, -} from "./utils/sessionStart.js"; -import { - cacheSessionTitle, - getSessionIdFromLog, - loadTranscriptFromFile, - saveAgentSetting, - saveMode, - searchSessionsByCustomTitle, - sessionIdExists, -} from "./utils/sessionStorage.js"; -import { ensureMdmSettingsLoaded } from "./utils/settings/mdm/settings.js"; -import { - getInitialSettings, - getManagedSettingsKeysForLogging, - getSettingsForSource, - getSettingsWithErrors, -} from "./utils/settings/settings.js"; -import { resetSettingsCache } from "./utils/settings/settingsCache.js"; -import type { ValidationError } from "./utils/settings/validation.js"; -import { - DEFAULT_TASKS_MODE_TASK_LIST_ID, - TASK_STATUSES, -} from "./utils/tasks.js"; -import { - logPluginLoadErrors, - logPluginsEnabledForSession, -} from "./utils/telemetry/pluginTelemetry.js"; -import { logSkillsLoaded } from "./utils/telemetry/skillLoadedEvent.js"; -import { generateTempFilePath } from "./utils/tempfile.js"; -import { validateUuid } from "./utils/uuid.js"; + getInitialSettings, + getManagedSettingsKeysForLogging, + getSettingsForSource, + getSettingsWithErrors, +} from './utils/settings/settings.js'; +import { resetSettingsCache } from './utils/settings/settingsCache.js'; +import type { ValidationError } from './utils/settings/validation.js'; +import { DEFAULT_TASKS_MODE_TASK_LIST_ID, TASK_STATUSES } from './utils/tasks.js'; +import { logPluginLoadErrors, logPluginsEnabledForSession } from './utils/telemetry/pluginTelemetry.js'; +import { logSkillsLoaded } from './utils/telemetry/skillLoadedEvent.js'; +import { generateTempFilePath } from './utils/tempfile.js'; +import { validateUuid } from './utils/uuid.js'; // Plugin startup checks are now handled non-blockingly in REPL.tsx -import { registerMcpAddCommand } from "src/commands/mcp/addCommand.js"; -import { registerMcpXaaIdpCommand } from "src/commands/mcp/xaaIdpCommand.js"; -import { logPermissionContextForAnts } from "src/services/internalLogging.js"; -import { fetchClaudeAIMcpConfigsIfEligible } from "src/services/mcp/claudeai.js"; -import { clearServerCache } from "src/services/mcp/client.js"; +import { registerMcpAddCommand } from 'src/commands/mcp/addCommand.js'; +import { registerMcpXaaIdpCommand } from 'src/commands/mcp/xaaIdpCommand.js'; +import { logPermissionContextForAnts } from 'src/services/internalLogging.js'; +import { fetchClaudeAIMcpConfigsIfEligible } from 'src/services/mcp/claudeai.js'; +import { clearServerCache } from 'src/services/mcp/client.js'; import { - areMcpConfigsAllowedWithEnterpriseMcpConfig, - dedupClaudeAiMcpServers, - doesEnterpriseMcpConfigExist, - filterMcpServersByPolicy, - getClaudeCodeMcpConfigs, - getMcpServerSignature, - parseMcpConfig, - parseMcpConfigFromFilePath, -} from "src/services/mcp/config.js"; + areMcpConfigsAllowedWithEnterpriseMcpConfig, + dedupClaudeAiMcpServers, + doesEnterpriseMcpConfigExist, + filterMcpServersByPolicy, + getClaudeCodeMcpConfigs, + getMcpServerSignature, + parseMcpConfig, + parseMcpConfigFromFilePath, +} from 'src/services/mcp/config.js'; +import { excludeCommandsByServer, excludeResourcesByServer } from 'src/services/mcp/utils.js'; +import { isXaaEnabled } from 'src/services/mcp/xaaIdpLogin.js'; +import { getRelevantTips } from 'src/services/tips/tipRegistry.js'; +import { logContextMetrics } from 'src/utils/api.js'; +import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isClaudeInChromeMCPServer } from 'src/utils/claudeInChrome/common.js'; +import { registerCleanup } from 'src/utils/cleanupRegistry.js'; +import { eagerParseCliFlag } from 'src/utils/cliArgs.js'; +import { createEmptyAttributionState } from 'src/utils/commitAttribution.js'; +import { countConcurrentSessions, registerSession, updateSessionName } from 'src/utils/concurrentSessions.js'; +import { getCwd } from 'src/utils/cwd.js'; +import { logForDebugging, setHasFormattedOutput } from 'src/utils/debug.js'; +import { errorMessage, getErrnoCode, isENOENT, TeleportOperationError, toError } from 'src/utils/errors.js'; +import { getFsImplementation, safeResolvePath } from 'src/utils/fsOperations.js'; +import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; +import { setAllHookEventsEnabled } from 'src/utils/hooks/hookEvents.js'; +import { refreshModelCapabilities } from 'src/utils/model/modelCapabilities.js'; +import { peekForStdinData, writeToStderr } from 'src/utils/process.js'; +import { setCwd } from 'src/utils/Shell.js'; +import { type ProcessedResume, processResumedConversation } from 'src/utils/sessionRestore.js'; +import { parseSettingSourcesFlag } from 'src/utils/settings/constants.js'; +import { plural } from 'src/utils/stringUtils.js'; import { - excludeCommandsByServer, - excludeResourcesByServer, -} from "src/services/mcp/utils.js"; -import { isXaaEnabled } from "src/services/mcp/xaaIdpLogin.js"; -import { getRelevantTips } from "src/services/tips/tipRegistry.js"; -import { logContextMetrics } from "src/utils/api.js"; -import { - CLAUDE_IN_CHROME_MCP_SERVER_NAME, - isClaudeInChromeMCPServer, -} from "src/utils/claudeInChrome/common.js"; -import { registerCleanup } from "src/utils/cleanupRegistry.js"; -import { eagerParseCliFlag } from "src/utils/cliArgs.js"; -import { createEmptyAttributionState } from "src/utils/commitAttribution.js"; -import { - countConcurrentSessions, - registerSession, - updateSessionName, -} from "src/utils/concurrentSessions.js"; -import { getCwd } from "src/utils/cwd.js"; -import { logForDebugging, setHasFormattedOutput } from "src/utils/debug.js"; -import { - errorMessage, - getErrnoCode, - isENOENT, - TeleportOperationError, - toError, -} from "src/utils/errors.js"; -import { - getFsImplementation, - safeResolvePath, -} from "src/utils/fsOperations.js"; -import { - gracefulShutdown, - gracefulShutdownSync, -} from "src/utils/gracefulShutdown.js"; -import { setAllHookEventsEnabled } from "src/utils/hooks/hookEvents.js"; -import { refreshModelCapabilities } from "src/utils/model/modelCapabilities.js"; -import { peekForStdinData, writeToStderr } from "src/utils/process.js"; -import { setCwd } from "src/utils/Shell.js"; -import { - type ProcessedResume, - processResumedConversation, -} from "src/utils/sessionRestore.js"; -import { parseSettingSourcesFlag } from "src/utils/settings/constants.js"; -import { plural } from "src/utils/stringUtils.js"; -import { - type ChannelEntry, - getInitialMainLoopModel, - getIsNonInteractiveSession, - getSdkBetas, - getSessionId, - getUserMsgOptIn, - setAllowedChannels, - setAllowedSettingSources, - setChromeFlagOverride, - setClientType, - setCwdState, - setDirectConnectServerUrl, - setFlagSettingsPath, - setInitialMainLoopModel, - setInlinePlugins, - setIsInteractive, - setKairosActive, - setOriginalCwd, - setQuestionPreviewFormat, - setSdkBetas, - setSessionBypassPermissionsMode, - setSessionPersistenceDisabled, - setSessionSource, - setUserMsgOptIn, - switchSession, -} from "./bootstrap/state.js"; + type ChannelEntry, + getInitialMainLoopModel, + getIsNonInteractiveSession, + getSdkBetas, + getSessionId, + getUserMsgOptIn, + setAllowedChannels, + setAllowedSettingSources, + setChromeFlagOverride, + setClientType, + setCwdState, + setDirectConnectServerUrl, + setFlagSettingsPath, + setInitialMainLoopModel, + setInlinePlugins, + setIsInteractive, + setKairosActive, + setOriginalCwd, + setQuestionPreviewFormat, + setSdkBetas, + setSessionBypassPermissionsMode, + setSessionPersistenceDisabled, + setSessionSource, + setUserMsgOptIn, + switchSession, +} from './bootstrap/state.js'; /* eslint-disable @typescript-eslint/no-require-imports */ -const autoModeStateModule = feature("TRANSCRIPT_CLASSIFIER") - ? (require("./utils/permissions/autoModeState.js") as typeof import("./utils/permissions/autoModeState.js")) - : null; +const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') + ? (require('./utils/permissions/autoModeState.js') as typeof import('./utils/permissions/autoModeState.js')) + : null; // TeleportRepoMismatchDialog, TeleportResumeWrapper dynamically imported at call sites -import { migrateBypassPermissionsAcceptedToSettings } from "./migrations/migrateBypassPermissionsAcceptedToSettings.js"; -import { migrateEnableAllProjectMcpServersToSettings } from "./migrations/migrateEnableAllProjectMcpServersToSettings.js"; -import { migrateFennecToOpus } from "./migrations/migrateFennecToOpus.js"; -import { migrateLegacyOpusToCurrent } from "./migrations/migrateLegacyOpusToCurrent.js"; -import { migrateOpusToOpus1m } from "./migrations/migrateOpusToOpus1m.js"; -import { migrateReplBridgeEnabledToRemoteControlAtStartup } from "./migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.js"; -import { migrateSonnet1mToSonnet45 } from "./migrations/migrateSonnet1mToSonnet45.js"; -import { migrateSonnet45ToSonnet46 } from "./migrations/migrateSonnet45ToSonnet46.js"; -import { resetAutoModeOptInForDefaultOffer } from "./migrations/resetAutoModeOptInForDefaultOffer.js"; -import { resetProToOpusDefault } from "./migrations/resetProToOpusDefault.js"; -import { createRemoteSessionConfig } from "./remote/RemoteSessionManager.js"; +import { migrateBypassPermissionsAcceptedToSettings } from './migrations/migrateBypassPermissionsAcceptedToSettings.js'; +import { migrateEnableAllProjectMcpServersToSettings } from './migrations/migrateEnableAllProjectMcpServersToSettings.js'; +import { migrateFennecToOpus } from './migrations/migrateFennecToOpus.js'; +import { migrateLegacyOpusToCurrent } from './migrations/migrateLegacyOpusToCurrent.js'; +import { migrateOpusToOpus1m } from './migrations/migrateOpusToOpus1m.js'; +import { migrateReplBridgeEnabledToRemoteControlAtStartup } from './migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.js'; +import { migrateSonnet1mToSonnet45 } from './migrations/migrateSonnet1mToSonnet45.js'; +import { migrateSonnet45ToSonnet46 } from './migrations/migrateSonnet45ToSonnet46.js'; +import { resetAutoModeOptInForDefaultOffer } from './migrations/resetAutoModeOptInForDefaultOffer.js'; +import { resetProToOpusDefault } from './migrations/resetProToOpusDefault.js'; +import { createRemoteSessionConfig } from './remote/RemoteSessionManager.js'; /* eslint-enable @typescript-eslint/no-require-imports */ // teleportWithProgress dynamically imported at call site +import { createDirectConnectSession, DirectConnectError } from './server/createDirectConnectSession.js'; +import { initializeLspServerManager } from './services/lsp/manager.js'; +import { shouldEnablePromptSuggestion } from './services/PromptSuggestion/promptSuggestion.js'; +import { type AppState, getDefaultAppState, IDLE_SPECULATION_STATE } from './state/AppStateStore.js'; +import { onChangeAppState } from './state/onChangeAppState.js'; +import { createStore } from './state/store.js'; +import { asSessionId } from './types/ids.js'; +import { filterAllowedSdkBetas } from './utils/betas.js'; +import { isInBundledMode, isRunningWithBun } from './utils/bundledMode.js'; +import { logForDiagnosticsNoPII } from './utils/diagLogs.js'; +import { filterExistingPaths, getKnownPathsForRepo } from './utils/githubRepoPathMapping.js'; +import { clearPluginCache, loadAllPluginsCacheOnly } from './utils/plugins/pluginLoader.js'; +import { migrateChangelogFromConfig } from './utils/releaseNotes.js'; +import { SandboxManager } from './utils/sandbox/sandbox-adapter.js'; +import { fetchSession, prepareApiRequest } from './utils/teleport/api.js'; import { - createDirectConnectSession, - DirectConnectError, -} from "./server/createDirectConnectSession.js"; -import { initializeLspServerManager } from "./services/lsp/manager.js"; -import { shouldEnablePromptSuggestion } from "./services/PromptSuggestion/promptSuggestion.js"; -import { - type AppState, - getDefaultAppState, - IDLE_SPECULATION_STATE, -} from "./state/AppStateStore.js"; -import { onChangeAppState } from "./state/onChangeAppState.js"; -import { createStore } from "./state/store.js"; -import { asSessionId } from "./types/ids.js"; -import { filterAllowedSdkBetas } from "./utils/betas.js"; -import { isInBundledMode, isRunningWithBun } from "./utils/bundledMode.js"; -import { logForDiagnosticsNoPII } from "./utils/diagLogs.js"; -import { - filterExistingPaths, - getKnownPathsForRepo, -} from "./utils/githubRepoPathMapping.js"; -import { - clearPluginCache, - loadAllPluginsCacheOnly, -} from "./utils/plugins/pluginLoader.js"; -import { migrateChangelogFromConfig } from "./utils/releaseNotes.js"; -import { SandboxManager } from "./utils/sandbox/sandbox-adapter.js"; -import { fetchSession, prepareApiRequest } from "./utils/teleport/api.js"; -import { - checkOutTeleportedSessionBranch, - processMessagesForTeleportResume, - teleportToRemoteWithErrorHandling, - validateGitState, - validateSessionRepository, -} from "./utils/teleport.js"; -import { - shouldEnableThinkingByDefault, - type ThinkingConfig, -} from './utils/thinking.js' -import { initUser, resetUserCache } from './utils/user.js' -import { - getTmuxInstallInstructions, - isTmuxAvailable, - parsePRReference, -} from "./utils/worktree.js"; + checkOutTeleportedSessionBranch, + processMessagesForTeleportResume, + teleportToRemoteWithErrorHandling, + validateGitState, + validateSessionRepository, +} from './utils/teleport.js'; +import { shouldEnableThinkingByDefault, type ThinkingConfig } from './utils/thinking.js'; +import { initUser, resetUserCache } from './utils/user.js'; +import { getTmuxInstallInstructions, isTmuxAvailable, parsePRReference } from './utils/worktree.js'; // eslint-disable-next-line custom-rules/no-top-level-side-effects -profileCheckpoint("main_tsx_imports_loaded"); +profileCheckpoint('main_tsx_imports_loaded'); /** * Log managed settings keys to Statsig for analytics. @@ -455,60 +368,54 @@ profileCheckpoint("main_tsx_imports_loaded"); * and environment variables are applied before model resolution. */ function logManagedSettings(): void { - try { - const policySettings = getSettingsForSource("policySettings"); - if (policySettings) { - const allKeys = getManagedSettingsKeysForLogging(policySettings); - logEvent("tengu_managed_settings_loaded", { - keyCount: allKeys.length, - keys: allKeys.join( - ",", - ) as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - } - } catch { - // Silently ignore errors - this is just for analytics - } + try { + const policySettings = getSettingsForSource('policySettings'); + if (policySettings) { + const allKeys = getManagedSettingsKeysForLogging(policySettings); + logEvent('tengu_managed_settings_loaded', { + keyCount: allKeys.length, + keys: allKeys.join(',') as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + } + } catch { + // Silently ignore errors - this is just for analytics + } } // Check if running in debug/inspection mode function isBeingDebugged() { - const isBun = isRunningWithBun(); + const isBun = isRunningWithBun(); - // Check for inspect flags in process arguments (including all variants) - const hasInspectArg = process.execArgv.some((arg) => { - if (isBun) { - // Note: Bun has an issue with single-file executables where application arguments - // from process.argv leak into process.execArgv (similar to https://github.com/oven-sh/bun/issues/11673) - // This breaks use of --debug mode if we omit this branch - // We're fine to skip that check, because Bun doesn't support Node.js legacy --debug or --debug-brk flags - return /--inspect(-brk)?/.test(arg); - } else { - // In Node.js, check for both --inspect and legacy --debug flags - return /--inspect(-brk)?|--debug(-brk)?/.test(arg); - } - }); + // Check for inspect flags in process arguments (including all variants) + const hasInspectArg = process.execArgv.some(arg => { + if (isBun) { + // Note: Bun has an issue with single-file executables where application arguments + // from process.argv leak into process.execArgv (similar to https://github.com/oven-sh/bun/issues/11673) + // This breaks use of --debug mode if we omit this branch + // We're fine to skip that check, because Bun doesn't support Node.js legacy --debug or --debug-brk flags + return /--inspect(-brk)?/.test(arg); + } else { + // In Node.js, check for both --inspect and legacy --debug flags + return /--inspect(-brk)?|--debug(-brk)?/.test(arg); + } + }); - // Check if NODE_OPTIONS contains inspect flags - const hasInspectEnv = - process.env.NODE_OPTIONS && - /--inspect(-brk)?|--debug(-brk)?/.test(process.env.NODE_OPTIONS); + // Check if NODE_OPTIONS contains inspect flags + const hasInspectEnv = process.env.NODE_OPTIONS && /--inspect(-brk)?|--debug(-brk)?/.test(process.env.NODE_OPTIONS); - // Check if inspector is available and active (indicates debugging) - try { - // Dynamic import would be better but is async - use global object instead - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const inspector = (global as any).require("inspector"); - const hasInspectorUrl = !!inspector.url(); - return hasInspectorUrl || hasInspectArg || hasInspectEnv; - } catch { - // Ignore error and fall back to argument detection - return hasInspectArg || hasInspectEnv; - } + // Check if inspector is available and active (indicates debugging) + try { + // Dynamic import would be better but is async - use global object instead + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inspector = (global as any).require('inspector'); + const hasInspectorUrl = !!inspector.url(); + return hasInspectorUrl || hasInspectArg || hasInspectEnv; + } catch { + // Ignore error and fall back to argument detection + return hasInspectArg || hasInspectEnv; + } } - - /** * Per-session skill/plugin telemetry. Called from both the interactive path * and the headless -p path (before runHeadless) — both go through @@ -516,97 +423,80 @@ function isBeingDebugged() { * call sites here rather than one here + one in QueryEngine. */ function logSessionTelemetry(): void { - const model = parseUserSpecifiedModel( - getInitialMainLoopModel() ?? getDefaultMainLoopModel(), - ); - void logSkillsLoaded( - getCwd(), - getContextWindowForModel(model, getSdkBetas()), - ); - void loadAllPluginsCacheOnly() - .then(({ enabled, errors }) => { - const managedNames = getManagedPluginNames(); - logPluginsEnabledForSession( - enabled, - managedNames, - getPluginSeedDirs(), - ); - logPluginLoadErrors(errors, managedNames); - }) - .catch((err) => logError(err)); + const model = parseUserSpecifiedModel(getInitialMainLoopModel() ?? getDefaultMainLoopModel()); + void logSkillsLoaded(getCwd(), getContextWindowForModel(model, getSdkBetas())); + void loadAllPluginsCacheOnly() + .then(({ enabled, errors }) => { + const managedNames = getManagedPluginNames(); + logPluginsEnabledForSession(enabled, managedNames, getPluginSeedDirs()); + logPluginLoadErrors(errors, managedNames); + }) + .catch(err => logError(err)); } function getCertEnvVarTelemetry(): Record { - const result: Record = {}; - if (process.env.NODE_EXTRA_CA_CERTS) { - result.has_node_extra_ca_certs = true; - } - if (process.env.CLAUDE_CODE_CLIENT_CERT) { - result.has_client_cert = true; - } - if (hasNodeOption("--use-system-ca")) { - result.has_use_system_ca = true; - } - if (hasNodeOption("--use-openssl-ca")) { - result.has_use_openssl_ca = true; - } - return result; + const result: Record = {}; + if (process.env.NODE_EXTRA_CA_CERTS) { + result.has_node_extra_ca_certs = true; + } + if (process.env.CLAUDE_CODE_CLIENT_CERT) { + result.has_client_cert = true; + } + if (hasNodeOption('--use-system-ca')) { + result.has_use_system_ca = true; + } + if (hasNodeOption('--use-openssl-ca')) { + result.has_use_openssl_ca = true; + } + return result; } async function logStartupTelemetry(): Promise { - if (isAnalyticsDisabled()) return; - const [isGit, worktreeCount, ghAuthStatus] = await Promise.all([ - getIsGit(), - getWorktreeCount(), - getGhAuthStatus(), - ]); + if (isAnalyticsDisabled()) return; + const [isGit, worktreeCount, ghAuthStatus] = await Promise.all([getIsGit(), getWorktreeCount(), getGhAuthStatus()]); - logEvent("tengu_startup_telemetry", { - is_git: isGit, - worktree_count: worktreeCount, - gh_auth_status: - ghAuthStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - sandbox_enabled: SandboxManager.isSandboxingEnabled(), - are_unsandboxed_commands_allowed: - SandboxManager.areUnsandboxedCommandsAllowed(), - is_auto_bash_allowed_if_sandbox_enabled: - SandboxManager.isAutoAllowBashIfSandboxedEnabled(), - auto_updater_disabled: isAutoUpdaterDisabled(), - prefers_reduced_motion: - getInitialSettings().prefersReducedMotion ?? false, - ...getCertEnvVarTelemetry(), - }); + logEvent('tengu_startup_telemetry', { + is_git: isGit, + worktree_count: worktreeCount, + gh_auth_status: ghAuthStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + sandbox_enabled: SandboxManager.isSandboxingEnabled(), + are_unsandboxed_commands_allowed: SandboxManager.areUnsandboxedCommandsAllowed(), + is_auto_bash_allowed_if_sandbox_enabled: SandboxManager.isAutoAllowBashIfSandboxedEnabled(), + auto_updater_disabled: isAutoUpdaterDisabled(), + prefers_reduced_motion: getInitialSettings().prefersReducedMotion ?? false, + ...getCertEnvVarTelemetry(), + }); } // @[MODEL LAUNCH]: Consider any migrations you may need for model strings. See migrateSonnet1mToSonnet45.ts for an example. // Bump this when adding a new sync migration so existing users re-run the set. const CURRENT_MIGRATION_VERSION = 11; function runMigrations(): void { - if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) { - migrateBypassPermissionsAcceptedToSettings(); - migrateEnableAllProjectMcpServersToSettings(); - resetProToOpusDefault(); - migrateSonnet1mToSonnet45(); - migrateLegacyOpusToCurrent(); - migrateSonnet45ToSonnet46(); - migrateOpusToOpus1m(); - migrateReplBridgeEnabledToRemoteControlAtStartup(); - if (feature("TRANSCRIPT_CLASSIFIER")) { - resetAutoModeOptInForDefaultOffer(); - } - if (process.env.USER_TYPE === "ant") { - migrateFennecToOpus(); - } - saveGlobalConfig((prev) => - prev.migrationVersion === CURRENT_MIGRATION_VERSION - ? prev - : { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION }, - ); - } - // Async migration - fire and forget since it's non-blocking - migrateChangelogFromConfig().catch(() => { - // Silently ignore migration errors - will retry on next startup - }); + if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) { + migrateBypassPermissionsAcceptedToSettings(); + migrateEnableAllProjectMcpServersToSettings(); + resetProToOpusDefault(); + migrateSonnet1mToSonnet45(); + migrateLegacyOpusToCurrent(); + migrateSonnet45ToSonnet46(); + migrateOpusToOpus1m(); + migrateReplBridgeEnabledToRemoteControlAtStartup(); + if (feature('TRANSCRIPT_CLASSIFIER')) { + resetAutoModeOptInForDefaultOffer(); + } + if (process.env.USER_TYPE === 'ant') { + migrateFennecToOpus(); + } + saveGlobalConfig(prev => + prev.migrationVersion === CURRENT_MIGRATION_VERSION + ? prev + : { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION }, + ); + } + // Async migration - fire and forget since it's non-blocking + migrateChangelogFromConfig().catch(() => { + // Silently ignore migration errors - will retry on next startup + }); } /** @@ -616,31 +506,25 @@ function runMigrations(): void { * non-interactive mode where trust is implicit. */ function prefetchSystemContextIfSafe(): void { - const isNonInteractiveSession = getIsNonInteractiveSession(); + const isNonInteractiveSession = getIsNonInteractiveSession(); - // In non-interactive mode (--print), trust dialog is skipped and - // execution is considered trusted (as documented in help text) - if (isNonInteractiveSession) { - logForDiagnosticsNoPII( - "info", - "prefetch_system_context_non_interactive", - ); - void getSystemContext(); - return; - } + // In non-interactive mode (--print), trust dialog is skipped and + // execution is considered trusted (as documented in help text) + if (isNonInteractiveSession) { + logForDiagnosticsNoPII('info', 'prefetch_system_context_non_interactive'); + void getSystemContext(); + return; + } - // In interactive mode, only prefetch if trust has already been established - const hasTrust = checkHasTrustDialogAccepted(); - if (hasTrust) { - logForDiagnosticsNoPII("info", "prefetch_system_context_has_trust"); - void getSystemContext(); - } else { - logForDiagnosticsNoPII( - "info", - "prefetch_system_context_skipped_no_trust", - ); - } - // Otherwise, don't prefetch - wait for trust to be established first + // In interactive mode, only prefetch if trust has already been established + const hasTrust = checkHasTrustDialogAccepted(); + if (hasTrust) { + logForDiagnosticsNoPII('info', 'prefetch_system_context_has_trust'); + void getSystemContext(); + } else { + logForDiagnosticsNoPII('info', 'prefetch_system_context_skipped_no_trust'); + } + // Otherwise, don't prefetch - wait for trust to be established first } /** @@ -650,142 +534,118 @@ function prefetchSystemContextIfSafe(): void { * Call this after the REPL has been rendered. */ export function startDeferredPrefetches(): void { - // This function runs after first render, so it doesn't block the initial paint. - // However, the spawned processes and async work still contend for CPU and event - // loop time, which skews startup benchmarks (CPU profiles, time-to-first-render - // measurements). Skip all of it when we're only measuring startup performance. - if ( - isEnvTruthy(process.env.CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER) || - // --bare: skip ALL prefetches. These are cache-warms for the REPL's - // first-turn responsiveness (initUser, getUserContext, tips, countFiles, - // modelCapabilities, change detectors). Scripted -p calls don't have a - // "user is typing" window to hide this work in — it's pure overhead on - // the critical path. - isBareMode() - ) { - return; - } + // This function runs after first render, so it doesn't block the initial paint. + // However, the spawned processes and async work still contend for CPU and event + // loop time, which skews startup benchmarks (CPU profiles, time-to-first-render + // measurements). Skip all of it when we're only measuring startup performance. + if ( + isEnvTruthy(process.env.CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER) || + // --bare: skip ALL prefetches. These are cache-warms for the REPL's + // first-turn responsiveness (initUser, getUserContext, tips, countFiles, + // modelCapabilities, change detectors). Scripted -p calls don't have a + // "user is typing" window to hide this work in — it's pure overhead on + // the critical path. + isBareMode() + ) { + return; + } - // Process-spawning prefetches (consumed at first API call, user is still typing) - void initUser(); - void getUserContext(); - prefetchSystemContextIfSafe(); - void getRelevantTips(); - if ( - isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && - !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH) - ) { - void prefetchAwsCredentialsAndBedRockInfoIfSafe(); - } - if ( - isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && - !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH) - ) { - void prefetchGcpCredentialsIfSafe(); - } - void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []); + // Process-spawning prefetches (consumed at first API call, user is still typing) + void initUser(); + void getUserContext(); + prefetchSystemContextIfSafe(); + void getRelevantTips(); + if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) { + void prefetchAwsCredentialsAndBedRockInfoIfSafe(); + } + if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { + void prefetchGcpCredentialsIfSafe(); + } + void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []); - // Analytics and feature flag initialization - void initializeAnalyticsGates(); + // Analytics and feature flag initialization + void initializeAnalyticsGates(); - void refreshModelCapabilities(); + void refreshModelCapabilities(); - // File change detectors deferred from init() to unblock first render - void settingsChangeDetector.initialize(); - if (!isBareMode()) { - void skillChangeDetector.initialize(); - } + // File change detectors deferred from init() to unblock first render + void settingsChangeDetector.initialize(); + if (!isBareMode()) { + void skillChangeDetector.initialize(); + } - // Event loop stall detector — logs when the main thread is blocked >500ms - if (process.env.USER_TYPE === "ant") { - void import("./utils/eventLoopStallDetector.js").then((m) => - m.startEventLoopStallDetector(), - ); - } + // Event loop stall detector — logs when the main thread is blocked >500ms + if (process.env.USER_TYPE === 'ant') { + void import('./utils/eventLoopStallDetector.js').then(m => m.startEventLoopStallDetector()); + } } function loadSettingsFromFlag(settingsFile: string): void { - try { - const trimmedSettings = settingsFile.trim(); - const looksLikeJson = - trimmedSettings.startsWith("{") && trimmedSettings.endsWith("}"); + try { + const trimmedSettings = settingsFile.trim(); + const looksLikeJson = trimmedSettings.startsWith('{') && trimmedSettings.endsWith('}'); - let settingsPath: string; + let settingsPath: string; - if (looksLikeJson) { - // It's a JSON string - validate and create temp file - const parsedJson = safeParseJSON(trimmedSettings); - if (!parsedJson) { - process.stderr.write( - chalk.red("Error: Invalid JSON provided to --settings\n"), - ); - process.exit(1); - } + if (looksLikeJson) { + // It's a JSON string - validate and create temp file + const parsedJson = safeParseJSON(trimmedSettings); + if (!parsedJson) { + process.stderr.write(chalk.red('Error: Invalid JSON provided to --settings\n')); + process.exit(1); + } - // Create a temporary file and write the JSON to it. - // Use a content-hash-based path instead of random UUID to avoid - // busting the Anthropic API prompt cache. The settings path ends up - // in the Bash tool's sandbox denyWithinAllow list, which is part of - // the tool description sent to the API. A random UUID per subprocess - // changes the tool description on every query() call, invalidating - // the cache prefix and causing a 12x input token cost penalty. - // The content hash ensures identical settings produce the same path - // across process boundaries (each SDK query() spawns a new process). - settingsPath = generateTempFilePath("claude-settings", ".json", { - contentHash: trimmedSettings, - }); - writeFileSync_DEPRECATED(settingsPath, trimmedSettings, "utf8"); - } else { - // It's a file path - resolve and validate by attempting to read - const { resolvedPath: resolvedSettingsPath } = safeResolvePath( - getFsImplementation(), - settingsFile, - ); - try { - readFileSync(resolvedSettingsPath, "utf8"); - } catch (e) { - if (isENOENT(e)) { - process.stderr.write( - chalk.red( - `Error: Settings file not found: ${resolvedSettingsPath}\n`, - ), - ); - process.exit(1); - } - throw e; - } - settingsPath = resolvedSettingsPath; - } + // Create a temporary file and write the JSON to it. + // Use a content-hash-based path instead of random UUID to avoid + // busting the Anthropic API prompt cache. The settings path ends up + // in the Bash tool's sandbox denyWithinAllow list, which is part of + // the tool description sent to the API. A random UUID per subprocess + // changes the tool description on every query() call, invalidating + // the cache prefix and causing a 12x input token cost penalty. + // The content hash ensures identical settings produce the same path + // across process boundaries (each SDK query() spawns a new process). + settingsPath = generateTempFilePath('claude-settings', '.json', { + contentHash: trimmedSettings, + }); + writeFileSync_DEPRECATED(settingsPath, trimmedSettings, 'utf8'); + } else { + // It's a file path - resolve and validate by attempting to read + const { resolvedPath: resolvedSettingsPath } = safeResolvePath(getFsImplementation(), settingsFile); + try { + readFileSync(resolvedSettingsPath, 'utf8'); + } catch (e) { + if (isENOENT(e)) { + process.stderr.write(chalk.red(`Error: Settings file not found: ${resolvedSettingsPath}\n`)); + process.exit(1); + } + throw e; + } + settingsPath = resolvedSettingsPath; + } - setFlagSettingsPath(settingsPath); - resetSettingsCache(); - } catch (error) { - if (error instanceof Error) { - logError(error); - } - process.stderr.write( - chalk.red(`Error processing settings: ${errorMessage(error)}\n`), - ); - process.exit(1); - } + setFlagSettingsPath(settingsPath); + resetSettingsCache(); + } catch (error) { + if (error instanceof Error) { + logError(error); + } + process.stderr.write(chalk.red(`Error processing settings: ${errorMessage(error)}\n`)); + process.exit(1); + } } function loadSettingSourcesFromFlag(settingSourcesArg: string): void { - try { - const sources = parseSettingSourcesFlag(settingSourcesArg); - setAllowedSettingSources(sources); - resetSettingsCache(); - } catch (error) { - if (error instanceof Error) { - logError(error); - } - process.stderr.write( - chalk.red( - `Error processing --setting-sources: ${errorMessage(error)}\n`, - ), - ); - process.exit(1); - } + try { + const sources = parseSettingSourcesFlag(settingSourcesArg); + setAllowedSettingSources(sources); + resetSettingsCache(); + } catch (error) { + if (error instanceof Error) { + logError(error); + } + process.stderr.write(chalk.red(`Error processing --setting-sources: ${errorMessage(error)}\n`)); + process.exit(1); + } } /** @@ -793,5860 +653,4794 @@ function loadSettingSourcesFromFlag(settingSourcesArg: string): void { * This ensures settings are filtered from the start of initialization */ function eagerLoadSettings(): void { - profileCheckpoint("eagerLoadSettings_start"); - // Parse --settings flag early to ensure settings are loaded before init() - const settingsFile = eagerParseCliFlag("--settings"); - if (settingsFile) { - loadSettingsFromFlag(settingsFile); - } + profileCheckpoint('eagerLoadSettings_start'); + // Parse --settings flag early to ensure settings are loaded before init() + const settingsFile = eagerParseCliFlag('--settings'); + if (settingsFile) { + loadSettingsFromFlag(settingsFile); + } - // Parse --setting-sources flag early to control which sources are loaded - const settingSourcesArg = eagerParseCliFlag("--setting-sources"); - if (settingSourcesArg !== undefined) { - loadSettingSourcesFromFlag(settingSourcesArg); - } - profileCheckpoint("eagerLoadSettings_end"); + // Parse --setting-sources flag early to control which sources are loaded + const settingSourcesArg = eagerParseCliFlag('--setting-sources'); + if (settingSourcesArg !== undefined) { + loadSettingSourcesFromFlag(settingSourcesArg); + } + profileCheckpoint('eagerLoadSettings_end'); } function initializeEntrypoint(isNonInteractive: boolean): void { - // Skip if already set (e.g., by SDK or other entrypoints) - if (process.env.CLAUDE_CODE_ENTRYPOINT) { - return; - } + // Skip if already set (e.g., by SDK or other entrypoints) + if (process.env.CLAUDE_CODE_ENTRYPOINT) { + return; + } - const cliArgs = process.argv.slice(2); + const cliArgs = process.argv.slice(2); - // Check for MCP serve command (handle flags before mcp serve, e.g., --debug mcp serve) - const mcpIndex = cliArgs.indexOf("mcp"); - if (mcpIndex !== -1 && cliArgs[mcpIndex + 1] === "serve") { - process.env.CLAUDE_CODE_ENTRYPOINT = "mcp"; - return; - } + // Check for MCP serve command (handle flags before mcp serve, e.g., --debug mcp serve) + const mcpIndex = cliArgs.indexOf('mcp'); + if (mcpIndex !== -1 && cliArgs[mcpIndex + 1] === 'serve') { + process.env.CLAUDE_CODE_ENTRYPOINT = 'mcp'; + return; + } - if (isEnvTruthy(process.env.CLAUDE_CODE_ACTION)) { - process.env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action"; - return; - } + if (isEnvTruthy(process.env.CLAUDE_CODE_ACTION)) { + process.env.CLAUDE_CODE_ENTRYPOINT = 'claude-code-github-action'; + return; + } - // Note: 'local-agent' entrypoint is set by the local agent mode launcher - // via CLAUDE_CODE_ENTRYPOINT env var (handled by early return above) + // Note: 'local-agent' entrypoint is set by the local agent mode launcher + // via CLAUDE_CODE_ENTRYPOINT env var (handled by early return above) - // Set based on interactive status - process.env.CLAUDE_CODE_ENTRYPOINT = isNonInteractive ? "sdk-cli" : "cli"; + // Set based on interactive status + process.env.CLAUDE_CODE_ENTRYPOINT = isNonInteractive ? 'sdk-cli' : 'cli'; } // Set by early argv processing when `claude open ` is detected (interactive mode only) type PendingConnect = { - url: string | undefined; - authToken: string | undefined; - dangerouslySkipPermissions: boolean; + url: string | undefined; + authToken: string | undefined; + dangerouslySkipPermissions: boolean; }; -const _pendingConnect: PendingConnect | undefined = feature("DIRECT_CONNECT") - ? { - url: undefined, - authToken: undefined, - dangerouslySkipPermissions: false, - } - : undefined; +const _pendingConnect: PendingConnect | undefined = feature('DIRECT_CONNECT') + ? { + url: undefined, + authToken: undefined, + dangerouslySkipPermissions: false, + } + : undefined; // Set by early argv processing when `claude assistant [sessionId]` is detected type PendingAssistantChat = { sessionId?: string; discover: boolean }; -const _pendingAssistantChat: PendingAssistantChat | undefined = feature( - "KAIROS", -) - ? { sessionId: undefined, discover: false } - : undefined; +const _pendingAssistantChat: PendingAssistantChat | undefined = feature('KAIROS') + ? { sessionId: undefined, discover: false } + : undefined; // `claude ssh [dir]` — parsed from argv early (same pattern as // DIRECT_CONNECT above) so the main command path can pick it up and hand // the REPL an SSH-backed session instead of a local one. type PendingSSH = { - host: string | undefined; - cwd: string | undefined; - permissionMode: string | undefined; - dangerouslySkipPermissions: boolean; - /** --local: spawn the child CLI directly, skip ssh/probe/deploy. e2e test mode. */ - local: boolean; - /** Extra CLI args to forward to the remote CLI on initial spawn (--resume, -c). */ - extraCliArgs: string[]; - remoteBin: string | undefined; + host: string | undefined; + cwd: string | undefined; + permissionMode: string | undefined; + dangerouslySkipPermissions: boolean; + /** --local: spawn the child CLI directly, skip ssh/probe/deploy. e2e test mode. */ + local: boolean; + /** Extra CLI args to forward to the remote CLI on initial spawn (--resume, -c). */ + extraCliArgs: string[]; + remoteBin: string | undefined; }; -const _pendingSSH: PendingSSH | undefined = feature("SSH_REMOTE") - ? { - host: undefined, - cwd: undefined, - permissionMode: undefined, - dangerouslySkipPermissions: false, - local: false, - extraCliArgs: [], - remoteBin: undefined, - } - : undefined; +const _pendingSSH: PendingSSH | undefined = feature('SSH_REMOTE') + ? { + host: undefined, + cwd: undefined, + permissionMode: undefined, + dangerouslySkipPermissions: false, + local: false, + extraCliArgs: [], + remoteBin: undefined, + } + : undefined; export async function main() { - profileCheckpoint("main_function_start"); + profileCheckpoint('main_function_start'); - // SECURITY: Prevent Windows from executing commands from current directory - // This must be set before ANY command execution to prevent PATH hijacking attacks - // See: https://docs.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-searchpathw - process.env.NoDefaultCurrentDirectoryInExePath = "1"; + // SECURITY: Prevent Windows from executing commands from current directory + // This must be set before ANY command execution to prevent PATH hijacking attacks + // See: https://docs.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-searchpathw + process.env.NoDefaultCurrentDirectoryInExePath = '1'; - // Initialize warning handler early to catch warnings - initializeWarningHandler(); + // Initialize warning handler early to catch warnings + initializeWarningHandler(); - process.on("exit", () => { - resetCursor(); - }); - process.on("SIGINT", () => { - // In print mode, print.ts registers its own SIGINT handler that aborts - // the in-flight query and calls gracefulShutdown; skip here to avoid - // preempting it with a synchronous process.exit(). - if (process.argv.includes("-p") || process.argv.includes("--print")) { - return; - } - process.exit(0); - }); - profileCheckpoint("main_warning_handler_initialized"); + process.on('exit', () => { + resetCursor(); + }); + process.on('SIGINT', () => { + // In print mode, print.ts registers its own SIGINT handler that aborts + // the in-flight query and calls gracefulShutdown; skip here to avoid + // preempting it with a synchronous process.exit(). + if (process.argv.includes('-p') || process.argv.includes('--print')) { + return; + } + process.exit(0); + }); + profileCheckpoint('main_warning_handler_initialized'); - // Check for cc:// or cc+unix:// URL in argv — rewrite so the main command - // handles it, giving the full interactive TUI instead of a stripped-down subcommand. - // For headless (-p), we rewrite to the internal `open` subcommand. - if (feature("DIRECT_CONNECT")) { - const rawCliArgs = process.argv.slice(2); - const ccIdx = rawCliArgs.findIndex( - (a) => a.startsWith("cc://") || a.startsWith("cc+unix://"), - ); - if (ccIdx !== -1 && _pendingConnect) { - const ccUrl = rawCliArgs[ccIdx]!; - const { parseConnectUrl } = - await import("./server/parseConnectUrl.js"); - const parsed = parseConnectUrl(ccUrl); - _pendingConnect.dangerouslySkipPermissions = rawCliArgs.includes( - "--dangerously-skip-permissions", - ); + // Check for cc:// or cc+unix:// URL in argv — rewrite so the main command + // handles it, giving the full interactive TUI instead of a stripped-down subcommand. + // For headless (-p), we rewrite to the internal `open` subcommand. + if (feature('DIRECT_CONNECT')) { + const rawCliArgs = process.argv.slice(2); + const ccIdx = rawCliArgs.findIndex(a => a.startsWith('cc://') || a.startsWith('cc+unix://')); + if (ccIdx !== -1 && _pendingConnect) { + const ccUrl = rawCliArgs[ccIdx]!; + const { parseConnectUrl } = await import('./server/parseConnectUrl.js'); + const parsed = parseConnectUrl(ccUrl); + _pendingConnect.dangerouslySkipPermissions = rawCliArgs.includes('--dangerously-skip-permissions'); - if (rawCliArgs.includes("-p") || rawCliArgs.includes("--print")) { - // Headless: rewrite to internal `open` subcommand - const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); - const dspIdx = stripped.indexOf( - "--dangerously-skip-permissions", - ); - if (dspIdx !== -1) { - stripped.splice(dspIdx, 1); - } - process.argv = [ - process.argv[0]!, - process.argv[1]!, - "open", - ccUrl, - ...stripped, - ]; - } else { - // Interactive: strip cc:// URL and flags, run main command - _pendingConnect.url = parsed.serverUrl; - _pendingConnect.authToken = parsed.authToken; - const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); - const dspIdx = stripped.indexOf( - "--dangerously-skip-permissions", - ); - if (dspIdx !== -1) { - stripped.splice(dspIdx, 1); - } - process.argv = [ - process.argv[0]!, - process.argv[1]!, - ...stripped, - ]; - } - } - } + if (rawCliArgs.includes('-p') || rawCliArgs.includes('--print')) { + // Headless: rewrite to internal `open` subcommand + const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); + const dspIdx = stripped.indexOf('--dangerously-skip-permissions'); + if (dspIdx !== -1) { + stripped.splice(dspIdx, 1); + } + process.argv = [process.argv[0]!, process.argv[1]!, 'open', ccUrl, ...stripped]; + } else { + // Interactive: strip cc:// URL and flags, run main command + _pendingConnect.url = parsed.serverUrl; + _pendingConnect.authToken = parsed.authToken; + const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); + const dspIdx = stripped.indexOf('--dangerously-skip-permissions'); + if (dspIdx !== -1) { + stripped.splice(dspIdx, 1); + } + process.argv = [process.argv[0]!, process.argv[1]!, ...stripped]; + } + } + } - // Handle deep link URIs early — this is invoked by the OS protocol handler - // and should bail out before full init since it only needs to parse the URI - // and open a terminal. - if (feature("LODESTONE")) { - const handleUriIdx = process.argv.indexOf("--handle-uri"); - if (handleUriIdx !== -1 && process.argv[handleUriIdx + 1]) { - const { enableConfigs } = await import("./utils/config.js"); - enableConfigs(); - const uri = process.argv[handleUriIdx + 1]!; - const { handleDeepLinkUri } = - await import("./utils/deepLink/protocolHandler.js"); - const exitCode = await handleDeepLinkUri(uri); - process.exit(exitCode); - } + // Handle deep link URIs early — this is invoked by the OS protocol handler + // and should bail out before full init since it only needs to parse the URI + // and open a terminal. + if (feature('LODESTONE')) { + const handleUriIdx = process.argv.indexOf('--handle-uri'); + if (handleUriIdx !== -1 && process.argv[handleUriIdx + 1]) { + const { enableConfigs } = await import('./utils/config.js'); + enableConfigs(); + const uri = process.argv[handleUriIdx + 1]!; + const { handleDeepLinkUri } = await import('./utils/deepLink/protocolHandler.js'); + const exitCode = await handleDeepLinkUri(uri); + process.exit(exitCode); + } - // macOS URL handler: when LaunchServices launches our .app bundle, the - // URL arrives via Apple Event (not argv). LaunchServices overwrites - // __CFBundleIdentifier to the launching bundle's ID, which is a precise - // positive signal — cheaper than importing and guessing with heuristics. - if ( - process.platform === "darwin" && - process.env.__CFBundleIdentifier === - "com.anthropic.claude-code-url-handler" - ) { - const { enableConfigs } = await import("./utils/config.js"); - enableConfigs(); - const { handleUrlSchemeLaunch } = - await import("./utils/deepLink/protocolHandler.js"); - const urlSchemeResult = await handleUrlSchemeLaunch(); - process.exit(urlSchemeResult ?? 1); - } - } + // macOS URL handler: when LaunchServices launches our .app bundle, the + // URL arrives via Apple Event (not argv). LaunchServices overwrites + // __CFBundleIdentifier to the launching bundle's ID, which is a precise + // positive signal — cheaper than importing and guessing with heuristics. + if (process.platform === 'darwin' && process.env.__CFBundleIdentifier === 'com.anthropic.claude-code-url-handler') { + const { enableConfigs } = await import('./utils/config.js'); + enableConfigs(); + const { handleUrlSchemeLaunch } = await import('./utils/deepLink/protocolHandler.js'); + const urlSchemeResult = await handleUrlSchemeLaunch(); + process.exit(urlSchemeResult ?? 1); + } + } - // `claude assistant [sessionId]` — stash and strip so the main - // command handles it, giving the full interactive TUI. Position-0 only - // (matching the ssh pattern below) — indexOf would false-positive on - // `claude -p "explain assistant"`. Root-flag-before-subcommand - // (e.g. `--debug assistant`) falls through to the stub, which - // prints usage. - if (feature("KAIROS") && _pendingAssistantChat) { - const rawArgs = process.argv.slice(2); - if (rawArgs[0] === "assistant") { - const nextArg = rawArgs[1]; - if (nextArg && !nextArg.startsWith("-")) { - _pendingAssistantChat.sessionId = nextArg; - rawArgs.splice(0, 2); // drop 'assistant' and sessionId - process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs]; - } else if (!nextArg) { - _pendingAssistantChat.discover = true; - rawArgs.splice(0, 1); // drop 'assistant' - process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs]; - } - // else: `claude assistant --help` → fall through to stub - } - } + // `claude assistant [sessionId]` — stash and strip so the main + // command handles it, giving the full interactive TUI. Position-0 only + // (matching the ssh pattern below) — indexOf would false-positive on + // `claude -p "explain assistant"`. Root-flag-before-subcommand + // (e.g. `--debug assistant`) falls through to the stub, which + // prints usage. + if (feature('KAIROS') && _pendingAssistantChat) { + const rawArgs = process.argv.slice(2); + if (rawArgs[0] === 'assistant') { + const nextArg = rawArgs[1]; + if (nextArg && !nextArg.startsWith('-')) { + _pendingAssistantChat.sessionId = nextArg; + rawArgs.splice(0, 2); // drop 'assistant' and sessionId + process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs]; + } else if (!nextArg) { + _pendingAssistantChat.discover = true; + rawArgs.splice(0, 1); // drop 'assistant' + process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs]; + } + // else: `claude assistant --help` → fall through to stub + } + } - // `claude ssh [dir]` — strip from argv so the main command handler - // runs (full interactive TUI), stash the host/dir for the REPL branch at - // ~line 3720 to pick up. Headless (-p) mode not supported in v1: SSH - // sessions need the local REPL to drive them (interrupt, permissions). - if (feature("SSH_REMOTE") && _pendingSSH) { - const rawCliArgs = process.argv.slice(2); - // SSH-specific flags can appear before the host positional (e.g. - // `ssh --permission-mode auto host /tmp` — standard POSIX flags-before- - // positionals). Pull them all out BEFORE checking whether a host was - // given, so `claude ssh --permission-mode auto host` and `claude ssh host - // --permission-mode auto` are equivalent. The host check below only needs - // to guard against `-h`/`--help` (which commander should handle). - if (rawCliArgs[0] === "ssh") { - const localIdx = rawCliArgs.indexOf("--local"); - if (localIdx !== -1) { - _pendingSSH.local = true; - rawCliArgs.splice(localIdx, 1); - } - const dspIdx = rawCliArgs.indexOf("--dangerously-skip-permissions"); - if (dspIdx !== -1) { - _pendingSSH.dangerouslySkipPermissions = true; - rawCliArgs.splice(dspIdx, 1); - } - const pmIdx = rawCliArgs.indexOf("--permission-mode"); - if ( - pmIdx !== -1 && - rawCliArgs[pmIdx + 1] && - !rawCliArgs[pmIdx + 1]!.startsWith("-") - ) { - _pendingSSH.permissionMode = rawCliArgs[pmIdx + 1]; - rawCliArgs.splice(pmIdx, 2); - } - const pmEqIdx = rawCliArgs.findIndex((a) => - a.startsWith("--permission-mode="), - ); - if (pmEqIdx !== -1) { - _pendingSSH.permissionMode = rawCliArgs[pmEqIdx]!.split("=")[1]; - rawCliArgs.splice(pmEqIdx, 1); - } - // Forward session-resume + model flags to the remote CLI's initial spawn. - // --continue/-c and --resume operate on the REMOTE session history - // (which persists under the remote's ~/.claude/projects//). - // --model controls which model the remote uses. - const extractFlag = ( - flag: string, - opts: { hasValue?: boolean; as?: string } = {}, - ) => { - const i = rawCliArgs.indexOf(flag); - if (i !== -1) { - _pendingSSH.extraCliArgs.push(opts.as ?? flag); - const val = rawCliArgs[i + 1]; - if (opts.hasValue && val && !val.startsWith("-")) { - _pendingSSH.extraCliArgs.push(val); - rawCliArgs.splice(i, 2); - } else { - rawCliArgs.splice(i, 1); - } - } - const eqI = rawCliArgs.findIndex((a) => - a.startsWith(`${flag}=`), - ); - if (eqI !== -1) { - _pendingSSH.extraCliArgs.push( - opts.as ?? flag, - rawCliArgs[eqI]!.slice(flag.length + 1), - ); - rawCliArgs.splice(eqI, 1); - } - }; - const rbIdx = rawCliArgs.indexOf('--remote-bin'); - if (rbIdx !== -1 && rawCliArgs[rbIdx + 1] && !rawCliArgs[rbIdx + 1]!.startsWith('-')) { - _pendingSSH.remoteBin = rawCliArgs[rbIdx + 1]; - rawCliArgs.splice(rbIdx, 2); - } - const rbEqIdx = rawCliArgs.findIndex(a => a.startsWith('--remote-bin=')); - if (rbEqIdx !== -1) { - _pendingSSH.remoteBin = rawCliArgs[rbEqIdx]!.split('=').slice(1).join('='); - rawCliArgs.splice(rbEqIdx, 1); - } + // `claude ssh [dir]` — strip from argv so the main command handler + // runs (full interactive TUI), stash the host/dir for the REPL branch at + // ~line 3720 to pick up. Headless (-p) mode not supported in v1: SSH + // sessions need the local REPL to drive them (interrupt, permissions). + if (feature('SSH_REMOTE') && _pendingSSH) { + const rawCliArgs = process.argv.slice(2); + // SSH-specific flags can appear before the host positional (e.g. + // `ssh --permission-mode auto host /tmp` — standard POSIX flags-before- + // positionals). Pull them all out BEFORE checking whether a host was + // given, so `claude ssh --permission-mode auto host` and `claude ssh host + // --permission-mode auto` are equivalent. The host check below only needs + // to guard against `-h`/`--help` (which commander should handle). + if (rawCliArgs[0] === 'ssh') { + const localIdx = rawCliArgs.indexOf('--local'); + if (localIdx !== -1) { + _pendingSSH.local = true; + rawCliArgs.splice(localIdx, 1); + } + const dspIdx = rawCliArgs.indexOf('--dangerously-skip-permissions'); + if (dspIdx !== -1) { + _pendingSSH.dangerouslySkipPermissions = true; + rawCliArgs.splice(dspIdx, 1); + } + const pmIdx = rawCliArgs.indexOf('--permission-mode'); + if (pmIdx !== -1 && rawCliArgs[pmIdx + 1] && !rawCliArgs[pmIdx + 1]!.startsWith('-')) { + _pendingSSH.permissionMode = rawCliArgs[pmIdx + 1]; + rawCliArgs.splice(pmIdx, 2); + } + const pmEqIdx = rawCliArgs.findIndex(a => a.startsWith('--permission-mode=')); + if (pmEqIdx !== -1) { + _pendingSSH.permissionMode = rawCliArgs[pmEqIdx]!.split('=')[1]; + rawCliArgs.splice(pmEqIdx, 1); + } + // Forward session-resume + model flags to the remote CLI's initial spawn. + // --continue/-c and --resume operate on the REMOTE session history + // (which persists under the remote's ~/.claude/projects//). + // --model controls which model the remote uses. + const extractFlag = (flag: string, opts: { hasValue?: boolean; as?: string } = {}) => { + const i = rawCliArgs.indexOf(flag); + if (i !== -1) { + _pendingSSH.extraCliArgs.push(opts.as ?? flag); + const val = rawCliArgs[i + 1]; + if (opts.hasValue && val && !val.startsWith('-')) { + _pendingSSH.extraCliArgs.push(val); + rawCliArgs.splice(i, 2); + } else { + rawCliArgs.splice(i, 1); + } + } + const eqI = rawCliArgs.findIndex(a => a.startsWith(`${flag}=`)); + if (eqI !== -1) { + _pendingSSH.extraCliArgs.push(opts.as ?? flag, rawCliArgs[eqI]!.slice(flag.length + 1)); + rawCliArgs.splice(eqI, 1); + } + }; + const rbIdx = rawCliArgs.indexOf('--remote-bin'); + if (rbIdx !== -1 && rawCliArgs[rbIdx + 1] && !rawCliArgs[rbIdx + 1]!.startsWith('-')) { + _pendingSSH.remoteBin = rawCliArgs[rbIdx + 1]; + rawCliArgs.splice(rbIdx, 2); + } + const rbEqIdx = rawCliArgs.findIndex(a => a.startsWith('--remote-bin=')); + if (rbEqIdx !== -1) { + _pendingSSH.remoteBin = rawCliArgs[rbEqIdx]!.split('=').slice(1).join('='); + rawCliArgs.splice(rbEqIdx, 1); + } - extractFlag("-c", { as: "--continue" }); - extractFlag("--continue"); - extractFlag("--resume", { hasValue: true }); - extractFlag("--model", { hasValue: true }); - } - // After pre-extraction, any remaining dash-arg at [1] is either -h/--help - // (commander handles) or an unknown-to-ssh flag (fall through to commander - // so it surfaces a proper error). Only a non-dash arg is the host. - if ( - rawCliArgs[0] === "ssh" && - rawCliArgs[1] && - !rawCliArgs[1].startsWith("-") - ) { - _pendingSSH.host = rawCliArgs[1]; - // Optional positional cwd. - let consumed = 2; - if (rawCliArgs[2] && !rawCliArgs[2].startsWith("-")) { - _pendingSSH.cwd = rawCliArgs[2]; - consumed = 3; - } - const rest = rawCliArgs.slice(consumed); + extractFlag('-c', { as: '--continue' }); + extractFlag('--continue'); + extractFlag('--resume', { hasValue: true }); + extractFlag('--model', { hasValue: true }); + } + // After pre-extraction, any remaining dash-arg at [1] is either -h/--help + // (commander handles) or an unknown-to-ssh flag (fall through to commander + // so it surfaces a proper error). Only a non-dash arg is the host. + if (rawCliArgs[0] === 'ssh' && rawCliArgs[1] && !rawCliArgs[1].startsWith('-')) { + _pendingSSH.host = rawCliArgs[1]; + // Optional positional cwd. + let consumed = 2; + if (rawCliArgs[2] && !rawCliArgs[2].startsWith('-')) { + _pendingSSH.cwd = rawCliArgs[2]; + consumed = 3; + } + const rest = rawCliArgs.slice(consumed); - // Headless (-p) mode is not supported with SSH in v1 — reject early - // so the flag doesn't silently cause local execution. - if (rest.includes("-p") || rest.includes("--print")) { - process.stderr.write( - "Error: headless (-p/--print) mode is not supported with claude ssh\n", - ); - gracefulShutdownSync(1); - return; - } + // Headless (-p) mode is not supported with SSH in v1 — reject early + // so the flag doesn't silently cause local execution. + if (rest.includes('-p') || rest.includes('--print')) { + process.stderr.write('Error: headless (-p/--print) mode is not supported with claude ssh\n'); + gracefulShutdownSync(1); + return; + } - // Rewrite argv so the main command sees remaining flags but not `ssh`. - process.argv = [process.argv[0]!, process.argv[1]!, ...rest]; - } - } + // Rewrite argv so the main command sees remaining flags but not `ssh`. + process.argv = [process.argv[0]!, process.argv[1]!, ...rest]; + } + } - // Check for -p/--print and --init-only flags early to set isInteractiveSession before init() - // This is needed because telemetry initialization calls auth functions that need this flag - const cliArgs = process.argv.slice(2); - const hasPrintFlag = cliArgs.includes("-p") || cliArgs.includes("--print"); - const hasInitOnlyFlag = cliArgs.includes("--init-only"); - const hasSdkUrl = cliArgs.some((arg) => arg.startsWith("--sdk-url")); - const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE); - const isNonInteractive = - hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || (!forceInteractive && !process.stdout.isTTY); + // Check for -p/--print and --init-only flags early to set isInteractiveSession before init() + // This is needed because telemetry initialization calls auth functions that need this flag + const cliArgs = process.argv.slice(2); + const hasPrintFlag = cliArgs.includes('-p') || cliArgs.includes('--print'); + const hasInitOnlyFlag = cliArgs.includes('--init-only'); + const hasSdkUrl = cliArgs.some(arg => arg.startsWith('--sdk-url')); + const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE); + const isNonInteractive = hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || (!forceInteractive && !process.stdout.isTTY); - // Stop capturing early input for non-interactive modes - if (isNonInteractive) { - stopCapturingEarlyInput(); - } + // Stop capturing early input for non-interactive modes + if (isNonInteractive) { + stopCapturingEarlyInput(); + } - // Set simplified tracking fields - const isInteractive = !isNonInteractive; - setIsInteractive(isInteractive); + // Set simplified tracking fields + const isInteractive = !isNonInteractive; + setIsInteractive(isInteractive); - // Initialize entrypoint based on mode - needs to be set before any event is logged - initializeEntrypoint(isNonInteractive); + // Initialize entrypoint based on mode - needs to be set before any event is logged + initializeEntrypoint(isNonInteractive); - // Determine client type - const clientType = (() => { - if (isEnvTruthy(process.env.GITHUB_ACTIONS)) return "github-action"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "sdk-ts") - return "sdk-typescript"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "sdk-py") - return "sdk-python"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "sdk-cli") return "sdk-cli"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "claude-vscode") - return "claude-vscode"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "local-agent") - return "local-agent"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "claude-desktop") - return "claude-desktop"; + // Determine client type + const clientType = (() => { + if (isEnvTruthy(process.env.GITHUB_ACTIONS)) return 'github-action'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-ts') return 'sdk-typescript'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-py') return 'sdk-python'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-cli') return 'sdk-cli'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-vscode') return 'claude-vscode'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') return 'local-agent'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-desktop') return 'claude-desktop'; - // Check if session-ingress token is provided (indicates remote session) - const hasSessionIngressToken = - process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN || - process.env.CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR; - if ( - process.env.CLAUDE_CODE_ENTRYPOINT === "remote" || - hasSessionIngressToken - ) { - return "remote"; - } + // Check if session-ingress token is provided (indicates remote session) + const hasSessionIngressToken = + process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN || process.env.CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'remote' || hasSessionIngressToken) { + return 'remote'; + } - return "cli"; - })(); - setClientType(clientType); + return 'cli'; + })(); + setClientType(clientType); - const previewFormat = process.env.CLAUDE_CODE_QUESTION_PREVIEW_FORMAT; - if (previewFormat === "markdown" || previewFormat === "html") { - setQuestionPreviewFormat(previewFormat); - } else if ( - !clientType.startsWith("sdk-") && - // Desktop and CCR pass previewFormat via toolConfig; when the feature is - // gated off they pass undefined — don't override that with markdown. - clientType !== "claude-desktop" && - clientType !== "local-agent" && - clientType !== "remote" - ) { - setQuestionPreviewFormat("markdown"); - } + const previewFormat = process.env.CLAUDE_CODE_QUESTION_PREVIEW_FORMAT; + if (previewFormat === 'markdown' || previewFormat === 'html') { + setQuestionPreviewFormat(previewFormat); + } else if ( + !clientType.startsWith('sdk-') && + // Desktop and CCR pass previewFormat via toolConfig; when the feature is + // gated off they pass undefined — don't override that with markdown. + clientType !== 'claude-desktop' && + clientType !== 'local-agent' && + clientType !== 'remote' + ) { + setQuestionPreviewFormat('markdown'); + } - // Tag sessions created via `claude remote-control` so the backend can identify them - if (process.env.CLAUDE_CODE_ENVIRONMENT_KIND === "bridge") { - setSessionSource("remote-control"); - } + // Tag sessions created via `claude remote-control` so the backend can identify them + if (process.env.CLAUDE_CODE_ENVIRONMENT_KIND === 'bridge') { + setSessionSource('remote-control'); + } - profileCheckpoint("main_client_type_determined"); + profileCheckpoint('main_client_type_determined'); - // Parse and load settings flags early, before init() - eagerLoadSettings(); + // Parse and load settings flags early, before init() + eagerLoadSettings(); - profileCheckpoint("main_before_run"); + profileCheckpoint('main_before_run'); - await run(); - profileCheckpoint("main_after_run"); + await run(); + profileCheckpoint('main_after_run'); } async function getInputPrompt( - prompt: string, - inputFormat: "text" | "stream-json", + prompt: string, + inputFormat: 'text' | 'stream-json', ): Promise> { - if ( - !process.stdin.isTTY && - // Input hijacking breaks MCP. - !process.argv.includes("mcp") - ) { - if (inputFormat === "stream-json") { - return process.stdin; - } - process.stdin.setEncoding("utf8"); - let data = ""; - const onData = (chunk: string) => { - data += chunk; - }; - process.stdin.on("data", onData); - // If no data arrives in 3s, stop waiting and warn. Stdin is likely an - // inherited pipe from a parent that isn't writing (subprocess spawned - // without explicit stdin handling). 3s covers slow producers like curl, - // jq on large files, python with import overhead. The warning makes - // silent data loss visible for the rare producer that's slower still. - const timedOut = await peekForStdinData(process.stdin, 3000); - process.stdin.off("data", onData); - if (timedOut) { - process.stderr.write( - "Warning: no stdin data received in 3s, proceeding without it. " + - "If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.\n", - ); - } - return [prompt, data].filter(Boolean).join("\n"); - } - return prompt; + if ( + !process.stdin.isTTY && + // Input hijacking breaks MCP. + !process.argv.includes('mcp') + ) { + if (inputFormat === 'stream-json') { + return process.stdin; + } + process.stdin.setEncoding('utf8'); + let data = ''; + const onData = (chunk: string) => { + data += chunk; + }; + process.stdin.on('data', onData); + // If no data arrives in 3s, stop waiting and warn. Stdin is likely an + // inherited pipe from a parent that isn't writing (subprocess spawned + // without explicit stdin handling). 3s covers slow producers like curl, + // jq on large files, python with import overhead. The warning makes + // silent data loss visible for the rare producer that's slower still. + const timedOut = await peekForStdinData(process.stdin, 3000); + process.stdin.off('data', onData); + if (timedOut) { + process.stderr.write( + 'Warning: no stdin data received in 3s, proceeding without it. ' + + 'If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.\n', + ); + } + return [prompt, data].filter(Boolean).join('\n'); + } + return prompt; } async function run(): Promise { - profileCheckpoint("run_function_start"); - - // Create help config that sorts options by long option name and puts - // Commands section before Options section in the root command's help output. - // Commander supports compareOptions/formatHelp at runtime but - // @commander-js/extra-typings doesn't include them in the type definitions, - // so we use Object.assign to add them. - function createSortedHelpConfig(): { - sortSubcommands: true; - sortOptions: true; - } { - const getOptionSortKey = (opt: Option): string => - opt.long?.replace(/^--/, "") ?? opt.short?.replace(/^-/, "") ?? ""; - return Object.assign( - { sortSubcommands: true, sortOptions: true } as const, - { - compareOptions: (a: Option, b: Option) => - getOptionSortKey(a).localeCompare(getOptionSortKey(b)), - // Reorder help sections: Usage → Description → Arguments → - // Commands → Options (matching cs/opencode style). - // Uses duck-typed access because @commander-js/extra-typings - // doesn't expose the full Help class API. - formatHelp: (cmd: CommanderCommand, helper: unknown) => { - const h = helper as { - padWidth: (cmd: CommanderCommand, helper: unknown) => number; - helpWidth: number; - commandUsage: (cmd: CommanderCommand) => string; - commandDescription: (cmd: CommanderCommand) => string; - visibleArguments: (cmd: CommanderCommand) => unknown[]; - visibleCommands: (cmd: CommanderCommand) => unknown[]; - visibleOptions: (cmd: CommanderCommand) => unknown[]; - argumentTerm: (arg: unknown) => string; - argumentDescription: (arg: unknown) => string; - subcommandTerm: (cmd: unknown) => string; - subcommandDescription: (cmd: unknown) => string; - optionTerm: (opt: unknown) => string; - optionDescription: (opt: unknown) => string; - formatItem: (term: string, termWidth: number, description: string, helper: unknown) => string; - styleTitle: (s: string) => string; - styleUsage: (s: string) => string; - styleCommandDescription: (s: string) => string; - styleArgumentTerm: (s: string) => string; - styleArgumentDescription: (s: string) => string; - styleSubcommandTerm: (s: string) => string; - styleSubcommandDescription: (s: string) => string; - styleOptionTerm: (s: string) => string; - styleOptionDescription: (s: string) => string; - boxWrap: (s: string, width: number) => string; - }; - const termWidth = h.padWidth(cmd, helper); - const helpWidth = h.helpWidth ?? 80; - const fmt = (term: string, desc: string) => - h.formatItem(term, termWidth, desc, helper); - - let output = [ - `${h.styleTitle('Usage:')} ${h.styleUsage(h.commandUsage(cmd))}`, - '', - ]; - - const desc = h.commandDescription(cmd); - if (desc.length > 0) { - output = output.concat([h.boxWrap(h.styleCommandDescription(desc), helpWidth), '']); - } - - // Order: Commands → Options → Arguments (matches usage line: [options] [command] [prompt]) - const cmdList = h.visibleCommands(cmd).map(c => - fmt(h.styleSubcommandTerm(h.subcommandTerm(c)), h.styleSubcommandDescription(h.subcommandDescription(c)))); - if (cmdList.length > 0) output = output.concat([h.styleTitle('Commands:'), ...cmdList, '']); - - const optList = h.visibleOptions(cmd).map(o => - fmt(h.styleOptionTerm(h.optionTerm(o)), h.styleOptionDescription(h.optionDescription(o)))); - if (optList.length > 0) output = output.concat([h.styleTitle('Options:'), ...optList, '']); - - const argList = h.visibleArguments(cmd).map(a => - fmt(h.styleArgumentTerm(h.argumentTerm(a)), h.styleArgumentDescription(h.argumentDescription(a)))); - if (argList.length > 0) output = output.concat([h.styleTitle('Arguments:'), ...argList, '']); - - return output.join('\n'); - }, - }, - ); - } - const program = new CommanderCommand() - .addHelpCommand(false) - .configureHelp(createSortedHelpConfig()) - .enablePositionalOptions(); - profileCheckpoint("run_commander_initialized"); - - // Use preAction hook to run initialization only when executing a command, - // not when displaying help. This avoids the need for env variable signaling. - program.hook("preAction", async (thisCommand) => { - profileCheckpoint("preAction_start"); - // Await async subprocess loads started at module evaluation (lines 12-20). - // Nearly free — subprocesses complete during the ~135ms of imports above. - // Must resolve before init() which triggers the first settings read - // (applySafeConfigEnvironmentVariables → getSettingsForSource('policySettings') - // → isRemoteManagedSettingsEligible → sync keychain reads otherwise ~65ms). - await Promise.all([ - ensureMdmSettingsLoaded(), - ensureKeychainPrefetchCompleted(), - ]); - profileCheckpoint("preAction_after_mdm"); - await init(); - profileCheckpoint("preAction_after_init"); - - // process.title on Windows sets the console title directly; on POSIX, - // terminal shell integration may mirror the process name to the tab. - // After init() so settings.json env can also gate this (gh-4765). - if (!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE)) { - process.title = "csc"; - } - - // Attach logging sinks so subcommand handlers can use logEvent/logError. - // Before PR #11106 logEvent dispatched directly; after, events queue until - // a sink attaches. setup() attaches sinks for the default command, but - // subcommands (doctor, mcp, plugin, auth) never call setup() and would - // silently drop events on process.exit(). Both inits are idempotent. - const { initSinks } = await import("./utils/sinks.js"); - initSinks(); - profileCheckpoint("preAction_after_sinks"); - - // gh-33508: --plugin-dir is a top-level program option. The default - // action reads it from its own options destructure, but subcommands - // (plugin list, plugin install, mcp *) have their own actions and - // never see it. Wire it up here so getInlinePlugins() works everywhere. - // thisCommand.opts() is typed {} here because this hook is attached - // before .option('--plugin-dir', ...) in the chain — extra-typings - // builds the type as options are added. Narrow with a runtime guard; - // the collect accumulator + [] default guarantee string[] in practice. - const pluginDir = thisCommand.getOptionValue("pluginDir"); - if ( - Array.isArray(pluginDir) && - pluginDir.length > 0 && - pluginDir.every((p) => typeof p === "string") - ) { - setInlinePlugins(pluginDir); - clearPluginCache("preAction: --plugin-dir inline plugins"); - } - - runMigrations(); - profileCheckpoint("preAction_after_migrations"); - - // Load remote managed settings for enterprise customers (non-blocking) - // Fails open - if fetch fails, continues without remote settings - // Settings are applied via hot-reload when they arrive - // Must happen after init() to ensure config reading is allowed - // void loadRemoteManagedSettings(); - void loadPolicyLimits(); - - profileCheckpoint("preAction_after_remote_settings"); - - // Load settings sync (non-blocking, fail-open) - // CLI: uploads local settings to remote (CCR download is handled by print.ts) - if (feature("UPLOAD_USER_SETTINGS")) { - void import("./services/settingsSync/index.js").then((m) => - m.uploadUserSettingsInBackground(), - ); - } - - profileCheckpoint("preAction_after_settings_sync"); - }); - - program - .name("csc") - .description(`CoStrict AI coding assistant`) - .usage("[command] [options] [prompt]") - .argument("[prompt]", "Your prompt", String) - // Subcommands inherit helpOption via commander's copyInheritedSettings — - // setting it once here covers mcp, plugin, auth, and all other subcommands. - .helpOption("-h, --help", "Display help for command") - .option( - "-d, --debug [filter]", - 'Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file")', - (_value: string | true) => { - // If value is provided, it will be the filter string - // If not provided but flag is present, value will be true - // The actual filtering is handled in debug.ts by parsing process.argv - return true; - }, - ) - .addOption( - new Option("--debug-to-stderr", "Enable debug mode (to stderr)") - .argParser(Boolean) - .hideHelp(), - ) - .addOption( - new Option("--debug-file ", "Write debug logs to a specific file path (implicitly enables debug mode)") - .argParser(() => true) - .hideHelp(), - ) - .addOption( - new Option("--verbose", "Override verbose mode setting from config") - .argParser(() => true) - .hideHelp(), - ) - .option( - "-p, --print", - "Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when CSC is run with the -p mode. Only use this flag in directories you trust.", - () => true, - ) - .addOption( - new Option( - "--bare", - "Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.", - ) - .argParser(() => true) - .hideHelp(), - ) - .addOption( - new Option( - "--init", - "Run Setup hooks with init trigger, then continue", - ).hideHelp(), - ) - .addOption( - new Option( - "--init-only", - "Run Setup and SessionStart:startup hooks, then exit", - ).hideHelp(), - ) - .addOption( - new Option( - "--maintenance", - "Run Setup hooks with maintenance trigger, then continue", - ).hideHelp(), - ) - .addOption( - new Option( - "--output-format ", - 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)', - ).choices(["text", "json", "stream-json"]), - ) - .addOption( - new Option( - "--json-schema ", - "JSON Schema for structured output validation. " + - 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', - ) - .argParser(String) - .hideHelp(), - ) - .addOption( - new Option("--include-hook-events", "Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)") - .argParser(() => true) - .hideHelp(), - ) - .addOption( - new Option("--include-partial-messages", "Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)") - .argParser(() => true) - .hideHelp(), - ) - .addOption( - new Option( - "--input-format ", - 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)', - ) - .choices(["text", "stream-json"]) - .hideHelp(), - ) - .addOption( - new Option( - "--mcp-debug", - "[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)", - ) - .argParser(() => true) - .hideHelp(), - ) - .addOption( - new Option("--dangerously-skip-permissions", "Bypass all permission checks. Recommended only for sandboxes with no internet access.") - .argParser(() => true), - ) - .addOption( - new Option( - "--allow-dangerously-skip-permissions", - "Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.", - ) - .argParser(() => true) - .hideHelp(), - ) - .addOption( - new Option( - "--thinking ", - "Thinking mode: enabled (equivalent to adaptive), disabled", - ) - .choices(["enabled", "adaptive", "disabled"]) - .hideHelp(), - ) - .addOption( - new Option( - "--max-thinking-tokens ", - "[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)", - ) - .argParser(Number) - .hideHelp(), - ) - .addOption( - new Option( - "--max-turns ", - "Maximum number of agentic turns in non-interactive mode. This will early exit the conversation after the specified number of turns. (only works with --print)", - ) - .argParser(Number) - .hideHelp(), - ) - .addOption( - new Option( - "--max-budget-usd ", - "Maximum dollar amount to spend on API calls (only works with --print)", - ) - .argParser((value) => { - const amount = Number(value); - if (isNaN(amount) || amount <= 0) { - throw new Error( - "--max-budget-usd must be a positive number greater than 0", - ); - } - return amount; - }) - .hideHelp(), - ) - .addOption( - new Option( - "--task-budget ", - "API-side task budget in tokens (output_config.task_budget)", - ) - .argParser((value) => { - const tokens = Number(value); - if ( - isNaN(tokens) || - tokens <= 0 || - !Number.isInteger(tokens) - ) { - throw new Error( - "--task-budget must be a positive integer", - ); - } - return tokens; - }) - .hideHelp(), - ) - .addOption( - new Option("--replay-user-messages", "Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json)") - .argParser(() => true) - .hideHelp(), - ) - .addOption( - new Option( - "--enable-auth-status", - "Enable auth status messages in SDK mode", - ) - .default(false) - .hideHelp(), - ) - .addOption( - new Option( - "--allowed-tools ", - 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")', - ).hideHelp(), - ) - .addOption( - new Option( - "--allowedTools ", - 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")', - ).hideHelp(), - ) - .addOption( - new Option("--tools ", 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read").').hideHelp(), - ) - .addOption( - new Option( - "--disallowed-tools ", - 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")', - ).hideHelp(), - ) - .addOption( - new Option( - "--disallowedTools ", - 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")', - ).hideHelp(), - ) - .addOption( - new Option("--mcp-config ", "Load MCP servers from JSON files or strings (space-separated)").hideHelp(), - ) - .addOption( - new Option( - "--permission-prompt-tool ", - "MCP tool to use for permission prompts (only works with --print)", - ) - .argParser(String) - .hideHelp(), - ) - .addOption( - new Option( - "--system-prompt ", - "System prompt to use for the session", - ).argParser(String), - ) - .addOption( - new Option( - "--system-prompt-file ", - "Read system prompt from a file", - ) - .argParser(String) - .hideHelp(), - ) - .addOption( - new Option( - "--append-system-prompt ", - "Append a system prompt to the default system prompt", - ) - .argParser(String) - .hideHelp(), - ) - .addOption( - new Option( - "--append-system-prompt-file ", - "Read system prompt from a file and append to the default system prompt", - ) - .argParser(String) - .hideHelp(), - ) - .addOption( - new Option( - "--permission-mode ", - "Permission mode to use for the session", - ) - .argParser(String) - .choices(PERMISSION_MODES), - ) - .option( - "-c, --continue", - "Continue the most recent conversation in the current directory", - () => true, - ) - .option( - "-r, --resume [value]", - "Resume a conversation by session ID, or open interactive picker with optional search term", - (value) => value || true, - ) - .addOption( - new Option("--fork-session", "When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)") - .argParser(() => true) - .hideHelp(), - ) - .addOption( - new Option( - "--prefill ", - "Pre-fill the prompt input with text without submitting it", - ).hideHelp(), - ) - .addOption( - new Option( - "--deep-link-origin", - "Signal that this session was launched from a deep link", - ).hideHelp(), - ) - .addOption( - new Option( - "--deep-link-repo ", - "Repo slug the deep link ?repo= parameter resolved to the current cwd", - ).hideHelp(), - ) - .addOption( - new Option( - "--deep-link-last-fetch ", - "FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline", - ) - .argParser((v) => { - const n = Number(v); - return Number.isFinite(n) ? n : undefined; - }) - .hideHelp(), - ) - .addOption( - new Option("--from-pr [value]", "Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term") - .argParser((value) => value || true) - .hideHelp(), - ) - .addOption( - new Option("--no-session-persistence", "Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)") - .hideHelp(), - ) - .addOption( - new Option( - "--resume-session-at ", - "When resuming, only messages up to and including the assistant message with (use with --resume in print mode)", - ) - .argParser(String) - .hideHelp(), - ) - .addOption( - new Option( - "--rewind-files ", - "Restore files to state at the specified user message and exit (requires --resume)", - ).hideHelp(), - ) - // @[MODEL LAUNCH]: Update the example model ID in the --model help text. - .option( - "--model ", - `Model for the current session. Provide a model's full name (e.g. 'GLM-5.1').`, - ) - .addOption( - new Option( - "--effort ", - `Effort level for the current session (low, medium, high, max)`, - ) - .argParser((rawValue: string) => { - const value = rawValue.toLowerCase(); - const allowed = ["low", "medium", "high", "max"]; - if (!allowed.includes(value)) { - throw new InvalidArgumentError( - `It must be one of: ${allowed.join(", ")}`, - ); - } - return value; - }) - .hideHelp(), - ) - .option( - "--agent ", - `Agent for the current session. Overrides the 'agent' setting.`, - ) - .addOption( - new Option("--betas ", "Beta headers to include in API requests (API key users only)").hideHelp(), - ) - .addOption( - new Option("--fallback-model ", "Enable automatic fallback to specified model when default model is overloaded (only works with --print)").hideHelp(), - ) - .addOption( - new Option( - "--workload ", - "Workload tag for billing-header attribution (cc_workload). Process-scoped; set by SDK daemon callers that spawn subprocesses for cron work. (only works with --print)", - ).hideHelp(), - ) - .option( - "--settings ", - "Path to a settings JSON file or a JSON string to load additional settings from", - ) - .option( - "--add-dir ", - "Additional directories to allow tool access to", - ) - .addOption( - new Option("--ide", "Automatically connect to IDE on startup if exactly one valid IDE is available") - .argParser(() => true) - .hideHelp(), - ) - .addOption( - new Option("--strict-mcp-config", "Only use MCP servers from --mcp-config, ignoring all other MCP configurations") - .argParser(() => true) - .hideHelp(), - ) - .addOption( - new Option("--session-id ", "Use a specific session ID for the conversation (must be a valid UUID)").hideHelp(), - ) - .addOption( - new Option("-n, --name ", "Set a display name for this session (shown in /resume and terminal title)").hideHelp(), - ) - .addOption( - new Option("--agents ", 'JSON object defining custom agents (e.g. \'{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}\')').hideHelp(), - ) - .addOption( - new Option("--setting-sources ", "Comma-separated list of setting sources to load (user, project, local).").hideHelp(), - ) - // gh-33508: (variadic) consumed everything until the next - // --flag. `claude --plugin-dir /path mcp add --transport http` swallowed - // `mcp` and `add` as paths, then choked on --transport as an unknown - // top-level option. Single-value + collect accumulator means each - // --plugin-dir takes exactly one arg; repeat the flag for multiple dirs. - .addOption( - new Option("--plugin-dir ", "Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B)") - .argParser((val: string, prev: string[]) => [...prev, val]) - .default([] as string[]) - .hideHelp(), - ) - .addOption( - new Option("--disable-slash-commands", "Disable all skills").argParser(() => true).hideHelp(), - ) - .addOption(new Option("--chrome", "Enable Claude in Chrome integration").hideHelp()) - .addOption(new Option("--no-chrome", "Disable Claude in Chrome integration").hideHelp()) - .addOption( - new Option("--file ", "File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png)").hideHelp(), - ) - .action(async (prompt, options) => { - profileCheckpoint("action_handler_start"); - - // --bare = one-switch minimal mode. Sets SIMPLE so all the existing - // gates fire (CLAUDE.md, skills, hooks inside executeHooks, agent - // dir-walk). Must be set before setup() / any of the gated work runs. - if ((options as { bare?: boolean }).bare) { - process.env.CLAUDE_CODE_SIMPLE = "1"; - } - - // Ignore "code" as a prompt - treat it the same as no prompt - if (prompt === "code") { - logEvent("tengu_code_prompt_ignored", {}); - console.warn( - chalk.yellow( - "Tip: You can launch CoStrict with just `csc`", - ), - ); - prompt = undefined; - } - - // Log event for any single-word prompt - if ( - prompt && - typeof prompt === "string" && - !/\s/.test(prompt) && - prompt.length > 0 - ) { - logEvent("tengu_single_word_prompt", { length: prompt.length }); - } - - // Assistant mode: when .claude/settings.json has assistant: true AND - // the tengu_kairos GrowthBook gate is on, force brief on. Permission - // mode is left to the user — settings defaultMode or --permission-mode - // apply as normal. REPL-typed messages already default to 'next' - // priority (messageQueueManager.enqueue) so they drain mid-turn between - // tool calls. SendUserMessage (BriefTool) is enabled via the brief env - // var. SleepTool stays disabled (its isEnabled() gates on proactive). - // kairosEnabled is computed once here and reused at the - // getAssistantSystemPromptAddendum() call site further down. - // - // Trust gate: .claude/settings.json is attacker-controllable in an - // untrusted clone. We run ~1000 lines before showSetupScreens() shows - // the trust dialog, and by then we've already appended - // .claude/agents/assistant.md to the system prompt. Refuse to activate - // until the directory has been explicitly trusted. - let kairosEnabled = false; - let assistantTeamContext: - | Awaited< - ReturnType< - NonNullable< - typeof assistantModule - >["initializeAssistantTeam"] - > - > - | undefined; - if ( - feature("KAIROS") && - (options as { assistant?: boolean }).assistant && - assistantModule - ) { - // --assistant (Agent SDK daemon mode): force the latch before - // isAssistantMode() runs below. The daemon has already checked - // entitlement — don't make the child re-check tengu_kairos. - assistantModule.markAssistantForced(); - } - if ( - feature("KAIROS") && - assistantModule && - (assistantModule.isAssistantForced() || - (options as Record).assistant === true) && - // Spawned teammates share the leader's cwd + settings.json, so - // the flag is true for them too. --agent-id being set - // means we ARE a spawned teammate (extractTeammateOptions runs - // ~170 lines later so check the raw commander option) — don't - // re-init the team or override teammateMode/proactive/brief. - !(options as { agentId?: unknown }).agentId && - kairosGate - ) { - if (!checkHasTrustDialogAccepted()) { - console.warn( - chalk.yellow( - "Assistant mode disabled: directory is not trusted. Accept the trust dialog and restart.", - ), - ); - } else { - // Blocking gate check — returns cached `true` instantly; if disk - // cache is false/missing, lazily inits GrowthBook and fetches fresh - // (max ~5s). --assistant skips the gate entirely (daemon is - // pre-entitled). - kairosEnabled = - assistantModule.isAssistantForced() || - (await kairosGate.isKairosEnabled()); - if (kairosEnabled) { - const opts = options as { brief?: boolean }; - opts.brief = true; - setKairosActive(true); - // Pre-seed an in-process team so Agent(name: "foo") spawns - // teammates without TeamCreate. Must run BEFORE setup() captures - // the teammateMode snapshot (initializeAssistantTeam calls - // setCliTeammateModeOverride internally). - assistantTeamContext = - await assistantModule.initializeAssistantTeam(); - } - } - } - - const { - debug = false, - debugToStderr = false, - dangerouslySkipPermissions, - allowDangerouslySkipPermissions = false, - tools: baseTools = [], - allowedTools = [], - disallowedTools = [], - mcpConfig = [], - permissionMode: permissionModeCli, - addDir = [], - fallbackModel, - betas = [], - ide = false, - sessionId, - includeHookEvents, - includePartialMessages, - } = options; - - if (options.prefill) { - seedEarlyInput(options.prefill); - } - - // Promise for file downloads - started early, awaited before REPL renders - let fileDownloadPromise: Promise | undefined; - - const agentsJson = options.agents; - const agentCli = options.agent; - if (feature("BG_SESSIONS") && agentCli) { - process.env.CLAUDE_CODE_AGENT = agentCli; - } - - // NOTE: LSP manager initialization is intentionally deferred until after - // the trust dialog is accepted. This prevents plugin LSP servers from - // executing code in untrusted directories before user consent. - - // Extract these separately so they can be modified if needed - let outputFormat = options.outputFormat; - let inputFormat = options.inputFormat; - let verbose = options.verbose ?? getGlobalConfig().verbose; - let print = options.print; - const init = options.init ?? false; - const initOnly = options.initOnly ?? false; - const maintenance = options.maintenance ?? false; - - // Extract disable slash commands flag - const disableSlashCommands = options.disableSlashCommands || false; - - // Extract tasks mode options (ant-only) - const tasksOption = - process.env.USER_TYPE === "ant" && - (options as { tasks?: boolean | string }).tasks; - const taskListId = tasksOption - ? typeof tasksOption === "string" - ? tasksOption - : DEFAULT_TASKS_MODE_TASK_LIST_ID - : undefined; - if (process.env.USER_TYPE === "ant" && taskListId) { - process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId; - } - - // Extract worktree option - // worktree can be true (flag without value) or a string (custom name or PR reference) - const worktreeOption = isWorktreeModeEnabled() - ? (options as { worktree?: boolean | string }).worktree - : undefined; - let worktreeName = - typeof worktreeOption === "string" ? worktreeOption : undefined; - const worktreeEnabled = worktreeOption !== undefined; - - // Check if worktree name is a PR reference (#N or GitHub PR URL) - let worktreePRNumber: number | undefined; - if (worktreeName) { - const prNum = parsePRReference(worktreeName); - if (prNum !== null) { - worktreePRNumber = prNum; - worktreeName = undefined; // slug will be generated in setup() - } - } - - // Extract tmux option (requires --worktree) - const tmuxEnabled = - isWorktreeModeEnabled() && - (options as { tmux?: boolean }).tmux === true; - - // Validate tmux option - if (tmuxEnabled) { - if (!worktreeEnabled) { - process.stderr.write( - chalk.red("Error: --tmux requires --worktree\n"), - ); - process.exit(1); - } - if (getPlatform() === "windows") { - process.stderr.write( - chalk.red( - "Error: --tmux is not supported on Windows\n", - ), - ); - process.exit(1); - } - if (!(await isTmuxAvailable())) { - process.stderr.write( - chalk.red( - `Error: tmux is not installed.\n${getTmuxInstallInstructions()}\n`, - ), - ); - process.exit(1); - } - } - - // Extract teammate options (for tmux-spawned agents) - // Declared outside the if block so it's accessible later for system prompt addendum - let storedTeammateOpts: TeammateOptions | undefined; - if (isAgentSwarmsEnabled()) { - // Extract agent identity options (for tmux-spawned agents) - // These replace the CLAUDE_CODE_* environment variables - const teammateOpts = extractTeammateOptions(options); - storedTeammateOpts = teammateOpts; - - // If any teammate identity option is provided, all three required ones must be present - const hasAnyTeammateOpt = - teammateOpts.agentId || - teammateOpts.agentName || - teammateOpts.teamName; - const hasAllRequiredTeammateOpts = - teammateOpts.agentId && - teammateOpts.agentName && - teammateOpts.teamName; - - if (hasAnyTeammateOpt && !hasAllRequiredTeammateOpts) { - process.stderr.write( - chalk.red( - "Error: --agent-id, --agent-name, and --team-name must all be provided together\n", - ), - ); - process.exit(1); - } - - // If teammate identity is provided via CLI, set up dynamicTeamContext - if ( - teammateOpts.agentId && - teammateOpts.agentName && - teammateOpts.teamName - ) { - getTeammateUtils().setDynamicTeamContext?.({ - agentId: teammateOpts.agentId, - agentName: teammateOpts.agentName, - teamName: teammateOpts.teamName, - color: teammateOpts.agentColor, - planModeRequired: - teammateOpts.planModeRequired ?? false, - parentSessionId: teammateOpts.parentSessionId, - }); - } - - // Set teammate mode CLI override if provided - // This must be done before setup() captures the snapshot - if (teammateOpts.teammateMode) { - getTeammateModeSnapshot().setCliTeammateModeOverride?.( - teammateOpts.teammateMode, - ); - } - } - - // Extract remote sdk options - const sdkUrl = (options as { sdkUrl?: string }).sdkUrl ?? undefined; - - // Allow env var to enable partial messages (used by sandbox gateway for baku) - const effectiveIncludePartialMessages = - includePartialMessages || - isEnvTruthy(process.env.CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES); - - // Enable all hook event types when explicitly requested via SDK option - // or when running in CLAUDE_CODE_REMOTE mode (CCR needs them). - // Without this, only SessionStart and Setup events are emitted. - if ( - includeHookEvents || - isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) - ) { - setAllHookEventsEnabled(true); - } - - // Auto-set input/output formats, verbose mode, and print mode when SDK URL is provided - if (sdkUrl) { - // If SDK URL is provided, automatically use stream-json formats unless explicitly set - if (!inputFormat) { - inputFormat = "stream-json"; - } - if (!outputFormat) { - outputFormat = "stream-json"; - } - // Auto-enable verbose mode unless explicitly disabled or already set - if (options.verbose === undefined) { - verbose = true; - } - // Auto-enable print mode unless explicitly disabled - if (!options.print) { - print = true; - } - } - - // Extract teleport option - const teleport = - (options as { teleport?: string | true }).teleport ?? null; - - // Extract remote option (can be true if no description provided, or a string) - const remoteOption = (options as { remote?: string | true }).remote; - const remote = remoteOption === true ? "" : (remoteOption ?? null); - - // Extract --remote-control / --rc flag (enable bridge in interactive session) - const remoteControlOption = - (options as { remoteControl?: string | true }).remoteControl ?? - (options as { rc?: string | true }).rc; - // Actual bridge check is deferred to after showSetupScreens() so that - // trust is established and GrowthBook has auth headers. - let remoteControl = false; - const remoteControlName = - typeof remoteControlOption === "string" && - remoteControlOption.length > 0 - ? remoteControlOption - : undefined; - - // Validate session ID if provided - if (sessionId) { - // Check for conflicting flags - // --session-id can be used with --continue or --resume when --fork-session is also provided - // (to specify a custom ID for the forked session) - if ( - (options.continue || options.resume) && - !options.forkSession - ) { - process.stderr.write( - chalk.red( - "Error: --session-id can only be used with --continue or --resume if --fork-session is also specified.\n", - ), - ); - process.exit(1); - } - - // When --sdk-url is provided (bridge/remote mode), the session ID is a - // server-assigned tagged ID (e.g. "session_local_01...") rather than a - // UUID. Skip UUID validation and local existence checks in that case. - if (!sdkUrl) { - const validatedSessionId = validateUuid(sessionId); - if (!validatedSessionId) { - process.stderr.write( - chalk.red( - "Error: Invalid session ID. Must be a valid UUID.\n", - ), - ); - process.exit(1); - } - - // Check if session ID already exists - if (sessionIdExists(validatedSessionId)) { - process.stderr.write( - chalk.red( - `Error: Session ID ${validatedSessionId} is already in use.\n`, - ), - ); - process.exit(1); - } - } - } - - // Download file resources if specified via --file flag - const fileSpecs = (options as { file?: string[] }).file; - if (fileSpecs && fileSpecs.length > 0) { - // Get session ingress token (provided by EnvManager via CLAUDE_CODE_SESSION_ACCESS_TOKEN) - const sessionToken = getSessionIngressAuthToken(); - if (!sessionToken) { - process.stderr.write( - chalk.red( - "Error: Session token required for file downloads. CLAUDE_CODE_SESSION_ACCESS_TOKEN must be set.\n", - ), - ); - process.exit(1); - } - - // Resolve session ID: prefer remote session ID, fall back to internal session ID - const fileSessionId = - process.env.CLAUDE_CODE_REMOTE_SESSION_ID || getSessionId(); - - const files = parseFileSpecs(fileSpecs); - if (files.length > 0) { - // Use ANTHROPIC_BASE_URL if set (by EnvManager), otherwise use OAuth config - // This ensures consistency with session ingress API in all environments - const config: FilesApiConfig = { - baseUrl: - process.env.ANTHROPIC_BASE_URL || - getOauthConfig().BASE_API_URL, - oauthToken: sessionToken, - sessionId: fileSessionId, - }; - - // Start download without blocking startup - await before REPL renders - fileDownloadPromise = downloadSessionFiles(files, config); - } - } - - // Get isNonInteractiveSession from state (was set before init()) - const isNonInteractiveSession = getIsNonInteractiveSession(); - - // Validate that fallback model is different from main model - if ( - fallbackModel && - options.model && - fallbackModel === options.model - ) { - process.stderr.write( - chalk.red( - "Error: Fallback model cannot be the same as the main model. Please specify a different model for --fallback-model.\n", - ), - ); - process.exit(1); - } - - // Handle system prompt options - let systemPrompt = options.systemPrompt; - if (options.systemPromptFile) { - if (options.systemPrompt) { - process.stderr.write( - chalk.red( - "Error: Cannot use both --system-prompt and --system-prompt-file. Please use only one.\n", - ), - ); - process.exit(1); - } - - try { - const filePath = resolve(options.systemPromptFile); - systemPrompt = readFileSync(filePath, "utf8"); - } catch (error) { - const code = getErrnoCode(error); - if (code === "ENOENT") { - process.stderr.write( - chalk.red( - `Error: System prompt file not found: ${resolve(options.systemPromptFile)}\n`, - ), - ); - process.exit(1); - } - process.stderr.write( - chalk.red( - `Error reading system prompt file: ${errorMessage(error)}\n`, - ), - ); - process.exit(1); - } - } - - // Handle append system prompt options - let appendSystemPrompt = options.appendSystemPrompt; - if (options.appendSystemPromptFile) { - if (options.appendSystemPrompt) { - process.stderr.write( - chalk.red( - "Error: Cannot use both --append-system-prompt and --append-system-prompt-file. Please use only one.\n", - ), - ); - process.exit(1); - } - - try { - const filePath = resolve(options.appendSystemPromptFile); - appendSystemPrompt = readFileSync(filePath, "utf8"); - } catch (error) { - const code = getErrnoCode(error); - if (code === "ENOENT") { - process.stderr.write( - chalk.red( - `Error: Append system prompt file not found: ${resolve(options.appendSystemPromptFile)}\n`, - ), - ); - process.exit(1); - } - process.stderr.write( - chalk.red( - `Error reading append system prompt file: ${errorMessage(error)}\n`, - ), - ); - process.exit(1); - } - } - - // Add teammate-specific system prompt addendum for tmux teammates - if ( - isAgentSwarmsEnabled() && - storedTeammateOpts?.agentId && - storedTeammateOpts?.agentName && - storedTeammateOpts?.teamName - ) { - const addendum = - getTeammatePromptAddendum().TEAMMATE_SYSTEM_PROMPT_ADDENDUM; - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${addendum}` - : addendum; - } - - const { - mode: permissionMode, - notification: permissionModeNotification, - } = initialPermissionModeFromCLI({ - permissionModeCli, - dangerouslySkipPermissions, - }); - - // Store session bypass permissions mode for trust dialog check - setSessionBypassPermissionsMode( - permissionMode === "bypassPermissions", - ); - if (feature("TRANSCRIPT_CLASSIFIER")) { - // autoModeFlagCli is the "did the user intend auto this session" signal. - // Set when: --enable-auto-mode, --permission-mode auto, resolved mode - // is auto, OR settings defaultMode is auto but the gate denied it - // (permissionMode resolved to default with no explicit CLI override). - // Used by verifyAutoModeGateAccess to decide whether to notify on - // auto-unavailable, and by tengu_auto_mode_config opt-in carousel. - if ( - (options as { enableAutoMode?: boolean }).enableAutoMode || - permissionModeCli === "auto" || - permissionMode === "auto" || - (!permissionModeCli && isDefaultPermissionModeAuto()) - ) { - autoModeStateModule?.setAutoModeFlagCli(true); - } - } - - // Parse the MCP config files/strings if provided - let dynamicMcpConfig: Record = { - // Built-in MCP servers (default disabled, user enables via /mcp) - "mcp-chrome": { - type: "http", - url: "http://127.0.0.1:12306/mcp", - scope: "dynamic", - "headers": { - "Authorization": "Bearer my-static-token", - } - }, - }; - - if (mcpConfig && mcpConfig.length > 0) { - // Process mcpConfig array - const processedConfigs = mcpConfig - .map((config) => config.trim()) - .filter((config) => config.length > 0); - - let allConfigs: Record = {}; - const allErrors: ValidationError[] = []; - - for (const configItem of processedConfigs) { - let configs: Record | null = null; - let errors: ValidationError[] = []; - - // First try to parse as JSON string - const parsedJson = safeParseJSON(configItem); - if (parsedJson) { - const result = parseMcpConfig({ - configObject: parsedJson, - filePath: "command line", - expandVars: true, - scope: "dynamic", - }); - if (result.config) { - configs = result.config.mcpServers; - } else { - errors = result.errors; - } - } else { - // Try as file path - const configPath = resolve(configItem); - const result = parseMcpConfigFromFilePath({ - filePath: configPath, - expandVars: true, - scope: "dynamic", - }); - if (result.config) { - configs = result.config.mcpServers; - } else { - errors = result.errors; - } - } - - if (errors.length > 0) { - allErrors.push(...errors); - } else if (configs) { - // Merge configs, later ones override earlier ones - allConfigs = { ...allConfigs, ...configs }; - } - } - - if (allErrors.length > 0) { - const formattedErrors = allErrors - .map( - (err) => - `${err.path ? err.path + ": " : ""}${err.message}`, - ) - .join("\n"); - logForDebugging( - `--mcp-config validation failed (${allErrors.length} errors): ${formattedErrors}`, - { - level: "error", - }, - ); - process.stderr.write( - `Error: Invalid MCP configuration:\n${formattedErrors}\n`, - ); - process.exit(1); - } - - if (Object.keys(allConfigs).length > 0) { - // SDK hosts (Nest/Desktop) own their server naming and may reuse - // built-in names — skip reserved-name checks for type:'sdk'. - const nonSdkConfigNames = Object.entries(allConfigs) - .filter(([, config]) => config.type !== "sdk") - .map(([name]) => name); - - let reservedNameError: string | null = null; - if (nonSdkConfigNames.some(isClaudeInChromeMCPServer)) { - reservedNameError = `Invalid MCP configuration: "${CLAUDE_IN_CHROME_MCP_SERVER_NAME}" is a reserved MCP name.`; - } else if (feature("CHICAGO_MCP")) { - const { - isComputerUseMCPServer, - COMPUTER_USE_MCP_SERVER_NAME, - } = await import("src/utils/computerUse/common.js"); - if (nonSdkConfigNames.some(isComputerUseMCPServer)) { - reservedNameError = `Invalid MCP configuration: "${COMPUTER_USE_MCP_SERVER_NAME}" is a reserved MCP name.`; - } - } - if (reservedNameError) { - // stderr+exit(1) — a throw here becomes a silent unhandled - // rejection in stream-json mode (void main() in cli.tsx). - process.stderr.write(`Error: ${reservedNameError}\n`); - process.exit(1); - } - - // Add dynamic scope to all configs. type:'sdk' entries pass through - // unchanged — they're extracted into sdkMcpConfigs downstream and - // passed to print.ts. The Python SDK relies on this path (it doesn't - // send sdkMcpServers in the initialize message). Dropping them here - // broke Coworker (inc-5122). The policy filter below already exempts - // type:'sdk', and the entries are inert without an SDK transport on - // stdin, so there's no bypass risk from letting them through. - const scopedConfigs = mapValues(allConfigs, (config) => ({ - ...config, - scope: "dynamic" as const, - })); - - // Enforce managed policy (allowedMcpServers / deniedMcpServers) on - // --mcp-config servers. Without this, the CLI flag bypasses the - // enterprise allowlist that user/project/local configs go through in - // getClaudeCodeMcpConfigs — callers spread dynamicMcpConfig back on - // top of filtered results. Filter here at the source so all - // downstream consumers see the policy-filtered set. - const { allowed, blocked } = - filterMcpServersByPolicy(scopedConfigs); - if (blocked.length > 0) { - process.stderr.write( - `Warning: MCP ${plural(blocked.length, "server")} blocked by enterprise policy: ${blocked.join(", ")}\n`, - ); - } - dynamicMcpConfig = { ...dynamicMcpConfig, ...(allowed as Record) }; - } - } - - // Extract Claude in Chrome option and enforce claude.ai subscriber check (unless user is ant) - const chromeOpts = options as { chrome?: boolean }; - // Store the explicit CLI flag so teammates can inherit it - setChromeFlagOverride(chromeOpts.chrome); - const enableClaudeInChrome = - shouldEnableClaudeInChrome(chromeOpts.chrome) && - (process.env.USER_TYPE === "ant" || isClaudeAISubscriber()); - const autoEnableClaudeInChrome = - !enableClaudeInChrome && shouldAutoEnableClaudeInChrome(); - - if (enableClaudeInChrome) { - const platform = getPlatform(); - try { - logEvent("tengu_claude_in_chrome_setup", { - platform: - platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - - const { - mcpConfig: chromeMcpConfig, - allowedTools: chromeMcpTools, - systemPrompt: chromeSystemPrompt, - } = setupClaudeInChrome(); - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...chromeMcpConfig, - }; - allowedTools.push(...chromeMcpTools); - if (chromeSystemPrompt) { - appendSystemPrompt = appendSystemPrompt - ? `${chromeSystemPrompt}\n\n${appendSystemPrompt}` - : chromeSystemPrompt; - } - } catch (error) { - logEvent("tengu_claude_in_chrome_setup_failed", { - platform: - platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - logForDebugging(`[Claude in Chrome] Error: ${error}`); - logError(error); - console.error( - `Error: Failed to run with Claude in Chrome.`, - ); - process.exit(1); - } - } else if (autoEnableClaudeInChrome) { - try { - const { mcpConfig: chromeMcpConfig } = - setupClaudeInChrome(); - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...chromeMcpConfig, - }; - - const hint = - feature("WEB_BROWSER_TOOL") && - typeof Bun !== "undefined" && - "WebView" in Bun - ? CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER - : CLAUDE_IN_CHROME_SKILL_HINT; - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${hint}` - : hint; - } catch (error) { - // Silently skip any errors for the auto-enable - logForDebugging( - `[Claude in Chrome] Error (auto-enable): ${error}`, - ); - } - } - - // Extract strict MCP config flag - const strictMcpConfig = options.strictMcpConfig || false; - - // Check if enterprise MCP configuration exists. When it does, only allow dynamic MCP - // configs that contain special server types (sdk) - if (doesEnterpriseMcpConfigExist()) { - if (strictMcpConfig) { - process.stderr.write( - chalk.red( - "You cannot use --strict-mcp-config when an enterprise MCP config is present", - ), - ); - process.exit(1); - } - - // For --mcp-config, allow if all servers are internal types (sdk) - if ( - dynamicMcpConfig && - !areMcpConfigsAllowedWithEnterpriseMcpConfig( - dynamicMcpConfig, - ) - ) { - process.stderr.write( - chalk.red( - "You cannot dynamically configure MCP servers when an enterprise MCP config is present", - ), - ); - process.exit(1); - } - } - - // chicago MCP: guarded Computer Use (app allowlist + frontmost gate + - // SCContentFilter screenshots). Ant-only, GrowthBook-gated — failures - // are silent (this is dogfooding). Platform + interactive checks inline - // so non-macOS / print-mode ants skip the heavy @ant/computer-use-mcp - // import entirely. gates.js is light (type-only package import). - // - // Placed AFTER the enterprise-MCP-config check: that check rejects any - // dynamicMcpConfig entry with `type !== 'sdk'`, and our config is - // `type: 'stdio'`. An enterprise-config ant with the GB gate on would - // otherwise process.exit(1). Chrome has the same latent issue but has - // shipped without incident; chicago places itself correctly. - if ( - feature("CHICAGO_MCP") && - getPlatform() !== "unknown" && - !getIsNonInteractiveSession() - ) { - try { - const { getChicagoEnabled } = - await import("src/utils/computerUse/gates.js"); - if (getChicagoEnabled()) { - const { setupComputerUseMCP } = - await import("src/utils/computerUse/setup.js"); - const { mcpConfig, allowedTools: cuTools } = - setupComputerUseMCP(); - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...mcpConfig, - }; - allowedTools.push(...cuTools); - } - } catch (error) { - logForDebugging( - `[Computer Use MCP] Setup failed: ${errorMessage(error)}`, - ); - } - } - - // Store additional directories for CLAUDE.md loading (controlled by env var) - setAdditionalDirectoriesForClaudeMd(addDir); - - // Channel server allowlist from --channels flag — servers whose - // inbound push notifications should register this session. The option - // is added inside a feature() block so TS doesn't know about it - // on the options type — same pattern as --assistant at main.tsx:1824. - // devChannels is deferred: showSetupScreens shows a confirmation dialog - // and only appends to allowedChannels on accept. - let devChannels: ChannelEntry[] | undefined; - // Parse plugin:name@marketplace / server:Y tags into typed entries. - // Tag decides trust model downstream: plugin-kind hits marketplace - // verification + GrowthBook allowlist, server-kind always fails - // allowlist (schema is plugin-only) unless dev flag is set. - // Untagged or marketplace-less plugin entries are hard errors — - // silently not-matching in the gate would look like channels are - // "on" but nothing ever fires. - const parseChannelEntries = ( - raw: string[], - flag: string, - ): ChannelEntry[] => { - const entries: ChannelEntry[] = []; - const bad: string[] = []; - for (const c of raw) { - if (c.startsWith("plugin:")) { - const rest = c.slice(7); - const at = rest.indexOf("@"); - if (at <= 0 || at === rest.length - 1) { - bad.push(c); - } else { - entries.push({ - kind: "plugin", - name: rest.slice(0, at), - marketplace: rest.slice(at + 1), - }); - } - } else if (c.startsWith("server:") && c.length > 7) { - entries.push({ kind: "server", name: c.slice(7) }); - } else { - bad.push(c); - } - } - if (bad.length > 0) { - process.stderr.write( - chalk.red( - `${flag} entries must be tagged: ${bad.join(", ")}\n` + - ` plugin:@ — plugin-provided channel (allowlist enforced)\n` + - ` server: — manually configured MCP server\n`, - ), - ); - process.exit(1); - } - return entries; - }; - - const channelOpts = options as { - channels?: string[]; - dangerouslyLoadDevelopmentChannels?: string[]; - }; - const rawChannels = channelOpts.channels; - const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels; - // Always parse + set. ChannelsNotice reads getAllowedChannels() and - // renders the appropriate branch (disabled/noAuth/policyBlocked/ - // listening) in the startup screen. gateChannelServer() enforces. - // --channels works in both interactive and print/SDK modes; dev-channels - // stays interactive-only (requires a confirmation dialog). - let channelEntries: ChannelEntry[] = []; - if (rawChannels && rawChannels.length > 0) { - channelEntries = parseChannelEntries( - rawChannels, - "--channels", - ); - setAllowedChannels(channelEntries); - } - if (!isNonInteractiveSession) { - if (rawDev && rawDev.length > 0) { - devChannels = parseChannelEntries( - rawDev, - "--dangerously-load-development-channels", - ); - } - } - // Flag-usage telemetry. Plugin identifiers are logged (same tier as - // tengu_plugin_installed — public-registry-style names); server-kind - // names are not (MCP-server-name tier, opt-in-only elsewhere). - // Per-server gate outcomes land in tengu_mcp_channel_gate once - // servers connect. Dev entries go through a confirmation dialog after - // this — dev_plugins captures what was typed, not what was accepted. - if ( - channelEntries.length > 0 || - (devChannels?.length ?? 0) > 0 - ) { - const joinPluginIds = (entries: ChannelEntry[]) => { - const ids = entries.flatMap((e) => - e.kind === "plugin" - ? [`${e.name}@${e.marketplace}`] - : [], - ); - return ids.length > 0 - ? (ids - .sort() - .join( - ",", - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) - : undefined; - }; - logEvent("tengu_mcp_channel_flags", { - channels_count: channelEntries.length, - dev_count: devChannels?.length ?? 0, - plugins: joinPluginIds(channelEntries), - dev_plugins: joinPluginIds(devChannels ?? []), - }); - } - - // SDK opt-in for SendUserMessage via --tools. All sessions require - // explicit opt-in; listing it in --tools signals intent. Runs BEFORE - // initializeToolPermissionContext so getToolsForDefaultPreset() sees - // the tool as enabled when computing the base-tools disallow filter. - // Conditional require avoids leaking the tool-name string into - // external builds. - if ( - (feature("KAIROS") || feature("KAIROS_BRIEF")) && - baseTools.length > 0 - ) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { BRIEF_TOOL_NAME, LEGACY_BRIEF_TOOL_NAME } = - require("@claude-code-best/builtin-tools/tools/BriefTool/prompt.js") as typeof import("@claude-code-best/builtin-tools/tools/BriefTool/prompt.js"); - const { isBriefEntitled } = - require("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js") as typeof import("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js"); - /* eslint-enable @typescript-eslint/no-require-imports */ - const parsed = parseToolListFromCLI(baseTools); - if ( - (parsed.includes(BRIEF_TOOL_NAME) || - parsed.includes(LEGACY_BRIEF_TOOL_NAME)) && - isBriefEntitled() - ) { - setUserMsgOptIn(true); - } - } - - // This await replaces blocking existsSync/statSync calls that were already in - // the startup path. Wall-clock time is unchanged; we just yield to the event - // loop during the fs I/O instead of blocking it. See #19661. - const initResult = await initializeToolPermissionContext({ - allowedToolsCli: allowedTools, - disallowedToolsCli: disallowedTools, - baseToolsCli: baseTools, - permissionMode, - allowDangerouslySkipPermissions, - addDirs: addDir, - }); - let toolPermissionContext = initResult.toolPermissionContext; - const { - warnings, - dangerousPermissions, - overlyBroadBashPermissions, - } = initResult; - - // Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*)) - if ( - process.env.USER_TYPE === "ant" && - overlyBroadBashPermissions.length > 0 - ) { - for (const permission of overlyBroadBashPermissions) { - logForDebugging( - `Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`, - ); - } - toolPermissionContext = removeDangerousPermissions( - toolPermissionContext, - overlyBroadBashPermissions, - ); - } - - if ( - feature("TRANSCRIPT_CLASSIFIER") && - dangerousPermissions.length > 0 - ) { - toolPermissionContext = stripDangerousPermissionsForAutoMode( - toolPermissionContext, - ); - } - - // Print any warnings from initialization - warnings.forEach((warning) => { - console.error(warning); - }); - - - // claude.ai config fetch: -p mode only (interactive uses useManageMCPConnections - // two-phase loading). Kicked off here to overlap with setup(); awaited - // before runHeadless so single-turn -p sees connectors. Skipped under - // enterprise/strict MCP to preserve policy boundaries. - const claudeaiConfigPromise: Promise< - Record - > = - isNonInteractiveSession && - !strictMcpConfig && - !doesEnterpriseMcpConfigExist() && - // --bare / SIMPLE: skip claude.ai proxy servers (datadog, Gmail, - // Slack, BigQuery, PubMed — 6-14s each to connect). Scripted calls - // that need MCP pass --mcp-config explicitly. - !isBareMode() - ? fetchClaudeAIMcpConfigsIfEligible().then((configs) => { - const { allowed, blocked } = - filterMcpServersByPolicy(configs); - if (blocked.length > 0) { - process.stderr.write( - `Warning: costrict.ai MCP ${plural(blocked.length, "server")} blocked by enterprise policy: ${blocked.join(", ")}\n`, - ); - } - return allowed; - }) - : Promise.resolve({}); - - // Kick off MCP config loading early (safe - just reads files, no execution). - // Both interactive and -p use getClaudeCodeMcpConfigs (local file reads only). - // The local promise is awaited later (before prefetchAllMcpResources) to - // overlap config I/O with setup(), commands loading, and trust dialog. - logForDebugging("[STARTUP] Loading MCP configs..."); - const mcpConfigStart = Date.now(); - let mcpConfigResolvedMs: number | undefined; - // --bare skips auto-discovered MCP (.mcp.json, user settings, plugins) — - // only explicit --mcp-config works. dynamicMcpConfig is spread onto - // allMcpConfigs downstream so it survives this skip. - const mcpConfigPromise = ( - strictMcpConfig || isBareMode() - ? Promise.resolve({ - servers: {} as Record< - string, - ScopedMcpServerConfig - >, - }) - : getClaudeCodeMcpConfigs(dynamicMcpConfig) - ).then((result) => { - mcpConfigResolvedMs = Date.now() - mcpConfigStart; - return result; - }); - - // NOTE: We do NOT call prefetchAllMcpResources here - that's deferred until after trust dialog - - if ( - inputFormat && - inputFormat !== "text" && - inputFormat !== "stream-json" - ) { - console.error(`Error: Invalid input format "${inputFormat}".`); - process.exit(1); - } - if ( - inputFormat === "stream-json" && - outputFormat !== "stream-json" - ) { - console.error( - `Error: --input-format=stream-json requires output-format=stream-json.`, - ); - process.exit(1); - } - - // Validate sdkUrl is only used with appropriate formats (formats are auto-set above) - if (sdkUrl) { - if ( - inputFormat !== "stream-json" || - outputFormat !== "stream-json" - ) { - console.error( - `Error: --sdk-url requires both --input-format=stream-json and --output-format=stream-json.`, - ); - process.exit(1); - } - } - - // Validate replayUserMessages is only used with stream-json formats - if (options.replayUserMessages) { - if ( - inputFormat !== "stream-json" || - outputFormat !== "stream-json" - ) { - console.error( - `Error: --replay-user-messages requires both --input-format=stream-json and --output-format=stream-json.`, - ); - process.exit(1); - } - } - - // Validate includePartialMessages is only used with print mode and stream-json output - if (effectiveIncludePartialMessages) { - if ( - !isNonInteractiveSession || - outputFormat !== "stream-json" - ) { - writeToStderr( - `Error: --include-partial-messages requires --print and --output-format=stream-json.`, - ); - process.exit(1); - } - } - - // Validate --no-session-persistence is only used with print mode - if ( - options.sessionPersistence === false && - !isNonInteractiveSession - ) { - writeToStderr( - `Error: --no-session-persistence can only be used with --print mode.`, - ); - process.exit(1); - } - - const effectivePrompt = prompt || ""; - let inputPrompt = await getInputPrompt( - effectivePrompt, - (inputFormat ?? "text") as "text" | "stream-json", - ); - profileCheckpoint("action_after_input_prompt"); - - // Activate proactive mode BEFORE getTools() so SleepTool.isEnabled() - // (which returns isProactiveActive()) passes and Sleep is included. - // The later REPL-path maybeActivateProactive() calls are idempotent. - maybeActivateProactive(options); - - let tools = getTools(toolPermissionContext); - - // Apply coordinator mode tool filtering for headless path - // (mirrors useMergedTools.ts filtering for REPL/interactive path) - if ( - feature("COORDINATOR_MODE") && - isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) - ) { - const { applyCoordinatorToolFilter } = - await import("./utils/toolPool.js"); - tools = applyCoordinatorToolFilter(tools); - } - - profileCheckpoint("action_tools_loaded"); - - let jsonSchema: ToolInputJSONSchema | undefined; - if ( - isSyntheticOutputToolEnabled({ isNonInteractiveSession }) && - options.jsonSchema - ) { - jsonSchema = jsonParse( - options.jsonSchema, - ) as ToolInputJSONSchema; - } - - if (jsonSchema) { - const syntheticOutputResult = - createSyntheticOutputTool(jsonSchema); - if ("tool" in syntheticOutputResult) { - // Add SyntheticOutputTool to the tools array AFTER getTools() filtering. - // This tool is excluded from normal filtering (see tools.ts) because it's - // an implementation detail for structured output, not a user-controlled tool. - tools = [...tools, syntheticOutputResult.tool]; - - logEvent("tengu_structured_output_enabled", { - schema_property_count: Object.keys( - (jsonSchema.properties as Record< - string, - unknown - >) || {}, - ) - .length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - has_required_fields: Boolean( - jsonSchema.required, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - } else { - logEvent("tengu_structured_output_failure", { - error: "Invalid JSON schema" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - } - } - - // IMPORTANT: setup() must be called before any other code that depends on the cwd or worktree setup - profileCheckpoint("action_before_setup"); - logForDebugging("[STARTUP] Running setup()..."); - const setupStart = Date.now(); - const { setup } = await import("./setup.js"); - const messagingSocketPath = feature("UDS_INBOX") - ? (options as { messagingSocketPath?: string }) - .messagingSocketPath - : undefined; - // Parallelize setup() with commands+agents loading. setup()'s ~28ms is - // mostly startUdsMessaging (socket bind, ~20ms) — not disk-bound, so it - // doesn't contend with getCommands' file reads. Gated on !worktreeEnabled - // since --worktree makes setup() process.chdir() (setup.ts:203), and - // commands/agents need the post-chdir cwd. - const preSetupCwd = getCwd(); - // Register bundled skills/plugins before kicking getCommands() — they're - // pure in-memory array pushes (<1ms, zero I/O) that getBundledSkills() - // reads synchronously. Previously ran inside setup() after ~20ms of - // await points, so the parallel getCommands() memoized an empty list. - if (process.env.CLAUDE_CODE_ENTRYPOINT !== "local-agent") { - initBuiltinPlugins(); - initBundledSkills(); - } - const setupPromise = setup( - preSetupCwd, - permissionMode, - allowDangerouslySkipPermissions, - worktreeEnabled, - worktreeName, - tmuxEnabled, - sessionId ? validateUuid(sessionId) : undefined, - worktreePRNumber, - messagingSocketPath, - ); - const commandsPromise = worktreeEnabled - ? null - : getCommands(preSetupCwd); - const agentDefsPromise = worktreeEnabled - ? null - : getAgentDefinitionsWithOverrides(preSetupCwd); - // Suppress transient unhandledRejection if these reject during the - // ~28ms setupPromise await before Promise.all joins them below. - commandsPromise?.catch(() => {}); - agentDefsPromise?.catch(() => {}); - await setupPromise; - logForDebugging( - `[STARTUP] setup() completed in ${Date.now() - setupStart}ms`, - ); - profileCheckpoint("action_after_setup"); - - // Replay user messages into stream-json only when the socket was - // explicitly requested. The auto-generated socket is passive — it - // lets tools inject if they want to, but turning it on by default - // shouldn't reshape stream-json for SDK consumers who never touch it. - // Callers who inject and also want those injections visible in the - // stream pass --messaging-socket-path explicitly (or --replay-user-messages). - let effectiveReplayUserMessages = !!options.replayUserMessages; - if (feature("UDS_INBOX")) { - if ( - !effectiveReplayUserMessages && - outputFormat === "stream-json" - ) { - effectiveReplayUserMessages = !!( - options as { messagingSocketPath?: string } - ).messagingSocketPath; - } - } - - if (getIsNonInteractiveSession()) { - // Apply full merged settings env now (including project-scoped - // .claude/settings.json PATH/GIT_DIR/GIT_WORK_TREE) so gitExe() and - // the git spawn below see it. Trust is implicit in -p mode; the - // docstring at managedEnv.ts:96-97 says this applies "potentially - // dangerous environment variables such as LD_PRELOAD, PATH" from all - // sources. The later call in the isNonInteractiveSession block below - // is idempotent (Object.assign, configureGlobalAgents ejects prior - // interceptor) and picks up any plugin-contributed env after plugin - // init. Project settings are already loaded here: - // applySafeConfigEnvironmentVariables in init() called - // getSettings_DEPRECATED at managedEnv.ts:86 which merges all enabled - // sources including projectSettings/localSettings. - applyConfigEnvironmentVariables(); - - // Spawn git status/log/branch now so the subprocess execution overlaps - // with the getCommands await below and startDeferredPrefetches. After - // setup() so cwd is final (setup.ts:254 may process.chdir(worktreePath) - // for --worktree) and after the applyConfigEnvironmentVariables above - // so PATH/GIT_DIR/GIT_WORK_TREE from all sources (trusted + project) - // are applied. getSystemContext is memoized; the - // prefetchSystemContextIfSafe call in startDeferredPrefetches becomes - // a cache hit. The microtask from await getIsGit() drains at the - // getCommands Promise.all await below. Trust is implicit in -p mode - // (same gate as prefetchSystemContextIfSafe). - void getSystemContext(); - // Kick getUserContext now too — its first await (fs.readFile in - // getMemoryFiles) yields naturally, so the CLAUDE.md directory walk - // runs during the ~280ms overlap window before the context - // Promise.all join in print.ts. The void getUserContext() in - // startDeferredPrefetches becomes a memoize cache-hit. - void getUserContext(); - // Kick ensureModelStringsInitialized now — for Bedrock this triggers - // a 100-200ms profile fetch that was awaited serially at - // print.ts:739. updateBedrockModelStrings is sequential()-wrapped so - // the await joins the in-flight fetch. Non-Bedrock is a sync - // early-return (zero-cost). - void ensureModelStringsInitialized(); - } - - // Apply --name: cache-only so no orphan file is created before the - // session ID is finalized by --continue/--resume. materializeSessionFile - // persists it on the first user message; REPL's useTerminalTitle reads it - // via getCurrentSessionTitle. - const sessionNameArg = options.name?.trim(); - if (sessionNameArg) { - cacheSessionTitle(sessionNameArg); - } - - // Ant model aliases (capybara-fast etc.) resolve via the - // tengu_ant_model_override GrowthBook flag. _CACHED_MAY_BE_STALE reads - // disk synchronously; disk is populated by a fire-and-forget write. On a - // cold cache, parseUserSpecifiedModel returns the unresolved alias, the - // API 404s, and -p exits before the async write lands — crashloop on - // fresh pods. Awaiting init here populates the in-memory payload map that - // _CACHED_MAY_BE_STALE now checks first. Gated so the warm path stays - // non-blocking: - // - explicit model via --model or ANTHROPIC_MODEL (both feed alias resolution) - // - no env override (which short-circuits _CACHED_MAY_BE_STALE before disk) - // - flag absent from disk (== null also catches pre-#22279 poisoned null) - const explicitModel = options.model || process.env.ANTHROPIC_MODEL; - if ( - process.env.USER_TYPE === "ant" && - explicitModel && - explicitModel !== "default" && - !hasGrowthBookEnvOverride("tengu_ant_model_override") && - getGlobalConfig().cachedGrowthBookFeatures?.[ - "tengu_ant_model_override" - ] == null - ) { - await initializeGrowthBook(); - } - - // Special case the default model with the null keyword - // NOTE: Model resolution happens after setup() to ensure trust is established before AWS auth - const userSpecifiedModel = - options.model === "default" - ? getDefaultMainLoopModel() - : options.model; - const userSpecifiedFallbackModel = - fallbackModel === "default" - ? getDefaultMainLoopModel() - : fallbackModel; - - // Reuse preSetupCwd unless setup() chdir'd (worktreeEnabled). Saves a - // getCwd() syscall in the common path. - const currentCwd = worktreeEnabled ? getCwd() : preSetupCwd; - logForDebugging("[STARTUP] Loading commands and agents..."); - const commandsStart = Date.now(); - // Join the promises kicked before setup() (or start fresh if - // worktreeEnabled gated the early kick). Both memoized by cwd. - const [commands, agentDefinitionsResult] = await Promise.all([ - commandsPromise ?? getCommands(currentCwd), - agentDefsPromise ?? - getAgentDefinitionsWithOverrides(currentCwd), - ]); - logForDebugging( - `[STARTUP] Commands and agents loaded in ${Date.now() - commandsStart}ms`, - ); - profileCheckpoint("action_commands_loaded"); - - // Parse CLI agents if provided via --agents flag - let cliAgents: typeof agentDefinitionsResult.activeAgents = []; - if (agentsJson) { - try { - const parsedAgents = safeParseJSON(agentsJson); - if (parsedAgents) { - cliAgents = parseAgentsFromJson( - parsedAgents, - "flagSettings", - ); - } - } catch (error) { - logError(error); - } - } - - // Merge CLI agents with existing ones - const allAgents = [ - ...agentDefinitionsResult.allAgents, - ...cliAgents, - ]; - const agentDefinitions = { - ...agentDefinitionsResult, - allAgents, - activeAgents: getActiveAgentsFromList(allAgents), - }; - - // Look up main thread agent from CLI flag or settings - const agentSetting = agentCli ?? getInitialSettings().agent; - let mainThreadAgentDefinition: - | (typeof agentDefinitions.activeAgents)[number] - | undefined; - if (agentSetting) { - mainThreadAgentDefinition = agentDefinitions.activeAgents.find( - (agent) => agent.agentType === agentSetting, - ); - if (!mainThreadAgentDefinition) { - logForDebugging( - `Warning: agent "${agentSetting}" not found. ` + - `Available agents: ${agentDefinitions.activeAgents.map((a) => a.agentType).join(", ")}. ` + - `Using default behavior.`, - ); - } - } - - // Store the main thread agent type in bootstrap state so hooks can access it - setMainThreadAgentType(mainThreadAgentDefinition?.agentType); - - // Log agent flag usage — only log agent name for built-in agents to avoid leaking custom agent names - if (mainThreadAgentDefinition) { - logEvent("tengu_agent_flag", { - agentType: isBuiltInAgent(mainThreadAgentDefinition) - ? (mainThreadAgentDefinition.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) - : ("custom" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS), - ...(agentCli && { - source: "cli" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - }); - } - - // Persist agent setting to session transcript for resume view display and restoration - if (mainThreadAgentDefinition?.agentType) { - saveAgentSetting(mainThreadAgentDefinition.agentType); - } - - // Apply the agent's system prompt for non-interactive sessions - // (interactive mode uses buildEffectiveSystemPrompt instead) - if ( - isNonInteractiveSession && - mainThreadAgentDefinition && - !systemPrompt && - !isBuiltInAgent(mainThreadAgentDefinition) - ) { - const agentSystemPrompt = - mainThreadAgentDefinition.getSystemPrompt(); - if (agentSystemPrompt) { - systemPrompt = agentSystemPrompt; - } - } - - // initialPrompt goes first so its slash command (if any) is processed; - // user-provided text becomes trailing context. - // Only concatenate when inputPrompt is a string. When it's an - // AsyncIterable (SDK stream-json mode), template interpolation would - // call .toString() producing "[object Object]". The AsyncIterable case - // is handled in print.ts via structuredIO.prependUserMessage(). - if (mainThreadAgentDefinition?.initialPrompt) { - if (typeof inputPrompt === "string") { - inputPrompt = inputPrompt - ? `${mainThreadAgentDefinition.initialPrompt}\n\n${inputPrompt}` - : mainThreadAgentDefinition.initialPrompt; - } else if (!inputPrompt) { - inputPrompt = mainThreadAgentDefinition.initialPrompt; - } - } - - // Compute effective model early so hooks can run in parallel with MCP - // If user didn't specify a model but agent has one, use the agent's model - let effectiveModel = userSpecifiedModel; - if ( - !effectiveModel && - mainThreadAgentDefinition?.model && - mainThreadAgentDefinition.model !== "inherit" - ) { - effectiveModel = parseUserSpecifiedModel( - mainThreadAgentDefinition.model, - ); - } - - setMainLoopModelOverride(effectiveModel); - - // Compute resolved model for hooks (use user-specified model at launch) - setInitialMainLoopModel(getUserSpecifiedModelSetting() || null); - const initialMainLoopModel = getInitialMainLoopModel(); - const resolvedInitialModel = parseUserSpecifiedModel( - initialMainLoopModel ?? getDefaultMainLoopModel(), - ); - - let advisorModel: string | undefined; - if (isAdvisorEnabled()) { - const advisorOption = canUserConfigureAdvisor() - ? (options as { advisor?: string }).advisor - : undefined; - if (advisorOption) { - logForDebugging(`[AdvisorTool] --advisor ${advisorOption}`); - if (!modelSupportsAdvisor(resolvedInitialModel)) { - process.stderr.write( - chalk.red( - `Error: The model "${resolvedInitialModel}" does not support the advisor tool.\n`, - ), - ); - process.exit(1); - } - const normalizedAdvisorModel = normalizeModelStringForAPI( - parseUserSpecifiedModel(advisorOption), - ); - if (!isValidAdvisorModel(normalizedAdvisorModel)) { - process.stderr.write( - chalk.red( - `Error: The model "${advisorOption}" cannot be used as an advisor.\n`, - ), - ); - process.exit(1); - } - } - advisorModel = canUserConfigureAdvisor() - ? (advisorOption ?? getInitialAdvisorSetting()) - : advisorOption; - if (advisorModel) { - logForDebugging( - `[AdvisorTool] Advisor model: ${advisorModel}`, - ); - } - } - - // For tmux teammates with --agent-type, append the custom agent's prompt - if ( - isAgentSwarmsEnabled() && - storedTeammateOpts?.agentId && - storedTeammateOpts?.agentName && - storedTeammateOpts?.teamName && - storedTeammateOpts?.agentType - ) { - // Look up the custom agent definition - const customAgent = agentDefinitions.activeAgents.find( - (a) => a.agentType === storedTeammateOpts.agentType, - ); - if (customAgent) { - // Get the prompt - need to handle both built-in and custom agents - let customPrompt: string | undefined; - if (customAgent.source === "built-in") { - // Built-in agents have getSystemPrompt that takes toolUseContext - // We can't access full toolUseContext here, so skip for now - logForDebugging( - `[teammate] Built-in agent ${storedTeammateOpts.agentType} - skipping custom prompt (not supported)`, - ); - } else { - // Custom agents have getSystemPrompt that takes no args - customPrompt = customAgent.getSystemPrompt(); - } - - // Log agent memory loaded event for tmux teammates - if (customAgent.memory) { - logEvent("tengu_agent_memory_loaded", { - ...(process.env.USER_TYPE === "ant" && { - agent_type: - customAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - scope: customAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: "teammate" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - } - - if (customPrompt) { - const customInstructions = `\n# Custom Agent Instructions\n${customPrompt}`; - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${customInstructions}` - : customInstructions; - } - } else { - logForDebugging( - `[teammate] Custom agent ${storedTeammateOpts.agentType} not found in available agents`, - ); - } - } - - maybeActivateBrief(options); - // defaultView: 'chat' is a persisted opt-in — check entitlement and set - // userMsgOptIn so the tool + prompt section activate. Interactive-only: - // defaultView is a display preference; SDK sessions have no display, and - // the assistant installer writes defaultView:'chat' to settings.local.json - // which would otherwise leak into --print sessions in the same directory. - // Runs right after maybeActivateBrief() so all startup opt-in paths fire - // BEFORE any isBriefEnabled() read below (proactive prompt's - // briefVisibility). A persisted 'chat' after a GB kill-switch falls - // through (entitlement fails). - if ( - (feature("KAIROS") || feature("KAIROS_BRIEF")) && - !getIsNonInteractiveSession() && - !getUserMsgOptIn() && - getInitialSettings().defaultView === "chat" - ) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { isBriefEntitled } = - require("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js") as typeof import("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js"); - /* eslint-enable @typescript-eslint/no-require-imports */ - if (isBriefEntitled()) { - setUserMsgOptIn(true); - } - } - // Coordinator mode has its own system prompt and filters out Sleep, so - // the generic proactive prompt would tell it to call a tool it can't - // access and conflict with delegation instructions. - if ( - (feature("PROACTIVE") || feature("KAIROS")) && - ((options as { proactive?: boolean }).proactive || - isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) && - !coordinatorModeModule?.isCoordinatorMode() - ) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const briefVisibility = - feature("KAIROS") || feature("KAIROS_BRIEF") - ? ( - require("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js") as typeof import("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js") - ).isBriefEnabled() - ? "Call SendUserMessage at checkpoints to mark where things stand." - : "The user will see any text you output." - : "The user will see any text you output."; - /* eslint-enable @typescript-eslint/no-require-imports */ - const proactivePrompt = `\n# Proactive Mode\n\nYou are in proactive mode. Take initiative — explore, act, and make progress without waiting for instructions.\n\nStart by briefly greeting the user.\n\nYou will receive periodic prompts. These are check-ins. Do whatever seems most useful, or call Sleep if there's nothing to do. ${briefVisibility}`; - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${proactivePrompt}` - : proactivePrompt; - } - - if (feature("KAIROS") && kairosEnabled && assistantModule) { - const assistantAddendum = - assistantModule.getAssistantSystemPromptAddendum(); - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${assistantAddendum}` - : assistantAddendum; - } - - // Ink root is only needed for interactive sessions — patchConsole in the - // Ink constructor would swallow console output in headless mode. - let root!: Root; - let getFpsMetrics!: () => FpsMetrics | undefined; - let stats!: StatsStore; - - // Show setup screens after commands are loaded - if (!isNonInteractiveSession) { - const ctx = getRenderContext(false); - getFpsMetrics = ctx.getFpsMetrics; - stats = ctx.stats; - // Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1) - if (process.env.USER_TYPE === "ant") { - installAsciicastRecorder(); - } - - const { createRoot } = await import('@anthropic/ink') - root = await createRoot(ctx.renderOptions) - - // Log startup time now, before any blocking dialog renders. Logging - // from REPL's first render (the old location) included however long - // the user sat on trust/OAuth/onboarding/resume-picker — p99 was ~70s - // dominated by dialog-wait time, not code-path startup. - logEvent("tengu_timer", { - event: "startup" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - durationMs: Math.round(process.uptime() * 1000), - }); - - logForDebugging("[STARTUP] Running showSetupScreens()..."); - const setupScreensStart = Date.now(); - const onboardingShown = await showSetupScreens( - root, - permissionMode, - allowDangerouslySkipPermissions, - commands, - enableClaudeInChrome, - devChannels, - ); - logForDebugging( - `[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`, - ); - - // Now that trust is established and GrowthBook has auth headers, - // resolve the --remote-control / --rc entitlement gate. - if ( - feature("BRIDGE_MODE") && - remoteControlOption !== undefined - ) { - const { getBridgeDisabledReason } = - await import("./bridge/bridgeEnabled.js"); - const disabledReason = await getBridgeDisabledReason(); - remoteControl = disabledReason === null; - if (disabledReason) { - process.stderr.write( - chalk.yellow( - `${disabledReason}\n--rc flag ignored.\n`, - ), - ); - } - } - - // Check for pending agent memory snapshot updates (only for --agent mode, ant-only) - if ( - feature("AGENT_MEMORY_SNAPSHOT") && - mainThreadAgentDefinition && - isCustomAgent(mainThreadAgentDefinition) && - mainThreadAgentDefinition.memory && - mainThreadAgentDefinition.pendingSnapshotUpdate - ) { - const agentDef = mainThreadAgentDefinition; - const choice = await launchSnapshotUpdateDialog(root, { - agentType: agentDef.agentType, - scope: agentDef.memory!, - snapshotTimestamp: - agentDef.pendingSnapshotUpdate!.snapshotTimestamp, - }); - if (choice === "merge") { - const { buildMergePrompt } = - await import("./components/agents/SnapshotUpdateDialog.js"); - const mergePrompt = buildMergePrompt( - agentDef.agentType, - agentDef.memory!, - ); - inputPrompt = inputPrompt - ? `${mergePrompt}\n\n${inputPrompt}` - : mergePrompt; - } - agentDef.pendingSnapshotUpdate = undefined; - } - - // Skip executing /login if we just completed onboarding for it - if ( - onboardingShown && - prompt?.trim().toLowerCase() === "/login" - ) { - prompt = ""; - } - - if (onboardingShown) { - // Refresh auth-dependent services now that the user has logged in during onboarding. - // Keep in sync with the post-login logic in src/commands/login.tsx - // void refreshRemoteManagedSettings(); - void refreshPolicyLimits(); - // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials - resetUserCache(); - // Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs) - refreshGrowthBookAfterAuthChange(); - // Clear any stale trusted device token then enroll for Remote Control. - // Both self-gate on tengu_sessions_elevated_auth_enforcement internally - // — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits - // the GrowthBook reinit above), clearTrustedDeviceToken() via the - // sync cached check (acceptable since clear is idempotent). - void import("./bridge/trustedDevice.js").then((m) => { - m.clearTrustedDeviceToken(); - return m.enrollTrustedDevice(); - }); - } - - // Validate that the active token's org matches forceLoginOrgUUID (if set - // in managed settings). Runs after onboarding so managed settings and - // login state are fully loaded. - const orgValidation = await validateForceLoginOrg(); - if (!orgValidation.valid) { - await exitWithError(root, (orgValidation as { valid: false; message: string }).message); - } - } - - // If gracefulShutdown was initiated (e.g., user rejected trust dialog), - // process.exitCode will be set. Skip all subsequent operations that could - // trigger code execution before the process exits (e.g. we don't want apiKeyHelper - // to run if trust was not established). - if (process.exitCode !== undefined) { - logForDebugging( - "Graceful shutdown initiated, skipping further initialization", - ); - return; - } - - // Initialize LSP manager AFTER trust is established (or in non-interactive mode - // where trust is implicit). This prevents plugin LSP servers from executing - // code in untrusted directories before user consent. - // Must be after inline plugins are set (if any) so --plugin-dir LSP servers are included. - initializeLspServerManager(); - - // Show settings validation errors after trust is established - // MCP config errors don't block settings from loading, so exclude them - if (!isNonInteractiveSession) { - const { errors } = getSettingsWithErrors(); - const nonMcpErrors = errors.filter((e) => !e.mcpErrorMetadata); - if (nonMcpErrors.length > 0) { - await launchInvalidSettingsDialog(root, { - settingsErrors: nonMcpErrors, - onExit: () => gracefulShutdownSync(1), - }); - } - } - - // Check quota status, fast mode, passes eligibility, and bootstrap data - // after trust is established. These make API calls which could trigger - // apiKeyHelper execution. - // --bare / SIMPLE: skip — these are cache-warms for the REPL's - // first-turn responsiveness (quota, passes, fastMode, bootstrap data). Fast - // mode doesn't apply to the Agent SDK anyway (see getFastModeUnavailableReason). - const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE( - "tengu_cicada_nap_ms", - 0, - ); - const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0; - const skipStartupPrefetches = - isBareMode() || - (bgRefreshThrottleMs > 0 && - Date.now() - lastPrefetched < bgRefreshThrottleMs); - - if (!skipStartupPrefetches) { - const lastPrefetchedInfo = - lastPrefetched > 0 - ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` - : ""; - logForDebugging( - `Starting background startup prefetches${lastPrefetchedInfo}`, - ); - - checkQuotaStatus().catch((error) => logError(error)); - - // Fetch bootstrap data from the server and update all cache values. - void fetchBootstrapData(); - - // TODO: Consolidate other prefetches into a single bootstrap request. - void prefetchPassesEligibility(); - if ( - !getFeatureValue_CACHED_MAY_BE_STALE( - "tengu_miraculo_the_bard", - false, - ) - ) { - void prefetchFastModeStatus(); - } else { - // Kill switch skips the network call, not org-policy enforcement. - // Resolve from cache so orgStatus doesn't stay 'pending' (which - // getFastModeUnavailableReason treats as permissive). - resolveFastModeStatusFromCache(); - } - if (bgRefreshThrottleMs > 0) { - saveGlobalConfig((current) => ({ - ...current, - startupPrefetchedAt: Date.now(), - })); - } - } else { - logForDebugging( - `Skipping startup prefetches, last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago`, - ); - // Resolve fast mode org status from cache (no network) - resolveFastModeStatusFromCache(); - } - - if (!isNonInteractiveSession) { - void refreshExampleCommands(); // Pre-fetch example commands (runs git log, no API call) - } - - // Resolve MCP configs (started early, overlaps with setup/trust dialog work) - const { servers: existingMcpConfigs } = await mcpConfigPromise; - logForDebugging( - `[STARTUP] MCP configs resolved in ${mcpConfigResolvedMs}ms (awaited at +${Date.now() - mcpConfigStart}ms)`, - ); - // CLI flag (--mcp-config) should override file-based configs, matching settings precedence - const allMcpConfigs = { - ...existingMcpConfigs, - ...dynamicMcpConfig, - }; - - // Separate SDK configs from regular MCP configs - const sdkMcpConfigs: Record = {}; - const regularMcpConfigs: Record = {}; - - for (const [name, config] of Object.entries(allMcpConfigs)) { - const typedConfig = config as - | ScopedMcpServerConfig - | McpSdkServerConfig; - if (typedConfig.type === "sdk") { - sdkMcpConfigs[name] = typedConfig as McpSdkServerConfig; - } else { - regularMcpConfigs[name] = - typedConfig as ScopedMcpServerConfig; - } - } - - profileCheckpoint("action_mcp_configs_loaded"); - - // Prefetch MCP resources after trust dialog (this is where execution happens). - // Interactive mode only: print mode defers connects until headlessStore exists - // and pushes per-server (below), so ToolSearch's pending-client handling works - // and one slow server doesn't block the batch. - const localMcpPromise = isNonInteractiveSession - ? Promise.resolve({ clients: [], tools: [], commands: [] }) - : prefetchAllMcpResources(regularMcpConfigs); - const claudeaiMcpPromise = isNonInteractiveSession - ? Promise.resolve({ clients: [], tools: [], commands: [] }) - : claudeaiConfigPromise.then((configs) => - Object.keys(configs).length > 0 - ? prefetchAllMcpResources(configs) - : { clients: [], tools: [], commands: [] }, - ); - // Merge with dedup by name: each prefetchAllMcpResources call independently - // adds helper tools (ListMcpResourcesTool, ReadMcpResourceTool) via - // local dedup flags, so merging two calls can yield duplicates. print.ts - // already uniqBy's the final tool pool, but dedup here keeps appState clean. - const mcpPromise = Promise.all([ - localMcpPromise, - claudeaiMcpPromise, - ]).then(([local, claudeai]) => ({ - clients: [...local.clients, ...claudeai.clients], - tools: uniqBy([...local.tools, ...claudeai.tools], "name"), - commands: uniqBy( - [...local.commands, ...claudeai.commands], - "name", - ), - })); - - // Start hooks early so they run in parallel with MCP connections. - // Skip for initOnly/init/maintenance (handled separately), non-interactive - // (handled via setupTrigger), and resume/continue (conversationRecovery.ts - // fires 'resume' instead — without this guard, hooks fire TWICE on /resume - // and the second systemMessage clobbers the first. gh-30825) - const hooksPromise = - initOnly || - init || - maintenance || - isNonInteractiveSession || - options.continue || - options.resume - ? null - : processSessionStartHooks("startup", { - agentType: mainThreadAgentDefinition?.agentType, - model: resolvedInitialModel, - }); - - // MCP never blocks REPL render OR turn 1 TTFT. useManageMCPConnections - // populates appState.mcp async as servers connect (connectToServer is - // memoized — the prefetch calls above and the hook converge on the same - // connections). getToolUseContext reads store.getState() fresh via - // computeTools(), so turn 1 sees whatever's connected by query time. - // Slow servers populate for turn 2+. Matches interactive-no-prompt - // behavior. Print mode: per-server push into headlessStore (below). - const hookMessages: Awaited> = []; - // Suppress transient unhandledRejection — the prefetch warms the - // memoized connectToServer cache but nobody awaits it in interactive. - mcpPromise.catch(() => {}); - - const mcpClients: Awaited["clients"] = []; - const mcpTools: Awaited["tools"] = []; - const mcpCommands: Awaited["commands"] = []; - - let thinkingEnabled = shouldEnableThinkingByDefault(); - let thinkingConfig: ThinkingConfig = - thinkingEnabled !== false - ? { type: "adaptive" } - : { type: "disabled" }; - - if ( - options.thinking === "adaptive" || - options.thinking === "enabled" - ) { - thinkingEnabled = true; - thinkingConfig = { type: "adaptive" }; - } else if (options.thinking === "disabled") { - thinkingEnabled = false; - thinkingConfig = { type: "disabled" }; - } else { - const maxThinkingTokens = process.env.MAX_THINKING_TOKENS - ? parseInt(process.env.MAX_THINKING_TOKENS, 10) - : options.maxThinkingTokens; - if (maxThinkingTokens !== undefined) { - if (maxThinkingTokens > 0) { - thinkingEnabled = true; - thinkingConfig = { - type: "enabled", - budgetTokens: maxThinkingTokens, - }; - } else if (maxThinkingTokens === 0) { - thinkingEnabled = false; - thinkingConfig = { type: "disabled" }; - } - } - } - - logForDiagnosticsNoPII("info", "started", { - version: MACRO.VERSION, - is_native_binary: isInBundledMode(), - }); - - registerCleanup(async () => { - logForDiagnosticsNoPII("info", "exited"); - }); - - void logTenguInit({ - hasInitialPrompt: Boolean(prompt), - hasStdin: Boolean(inputPrompt), - verbose, - debug, - debugToStderr, - print: print ?? false, - outputFormat: outputFormat ?? "text", - inputFormat: inputFormat ?? "text", - numAllowedTools: allowedTools.length, - numDisallowedTools: disallowedTools.length, - mcpClientCount: Object.keys(allMcpConfigs).length, - worktreeEnabled, - skipWebFetchPreflight: - getInitialSettings().skipWebFetchPreflight, - githubActionInputs: process.env.GITHUB_ACTION_INPUTS, - dangerouslySkipPermissionsPassed: - dangerouslySkipPermissions ?? false, - permissionMode, - modeIsBypass: permissionMode === "bypassPermissions", - allowDangerouslySkipPermissionsPassed: - allowDangerouslySkipPermissions, - systemPromptFlag: systemPrompt - ? options.systemPromptFile - ? "file" - : "flag" - : undefined, - appendSystemPromptFlag: appendSystemPrompt - ? options.appendSystemPromptFile - ? "file" - : "flag" - : undefined, - thinkingConfig, - assistantActivationPath: - feature("KAIROS") && kairosEnabled - ? assistantModule?.getAssistantActivationPath() - : undefined, - }); - - // Log context metrics once at initialization - void logContextMetrics(regularMcpConfigs, toolPermissionContext); - - void logPermissionContextForAnts(null, "initialization"); - - logManagedSettings(); - - // Register PID file for concurrent-session detection (~/.claude/sessions/) - // and fire multi-clauding telemetry. Lives here (not init.ts) so only the - // REPL path registers — not subcommands like `claude doctor`. Chained: - // count must run after register's write completes or it misses our own file. - void registerSession().then((registered) => { - if (!registered) return; - if (sessionNameArg) { - void updateSessionName(sessionNameArg); - } - void countConcurrentSessions().then((count) => { - if (count >= 2) { - logEvent("tengu_concurrent_sessions", { - num_sessions: count, - }); - } - }); - }); - - // Initialize versioned plugins system (triggers V1→V2 migration if - // needed). Then run orphan GC, THEN warm the Grep/Glob exclusion cache. - // Sequencing matters: the warmup scans disk for .orphaned_at markers, - // so it must see the GC's Pass 1 (remove markers from reinstalled - // versions) and Pass 2 (stamp unmarked orphans) already applied. The - // warm also lands before autoupdate (fires on first submit in REPL) - // can orphan this session's active version underneath us. - // --bare / SIMPLE: skip plugin version sync + orphan cleanup. These - // are install/upgrade bookkeeping that scripted calls don't need — - // the next interactive session will reconcile. The await here was - // blocking -p on a marketplace round-trip. - if (isBareMode()) { - // skip — no-op - } else if (isNonInteractiveSession) { - // In headless mode, await to ensure plugin sync completes before CLI exits - await initializeVersionedPlugins(); - profileCheckpoint("action_after_plugins_init"); - void cleanupOrphanedPluginVersionsInBackground().then(() => - getGlobExclusionsForPluginCache(), - ); - } else { - // In interactive mode, fire-and-forget — this is purely bookkeeping - // that doesn't affect runtime behavior of the current session - void initializeVersionedPlugins().then(async () => { - profileCheckpoint("action_after_plugins_init"); - await cleanupOrphanedPluginVersionsInBackground(); - void getGlobExclusionsForPluginCache(); - }); - } - - const setupTrigger = - initOnly || init ? "init" : maintenance ? "maintenance" : null; - if (initOnly) { - applyConfigEnvironmentVariables(); - await processSetupHooks("init", { forceSyncExecution: true }); - await processSessionStartHooks("startup", { - forceSyncExecution: true, - }); - gracefulShutdownSync(0); - return; - } - - // --print mode - if (isNonInteractiveSession) { - if (outputFormat === "stream-json" || outputFormat === "json") { - setHasFormattedOutput(true); - } - - // Apply full environment variables in print mode since trust dialog is bypassed - // This includes potentially dangerous environment variables from untrusted sources - // but print mode is considered trusted (as documented in help text) - applyConfigEnvironmentVariables(); - - // Initialize telemetry after env vars are applied so OTEL endpoint env vars and - // otelHeadersHelper (which requires trust to execute) are available. - initializeTelemetryAfterTrust(); - - // Kick SessionStart hooks now so the subprocess spawn overlaps with - // MCP connect + plugin init + print.ts import below. loadInitialMessages - // joins this at print.ts:4397. Guarded same as loadInitialMessages — - // continue/resume/teleport paths don't fire startup hooks (or fire them - // conditionally inside the resume branch, where this promise is - // undefined and the ?? fallback runs). Also skip when setupTrigger is - // set — those paths run setup hooks first (print.ts:544), and session - // start hooks must wait until setup completes. - const sessionStartHooksPromise = - options.continue || - options.resume || - teleport || - setupTrigger - ? undefined - : processSessionStartHooks("startup"); - // Suppress transient unhandledRejection if this rejects before - // loadInitialMessages awaits it. Downstream await still observes the - // rejection — this just prevents the spurious global handler fire. - sessionStartHooksPromise?.catch(() => {}); - - profileCheckpoint("before_validateForceLoginOrg"); - // Validate org restriction for non-interactive sessions - const orgValidation = await validateForceLoginOrg(); - if (!orgValidation.valid) { - process.stderr.write((orgValidation as { valid: false; message: string }).message + "\n"); - process.exit(1); - } - - // Headless mode supports all prompt commands and some local commands - // If disableSlashCommands is true, return empty array - const commandsHeadless = disableSlashCommands - ? [] - : commands.filter( - (command) => - (command.type === "prompt" && - !command.disableNonInteractive) || - (command.type === "local" && - command.supportsNonInteractive), - ); - - const defaultState = getDefaultAppState(); - const headlessInitialState: AppState = { - ...defaultState, - mcp: { - ...defaultState.mcp, - clients: mcpClients, - commands: mcpCommands, - tools: mcpTools, - }, - // In headless (-p) mode the TUI is never mounted, so - // AskUserQuestionPermissionRequest's useEffect/setTimeout never fires. - // Mark shouldAvoidPermissionPrompts so permissions.ts step 1e-headless - // picks up askUserQuestionTimeoutSeconds from settings and applies the - // configured auto-select timeout instead of hanging indefinitely. - toolPermissionContext: { - ...toolPermissionContext, - shouldAvoidPermissionPrompts: true, - }, - effortValue: - parseEffortValue(options.effort) ?? - getInitialEffortSetting(), - ...(isFastModeEnabled() && { - fastMode: getInitialFastModeSetting( - effectiveModel ?? null, - ), - }), - ...(isAdvisorEnabled() && advisorModel && { advisorModel }), - // kairosEnabled gates the async fire-and-forget path in - // executeForkedSlashCommand (processSlashCommand.tsx:132) and - // AgentTool's shouldRunAsync. The REPL initialState sets this at - // ~3459; headless was defaulting to false, so the daemon child's - // scheduled tasks and Agent-tool calls ran synchronously — N - // overdue cron tasks on spawn = N serial subagent turns blocking - // user input. Computed at :1620, well before this branch. - ...(feature("KAIROS") ? { kairosEnabled } : {}), - }; - - // Init app state - const headlessStore = createStore( - headlessInitialState, - onChangeAppState, - ); - - // Async check of auto mode gate — corrects state and disables auto if needed. - if (feature("TRANSCRIPT_CLASSIFIER")) { - void verifyAutoModeGateAccess( - toolPermissionContext, - headlessStore.getState().fastMode, - ).then(({ updateContext }) => { - headlessStore.setState((prev) => { - const nextCtx = updateContext( - prev.toolPermissionContext, - ); - if (nextCtx === prev.toolPermissionContext) - return prev; - return { ...prev, toolPermissionContext: nextCtx }; - }); - }); - } - - // Set global state for session persistence - if (options.sessionPersistence === false) { - setSessionPersistenceDisabled(true); - } - - // Store SDK betas in global state for context window calculation - // Only store allowed betas (filters by allowlist and subscriber status) - setSdkBetas(filterAllowedSdkBetas(betas)); - - // Print-mode MCP: per-server incremental push into headlessStore. - // Mirrors useManageMCPConnections — push pending first (so ToolSearch's - // pending-check at ToolSearchTool.ts:334 sees them), then replace with - // connected/failed as each server settles. - const connectMcpBatch = ( - configs: Record, - label: string, - ): Promise => { - if (Object.keys(configs).length === 0) - return Promise.resolve(); - headlessStore.setState((prev) => ({ - ...prev, - mcp: { - ...prev.mcp, - clients: [ - ...prev.mcp.clients, - ...Object.entries(configs).map( - ([name, config]) => ({ - name, - type: "pending" as const, - config, - }), - ), - ], - }, - })); - return getMcpToolsCommandsAndResources( - ({ client, tools, commands }) => { - headlessStore.setState((prev) => ({ - ...prev, - mcp: { - ...prev.mcp, - clients: prev.mcp.clients.some( - (c) => c.name === client.name, - ) - ? prev.mcp.clients.map((c) => - c.name === client.name - ? client - : c, - ) - : [...prev.mcp.clients, client], - tools: uniqBy( - [...prev.mcp.tools, ...tools], - "name", - ), - commands: uniqBy( - [...prev.mcp.commands, ...commands], - "name", - ), - }, - })); - }, - configs, - ).catch((err) => - logForDebugging(`[MCP] ${label} connect error: ${err}`), - ); - }; - // Await all MCP configs — print mode is often single-turn, so - // "late-connecting servers visible next turn" doesn't help. SDK init - // message and turn-1 tool list both need configured MCP tools present. - // Zero-server case is free via the early return in connectMcpBatch. - // Connectors parallelize inside getMcpToolsCommandsAndResources - // (processBatched with Promise.all). claude.ai is awaited too — its - // fetch was kicked off early (line ~2558) so only residual time blocks - // here. --bare skips claude.ai entirely for perf-sensitive scripts. - profileCheckpoint("before_connectMcp"); - await connectMcpBatch(regularMcpConfigs, "regular"); - profileCheckpoint("after_connectMcp"); - // Dedup: suppress plugin MCP servers that duplicate a claude.ai - // connector (connector wins), then connect claude.ai servers. - // Bounded wait — #23725 made this blocking so single-turn -p sees - // connectors, but with 40+ slow connectors tengu_startup_perf p99 - // climbed to 76s. If fetch+connect doesn't finish in time, proceed; - // the promise keeps running and updates headlessStore in the - // background so turn 2+ still sees connectors. - const CLAUDE_AI_MCP_TIMEOUT_MS = 5_000; - const claudeaiConnect = claudeaiConfigPromise.then( - (claudeaiConfigs) => { - if (Object.keys(claudeaiConfigs).length > 0) { - const claudeaiSigs = new Set(); - for (const config of Object.values( - claudeaiConfigs, - )) { - const sig = getMcpServerSignature(config); - if (sig) claudeaiSigs.add(sig); - } - const suppressed = new Set(); - for (const [name, config] of Object.entries( - regularMcpConfigs, - )) { - if (!name.startsWith("plugin:")) continue; - const sig = getMcpServerSignature(config); - if (sig && claudeaiSigs.has(sig)) - suppressed.add(name); - } - if (suppressed.size > 0) { - logForDebugging( - `[MCP] Lazy dedup: suppressing ${suppressed.size} plugin server(s) that duplicate claude.ai connectors: ${[...suppressed].join(", ")}`, - ); - // Disconnect before filtering from state. Only connected - // servers need cleanup — clearServerCache on a never-connected - // server triggers a real connect just to kill it (memoize - // cache-miss path, see useManageMCPConnections.ts:870). - for (const c of headlessStore.getState().mcp - .clients) { - if ( - !suppressed.has(c.name) || - c.type !== "connected" - ) - continue; - c.client.onclose = undefined; - void clearServerCache( - c.name, - c.config, - ).catch(() => {}); - } - headlessStore.setState((prev) => { - let { - clients, - tools, - commands, - resources, - } = prev.mcp; - clients = clients.filter( - (c) => !suppressed.has(c.name), - ); - tools = tools.filter( - (t) => - !t.mcpInfo || - !suppressed.has( - t.mcpInfo.serverName, - ), - ); - for (const name of suppressed) { - commands = excludeCommandsByServer( - commands, - name, - ); - resources = excludeResourcesByServer( - resources, - name, - ); - } - return { - ...prev, - mcp: { - ...prev.mcp, - clients, - tools, - commands, - resources, - }, - }; - }); - } - } - // Suppress claude.ai connectors that duplicate an enabled - // manual server (URL-signature match). Plugin dedup above only - // handles `plugin:*` keys; this catches manual `.mcp.json` entries. - // plugin:* must be excluded here — step 1 already suppressed - // those (claude.ai wins); leaving them in suppresses the - // connector too, and neither survives (gh-39974). - const nonPluginConfigs = pickBy( - regularMcpConfigs, - (_, n) => !n.startsWith("plugin:"), - ); - const { servers: dedupedClaudeAi } = - dedupClaudeAiMcpServers( - claudeaiConfigs, - nonPluginConfigs, - ); - return connectMcpBatch(dedupedClaudeAi, "claudeai"); - }, - ); - let claudeaiTimer: ReturnType | undefined; - const claudeaiTimedOut = await Promise.race([ - claudeaiConnect.then(() => false), - new Promise((resolve) => { - claudeaiTimer = setTimeout( - (r) => r(true), - CLAUDE_AI_MCP_TIMEOUT_MS, - resolve, - ); - }), - ]); - if (claudeaiTimer) clearTimeout(claudeaiTimer); - if (claudeaiTimedOut) { - logForDebugging( - `[MCP] claude.ai connectors not ready after ${CLAUDE_AI_MCP_TIMEOUT_MS}ms — proceeding; background connection continues`, - ); - } - profileCheckpoint("after_connectMcp_claudeai"); - - // In headless mode, start deferred prefetches immediately (no user typing delay) - // --bare / SIMPLE: startDeferredPrefetches early-returns internally. - // backgroundHousekeeping (initExtractMemories, pruneShellSnapshots, - // cleanupOldMessageFiles) and sdkHeapDumpMonitor are all bookkeeping - // that scripted calls don't need — the next interactive session reconciles. - if (!isBareMode()) { - startDeferredPrefetches(); - void import("./utils/backgroundHousekeeping.js").then((m) => - m.startBackgroundHousekeeping(), - ); - if (process.env.USER_TYPE === "ant") { - void import("./utils/sdkHeapDumpMonitor.js").then((m) => - m.startSdkMemoryMonitor(), - ); - } - } - - logSessionTelemetry(); - profileCheckpoint("before_print_import"); - const { runHeadless } = await import("src/cli/print.js"); - profileCheckpoint("after_print_import"); - void runHeadless( - inputPrompt, - () => headlessStore.getState(), - headlessStore.setState, - commandsHeadless, - tools, - sdkMcpConfigs, - agentDefinitions.activeAgents, - { - continue: options.continue, - resume: options.resume, - verbose: verbose, - outputFormat: outputFormat, - jsonSchema, - permissionPromptToolName: options.permissionPromptTool, - allowedTools, - thinkingConfig, - maxTurns: options.maxTurns, - maxBudgetUsd: options.maxBudgetUsd, - taskBudget: options.taskBudget - ? { total: options.taskBudget } - : undefined, - systemPrompt, - appendSystemPrompt, - userSpecifiedModel: effectiveModel, - fallbackModel: userSpecifiedFallbackModel, - teleport, - sdkUrl, - replayUserMessages: effectiveReplayUserMessages, - includePartialMessages: effectiveIncludePartialMessages, - forkSession: options.forkSession || false, - resumeSessionAt: options.resumeSessionAt || undefined, - rewindFiles: options.rewindFiles, - enableAuthStatus: options.enableAuthStatus, - agent: agentCli, - workload: options.workload, - setupTrigger: setupTrigger ?? undefined, - sessionStartHooksPromise, - }, - ); - return; - } - - // Log model config at startup - logEvent("tengu_startup_manual_model_config", { - cli_flag: - options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - env_var: process.env - .ANTHROPIC_MODEL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - settings_file: (getInitialSettings() || {}) - .model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - subscriptionType: - getSubscriptionType() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - agent: agentSetting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - - // Get deprecation warning for the initial model (resolvedInitialModel computed earlier for hooks parallelization) - const deprecationWarning = - getModelDeprecationWarning(resolvedInitialModel); - - // Build initial notification queue - const initialNotifications: Array<{ - key: string; - text: string; - color?: "warning"; - priority: "high"; - }> = []; - if (permissionModeNotification) { - initialNotifications.push({ - key: "permission-mode-notification", - text: permissionModeNotification, - priority: "high", - }); - } - if (deprecationWarning) { - initialNotifications.push({ - key: "model-deprecation-warning", - text: deprecationWarning, - color: "warning", - priority: "high", - }); - } - if (overlyBroadBashPermissions.length > 0) { - const displayList = uniq( - overlyBroadBashPermissions.map((p) => p.ruleDisplay), - ); - const displays = displayList.join(", "); - const sources = uniq( - overlyBroadBashPermissions.map((p) => p.sourceDisplay), - ).join(", "); - const n = displayList.length; - initialNotifications.push({ - key: "overly-broad-bash-notification", - text: `${displays} allow ${plural(n, "rule")} from ${sources} ${plural(n, "was", "were")} ignored \u2014 not available for Ants, please use auto-mode instead`, - color: "warning", - priority: "high", - }); - } - - const teammateUtils = getTeammateUtils(); - const effectiveToolPermissionContext = { - ...toolPermissionContext, - mode: - isAgentSwarmsEnabled() && - teammateUtils?.isPlanModeRequired?.() - ? ("plan" as const) - : toolPermissionContext.mode, - }; - // All startup opt-in paths (--tools, --brief, defaultView) have fired - // above; initialIsBriefOnly just reads the resulting state. - const initialIsBriefOnly = - feature("KAIROS") || feature("KAIROS_BRIEF") - ? getUserMsgOptIn() - : false; - const fullRemoteControl = - remoteControl || getRemoteControlAtStartup() || kairosEnabled; - let ccrMirrorEnabled = false; - if (feature("CCR_MIRROR") && !fullRemoteControl) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { isCcrMirrorEnabled } = - require("./bridge/bridgeEnabled.js") as typeof import("./bridge/bridgeEnabled.js"); - /* eslint-enable @typescript-eslint/no-require-imports */ - ccrMirrorEnabled = isCcrMirrorEnabled(); - } - - const initialState: AppState = { - settings: getInitialSettings(), - tasks: {}, - agentNameRegistry: new Map(), - verbose: verbose ?? getGlobalConfig().verbose ?? false, - mainLoopModel: initialMainLoopModel, - mainLoopModelForSession: null, - isBriefOnly: initialIsBriefOnly, - expandedView: getGlobalConfig().showSpinnerTree - ? "teammates" - : getGlobalConfig().showExpandedTodos - ? "tasks" - : "none", - showTeammateMessagePreview: isAgentSwarmsEnabled() - ? false - : undefined, - selectedIPAgentIndex: -1, - coordinatorTaskIndex: -1, - viewSelectionMode: "none", - footerSelection: null, - toolPermissionContext: effectiveToolPermissionContext, - agent: mainThreadAgentDefinition?.agentType, - agentDefinitions, - mcp: { - clients: [], - tools: [], - commands: [], - resources: {}, - pluginReconnectKey: 0, - }, - plugins: { - enabled: [], - disabled: [], - commands: [], - errors: [], - installationStatus: { - marketplaces: [], - plugins: [], - }, - needsRefresh: false, - }, - statusLineText: undefined, - kairosEnabled, - remoteSessionUrl: undefined, - remoteConnectionStatus: "connecting", - remoteBackgroundTaskCount: 0, - replBridgeEnabled: fullRemoteControl || ccrMirrorEnabled, - replBridgeExplicit: remoteControl, - replBridgeOutboundOnly: ccrMirrorEnabled, - replBridgeConnected: false, - replBridgeSessionActive: false, - replBridgeReconnecting: false, - replBridgeConnectUrl: undefined, - replBridgeSessionUrl: undefined, - replBridgeEnvironmentId: undefined, - replBridgeSessionId: undefined, - replBridgeError: undefined, - replBridgeInitialName: remoteControlName, - showRemoteCallout: false, - notifications: { - current: null, - queue: initialNotifications, - }, - elicitation: { - queue: [], - }, - todos: {}, - remoteAgentTaskSuggestions: [], - fileHistory: { - snapshots: [], - trackedFiles: new Set(), - snapshotSequence: 0, - }, - attribution: createEmptyAttributionState(), - thinkingEnabled, - promptSuggestionEnabled: shouldEnablePromptSuggestion(), - sessionHooks: new Map(), - inbox: { - messages: [], - }, - promptSuggestion: { - text: null, - promptId: null, - shownAt: 0, - acceptedAt: 0, - generationRequestId: null, - }, - speculation: IDLE_SPECULATION_STATE, - speculationSessionTimeSavedMs: 0, - skillImprovement: { - suggestion: null, - }, - workerSandboxPermissions: { - queue: [], - selectedIndex: 0, - }, - pendingWorkerRequest: null, - pendingSandboxRequest: null, - authVersion: 0, - initialMessage: inputPrompt - ? { - message: createUserMessage({ - content: String(inputPrompt), - }), - } - : null, - effortValue: - parseEffortValue(options.effort) ?? - getInitialEffortSetting(), - activeOverlays: new Set(), - fastMode: getInitialFastModeSetting(resolvedInitialModel), - ...(isAdvisorEnabled() && advisorModel && { advisorModel }), - // Compute teamContext synchronously to avoid useEffect setState during render. - // KAIROS: assistantTeamContext takes precedence — set earlier in the - // KAIROS block so Agent(name: "foo") can spawn in-process teammates - // without TeamCreate. computeInitialTeamContext() is for tmux-spawned - // teammates reading their own identity, not the assistant-mode leader. - teamContext: (feature("KAIROS") - ? (assistantTeamContext ?? computeInitialTeamContext()) - : computeInitialTeamContext()) as AppState["teamContext"], - }; - - // Add CLI initial prompt to history - if (inputPrompt) { - addToHistory(String(inputPrompt)); - } - - const initialTools = mcpTools; - - // Increment numStartups synchronously — first-render readers like - // shouldShowEffortCallout (via useState initializer) need the updated - // value before setImmediate fires. Defer only telemetry. - saveGlobalConfig((current) => ({ - ...current, - numStartups: (current.numStartups ?? 0) + 1, - })); - setImmediate(() => { - void logStartupTelemetry(); - logSessionTelemetry(); - }); - - // Set up per-turn session environment data uploader (ant-only build). - // Default-enabled for all ant users when working in an Anthropic-owned - // repo. Captures git/filesystem state (NOT transcripts) at each turn so - // environments can be recreated at any user message index. Gating: - // - Build-time: this import is stubbed in external builds. - // - Runtime: uploader checks github.com/anthropics/* remote + gcloud auth. - // - Safety: CLAUDE_CODE_DISABLE_SESSION_DATA_UPLOAD=1 bypasses (tests set this). - // Import is dynamic + async to avoid adding startup latency. - const sessionUploaderPromise = - process.env.USER_TYPE === "ant" - ? import("./utils/sessionDataUploader.js") - : null; - - // Defer session uploader resolution to the onTurnComplete callback to avoid - // adding a new top-level await in main.tsx (performance-critical path). - // The per-turn auth logic in sessionDataUploader.ts handles unauthenticated - // state gracefully (re-checks each turn, so auth recovery mid-session works). - const uploaderReady = sessionUploaderPromise - ? sessionUploaderPromise - .then((mod) => mod.createSessionTurnUploader()) - .catch(() => null) - : null; - - const sessionConfig = { - debug: debug || debugToStderr, - commands: [...commands, ...mcpCommands], - initialTools, - mcpClients, - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - dynamicMcpConfig, - strictMcpConfig, - systemPrompt, - appendSystemPrompt, - taskListId, - thinkingConfig, - ...(uploaderReady && { - onTurnComplete: (messages: MessageType[]) => { - void uploaderReady.then((uploader) => - (uploader as ((msgs: MessageType[]) => void) | null)?.(messages), - ); - }, - }), - }; - - // Shared context for processResumedConversation calls - const resumeContext = { - modeApi: coordinatorModeModule, - mainThreadAgentDefinition, - agentDefinitions, - currentCwd, - cliAgents, - initialState, - }; - - if (options.continue) { - // Continue the most recent conversation directly - let resumeSucceeded = false; - try { - const resumeStart = performance.now(); - - // Clear stale caches before resuming to ensure fresh file/skill discovery - const { clearSessionCaches } = - await import("./commands/clear/caches.js"); - clearSessionCaches(); - - const result = await loadConversationForResume( - undefined /* sessionId */, - undefined /* sourceFile */, - ); - if (!result) { - logEvent("tengu_continue", { - success: false, - }); - return await exitWithError( - root, - "No conversation found to continue", - ); - } - - const loaded = await processResumedConversation( - result, - { - forkSession: !!options.forkSession, - includeAttribution: true, - transcriptPath: result.fullPath, - }, - resumeContext, - ); - - if (loaded.restoredAgentDef) { - mainThreadAgentDefinition = loaded.restoredAgentDef; - } - - maybeActivateProactive(options); - maybeActivateBrief(options); - - logEvent("tengu_continue", { - success: true, - resume_duration_ms: Math.round( - performance.now() - resumeStart, - ), - }); - resumeSucceeded = true; - - await launchRepl( - root, - { - getFpsMetrics, - stats, - initialState: loaded.initialState, - }, - { - ...sessionConfig, - mainThreadAgentDefinition: - loaded.restoredAgentDef ?? - mainThreadAgentDefinition, - initialMessages: loaded.messages, - initialFileHistorySnapshots: - loaded.fileHistorySnapshots, - initialContentReplacements: - loaded.contentReplacements, - initialAgentName: loaded.agentName, - initialAgentColor: loaded.agentColor, - }, - renderAndRun, - ); - } catch (error) { - if (!resumeSucceeded) { - logEvent("tengu_continue", { - success: false, - }); - } - logError(error); - process.exit(1); - } - } else if (feature("DIRECT_CONNECT") && _pendingConnect?.url) { - // `claude connect ` — full interactive TUI connected to a remote server - let directConnectConfig; - try { - const session = await createDirectConnectSession({ - serverUrl: _pendingConnect.url, - authToken: _pendingConnect.authToken, - cwd: getOriginalCwd(), - dangerouslySkipPermissions: - _pendingConnect.dangerouslySkipPermissions, - }); - if (session.workDir) { - setOriginalCwd(session.workDir); - setCwdState(session.workDir); - } - setDirectConnectServerUrl(_pendingConnect.url); - directConnectConfig = session.config; - } catch (err) { - return await exitWithError( - root, - err instanceof DirectConnectError - ? err.message - : String(err), - () => gracefulShutdown(1), - ); - } - - const connectInfoMessage = createSystemMessage( - `Connected to server at ${_pendingConnect.url}\nSession: ${directConnectConfig.sessionId}`, - "info", - ); - - await launchRepl( - root, - { getFpsMetrics, stats, initialState }, - { - debug: debug || debugToStderr, - commands, - initialTools: [], - initialMessages: [connectInfoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - directConnectConfig, - thinkingConfig, - }, - renderAndRun, - ); - return; - } else if (feature("SSH_REMOTE") && _pendingSSH?.host) { - // `claude ssh [dir]` — probe remote, deploy binary if needed, - // spawn ssh with unix-socket -R forward to a local auth proxy, hand - // the REPL an SSHSession. Tools run remotely, UI renders locally. - // `--local` skips probe/deploy/ssh and spawns the current binary - // directly with the same env — e2e test of the proxy/auth plumbing. - const { - createSSHSession, - createLocalSSHSession, - SSHSessionError, - } = await import("./ssh/createSSHSession.js"); - let sshSession: import('./ssh/createSSHSession.js').SSHSession | undefined; - try { - if (_pendingSSH.local) { - process.stderr.write( - "Starting local ssh-proxy test session...\n", - ); - sshSession = await createLocalSSHSession({ - cwd: _pendingSSH.cwd, - permissionMode: _pendingSSH.permissionMode, - dangerouslySkipPermissions: - _pendingSSH.dangerouslySkipPermissions, - }); - } else { - process.stderr.write( - `Connecting to ${_pendingSSH.host}…\n`, - ); - // In-place progress: \r + EL0 (erase to end of line). Final \n on - // success so the next message lands on a fresh line. No-op when - // stderr isn't a TTY (piped/redirected) — \r would just emit noise. - const isTTY = process.stderr.isTTY; - let hadProgress = false; - sshSession = await createSSHSession( - { - host: _pendingSSH.host, - cwd: _pendingSSH.cwd, - localVersion: MACRO.VERSION, - permissionMode: _pendingSSH.permissionMode, - dangerouslySkipPermissions: - _pendingSSH.dangerouslySkipPermissions, - extraCliArgs: _pendingSSH.extraCliArgs, - remoteBin: _pendingSSH.remoteBin, - }, - isTTY - ? { - onProgress: (msg: string) => { - hadProgress = true; - process.stderr.write( - `\r ${msg}\x1b[K`, - ); - }, - } - : {}, - ); - if (hadProgress) process.stderr.write("\n"); - } - setOriginalCwd(sshSession.remoteCwd); - setCwdState(sshSession.remoteCwd); - setDirectConnectServerUrl( - _pendingSSH.local ? "local" : _pendingSSH.host, - ); - } catch (err) { - return await exitWithError( - root, - err instanceof SSHSessionError - ? err.message - : String(err), - () => gracefulShutdown(1), - ); - } - - const sshInfoMessage = createSystemMessage( - _pendingSSH.local - ? `Local ssh-proxy test session\ncwd: ${sshSession.remoteCwd}\nAuth: unix socket → local proxy` - : `SSH session to ${_pendingSSH.host}\nRemote cwd: ${sshSession.remoteCwd}\nAuth: unix socket -R → local proxy`, - "info", - ); - - await launchRepl( - root, - { getFpsMetrics, stats, initialState }, - { - debug: debug || debugToStderr, - commands, - initialTools: [], - initialMessages: [sshInfoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - sshSession, - thinkingConfig, - }, - renderAndRun, - ); - return; - } else if ( - feature("KAIROS") && - _pendingAssistantChat && - (_pendingAssistantChat.sessionId || - _pendingAssistantChat.discover) - ) { - // `claude assistant [sessionId]` — REPL as a pure viewer client - // of a remote assistant session. The agentic loop runs remotely; this - // process streams live events and POSTs messages. History is lazy- - // loaded by useAssistantHistory on scroll-up (no blocking fetch here). - const { discoverAssistantSessions } = - await import("./assistant/sessionDiscovery.js"); - - let targetSessionId = _pendingAssistantChat.sessionId; - - // Discovery flow — list bridge environments, filter sessions - if (!targetSessionId) { - let sessions; - try { - sessions = await discoverAssistantSessions(); - } catch (e) { - return await exitWithError( - root, - `Failed to discover sessions: ${e instanceof Error ? e.message : e}`, - () => gracefulShutdown(1), - ); - } - if (sessions.length === 0) { - let installedDir: string | null; - try { - installedDir = - await launchAssistantInstallWizard(root); - } catch (e) { - return await exitWithError( - root, - `Assistant installation failed: ${e instanceof Error ? e.message : e}`, - () => gracefulShutdown(1), - ); - } - if (installedDir === null) { - await gracefulShutdown(0); - process.exit(0); - } - // The daemon needs a few seconds to spin up its worker and - // establish a bridge session before discovery will find it. - return await exitWithMessage( - root, - `Assistant installed in ${installedDir}. The daemon is starting up — run \`claude assistant\` again in a few seconds to connect.`, - { - exitCode: 0, - beforeExit: () => gracefulShutdown(0), - }, - ); - } - if (sessions.length === 1) { - targetSessionId = sessions[0]!.id; - } else { - const picked = await launchAssistantSessionChooser( - root, - { - sessions, - }, - ); - if (!picked) { - await gracefulShutdown(0); - process.exit(0); - } - targetSessionId = picked; - } - } - - // Auth — call prepareApiRequest() once for orgUUID, but use a - // getAccessToken closure for the token so reconnects get fresh tokens. - const { - checkAndRefreshOAuthTokenIfNeeded, - getClaudeAIOAuthTokens, - } = await import("./utils/auth.js"); - await checkAndRefreshOAuthTokenIfNeeded(); - let apiCreds; - try { - apiCreds = await prepareApiRequest(); - } catch (e) { - return await exitWithError( - root, - `Error: ${e instanceof Error ? e.message : "Failed to authenticate"}`, - () => gracefulShutdown(1), - ); - } - const getAccessToken = (): string => - getClaudeAIOAuthTokens()?.accessToken ?? - apiCreds.accessToken; - - // Brief mode activation: setKairosActive(true) satisfies BOTH opt-in - // and entitlement for isBriefEnabled() (BriefTool.ts:124-132). - setKairosActive(true); - setUserMsgOptIn(true); - setIsRemoteMode(true); - - const remoteSessionConfig = createRemoteSessionConfig( - targetSessionId, - getAccessToken, - apiCreds.orgUUID, - /* hasInitialPrompt */ false, - /* viewerOnly */ true, - ); - - const infoMessage = createSystemMessage( - `Attached to assistant session ${targetSessionId.slice(0, 8)}…`, - "info", - ); - - const assistantInitialState: AppState = { - ...initialState, - isBriefOnly: true, - kairosEnabled: false, - replBridgeEnabled: false, - }; - - const remoteCommands = filterCommandsForRemoteMode(commands); - await launchRepl( - root, - { - getFpsMetrics, - stats, - initialState: assistantInitialState, - }, - { - debug: debug || debugToStderr, - commands: remoteCommands, - initialTools: [], - initialMessages: [infoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - remoteSessionConfig, - thinkingConfig, - }, - renderAndRun, - ); - return; - } else if ( - options.resume || - options.fromPr || - teleport || - remote !== null - ) { - // Handle resume flow - from file (ant-only), session ID, or interactive selector - - // Clear stale caches before resuming to ensure fresh file/skill discovery - const { clearSessionCaches } = - await import("./commands/clear/caches.js"); - clearSessionCaches(); - - let messages: MessageType[] | null = null; - let processedResume: ProcessedResume | undefined; - - let maybeSessionId = validateUuid(options.resume); - let searchTerm: string | undefined; - // Store full LogOption when found by custom title (for cross-worktree resume) - let matchedLog: LogOption | null = null; - // PR filter for --from-pr flag - let filterByPr: boolean | number | string | undefined; - - // Handle --from-pr flag - if (options.fromPr) { - if (options.fromPr === true) { - // Show all sessions with linked PRs - filterByPr = true; - } else if (typeof options.fromPr === "string") { - // Could be a PR number or URL - filterByPr = options.fromPr; - } - } - - // If resume value is not a UUID, try exact match by custom title first - if ( - options.resume && - typeof options.resume === "string" && - !maybeSessionId - ) { - const trimmedValue = options.resume.trim(); - if (trimmedValue) { - const matches = await searchSessionsByCustomTitle( - trimmedValue, - { - exact: true, - }, - ); - - if (matches.length === 1) { - // Exact match found - store full LogOption for cross-worktree resume - matchedLog = matches[0]!; - maybeSessionId = - getSessionIdFromLog(matchedLog) ?? null; - } else { - // No match or multiple matches - use as search term for picker - searchTerm = trimmedValue; - } - } - } - - // --remote and --teleport both create/resume CoStrict remote sessions. - // Remote Control (--rc) is a separate feature gated in initReplBridge.ts. - if (remote !== null || teleport) { - await waitForPolicyLimitsToLoad(); - if (!isPolicyAllowed("allow_remote_sessions")) { - return await exitWithError( - root, - "Error: Remote sessions are disabled by your organization's policy.", - () => gracefulShutdown(1), - ); - } - } - - if (remote !== null) { - // Create remote session (optionally with initial prompt) - const hasInitialPrompt = remote.length > 0; - - // Check if TUI mode is enabled - description is only optional in TUI mode - const isRemoteTuiEnabled = - getFeatureValue_CACHED_MAY_BE_STALE( - "tengu_remote_backend", - false, - ); - if (!isRemoteTuiEnabled && !hasInitialPrompt) { - return await exitWithError( - root, - 'Error: --remote requires a description.\nUsage: csc --remote "your task description"', - () => gracefulShutdown(1), - ); - } - - logEvent("tengu_remote_create_session", { - has_initial_prompt: String( - hasInitialPrompt, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - - // Pass current branch so CCR clones the repo at the right revision - const currentBranch = await getBranch(); - const createdSession = - await teleportToRemoteWithErrorHandling( - root, - hasInitialPrompt ? remote : null, - new AbortController().signal, - currentBranch || undefined, - ); - if (!createdSession) { - logEvent("tengu_remote_create_session_error", { - error: "unable_to_create_session" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - return await exitWithError( - root, - "Error: Unable to create remote session", - () => gracefulShutdown(1), - ); - } - logEvent("tengu_remote_create_session_success", { - session_id: - createdSession.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - - // Check if new remote TUI mode is enabled via feature gate - if (!isRemoteTuiEnabled) { - // Original behavior: print session info and exit - process.stdout.write( - `Created remote session: ${createdSession.title}\n`, - ); - process.stdout.write( - `View: ${getRemoteSessionUrl(createdSession.id)}?m=0\n`, - ); - process.stdout.write( - `Resume with: claude --teleport ${createdSession.id}\n`, - ); - await gracefulShutdown(0); - process.exit(0); - } - - // New behavior: start local TUI with CCR engine - // Mark that we're in remote mode for command visibility - setIsRemoteMode(true); - switchSession(asSessionId(createdSession.id)); - - // Get OAuth credentials for remote session - let apiCreds: { accessToken: string; orgUUID: string }; - try { - apiCreds = await prepareApiRequest(); - } catch (error) { - logError(toError(error)); - return await exitWithError( - root, - `Error: ${errorMessage(error) || "Failed to authenticate"}`, - () => gracefulShutdown(1), - ); - } - - // Create remote session config for the REPL - const { getClaudeAIOAuthTokens: getTokensForRemote } = - await import("./utils/auth.js"); - const getAccessTokenForRemote = (): string => - getTokensForRemote()?.accessToken ?? - apiCreds.accessToken; - const remoteSessionConfig = createRemoteSessionConfig( - createdSession.id, - getAccessTokenForRemote, - apiCreds.orgUUID, - hasInitialPrompt, - ); - - // Add remote session info as initial system message - const remoteSessionUrl = `${getRemoteSessionUrl(createdSession.id)}?m=0`; - const remoteInfoMessage = createSystemMessage( - `/remote-control is active. Code in CLI or at ${remoteSessionUrl}`, - "info", - ); - - // Create initial user message from the prompt if provided (CCR echoes it back but we ignore that) - const initialUserMessage = hasInitialPrompt - ? createUserMessage({ content: remote }) - : null; - - // Set remote session URL in app state for footer indicator - const remoteInitialState = { - ...initialState, - remoteSessionUrl, - }; - - // Pre-filter commands to only include remote-safe ones. - // CCR's init response may further refine the list (via handleRemoteInit in REPL). - const remoteCommands = - filterCommandsForRemoteMode(commands); - await launchRepl( - root, - { - getFpsMetrics, - stats, - initialState: remoteInitialState, - }, - { - debug: debug || debugToStderr, - commands: remoteCommands, - initialTools: [], - initialMessages: initialUserMessage - ? [remoteInfoMessage, initialUserMessage] - : [remoteInfoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - remoteSessionConfig, - thinkingConfig, - }, - renderAndRun, - ); - return; - } else if (teleport) { - if (teleport === true || teleport === "") { - // Interactive mode: show task selector and handle resume - logEvent("tengu_teleport_interactive_mode", {}); - logForDebugging( - "selectAndResumeTeleportTask: Starting teleport flow...", - ); - const teleportResult = - await launchTeleportResumeWrapper(root); - if (!teleportResult) { - // User cancelled or error occurred - await gracefulShutdown(0); - process.exit(0); - } - const { branchError } = - await checkOutTeleportedSessionBranch( - teleportResult.branch, - ); - messages = processMessagesForTeleportResume( - teleportResult.log, - branchError, - ); - } else if (typeof teleport === "string") { - logEvent("tengu_teleport_resume_session", { - mode: "direct" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - try { - // First, fetch session and validate repository before checking git state - const sessionData = await fetchSession(teleport); - const repoValidation = - await validateSessionRepository(sessionData); - - // Handle repo mismatch or not in repo cases - if ( - repoValidation.status === "mismatch" || - repoValidation.status === "not_in_repo" - ) { - const sessionRepo = repoValidation.sessionRepo; - if (sessionRepo) { - // Check for known paths - const knownPaths = - getKnownPathsForRepo(sessionRepo); - const existingPaths = - await filterExistingPaths(knownPaths); - - if (existingPaths.length > 0) { - // Show directory switch dialog - const selectedPath = - await launchTeleportRepoMismatchDialog( - root, - { - targetRepo: sessionRepo, - initialPaths: existingPaths, - }, - ); - - if (selectedPath) { - // Change to the selected directory - process.chdir(selectedPath); - setCwd(selectedPath); - setOriginalCwd(selectedPath); - } else { - // User cancelled - await gracefulShutdown(0); - } - } else { - // No known paths - show original error - throw new TeleportOperationError( - `You must run claude --teleport ${teleport} from a checkout of ${sessionRepo}.`, - chalk.red( - `You must run claude --teleport ${teleport} from a checkout of ${chalk.bold(sessionRepo)}.\n`, - ), - ); - } - } - } else if (repoValidation.status === "error") { - throw new TeleportOperationError( - repoValidation.errorMessage || - "Failed to validate session", - chalk.red( - `Error: ${repoValidation.errorMessage || "Failed to validate session"}\n`, - ), - ); - } - - await validateGitState(); - - // Use progress UI for teleport - const { teleportWithProgress } = - await import("./components/TeleportProgress.js"); - const result = await teleportWithProgress( - root, - teleport, - ); - // Track teleported session for reliability logging - setTeleportedSessionInfo({ sessionId: teleport }); - messages = result.messages; - } catch (error) { - if (error instanceof TeleportOperationError) { - process.stderr.write( - error.formattedMessage + "\n", - ); - } else { - logError(error); - process.stderr.write( - chalk.red( - `Error: ${errorMessage(error)}\n`, - ), - ); - } - await gracefulShutdown(1); - } - } - } - if (process.env.USER_TYPE === "ant") { - if ( - options.resume && - typeof options.resume === "string" && - !maybeSessionId - ) { - // Check for ccshare URL (e.g. https://go/ccshare/boris-20260311-211036) - const { parseCcshareId, loadCcshare } = - await import("./utils/ccshareResume.js"); - const ccshareId = parseCcshareId(options.resume); - if (ccshareId) { - try { - const resumeStart = performance.now(); - const logOption = await loadCcshare(ccshareId); - const result = await loadConversationForResume( - logOption, - undefined, - ); - if (result) { - processedResume = - await processResumedConversation( - result, - { - forkSession: true, - transcriptPath: result.fullPath, - }, - resumeContext, - ); - if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = - processedResume.restoredAgentDef; - } - logEvent("tengu_session_resumed", { - entrypoint: - "ccshare" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: true, - resume_duration_ms: Math.round( - performance.now() - resumeStart, - ), - }); - } else { - logEvent("tengu_session_resumed", { - entrypoint: - "ccshare" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - } - } catch (error) { - logEvent("tengu_session_resumed", { - entrypoint: - "ccshare" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - logError(error); - await exitWithError( - root, - `Unable to resume from ccshare: ${errorMessage(error)}`, - () => gracefulShutdown(1), - ); - } - } else { - const resolvedPath = resolve(options.resume); - try { - const resumeStart = performance.now(); - let logOption; - try { - // Attempt to load as a transcript file; ENOENT falls through to session-ID handling - logOption = - await loadTranscriptFromFile( - resolvedPath, - ); - } catch (error) { - if (!isENOENT(error)) throw error; - // ENOENT: not a file path — fall through to session-ID handling - } - if (logOption) { - const result = - await loadConversationForResume( - logOption, - undefined /* sourceFile */, - ); - if (result) { - processedResume = - await processResumedConversation( - result, - { - forkSession: - !!options.forkSession, - transcriptPath: - result.fullPath, - }, - resumeContext, - ); - if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = - processedResume.restoredAgentDef; - } - logEvent("tengu_session_resumed", { - entrypoint: - "file" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: true, - resume_duration_ms: Math.round( - performance.now() - resumeStart, - ), - }); - } else { - logEvent("tengu_session_resumed", { - entrypoint: - "file" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - } - } - } catch (error) { - logEvent("tengu_session_resumed", { - entrypoint: - "file" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - logError(error); - await exitWithError( - root, - `Unable to load transcript from file: ${options.resume}`, - () => gracefulShutdown(1), - ); - } - } - } - } - - // If not loaded as a file, try as session ID - if (maybeSessionId) { - // Resume specific session by ID - const sessionId = maybeSessionId; - try { - const resumeStart = performance.now(); - // Use matchedLog if available (for cross-worktree resume by custom title) - // Otherwise fall back to sessionId string (for direct UUID resume) - const result = await loadConversationForResume( - matchedLog ?? sessionId, - undefined, - ); - - if (!result) { - logEvent("tengu_session_resumed", { - entrypoint: - "cli_flag" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - return await exitWithError( - root, - `No conversation found with session ID: ${sessionId}`, - ); - } - - const fullPath = - matchedLog?.fullPath ?? result.fullPath; - processedResume = await processResumedConversation( - result, - { - forkSession: !!options.forkSession, - sessionIdOverride: sessionId, - transcriptPath: fullPath, - }, - resumeContext, - ); - - if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = - processedResume.restoredAgentDef; - } - logEvent("tengu_session_resumed", { - entrypoint: - "cli_flag" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: true, - resume_duration_ms: Math.round( - performance.now() - resumeStart, - ), - }); - } catch (error) { - logEvent("tengu_session_resumed", { - entrypoint: - "cli_flag" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - logError(error); - await exitWithError( - root, - `Failed to resume session ${sessionId}`, - ); - } - } - - // Await file downloads before rendering REPL (files must be available) - if (fileDownloadPromise) { - try { - const results = await fileDownloadPromise; - const failedCount = count(results, (r) => !r.success); - if (failedCount > 0) { - process.stderr.write( - chalk.yellow( - `Warning: ${failedCount}/${results.length} file(s) failed to download.\n`, - ), - ); - } - } catch (error) { - return await exitWithError( - root, - `Error downloading files: ${errorMessage(error)}`, - ); - } - } - - // If we have a processed resume or teleport messages, render the REPL - const resumeData = - processedResume ?? - (Array.isArray(messages) - ? { - messages, - fileHistorySnapshots: undefined, - agentName: undefined, - agentColor: undefined as - | AgentColorName - | undefined, - restoredAgentDef: mainThreadAgentDefinition, - initialState, - contentReplacements: undefined, - } - : undefined); - if (resumeData) { - maybeActivateProactive(options); - maybeActivateBrief(options); - - await launchRepl( - root, - { - getFpsMetrics, - stats, - initialState: resumeData.initialState, - }, - { - ...sessionConfig, - mainThreadAgentDefinition: - resumeData.restoredAgentDef ?? - mainThreadAgentDefinition, - initialMessages: resumeData.messages, - initialFileHistorySnapshots: - resumeData.fileHistorySnapshots, - initialContentReplacements: - resumeData.contentReplacements, - initialAgentName: resumeData.agentName, - initialAgentColor: resumeData.agentColor, - }, - renderAndRun, - ); - } else { - // Show interactive selector (includes same-repo worktrees) - // Note: ResumeConversation loads logs internally to ensure proper GC after selection - await launchResumeChooser( - root, - { getFpsMetrics, stats, initialState }, - getWorktreePaths(getOriginalCwd()), - { - ...sessionConfig, - initialSearchQuery: searchTerm, - forkSession: options.forkSession, - filterByPr, - }, - ); - } - } else { - // Pass unresolved hooks promise to REPL so it can render immediately - // instead of blocking ~500ms waiting for SessionStart hooks to finish. - // REPL will inject hook messages when they resolve and await them before - // the first API call so the model always sees hook context. - const pendingHookMessages = - hooksPromise && hookMessages.length === 0 - ? hooksPromise - : undefined; - - profileCheckpoint("action_after_hooks"); - maybeActivateProactive(options); - maybeActivateBrief(options); - // Persist the current mode for fresh sessions so future resumes know what mode was used - if (feature("COORDINATOR_MODE")) { - saveMode( - coordinatorModeModule?.isCoordinatorMode() - ? "coordinator" - : "normal", - ); - } - - // If launched via a deep link, show a provenance banner so the user - // knows the session originated externally. Linux xdg-open and - // browsers with "always allow" set dispatch the link with no OS-level - // confirmation, so this is the only signal the user gets that the - // prompt — and the working directory / CLAUDE.md it implies — came - // from an external source rather than something they typed. - let deepLinkBanner: ReturnType< - typeof createSystemMessage - > | null = null; - if (feature("LODESTONE")) { - if (options.deepLinkOrigin) { - logEvent("tengu_deep_link_opened", { - has_prefill: Boolean(options.prefill), - has_repo: Boolean(options.deepLinkRepo), - }); - deepLinkBanner = createSystemMessage( - buildDeepLinkBanner({ - cwd: getCwd(), - prefillLength: options.prefill?.length, - repo: options.deepLinkRepo, - lastFetch: - options.deepLinkLastFetch !== undefined - ? new Date(options.deepLinkLastFetch) - : undefined, - }), - "warning", - ); - } else if (options.prefill) { - deepLinkBanner = createSystemMessage( - "Launched with a pre-filled prompt — review it before pressing Enter.", - "warning", - ); - } - } - const initialMessages = deepLinkBanner - ? [deepLinkBanner, ...hookMessages] - : hookMessages.length > 0 - ? hookMessages - : undefined; - - await launchRepl( - root, - { getFpsMetrics, stats, initialState }, - { - ...sessionConfig, - initialMessages, - pendingHookMessages, - }, - renderAndRun, - ); - } - }) - .version( - `${MACRO.VERSION} (CoStrict)`, - "-v, --version", - "Output the version number", - ); - - // Worktree flags - program.addOption( - new Option("-w, --worktree [name]", "Create a new git worktree for this session (optionally specify a name)").hideHelp(), - ); - program.addOption( - new Option("--tmux", "Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.").hideHelp(), - ); - - if (canUserConfigureAdvisor()) { - program.addOption( - new Option( - "--advisor ", - "Enable the server-side advisor tool with the specified model (alias or full ID).", - ).hideHelp(), - ); - } - - if (process.env.USER_TYPE === "ant") { - program.addOption( - new Option( - "--delegate-permissions", - "[ANT-ONLY] Alias for --permission-mode auto.", - ).implies({ - permissionMode: "auto", - }), - ); - program.addOption( - new Option( - "--dangerously-skip-permissions-with-classifiers", - "[ANT-ONLY] Deprecated alias for --permission-mode auto.", - ) - .hideHelp() - .implies({ permissionMode: "auto" }), - ); - program.addOption( - new Option( - "--afk", - "[ANT-ONLY] Deprecated alias for --permission-mode auto.", - ) - .hideHelp() - .implies({ permissionMode: "auto" }), - ); - program.addOption( - new Option( - "--tasks [id]", - '[ANT-ONLY] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").', - ) - .argParser(String) - .hideHelp(), - ); - program.option( - "--agent-teams", - "[ANT-ONLY] Force Claude to use multi-agent mode for solving problems", - () => true, - ); - } - - if (feature("TRANSCRIPT_CLASSIFIER")) { - program.addOption( - new Option("--enable-auto-mode", "Opt in to auto mode").hideHelp(), - ); - } - - if (feature("PROACTIVE") || feature("KAIROS")) { - program.addOption( - new Option("--proactive", "Start in proactive autonomous mode").hideHelp(), - ); - } - - if (feature("UDS_INBOX")) { - program.addOption( - new Option( - "--messaging-socket-path ", - "Unix domain socket path for the UDS messaging server (defaults to a tmp path)", - ).hideHelp(), - ); - } - - if (feature("KAIROS") || feature("KAIROS_BRIEF")) { - program.addOption( - new Option( - "--brief", - "Enable SendUserMessage tool for agent-to-user communication", - ).hideHelp(), - ); - } - if (feature("KAIROS")) { - program.addOption( - new Option( - "--assistant", - "Force assistant mode (Agent SDK daemon use)", - ).hideHelp(), - ); - } - program.addOption( - new Option( - "--channels ", - "MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.", - ).hideHelp(), - ); - program.addOption( - new Option( - "--dangerously-load-development-channels ", - "Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.", - ).hideHelp(), - ); - - // Teammate identity options (set by leader when spawning tmux teammates) - // These replace the CLAUDE_CODE_* environment variables - program.addOption( - new Option("--agent-id ", "Teammate agent ID").hideHelp(), - ); - program.addOption( - new Option("--agent-name ", "Teammate display name").hideHelp(), - ); - program.addOption( - new Option( - "--team-name ", - "Team name for swarm coordination", - ).hideHelp(), - ); - program.addOption( - new Option("--agent-color ", "Teammate UI color").hideHelp(), - ); - program.addOption( - new Option( - "--plan-mode-required", - "Require plan mode before implementation", - ).hideHelp(), - ); - program.addOption( - new Option( - "--parent-session-id ", - "Parent session ID for analytics correlation", - ).hideHelp(), - ); - program.addOption( - new Option( - "--teammate-mode ", - 'How to spawn teammates: "tmux", "in-process", or "auto"', - ) - .choices(["auto", "tmux", "in-process"]) - .hideHelp(), - ); - program.addOption( - new Option( - "--agent-type ", - "Custom agent type for this teammate", - ).hideHelp(), - ); - - // Enable SDK URL for all builds but hide from help - program.addOption( - new Option( - "--sdk-url ", - "Use remote WebSocket endpoint for SDK I/O streaming (only with -p and stream-json format)", - ).hideHelp(), - ); - - // Enable teleport/remote flags for all builds but keep them undocumented until GA - program.addOption( - new Option( - "--teleport [session]", - "Resume a teleport session, optionally specify session ID", - ).hideHelp(), - ); - program.addOption( - new Option( - "--remote [description]", - "Create a remote session with the given description", - ).hideHelp(), - ); - if (feature("BRIDGE_MODE")) { - program.addOption( - new Option( - "--remote-control [name]", - "Start an interactive session with Remote Control enabled (optionally named)", - ) - .argParser((value) => value || true) - .hideHelp(), - ); - program.addOption( - new Option("--rc [name]", "Alias for --remote-control") - .argParser((value) => value || true) - .hideHelp(), - ); - } - - if (feature("HARD_FAIL")) { - program.addOption( - new Option( - "--hard-fail", - "Crash on logError calls instead of silently logging", - ).hideHelp(), - ); - } - - profileCheckpoint("run_main_options_built"); - - // -p/--print mode: skip subcommand registration. The 52 subcommands - // (mcp, auth, plugin, skill, task, config, doctor, update, etc.) are - // never dispatched in print mode — commander routes the prompt to the - // default action. The subcommand registration path was measured at ~65ms - // on baseline — mostly the isBridgeEnabled() call (25ms settings Zod parse - // + 40ms sync keychain subprocess), both hidden by the try/catch that - // always returns false before enableConfigs(). cc:// URLs are rewritten to - // `open` at main() line ~851 BEFORE this runs, so argv check is safe here. - const isPrintMode = - process.argv.includes("-p") || process.argv.includes("--print"); - const isCcUrl = process.argv.some( - (a) => a.startsWith("cc://") || a.startsWith("cc+unix://"), - ); - if (isPrintMode && !isCcUrl) { - profileCheckpoint("run_before_parse"); - await program.parseAsync(process.argv); - profileCheckpoint("run_after_parse"); - return program; - } - - // claude mcp - - const mcp = program - .command("mcp") - .description("Configure and manage MCP servers") - .addHelpCommand(false) - .configureHelp(createSortedHelpConfig()) - .enablePositionalOptions(); - - mcp.command("serve") - .description(`Start the CoStrict MCP server`) - .option("-d, --debug", "Enable debug mode", () => true) - .option( - "--verbose", - "Override verbose mode setting from config", - () => true, - ) - .action( - async ({ - debug, - verbose, - }: { - debug?: boolean; - verbose?: boolean; - }) => { - const { mcpServeHandler } = - await import("./cli/handlers/mcp.js"); - await mcpServeHandler({ debug, verbose }); - }, - ); - - // Register the mcp add subcommand (extracted for testability) - registerMcpAddCommand(mcp); - - if (isXaaEnabled()) { - registerMcpXaaIdpCommand(mcp); - } - - mcp.command("remove ") - .alias("rm") - .description("Remove an MCP server") - .option( - "-s, --scope ", - "Configuration scope (local, user, or project) - if not specified, removes from whichever scope it exists in", - ) - .action(async (name: string, options: { scope?: string }) => { - const { mcpRemoveHandler } = await import("./cli/handlers/mcp.js"); - await mcpRemoveHandler(name, options); - }); - - mcp.command("list") - .alias("ls") - .description("List configured MCP servers") - .action(async () => { - const { mcpListHandler } = await import("./cli/handlers/mcp.js"); - await mcpListHandler(); - }); - - mcp.command("get ") - .description("Get details about an MCP server") - .action(async (name: string) => { - const { mcpGetHandler } = await import("./cli/handlers/mcp.js"); - await mcpGetHandler(name); - }); - - mcp.command("add-json ") - .description("Add an MCP server (stdio or SSE) with a JSON string") - .option( - "-s, --scope ", - "Configuration scope (local, user, or project)", - "local", - ) - .option( - "--client-secret", - "Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)", - ) - .action( - async ( - name: string, - json: string, - options: { scope?: string; clientSecret?: true }, - ) => { - const { mcpAddJsonHandler } = - await import("./cli/handlers/mcp.js"); - await mcpAddJsonHandler(name, json, options); - }, - ); - - mcp.command("add-from-claude-desktop") - .description( - "Import MCP servers from Claude Desktop (Mac and WSL only)", - ) - .option( - "-s, --scope ", - "Configuration scope (local, user, or project)", - "local", - ) - .action(async (options: { scope?: string }) => { - const { mcpAddFromDesktopHandler } = - await import("./cli/handlers/mcp.js"); - await mcpAddFromDesktopHandler(options); - }); - - mcp.command("reset-project-choices") - .description( - "Reset all approved and rejected project-scoped (.mcp.json) servers within this project", - ) - .action(async () => { - const { mcpResetChoicesHandler } = - await import("./cli/handlers/mcp.js"); - await mcpResetChoicesHandler(); - }); - - // csc serve — HTTP API server for IDE plugins and web clients. - // Also accessible via `csc serve` alias. - if (feature("DIRECT_CONNECT")) { - const serveAction = async (opts: { - port: string; - host: string; - authToken?: string; - unix?: string; - workspace?: string; - idleTimeout: string; - maxSessions: string; - }) => { - const { startServer } = await import("./server/server.js"); - const { EventBus } = await import("./server/eventBus.js"); - const { SessionManager } = - await import("./server/sessionManager.js"); - const { printBanner } = - await import("./server/serverBanner.js"); - const { getScriptArgsForChild, saveChildSpawnPrefix } = - await import("./server/sessionHandle.js"); - - await saveChildSpawnPrefix(); - const eventBus = new EventBus(); - const config = { - port: parseInt(opts.port, 10), - host: opts.host, - authToken: undefined, - unix: opts.unix, - workspace: opts.workspace, - idleTimeoutMs: parseInt(opts.idleTimeout, 10), - maxSessions: parseInt(opts.maxSessions, 10), - }; - - const sessionManager = new SessionManager({ - eventBus, - idleTimeoutMs: config.idleTimeoutMs, - maxSessions: config.maxSessions, - workspace: config.workspace, - }); - await sessionManager.init(); - - const server = startServer(config, sessionManager, eventBus); - const actualPort = server.port ?? config.port; - printBanner(config, undefined, actualPort); - - sessionManager.startProbeSession({ - cwd: config.workspace || process.cwd(), - execPath: process.execPath, - scriptArgs: getScriptArgsForChild(), - }); - - let shuttingDown = false; - const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; - sessionManager.killProbe(); - server.stop(true); - eventBus.destroy(); - await sessionManager.destroyAll(); - process.exit(0); - }; - process.on("SIGINT", () => void shutdown()); - process.on("SIGTERM", () => void shutdown()); - }; - - program - .command("server") - .description("Start a CoStrict session server (alias: serve)") - .option("--port ", "HTTP port", "0") - .option("--host ", "Bind address", "127.0.0.1") - .option("--auth-token ", "Bearer token for auth (currently disabled)") - .option("--unix ", "Listen on a unix domain socket") - .option( - "--workspace ", - "Default working directory for sessions that do not specify cwd", - ) - .option( - "--idle-timeout ", - "Idle timeout for detached sessions in ms (0 = never expire)", - "1800000", - ) - .option( - "--max-sessions ", - "Maximum concurrent sessions (0 = unlimited)", - "32", - ) - .action(serveAction); - - program - .command("serve") - .description("Start a CoStrict HTTP API server") - .option("--port ", "HTTP port", "0") - .option("--host ", "Bind address", "127.0.0.1") - .option("--auth-token ", "Bearer token for auth (currently disabled)") - .option("--unix ", "Listen on a unix domain socket") - .option( - "--workspace ", - "Default working directory for sessions that do not specify cwd", - ) - .option( - "--idle-timeout ", - "Idle timeout for detached sessions in ms (0 = never expire)", - "1800000", - ) - .option( - "--max-sessions ", - "Maximum concurrent sessions (0 = unlimited)", - "32", - ) - .action(serveAction); - } - - // `claude ssh [dir]` — registered here only so --help shows it. - // The actual interactive flow is handled by early argv rewriting in main() - // (parallels the DIRECT_CONNECT/cc:// pattern above). If commander reaches - // this action it means the argv rewrite didn't fire (e.g. user ran - // `claude ssh` with no host) — just print usage. - if (feature("SSH_REMOTE")) { - program - .command("ssh [dir]") - .description( - "Run CoStrict on a remote host over SSH. Deploys the binary and " + - "tunnels API auth back through your local machine — no remote setup needed.", - ) - .option( - "--permission-mode ", - "Permission mode for the remote session", - ) - .option( - "--dangerously-skip-permissions", - "Skip all permission prompts on the remote (dangerous)", - ) - .option( - "--remote-bin ", - "Custom remote binary command (skips probe/deploy). " + - "Example: --remote-bin 'bun /path/to/project/dist/cli.js'", - ) - .option( - "--local", - "e2e test mode — spawn the child CLI locally (skip ssh/deploy). " + - "Exercises the auth proxy and unix-socket plumbing without a remote host.", - ) - .action(async () => { - // Argv rewriting in main() should have consumed `ssh ` before - // commander runs. Reaching here means host was missing or the - // rewrite predicate didn't match. - process.stderr.write( - "Usage: csc ssh [dir]\n\n" + - "Runs CoStrict on a remote Linux host. You don't need to install\n" + - "anything on the remote or run `csc auth login` there — the binary is\n" + - "deployed over SSH and API auth tunnels back through your local machine.\n", - ); - process.exit(1); - }); - } - - // claude connect — subcommand only handles -p (headless) mode. - // Interactive mode (without -p) is handled by early argv rewriting in main() - // which redirects to the main command with full TUI support. - if (feature("DIRECT_CONNECT")) { - program - .command("open ") - .description( - "Connect to a CoStrict server (internal — use cc:// URLs)", - ) - .option("-p, --print [prompt]", "Print mode (headless)") - .option( - "--output-format ", - "Output format: text, json, stream-json", - "text", - ) - .action( - async ( - ccUrl: string, - opts: { - print?: string | true; - outputFormat?: string; - }, - ) => { - const { parseConnectUrl } = - await import("./server/parseConnectUrl.js"); - const { serverUrl, authToken } = parseConnectUrl(ccUrl); - - let connectConfig; - try { - const session = await createDirectConnectSession({ - serverUrl, - authToken, - cwd: getOriginalCwd(), - dangerouslySkipPermissions: - _pendingConnect?.dangerouslySkipPermissions, - }); - if (session.workDir) { - setOriginalCwd(session.workDir); - setCwdState(session.workDir); - } - setDirectConnectServerUrl(serverUrl); - connectConfig = session.config; - } catch (err) { - console.error( - err instanceof DirectConnectError - ? err.message - : String(err), - ); - process.exit(1); - } - - const { runConnectHeadless } = - await import("./server/connectHeadless.js"); - - const prompt = - typeof opts.print === "string" ? opts.print : ""; - const interactive = opts.print === true; - await runConnectHeadless( - connectConfig, - prompt, - opts.outputFormat, - interactive, - ); - }, - ); - } - - // claude auth - - const auth = program - .command("auth") - .description("Manage authentication") - .addHelpCommand(false) - .configureHelp(createSortedHelpConfig()); - - auth.command("login") - .description("Sign in to your CoStrict account") - .option( - "--email ", - "Pre-populate email address on the login page (Anthropic OAuth only)", - ) - .option("--sso", "Force SSO login flow (Anthropic OAuth only)") - .option( - "--console", - "Use Anthropic Console (API usage billing) instead of CoStrict", - ) - .option("--claudeai", "Use Claude subscription instead of CoStrict") - .action( - async ({ - email, - sso, - console: useConsole, - claudeai, - }: { - email?: string; - sso?: boolean; - console?: boolean; - claudeai?: boolean; - }) => { - const { authLogin } = await import("./cli/handlers/auth.js"); - await authLogin({ email, sso, console: useConsole, claudeai }); - }, - ); - - auth.command("status") - .description("Show authentication status") - .option("--json", "Output as JSON (default)") - .option("--text", "Output as human-readable text") - .action(async (opts: { json?: boolean; text?: boolean }) => { - const { authStatus } = await import("./cli/handlers/auth.js"); - await authStatus(opts); - }); - - auth.command("logout") - .description("Log out from your CoStrict account") - .action(async () => { - const { authLogout } = await import("./cli/handlers/auth.js"); - await authLogout(); - }); - - /** - * Helper function to handle marketplace command errors consistently. - * Logs the error and exits the process with status 1. - * @param error The error that occurred - * @param action Description of the action that failed - */ - // Hidden flag on all plugin/marketplace subcommands to target cowork_plugins. - const coworkOption = () => - new Option("--cowork", "Use cowork_plugins directory").hideHelp(); - - // Plugin validate command - const pluginCmd = program - .command("plugin") - .alias("plugins") - .alias("plug") - .description("Manage CoStrict plugins") - .addHelpCommand(false) - .configureHelp(createSortedHelpConfig()); - - pluginCmd - .command("validate ") - .description("Validate a plugin or marketplace manifest") - .addOption(coworkOption()) - .action(async (manifestPath: string, options: { cowork?: boolean }) => { - const { pluginValidateHandler } = - await import("./cli/handlers/plugins.js"); - await pluginValidateHandler(manifestPath, options); - }); - - // Plugin list command - pluginCmd - .command("list") - .alias("ls") - .description("List installed plugins") - .option("--json", "Output as JSON") - .option( - "--available", - "Include available plugins from marketplaces (requires --json)", - ) - .addOption(coworkOption()) - .action( - async (options: { - json?: boolean; - available?: boolean; - cowork?: boolean; - }) => { - const { pluginListHandler } = - await import("./cli/handlers/plugins.js"); - await pluginListHandler(options); - }, - ); - - // Marketplace subcommands - const marketplaceCmd = pluginCmd - .command("marketplace") - .description("Manage CoStrict marketplaces") - .configureHelp(createSortedHelpConfig()); - - marketplaceCmd - .command("add ") - .description("Add a marketplace from a URL, path, or GitHub repo") - .addOption(coworkOption()) - .option( - "--sparse ", - "Limit checkout to specific directories via git sparse-checkout (for monorepos). Example: --sparse .claude-plugin plugins", - ) - .option( - "--scope ", - "Where to declare the marketplace: user (default), project, or local", - ) - .action( - async ( - source: string, - options: { - cowork?: boolean; - sparse?: string[]; - scope?: string; - }, - ) => { - const { marketplaceAddHandler } = - await import("./cli/handlers/plugins.js"); - await marketplaceAddHandler(source, options); - }, - ); - - marketplaceCmd - .command("list") - .description("List all configured marketplaces") - .option("--json", "Output as JSON") - .addOption(coworkOption()) - .action(async (options: { json?: boolean; cowork?: boolean }) => { - const { marketplaceListHandler } = - await import("./cli/handlers/plugins.js"); - await marketplaceListHandler(options); - }); - - marketplaceCmd - .command("remove ") - .alias("rm") - .description("Remove a configured marketplace") - .addOption(coworkOption()) - .action(async (name: string, options: { cowork?: boolean }) => { - const { marketplaceRemoveHandler } = - await import("./cli/handlers/plugins.js"); - await marketplaceRemoveHandler(name, options); - }); - - marketplaceCmd - .command("update [name]") - .description( - "Update marketplace(s) from their source - updates all if no name specified", - ) - .addOption(coworkOption()) - .action( - async (name: string | undefined, options: { cowork?: boolean }) => { - const { marketplaceUpdateHandler } = - await import("./cli/handlers/plugins.js"); - await marketplaceUpdateHandler(name, options); - }, - ); - - // Plugin install command - pluginCmd - .command("install ") - .alias("i") - .description( - "Install a plugin from available marketplaces (use plugin@marketplace for specific marketplace)", - ) - .option( - "-s, --scope ", - "Installation scope: user, project, or local", - "user", - ) - .addOption(coworkOption()) - .action( - async ( - plugin: string, - options: { scope?: string; cowork?: boolean }, - ) => { - const { pluginInstallHandler } = - await import("./cli/handlers/plugins.js"); - await pluginInstallHandler(plugin, options); - }, - ); - - // Plugin uninstall command - pluginCmd - .command("uninstall ") - .alias("remove") - .alias("rm") - .description("Uninstall an installed plugin") - .option( - "-s, --scope ", - "Uninstall from scope: user, project, or local", - "user", - ) - .option( - "--keep-data", - "Preserve the plugin's persistent data directory (~/.claude/plugins/data/{id}/)", - ) - .addOption(coworkOption()) - .action( - async ( - plugin: string, - options: { - scope?: string; - cowork?: boolean; - keepData?: boolean; - }, - ) => { - const { pluginUninstallHandler } = - await import("./cli/handlers/plugins.js"); - await pluginUninstallHandler(plugin, options); - }, - ); - - // Plugin enable command - pluginCmd - .command("enable ") - .description("Enable a disabled plugin") - .option( - "-s, --scope ", - `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(", ")} (default: auto-detect)`, - ) - .addOption(coworkOption()) - .action( - async ( - plugin: string, - options: { scope?: string; cowork?: boolean }, - ) => { - const { pluginEnableHandler } = - await import("./cli/handlers/plugins.js"); - await pluginEnableHandler(plugin, options); - }, - ); - - // Plugin disable command - pluginCmd - .command("disable [plugin]") - .description("Disable an enabled plugin") - .option("-a, --all", "Disable all enabled plugins") - .option( - "-s, --scope ", - `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(", ")} (default: auto-detect)`, - ) - .addOption(coworkOption()) - .action( - async ( - plugin: string | undefined, - options: { scope?: string; cowork?: boolean; all?: boolean }, - ) => { - const { pluginDisableHandler } = - await import("./cli/handlers/plugins.js"); - await pluginDisableHandler(plugin, options); - }, - ); - - // Plugin update command - pluginCmd - .command("update ") - .description( - "Update a plugin to the latest version (restart required to apply)", - ) - .option( - "-s, --scope ", - `Installation scope: ${VALID_UPDATE_SCOPES.join(", ")} (default: user)`, - ) - .addOption(coworkOption()) - .action( - async ( - plugin: string, - options: { scope?: string; cowork?: boolean }, - ) => { - const { pluginUpdateHandler } = - await import("./cli/handlers/plugins.js"); - await pluginUpdateHandler(plugin, options); - }, - ); - // END ANT-ONLY + profileCheckpoint('run_function_start'); + + // Create help config that sorts options by long option name and puts + // Commands section before Options section in the root command's help output. + // Commander supports compareOptions/formatHelp at runtime but + // @commander-js/extra-typings doesn't include them in the type definitions, + // so we use Object.assign to add them. + function createSortedHelpConfig(): { + sortSubcommands: true; + sortOptions: true; + } { + const getOptionSortKey = (opt: Option): string => + opt.long?.replace(/^--/, '') ?? opt.short?.replace(/^-/, '') ?? ''; + return Object.assign({ sortSubcommands: true, sortOptions: true } as const, { + compareOptions: (a: Option, b: Option) => getOptionSortKey(a).localeCompare(getOptionSortKey(b)), + // Reorder help sections: Usage → Description → Arguments → + // Commands → Options (matching cs/opencode style). + // Uses duck-typed access because @commander-js/extra-typings + // doesn't expose the full Help class API. + formatHelp: (cmd: CommanderCommand, helper: unknown) => { + const h = helper as { + padWidth: (cmd: CommanderCommand, helper: unknown) => number; + helpWidth: number; + commandUsage: (cmd: CommanderCommand) => string; + commandDescription: (cmd: CommanderCommand) => string; + visibleArguments: (cmd: CommanderCommand) => unknown[]; + visibleCommands: (cmd: CommanderCommand) => unknown[]; + visibleOptions: (cmd: CommanderCommand) => unknown[]; + argumentTerm: (arg: unknown) => string; + argumentDescription: (arg: unknown) => string; + subcommandTerm: (cmd: unknown) => string; + subcommandDescription: (cmd: unknown) => string; + optionTerm: (opt: unknown) => string; + optionDescription: (opt: unknown) => string; + formatItem: (term: string, termWidth: number, description: string, helper: unknown) => string; + styleTitle: (s: string) => string; + styleUsage: (s: string) => string; + styleCommandDescription: (s: string) => string; + styleArgumentTerm: (s: string) => string; + styleArgumentDescription: (s: string) => string; + styleSubcommandTerm: (s: string) => string; + styleSubcommandDescription: (s: string) => string; + styleOptionTerm: (s: string) => string; + styleOptionDescription: (s: string) => string; + boxWrap: (s: string, width: number) => string; + }; + const termWidth = h.padWidth(cmd, helper); + const helpWidth = h.helpWidth ?? 80; + const fmt = (term: string, desc: string) => h.formatItem(term, termWidth, desc, helper); + + let output = [`${h.styleTitle('Usage:')} ${h.styleUsage(h.commandUsage(cmd))}`, '']; + + const desc = h.commandDescription(cmd); + if (desc.length > 0) { + output = output.concat([h.boxWrap(h.styleCommandDescription(desc), helpWidth), '']); + } + + // Order: Commands → Options → Arguments (matches usage line: [options] [command] [prompt]) + const cmdList = h + .visibleCommands(cmd) + .map(c => + fmt(h.styleSubcommandTerm(h.subcommandTerm(c)), h.styleSubcommandDescription(h.subcommandDescription(c))), + ); + if (cmdList.length > 0) output = output.concat([h.styleTitle('Commands:'), ...cmdList, '']); + + const optList = h + .visibleOptions(cmd) + .map(o => fmt(h.styleOptionTerm(h.optionTerm(o)), h.styleOptionDescription(h.optionDescription(o)))); + if (optList.length > 0) output = output.concat([h.styleTitle('Options:'), ...optList, '']); + + const argList = h + .visibleArguments(cmd) + .map(a => fmt(h.styleArgumentTerm(h.argumentTerm(a)), h.styleArgumentDescription(h.argumentDescription(a)))); + if (argList.length > 0) output = output.concat([h.styleTitle('Arguments:'), ...argList, '']); + + return output.join('\n'); + }, + }); + } + const program = new CommanderCommand() + .addHelpCommand(false) + .configureHelp(createSortedHelpConfig()) + .enablePositionalOptions(); + profileCheckpoint('run_commander_initialized'); + + // Use preAction hook to run initialization only when executing a command, + // not when displaying help. This avoids the need for env variable signaling. + program.hook('preAction', async thisCommand => { + profileCheckpoint('preAction_start'); + // Await async subprocess loads started at module evaluation (lines 12-20). + // Nearly free — subprocesses complete during the ~135ms of imports above. + // Must resolve before init() which triggers the first settings read + // (applySafeConfigEnvironmentVariables → getSettingsForSource('policySettings') + // → isRemoteManagedSettingsEligible → sync keychain reads otherwise ~65ms). + await Promise.all([ensureMdmSettingsLoaded(), ensureKeychainPrefetchCompleted()]); + profileCheckpoint('preAction_after_mdm'); + await init(); + profileCheckpoint('preAction_after_init'); + + // process.title on Windows sets the console title directly; on POSIX, + // terminal shell integration may mirror the process name to the tab. + // After init() so settings.json env can also gate this (gh-4765). + if (!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE)) { + process.title = 'csc'; + } + + // Attach logging sinks so subcommand handlers can use logEvent/logError. + // Before PR #11106 logEvent dispatched directly; after, events queue until + // a sink attaches. setup() attaches sinks for the default command, but + // subcommands (doctor, mcp, plugin, auth) never call setup() and would + // silently drop events on process.exit(). Both inits are idempotent. + const { initSinks } = await import('./utils/sinks.js'); + initSinks(); + profileCheckpoint('preAction_after_sinks'); + + // gh-33508: --plugin-dir is a top-level program option. The default + // action reads it from its own options destructure, but subcommands + // (plugin list, plugin install, mcp *) have their own actions and + // never see it. Wire it up here so getInlinePlugins() works everywhere. + // thisCommand.opts() is typed {} here because this hook is attached + // before .option('--plugin-dir', ...) in the chain — extra-typings + // builds the type as options are added. Narrow with a runtime guard; + // the collect accumulator + [] default guarantee string[] in practice. + const pluginDir = thisCommand.getOptionValue('pluginDir'); + if (Array.isArray(pluginDir) && pluginDir.length > 0 && pluginDir.every(p => typeof p === 'string')) { + setInlinePlugins(pluginDir); + clearPluginCache('preAction: --plugin-dir inline plugins'); + } + + runMigrations(); + profileCheckpoint('preAction_after_migrations'); + + // Load remote managed settings for enterprise customers (non-blocking) + // Fails open - if fetch fails, continues without remote settings + // Settings are applied via hot-reload when they arrive + // Must happen after init() to ensure config reading is allowed + // void loadRemoteManagedSettings(); + void loadPolicyLimits(); + + profileCheckpoint('preAction_after_remote_settings'); + + // Load settings sync (non-blocking, fail-open) + // CLI: uploads local settings to remote (CCR download is handled by print.ts) + if (feature('UPLOAD_USER_SETTINGS')) { + void import('./services/settingsSync/index.js').then(m => m.uploadUserSettingsInBackground()); + } + + profileCheckpoint('preAction_after_settings_sync'); + }); + + program + .name('csc') + .description(`CoStrict AI coding assistant`) + .usage('[command] [options] [prompt]') + .argument('[prompt]', 'Your prompt', String) + // Subcommands inherit helpOption via commander's copyInheritedSettings — + // setting it once here covers mcp, plugin, auth, and all other subcommands. + .helpOption('-h, --help', 'Display help for command') + .option( + '-d, --debug [filter]', + 'Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file")', + (_value: string | true) => { + // If value is provided, it will be the filter string + // If not provided but flag is present, value will be true + // The actual filtering is handled in debug.ts by parsing process.argv + return true; + }, + ) + .addOption(new Option('--debug-to-stderr', 'Enable debug mode (to stderr)').argParser(Boolean).hideHelp()) + .addOption( + new Option('--debug-file ', 'Write debug logs to a specific file path (implicitly enables debug mode)') + .argParser(() => true) + .hideHelp(), + ) + .addOption(new Option('--verbose', 'Override verbose mode setting from config').argParser(() => true).hideHelp()) + .option( + '-p, --print', + 'Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when CSC is run with the -p mode. Only use this flag in directories you trust.', + () => true, + ) + .addOption( + new Option( + '--bare', + 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', + ) + .argParser(() => true) + .hideHelp(), + ) + .addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()) + .addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()) + .addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()) + .addOption( + new Option( + '--output-format ', + 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)', + ).choices(['text', 'json', 'stream-json']), + ) + .addOption( + new Option( + '--json-schema ', + 'JSON Schema for structured output validation. ' + + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--include-hook-events', + 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', + ) + .argParser(() => true) + .hideHelp(), + ) + .addOption( + new Option( + '--include-partial-messages', + 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', + ) + .argParser(() => true) + .hideHelp(), + ) + .addOption( + new Option( + '--input-format ', + 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)', + ) + .choices(['text', 'stream-json']) + .hideHelp(), + ) + .addOption( + new Option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)') + .argParser(() => true) + .hideHelp(), + ) + .addOption( + new Option( + '--dangerously-skip-permissions', + 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', + ).argParser(() => true), + ) + .addOption( + new Option( + '--allow-dangerously-skip-permissions', + 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', + ) + .argParser(() => true) + .hideHelp(), + ) + .addOption( + new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled') + .choices(['enabled', 'adaptive', 'disabled']) + .hideHelp(), + ) + .addOption( + new Option( + '--max-thinking-tokens ', + '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)', + ) + .argParser(Number) + .hideHelp(), + ) + .addOption( + new Option( + '--max-turns ', + 'Maximum number of agentic turns in non-interactive mode. This will early exit the conversation after the specified number of turns. (only works with --print)', + ) + .argParser(Number) + .hideHelp(), + ) + .addOption( + new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)') + .argParser(value => { + const amount = Number(value); + if (isNaN(amount) || amount <= 0) { + throw new Error('--max-budget-usd must be a positive number greater than 0'); + } + return amount; + }) + .hideHelp(), + ) + .addOption( + new Option('--task-budget ', 'API-side task budget in tokens (output_config.task_budget)') + .argParser(value => { + const tokens = Number(value); + if (isNaN(tokens) || tokens <= 0 || !Number.isInteger(tokens)) { + throw new Error('--task-budget must be a positive integer'); + } + return tokens; + }) + .hideHelp(), + ) + .addOption( + new Option( + '--replay-user-messages', + 'Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json)', + ) + .argParser(() => true) + .hideHelp(), + ) + .addOption(new Option('--enable-auth-status', 'Enable auth status messages in SDK mode').default(false).hideHelp()) + .addOption( + new Option( + '--allowed-tools ', + 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")', + ).hideHelp(), + ) + .addOption( + new Option( + '--allowedTools ', + 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")', + ).hideHelp(), + ) + .addOption( + new Option( + '--tools ', + 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read").', + ).hideHelp(), + ) + .addOption( + new Option( + '--disallowed-tools ', + 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")', + ).hideHelp(), + ) + .addOption( + new Option( + '--disallowedTools ', + 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")', + ).hideHelp(), + ) + .addOption( + new Option( + '--mcp-config ', + 'Load MCP servers from JSON files or strings (space-separated)', + ).hideHelp(), + ) + .addOption( + new Option('--permission-prompt-tool ', 'MCP tool to use for permission prompts (only works with --print)') + .argParser(String) + .hideHelp(), + ) + .addOption(new Option('--system-prompt ', 'System prompt to use for the session').argParser(String)) + .addOption(new Option('--system-prompt-file ', 'Read system prompt from a file').argParser(String).hideHelp()) + .addOption( + new Option('--append-system-prompt ', 'Append a system prompt to the default system prompt') + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--append-system-prompt-file ', + 'Read system prompt from a file and append to the default system prompt', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option('--permission-mode ', 'Permission mode to use for the session') + .argParser(String) + .choices(PERMISSION_MODES), + ) + .option('-c, --continue', 'Continue the most recent conversation in the current directory', () => true) + .option( + '-r, --resume [value]', + 'Resume a conversation by session ID, or open interactive picker with optional search term', + value => value || true, + ) + .addOption( + new Option( + '--fork-session', + 'When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)', + ) + .argParser(() => true) + .hideHelp(), + ) + .addOption(new Option('--prefill ', 'Pre-fill the prompt input with text without submitting it').hideHelp()) + .addOption(new Option('--deep-link-origin', 'Signal that this session was launched from a deep link').hideHelp()) + .addOption( + new Option( + '--deep-link-repo ', + 'Repo slug the deep link ?repo= parameter resolved to the current cwd', + ).hideHelp(), + ) + .addOption( + new Option('--deep-link-last-fetch ', 'FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline') + .argParser(v => { + const n = Number(v); + return Number.isFinite(n) ? n : undefined; + }) + .hideHelp(), + ) + .addOption( + new Option( + '--from-pr [value]', + 'Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term', + ) + .argParser(value => value || true) + .hideHelp(), + ) + .addOption( + new Option( + '--no-session-persistence', + 'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)', + ).hideHelp(), + ) + .addOption( + new Option( + '--resume-session-at ', + 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--rewind-files ', + 'Restore files to state at the specified user message and exit (requires --resume)', + ).hideHelp(), + ) + // @[MODEL LAUNCH]: Update the example model ID in the --model help text. + .option('--model ', `Model for the current session. Provide a model's full name (e.g. 'GLM-5.1').`) + .addOption( + new Option('--effort ', `Effort level for the current session (low, medium, high, max)`) + .argParser((rawValue: string) => { + const value = rawValue.toLowerCase(); + const allowed = ['low', 'medium', 'high', 'max']; + if (!allowed.includes(value)) { + throw new InvalidArgumentError(`It must be one of: ${allowed.join(', ')}`); + } + return value; + }) + .hideHelp(), + ) + .option('--agent ', `Agent for the current session. Overrides the 'agent' setting.`) + .addOption( + new Option('--betas ', 'Beta headers to include in API requests (API key users only)').hideHelp(), + ) + .addOption( + new Option( + '--fallback-model ', + 'Enable automatic fallback to specified model when default model is overloaded (only works with --print)', + ).hideHelp(), + ) + .addOption( + new Option( + '--workload ', + 'Workload tag for billing-header attribution (cc_workload). Process-scoped; set by SDK daemon callers that spawn subprocesses for cron work. (only works with --print)', + ).hideHelp(), + ) + .option( + '--settings ', + 'Path to a settings JSON file or a JSON string to load additional settings from', + ) + .option('--add-dir ', 'Additional directories to allow tool access to') + .addOption( + new Option('--ide', 'Automatically connect to IDE on startup if exactly one valid IDE is available') + .argParser(() => true) + .hideHelp(), + ) + .addOption( + new Option('--strict-mcp-config', 'Only use MCP servers from --mcp-config, ignoring all other MCP configurations') + .argParser(() => true) + .hideHelp(), + ) + .addOption( + new Option( + '--session-id ', + 'Use a specific session ID for the conversation (must be a valid UUID)', + ).hideHelp(), + ) + .addOption( + new Option( + '-n, --name ', + 'Set a display name for this session (shown in /resume and terminal title)', + ).hideHelp(), + ) + .addOption( + new Option( + '--agents ', + 'JSON object defining custom agents (e.g. \'{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}\')', + ).hideHelp(), + ) + .addOption( + new Option( + '--setting-sources ', + 'Comma-separated list of setting sources to load (user, project, local).', + ).hideHelp(), + ) + // gh-33508: (variadic) consumed everything until the next + // --flag. `claude --plugin-dir /path mcp add --transport http` swallowed + // `mcp` and `add` as paths, then choked on --transport as an unknown + // top-level option. Single-value + collect accumulator means each + // --plugin-dir takes exactly one arg; repeat the flag for multiple dirs. + .addOption( + new Option( + '--plugin-dir ', + 'Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B)', + ) + .argParser((val: string, prev: string[]) => [...prev, val]) + .default([] as string[]) + .hideHelp(), + ) + .addOption(new Option('--disable-slash-commands', 'Disable all skills').argParser(() => true).hideHelp()) + .addOption(new Option('--chrome', 'Enable Claude in Chrome integration').hideHelp()) + .addOption(new Option('--no-chrome', 'Disable Claude in Chrome integration').hideHelp()) + .addOption( + new Option( + '--file ', + 'File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png)', + ).hideHelp(), + ) + .action(async (prompt, options) => { + profileCheckpoint('action_handler_start'); + + // --bare = one-switch minimal mode. Sets SIMPLE so all the existing + // gates fire (CLAUDE.md, skills, hooks inside executeHooks, agent + // dir-walk). Must be set before setup() / any of the gated work runs. + if ((options as { bare?: boolean }).bare) { + process.env.CLAUDE_CODE_SIMPLE = '1'; + } + + // Ignore "code" as a prompt - treat it the same as no prompt + if (prompt === 'code') { + logEvent('tengu_code_prompt_ignored', {}); + console.warn(chalk.yellow('Tip: You can launch CoStrict with just `csc`')); + prompt = undefined; + } + + // Log event for any single-word prompt + if (prompt && typeof prompt === 'string' && !/\s/.test(prompt) && prompt.length > 0) { + logEvent('tengu_single_word_prompt', { length: prompt.length }); + } + + // Assistant mode: when .claude/settings.json has assistant: true AND + // the tengu_kairos GrowthBook gate is on, force brief on. Permission + // mode is left to the user — settings defaultMode or --permission-mode + // apply as normal. REPL-typed messages already default to 'next' + // priority (messageQueueManager.enqueue) so they drain mid-turn between + // tool calls. SendUserMessage (BriefTool) is enabled via the brief env + // var. SleepTool stays disabled (its isEnabled() gates on proactive). + // kairosEnabled is computed once here and reused at the + // getAssistantSystemPromptAddendum() call site further down. + // + // Trust gate: .claude/settings.json is attacker-controllable in an + // untrusted clone. We run ~1000 lines before showSetupScreens() shows + // the trust dialog, and by then we've already appended + // .claude/agents/assistant.md to the system prompt. Refuse to activate + // until the directory has been explicitly trusted. + let kairosEnabled = false; + let assistantTeamContext: + | Awaited['initializeAssistantTeam']>> + | undefined; + if (feature('KAIROS') && (options as { assistant?: boolean }).assistant && assistantModule) { + // --assistant (Agent SDK daemon mode): force the latch before + // isAssistantMode() runs below. The daemon has already checked + // entitlement — don't make the child re-check tengu_kairos. + assistantModule.markAssistantForced(); + } + if ( + feature('KAIROS') && + assistantModule && + (assistantModule.isAssistantForced() || (options as Record).assistant === true) && + // Spawned teammates share the leader's cwd + settings.json, so + // the flag is true for them too. --agent-id being set + // means we ARE a spawned teammate (extractTeammateOptions runs + // ~170 lines later so check the raw commander option) — don't + // re-init the team or override teammateMode/proactive/brief. + !(options as { agentId?: unknown }).agentId && + kairosGate + ) { + if (!checkHasTrustDialogAccepted()) { + console.warn( + chalk.yellow('Assistant mode disabled: directory is not trusted. Accept the trust dialog and restart.'), + ); + } else { + // Blocking gate check — returns cached `true` instantly; if disk + // cache is false/missing, lazily inits GrowthBook and fetches fresh + // (max ~5s). --assistant skips the gate entirely (daemon is + // pre-entitled). + kairosEnabled = assistantModule.isAssistantForced() || (await kairosGate.isKairosEnabled()); + if (kairosEnabled) { + const opts = options as { brief?: boolean }; + opts.brief = true; + setKairosActive(true); + // Pre-seed an in-process team so Agent(name: "foo") spawns + // teammates without TeamCreate. Must run BEFORE setup() captures + // the teammateMode snapshot (initializeAssistantTeam calls + // setCliTeammateModeOverride internally). + assistantTeamContext = await assistantModule.initializeAssistantTeam(); + } + } + } + + const { + debug = false, + debugToStderr = false, + dangerouslySkipPermissions, + allowDangerouslySkipPermissions = false, + tools: baseTools = [], + allowedTools = [], + disallowedTools = [], + mcpConfig = [], + permissionMode: permissionModeCli, + addDir = [], + fallbackModel, + betas = [], + ide = false, + sessionId, + includeHookEvents, + includePartialMessages, + } = options; + + if (options.prefill) { + seedEarlyInput(options.prefill); + } + + // Promise for file downloads - started early, awaited before REPL renders + let fileDownloadPromise: Promise | undefined; + + const agentsJson = options.agents; + const agentCli = options.agent; + if (feature('BG_SESSIONS') && agentCli) { + process.env.CLAUDE_CODE_AGENT = agentCli; + } + + // NOTE: LSP manager initialization is intentionally deferred until after + // the trust dialog is accepted. This prevents plugin LSP servers from + // executing code in untrusted directories before user consent. + + // Extract these separately so they can be modified if needed + let outputFormat = options.outputFormat; + let inputFormat = options.inputFormat; + let verbose = options.verbose ?? getGlobalConfig().verbose; + let print = options.print; + const init = options.init ?? false; + const initOnly = options.initOnly ?? false; + const maintenance = options.maintenance ?? false; + + // Extract disable slash commands flag + const disableSlashCommands = options.disableSlashCommands || false; + + // Extract tasks mode options (ant-only) + const tasksOption = process.env.USER_TYPE === 'ant' && (options as { tasks?: boolean | string }).tasks; + const taskListId = tasksOption + ? typeof tasksOption === 'string' + ? tasksOption + : DEFAULT_TASKS_MODE_TASK_LIST_ID + : undefined; + if (process.env.USER_TYPE === 'ant' && taskListId) { + process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId; + } + + // Extract worktree option + // worktree can be true (flag without value) or a string (custom name or PR reference) + const worktreeOption = isWorktreeModeEnabled() + ? (options as { worktree?: boolean | string }).worktree + : undefined; + let worktreeName = typeof worktreeOption === 'string' ? worktreeOption : undefined; + const worktreeEnabled = worktreeOption !== undefined; + + // Check if worktree name is a PR reference (#N or GitHub PR URL) + let worktreePRNumber: number | undefined; + if (worktreeName) { + const prNum = parsePRReference(worktreeName); + if (prNum !== null) { + worktreePRNumber = prNum; + worktreeName = undefined; // slug will be generated in setup() + } + } + + // Extract tmux option (requires --worktree) + const tmuxEnabled = isWorktreeModeEnabled() && (options as { tmux?: boolean }).tmux === true; + + // Validate tmux option + if (tmuxEnabled) { + if (!worktreeEnabled) { + process.stderr.write(chalk.red('Error: --tmux requires --worktree\n')); + process.exit(1); + } + if (getPlatform() === 'windows') { + process.stderr.write(chalk.red('Error: --tmux is not supported on Windows\n')); + process.exit(1); + } + if (!(await isTmuxAvailable())) { + process.stderr.write(chalk.red(`Error: tmux is not installed.\n${getTmuxInstallInstructions()}\n`)); + process.exit(1); + } + } + + // Extract teammate options (for tmux-spawned agents) + // Declared outside the if block so it's accessible later for system prompt addendum + let storedTeammateOpts: TeammateOptions | undefined; + if (isAgentSwarmsEnabled()) { + // Extract agent identity options (for tmux-spawned agents) + // These replace the CLAUDE_CODE_* environment variables + const teammateOpts = extractTeammateOptions(options); + storedTeammateOpts = teammateOpts; + + // If any teammate identity option is provided, all three required ones must be present + const hasAnyTeammateOpt = teammateOpts.agentId || teammateOpts.agentName || teammateOpts.teamName; + const hasAllRequiredTeammateOpts = teammateOpts.agentId && teammateOpts.agentName && teammateOpts.teamName; + + if (hasAnyTeammateOpt && !hasAllRequiredTeammateOpts) { + process.stderr.write( + chalk.red('Error: --agent-id, --agent-name, and --team-name must all be provided together\n'), + ); + process.exit(1); + } + + // If teammate identity is provided via CLI, set up dynamicTeamContext + if (teammateOpts.agentId && teammateOpts.agentName && teammateOpts.teamName) { + getTeammateUtils().setDynamicTeamContext?.({ + agentId: teammateOpts.agentId, + agentName: teammateOpts.agentName, + teamName: teammateOpts.teamName, + color: teammateOpts.agentColor, + planModeRequired: teammateOpts.planModeRequired ?? false, + parentSessionId: teammateOpts.parentSessionId, + }); + } + + // Set teammate mode CLI override if provided + // This must be done before setup() captures the snapshot + if (teammateOpts.teammateMode) { + getTeammateModeSnapshot().setCliTeammateModeOverride?.(teammateOpts.teammateMode); + } + } + + // Extract remote sdk options + const sdkUrl = (options as { sdkUrl?: string }).sdkUrl ?? undefined; + + // Allow env var to enable partial messages (used by sandbox gateway for baku) + const effectiveIncludePartialMessages = + includePartialMessages || isEnvTruthy(process.env.CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES); + + // Enable all hook event types when explicitly requested via SDK option + // or when running in CLAUDE_CODE_REMOTE mode (CCR needs them). + // Without this, only SessionStart and Setup events are emitted. + if (includeHookEvents || isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { + setAllHookEventsEnabled(true); + } + + // Auto-set input/output formats, verbose mode, and print mode when SDK URL is provided + if (sdkUrl) { + // If SDK URL is provided, automatically use stream-json formats unless explicitly set + if (!inputFormat) { + inputFormat = 'stream-json'; + } + if (!outputFormat) { + outputFormat = 'stream-json'; + } + // Auto-enable verbose mode unless explicitly disabled or already set + if (options.verbose === undefined) { + verbose = true; + } + // Auto-enable print mode unless explicitly disabled + if (!options.print) { + print = true; + } + } + + // Extract teleport option + const teleport = (options as { teleport?: string | true }).teleport ?? null; + + // Extract remote option (can be true if no description provided, or a string) + const remoteOption = (options as { remote?: string | true }).remote; + const remote = remoteOption === true ? '' : (remoteOption ?? null); + + // Extract --remote-control / --rc flag (enable bridge in interactive session) + const remoteControlOption = + (options as { remoteControl?: string | true }).remoteControl ?? (options as { rc?: string | true }).rc; + // Actual bridge check is deferred to after showSetupScreens() so that + // trust is established and GrowthBook has auth headers. + let remoteControl = false; + const remoteControlName = + typeof remoteControlOption === 'string' && remoteControlOption.length > 0 ? remoteControlOption : undefined; + + // Validate session ID if provided + if (sessionId) { + // Check for conflicting flags + // --session-id can be used with --continue or --resume when --fork-session is also provided + // (to specify a custom ID for the forked session) + if ((options.continue || options.resume) && !options.forkSession) { + process.stderr.write( + chalk.red( + 'Error: --session-id can only be used with --continue or --resume if --fork-session is also specified.\n', + ), + ); + process.exit(1); + } + + // When --sdk-url is provided (bridge/remote mode), the session ID is a + // server-assigned tagged ID (e.g. "session_local_01...") rather than a + // UUID. Skip UUID validation and local existence checks in that case. + if (!sdkUrl) { + const validatedSessionId = validateUuid(sessionId); + if (!validatedSessionId) { + process.stderr.write(chalk.red('Error: Invalid session ID. Must be a valid UUID.\n')); + process.exit(1); + } + + // Check if session ID already exists + if (sessionIdExists(validatedSessionId)) { + process.stderr.write(chalk.red(`Error: Session ID ${validatedSessionId} is already in use.\n`)); + process.exit(1); + } + } + } + + // Download file resources if specified via --file flag + const fileSpecs = (options as { file?: string[] }).file; + if (fileSpecs && fileSpecs.length > 0) { + // Get session ingress token (provided by EnvManager via CLAUDE_CODE_SESSION_ACCESS_TOKEN) + const sessionToken = getSessionIngressAuthToken(); + if (!sessionToken) { + process.stderr.write( + chalk.red( + 'Error: Session token required for file downloads. CLAUDE_CODE_SESSION_ACCESS_TOKEN must be set.\n', + ), + ); + process.exit(1); + } + + // Resolve session ID: prefer remote session ID, fall back to internal session ID + const fileSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID || getSessionId(); + + const files = parseFileSpecs(fileSpecs); + if (files.length > 0) { + // Use ANTHROPIC_BASE_URL if set (by EnvManager), otherwise use OAuth config + // This ensures consistency with session ingress API in all environments + const config: FilesApiConfig = { + baseUrl: process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL, + oauthToken: sessionToken, + sessionId: fileSessionId, + }; + + // Start download without blocking startup - await before REPL renders + fileDownloadPromise = downloadSessionFiles(files, config); + } + } + + // Get isNonInteractiveSession from state (was set before init()) + const isNonInteractiveSession = getIsNonInteractiveSession(); + + // Validate that fallback model is different from main model + if (fallbackModel && options.model && fallbackModel === options.model) { + process.stderr.write( + chalk.red( + 'Error: Fallback model cannot be the same as the main model. Please specify a different model for --fallback-model.\n', + ), + ); + process.exit(1); + } + + // Handle system prompt options + let systemPrompt = options.systemPrompt; + if (options.systemPromptFile) { + if (options.systemPrompt) { + process.stderr.write( + chalk.red('Error: Cannot use both --system-prompt and --system-prompt-file. Please use only one.\n'), + ); + process.exit(1); + } + + try { + const filePath = resolve(options.systemPromptFile); + systemPrompt = readFileSync(filePath, 'utf8'); + } catch (error) { + const code = getErrnoCode(error); + if (code === 'ENOENT') { + process.stderr.write( + chalk.red(`Error: System prompt file not found: ${resolve(options.systemPromptFile)}\n`), + ); + process.exit(1); + } + process.stderr.write(chalk.red(`Error reading system prompt file: ${errorMessage(error)}\n`)); + process.exit(1); + } + } + + // Handle append system prompt options + let appendSystemPrompt = options.appendSystemPrompt; + if (options.appendSystemPromptFile) { + if (options.appendSystemPrompt) { + process.stderr.write( + chalk.red( + 'Error: Cannot use both --append-system-prompt and --append-system-prompt-file. Please use only one.\n', + ), + ); + process.exit(1); + } + + try { + const filePath = resolve(options.appendSystemPromptFile); + appendSystemPrompt = readFileSync(filePath, 'utf8'); + } catch (error) { + const code = getErrnoCode(error); + if (code === 'ENOENT') { + process.stderr.write( + chalk.red(`Error: Append system prompt file not found: ${resolve(options.appendSystemPromptFile)}\n`), + ); + process.exit(1); + } + process.stderr.write(chalk.red(`Error reading append system prompt file: ${errorMessage(error)}\n`)); + process.exit(1); + } + } + + // Add teammate-specific system prompt addendum for tmux teammates + if ( + isAgentSwarmsEnabled() && + storedTeammateOpts?.agentId && + storedTeammateOpts?.agentName && + storedTeammateOpts?.teamName + ) { + const addendum = getTeammatePromptAddendum().TEAMMATE_SYSTEM_PROMPT_ADDENDUM; + appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${addendum}` : addendum; + } + + const { mode: permissionMode, notification: permissionModeNotification } = initialPermissionModeFromCLI({ + permissionModeCli, + dangerouslySkipPermissions, + }); + + // Store session bypass permissions mode for trust dialog check + setSessionBypassPermissionsMode(permissionMode === 'bypassPermissions'); + if (feature('TRANSCRIPT_CLASSIFIER')) { + // autoModeFlagCli is the "did the user intend auto this session" signal. + // Set when: --enable-auto-mode, --permission-mode auto, resolved mode + // is auto, OR settings defaultMode is auto but the gate denied it + // (permissionMode resolved to default with no explicit CLI override). + // Used by verifyAutoModeGateAccess to decide whether to notify on + // auto-unavailable, and by tengu_auto_mode_config opt-in carousel. + if ( + (options as { enableAutoMode?: boolean }).enableAutoMode || + permissionModeCli === 'auto' || + permissionMode === 'auto' || + (!permissionModeCli && isDefaultPermissionModeAuto()) + ) { + autoModeStateModule?.setAutoModeFlagCli(true); + } + } + + // Parse the MCP config files/strings if provided + let dynamicMcpConfig: Record = { + // Built-in MCP servers (default disabled, user enables via /mcp) + 'mcp-chrome': { + type: 'http', + url: 'http://127.0.0.1:12306/mcp', + scope: 'dynamic', + headers: { + Authorization: 'Bearer my-static-token', + }, + }, + }; + + if (mcpConfig && mcpConfig.length > 0) { + // Process mcpConfig array + const processedConfigs = mcpConfig.map(config => config.trim()).filter(config => config.length > 0); + + let allConfigs: Record = {}; + const allErrors: ValidationError[] = []; + + for (const configItem of processedConfigs) { + let configs: Record | null = null; + let errors: ValidationError[] = []; + + // First try to parse as JSON string + const parsedJson = safeParseJSON(configItem); + if (parsedJson) { + const result = parseMcpConfig({ + configObject: parsedJson, + filePath: 'command line', + expandVars: true, + scope: 'dynamic', + }); + if (result.config) { + configs = result.config.mcpServers; + } else { + errors = result.errors; + } + } else { + // Try as file path + const configPath = resolve(configItem); + const result = parseMcpConfigFromFilePath({ + filePath: configPath, + expandVars: true, + scope: 'dynamic', + }); + if (result.config) { + configs = result.config.mcpServers; + } else { + errors = result.errors; + } + } + + if (errors.length > 0) { + allErrors.push(...errors); + } else if (configs) { + // Merge configs, later ones override earlier ones + allConfigs = { ...allConfigs, ...configs }; + } + } + + if (allErrors.length > 0) { + const formattedErrors = allErrors.map(err => `${err.path ? err.path + ': ' : ''}${err.message}`).join('\n'); + logForDebugging(`--mcp-config validation failed (${allErrors.length} errors): ${formattedErrors}`, { + level: 'error', + }); + process.stderr.write(`Error: Invalid MCP configuration:\n${formattedErrors}\n`); + process.exit(1); + } + + if (Object.keys(allConfigs).length > 0) { + // SDK hosts (Nest/Desktop) own their server naming and may reuse + // built-in names — skip reserved-name checks for type:'sdk'. + const nonSdkConfigNames = Object.entries(allConfigs) + .filter(([, config]) => config.type !== 'sdk') + .map(([name]) => name); + + let reservedNameError: string | null = null; + if (nonSdkConfigNames.some(isClaudeInChromeMCPServer)) { + reservedNameError = `Invalid MCP configuration: "${CLAUDE_IN_CHROME_MCP_SERVER_NAME}" is a reserved MCP name.`; + } else if (feature('CHICAGO_MCP')) { + const { isComputerUseMCPServer, COMPUTER_USE_MCP_SERVER_NAME } = await import( + 'src/utils/computerUse/common.js' + ); + if (nonSdkConfigNames.some(isComputerUseMCPServer)) { + reservedNameError = `Invalid MCP configuration: "${COMPUTER_USE_MCP_SERVER_NAME}" is a reserved MCP name.`; + } + } + if (reservedNameError) { + // stderr+exit(1) — a throw here becomes a silent unhandled + // rejection in stream-json mode (void main() in cli.tsx). + process.stderr.write(`Error: ${reservedNameError}\n`); + process.exit(1); + } + + // Add dynamic scope to all configs. type:'sdk' entries pass through + // unchanged — they're extracted into sdkMcpConfigs downstream and + // passed to print.ts. The Python SDK relies on this path (it doesn't + // send sdkMcpServers in the initialize message). Dropping them here + // broke Coworker (inc-5122). The policy filter below already exempts + // type:'sdk', and the entries are inert without an SDK transport on + // stdin, so there's no bypass risk from letting them through. + const scopedConfigs = mapValues(allConfigs, config => ({ + ...config, + scope: 'dynamic' as const, + })); + + // Enforce managed policy (allowedMcpServers / deniedMcpServers) on + // --mcp-config servers. Without this, the CLI flag bypasses the + // enterprise allowlist that user/project/local configs go through in + // getClaudeCodeMcpConfigs — callers spread dynamicMcpConfig back on + // top of filtered results. Filter here at the source so all + // downstream consumers see the policy-filtered set. + const { allowed, blocked } = filterMcpServersByPolicy(scopedConfigs); + if (blocked.length > 0) { + process.stderr.write( + `Warning: MCP ${plural(blocked.length, 'server')} blocked by enterprise policy: ${blocked.join(', ')}\n`, + ); + } + dynamicMcpConfig = { ...dynamicMcpConfig, ...(allowed as Record) }; + } + } + + // Extract Claude in Chrome option and enforce claude.ai subscriber check (unless user is ant) + const chromeOpts = options as { chrome?: boolean }; + // Store the explicit CLI flag so teammates can inherit it + setChromeFlagOverride(chromeOpts.chrome); + const enableClaudeInChrome = + shouldEnableClaudeInChrome(chromeOpts.chrome) && (process.env.USER_TYPE === 'ant' || isClaudeAISubscriber()); + const autoEnableClaudeInChrome = !enableClaudeInChrome && shouldAutoEnableClaudeInChrome(); + + if (enableClaudeInChrome) { + const platform = getPlatform(); + try { + logEvent('tengu_claude_in_chrome_setup', { + platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + + const { + mcpConfig: chromeMcpConfig, + allowedTools: chromeMcpTools, + systemPrompt: chromeSystemPrompt, + } = setupClaudeInChrome(); + dynamicMcpConfig = { + ...dynamicMcpConfig, + ...chromeMcpConfig, + }; + allowedTools.push(...chromeMcpTools); + if (chromeSystemPrompt) { + appendSystemPrompt = appendSystemPrompt + ? `${chromeSystemPrompt}\n\n${appendSystemPrompt}` + : chromeSystemPrompt; + } + } catch (error) { + logEvent('tengu_claude_in_chrome_setup_failed', { + platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + logForDebugging(`[Claude in Chrome] Error: ${error}`); + logError(error); + console.error(`Error: Failed to run with Claude in Chrome.`); + process.exit(1); + } + } else if (autoEnableClaudeInChrome) { + try { + const { mcpConfig: chromeMcpConfig } = setupClaudeInChrome(); + dynamicMcpConfig = { + ...dynamicMcpConfig, + ...chromeMcpConfig, + }; + + const hint = + feature('WEB_BROWSER_TOOL') && typeof Bun !== 'undefined' && 'WebView' in Bun + ? CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER + : CLAUDE_IN_CHROME_SKILL_HINT; + appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${hint}` : hint; + } catch (error) { + // Silently skip any errors for the auto-enable + logForDebugging(`[Claude in Chrome] Error (auto-enable): ${error}`); + } + } + + // Extract strict MCP config flag + const strictMcpConfig = options.strictMcpConfig || false; + + // Check if enterprise MCP configuration exists. When it does, only allow dynamic MCP + // configs that contain special server types (sdk) + if (doesEnterpriseMcpConfigExist()) { + if (strictMcpConfig) { + process.stderr.write( + chalk.red('You cannot use --strict-mcp-config when an enterprise MCP config is present'), + ); + process.exit(1); + } + + // For --mcp-config, allow if all servers are internal types (sdk) + if (dynamicMcpConfig && !areMcpConfigsAllowedWithEnterpriseMcpConfig(dynamicMcpConfig)) { + process.stderr.write( + chalk.red('You cannot dynamically configure MCP servers when an enterprise MCP config is present'), + ); + process.exit(1); + } + } + + // chicago MCP: guarded Computer Use (app allowlist + frontmost gate + + // SCContentFilter screenshots). Ant-only, GrowthBook-gated — failures + // are silent (this is dogfooding). Platform + interactive checks inline + // so non-macOS / print-mode ants skip the heavy @ant/computer-use-mcp + // import entirely. gates.js is light (type-only package import). + // + // Placed AFTER the enterprise-MCP-config check: that check rejects any + // dynamicMcpConfig entry with `type !== 'sdk'`, and our config is + // `type: 'stdio'`. An enterprise-config ant with the GB gate on would + // otherwise process.exit(1). Chrome has the same latent issue but has + // shipped without incident; chicago places itself correctly. + if (feature('CHICAGO_MCP') && getPlatform() !== 'unknown' && !getIsNonInteractiveSession()) { + try { + const { getChicagoEnabled } = await import('src/utils/computerUse/gates.js'); + if (getChicagoEnabled()) { + const { setupComputerUseMCP } = await import('src/utils/computerUse/setup.js'); + const { mcpConfig, allowedTools: cuTools } = setupComputerUseMCP(); + dynamicMcpConfig = { + ...dynamicMcpConfig, + ...mcpConfig, + }; + allowedTools.push(...cuTools); + } + } catch (error) { + logForDebugging(`[Computer Use MCP] Setup failed: ${errorMessage(error)}`); + } + } + + // Store additional directories for CLAUDE.md loading (controlled by env var) + setAdditionalDirectoriesForClaudeMd(addDir); + + // Channel server allowlist from --channels flag — servers whose + // inbound push notifications should register this session. The option + // is added inside a feature() block so TS doesn't know about it + // on the options type — same pattern as --assistant at main.tsx:1824. + // devChannels is deferred: showSetupScreens shows a confirmation dialog + // and only appends to allowedChannels on accept. + let devChannels: ChannelEntry[] | undefined; + // Parse plugin:name@marketplace / server:Y tags into typed entries. + // Tag decides trust model downstream: plugin-kind hits marketplace + // verification + GrowthBook allowlist, server-kind always fails + // allowlist (schema is plugin-only) unless dev flag is set. + // Untagged or marketplace-less plugin entries are hard errors — + // silently not-matching in the gate would look like channels are + // "on" but nothing ever fires. + const parseChannelEntries = (raw: string[], flag: string): ChannelEntry[] => { + const entries: ChannelEntry[] = []; + const bad: string[] = []; + for (const c of raw) { + if (c.startsWith('plugin:')) { + const rest = c.slice(7); + const at = rest.indexOf('@'); + if (at <= 0 || at === rest.length - 1) { + bad.push(c); + } else { + entries.push({ + kind: 'plugin', + name: rest.slice(0, at), + marketplace: rest.slice(at + 1), + }); + } + } else if (c.startsWith('server:') && c.length > 7) { + entries.push({ kind: 'server', name: c.slice(7) }); + } else { + bad.push(c); + } + } + if (bad.length > 0) { + process.stderr.write( + chalk.red( + `${flag} entries must be tagged: ${bad.join(', ')}\n` + + ` plugin:@ — plugin-provided channel (allowlist enforced)\n` + + ` server: — manually configured MCP server\n`, + ), + ); + process.exit(1); + } + return entries; + }; + + const channelOpts = options as { + channels?: string[]; + dangerouslyLoadDevelopmentChannels?: string[]; + }; + const rawChannels = channelOpts.channels; + const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels; + // Always parse + set. ChannelsNotice reads getAllowedChannels() and + // renders the appropriate branch (disabled/noAuth/policyBlocked/ + // listening) in the startup screen. gateChannelServer() enforces. + // --channels works in both interactive and print/SDK modes; dev-channels + // stays interactive-only (requires a confirmation dialog). + let channelEntries: ChannelEntry[] = []; + if (rawChannels && rawChannels.length > 0) { + channelEntries = parseChannelEntries(rawChannels, '--channels'); + setAllowedChannels(channelEntries); + } + if (!isNonInteractiveSession) { + if (rawDev && rawDev.length > 0) { + devChannels = parseChannelEntries(rawDev, '--dangerously-load-development-channels'); + } + } + // Flag-usage telemetry. Plugin identifiers are logged (same tier as + // tengu_plugin_installed — public-registry-style names); server-kind + // names are not (MCP-server-name tier, opt-in-only elsewhere). + // Per-server gate outcomes land in tengu_mcp_channel_gate once + // servers connect. Dev entries go through a confirmation dialog after + // this — dev_plugins captures what was typed, not what was accepted. + if (channelEntries.length > 0 || (devChannels?.length ?? 0) > 0) { + const joinPluginIds = (entries: ChannelEntry[]) => { + const ids = entries.flatMap(e => (e.kind === 'plugin' ? [`${e.name}@${e.marketplace}`] : [])); + return ids.length > 0 + ? (ids.sort().join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + : undefined; + }; + logEvent('tengu_mcp_channel_flags', { + channels_count: channelEntries.length, + dev_count: devChannels?.length ?? 0, + plugins: joinPluginIds(channelEntries), + dev_plugins: joinPluginIds(devChannels ?? []), + }); + } + + // SDK opt-in for SendUserMessage via --tools. All sessions require + // explicit opt-in; listing it in --tools signals intent. Runs BEFORE + // initializeToolPermissionContext so getToolsForDefaultPreset() sees + // the tool as enabled when computing the base-tools disallow filter. + // Conditional require avoids leaking the tool-name string into + // external builds. + if ((feature('KAIROS') || feature('KAIROS_BRIEF')) && baseTools.length > 0) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { BRIEF_TOOL_NAME, LEGACY_BRIEF_TOOL_NAME } = + require('@claude-code-best/builtin-tools/tools/BriefTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/prompt.js'); + const { isBriefEntitled } = + require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js'); + /* eslint-enable @typescript-eslint/no-require-imports */ + const parsed = parseToolListFromCLI(baseTools); + if ((parsed.includes(BRIEF_TOOL_NAME) || parsed.includes(LEGACY_BRIEF_TOOL_NAME)) && isBriefEntitled()) { + setUserMsgOptIn(true); + } + } + + // This await replaces blocking existsSync/statSync calls that were already in + // the startup path. Wall-clock time is unchanged; we just yield to the event + // loop during the fs I/O instead of blocking it. See #19661. + const initResult = await initializeToolPermissionContext({ + allowedToolsCli: allowedTools, + disallowedToolsCli: disallowedTools, + baseToolsCli: baseTools, + permissionMode, + allowDangerouslySkipPermissions, + addDirs: addDir, + }); + let toolPermissionContext = initResult.toolPermissionContext; + const { warnings, dangerousPermissions, overlyBroadBashPermissions } = initResult; + + // Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*)) + if (process.env.USER_TYPE === 'ant' && overlyBroadBashPermissions.length > 0) { + for (const permission of overlyBroadBashPermissions) { + logForDebugging( + `Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`, + ); + } + toolPermissionContext = removeDangerousPermissions(toolPermissionContext, overlyBroadBashPermissions); + } + + if (feature('TRANSCRIPT_CLASSIFIER') && dangerousPermissions.length > 0) { + toolPermissionContext = stripDangerousPermissionsForAutoMode(toolPermissionContext); + } + + // Print any warnings from initialization + warnings.forEach(warning => { + console.error(warning); + }); + + // claude.ai config fetch: -p mode only (interactive uses useManageMCPConnections + // two-phase loading). Kicked off here to overlap with setup(); awaited + // before runHeadless so single-turn -p sees connectors. Skipped under + // enterprise/strict MCP to preserve policy boundaries. + const claudeaiConfigPromise: Promise> = + isNonInteractiveSession && + !strictMcpConfig && + !doesEnterpriseMcpConfigExist() && + // --bare / SIMPLE: skip claude.ai proxy servers (datadog, Gmail, + // Slack, BigQuery, PubMed — 6-14s each to connect). Scripted calls + // that need MCP pass --mcp-config explicitly. + !isBareMode() + ? fetchClaudeAIMcpConfigsIfEligible().then(configs => { + const { allowed, blocked } = filterMcpServersByPolicy(configs); + if (blocked.length > 0) { + process.stderr.write( + `Warning: costrict.ai MCP ${plural(blocked.length, 'server')} blocked by enterprise policy: ${blocked.join(', ')}\n`, + ); + } + return allowed; + }) + : Promise.resolve({}); + + // Kick off MCP config loading early (safe - just reads files, no execution). + // Both interactive and -p use getClaudeCodeMcpConfigs (local file reads only). + // The local promise is awaited later (before prefetchAllMcpResources) to + // overlap config I/O with setup(), commands loading, and trust dialog. + logForDebugging('[STARTUP] Loading MCP configs...'); + const mcpConfigStart = Date.now(); + let mcpConfigResolvedMs: number | undefined; + // --bare skips auto-discovered MCP (.mcp.json, user settings, plugins) — + // only explicit --mcp-config works. dynamicMcpConfig is spread onto + // allMcpConfigs downstream so it survives this skip. + const mcpConfigPromise = ( + strictMcpConfig || isBareMode() + ? Promise.resolve({ + servers: {} as Record, + }) + : getClaudeCodeMcpConfigs(dynamicMcpConfig) + ).then(result => { + mcpConfigResolvedMs = Date.now() - mcpConfigStart; + return result; + }); + + // NOTE: We do NOT call prefetchAllMcpResources here - that's deferred until after trust dialog + + if (inputFormat && inputFormat !== 'text' && inputFormat !== 'stream-json') { + console.error(`Error: Invalid input format "${inputFormat}".`); + process.exit(1); + } + if (inputFormat === 'stream-json' && outputFormat !== 'stream-json') { + console.error(`Error: --input-format=stream-json requires output-format=stream-json.`); + process.exit(1); + } + + // Validate sdkUrl is only used with appropriate formats (formats are auto-set above) + if (sdkUrl) { + if (inputFormat !== 'stream-json' || outputFormat !== 'stream-json') { + console.error(`Error: --sdk-url requires both --input-format=stream-json and --output-format=stream-json.`); + process.exit(1); + } + } + + // Validate replayUserMessages is only used with stream-json formats + if (options.replayUserMessages) { + if (inputFormat !== 'stream-json' || outputFormat !== 'stream-json') { + console.error( + `Error: --replay-user-messages requires both --input-format=stream-json and --output-format=stream-json.`, + ); + process.exit(1); + } + } + + // Validate includePartialMessages is only used with print mode and stream-json output + if (effectiveIncludePartialMessages) { + if (!isNonInteractiveSession || outputFormat !== 'stream-json') { + writeToStderr(`Error: --include-partial-messages requires --print and --output-format=stream-json.`); + process.exit(1); + } + } + + // Validate --no-session-persistence is only used with print mode + if (options.sessionPersistence === false && !isNonInteractiveSession) { + writeToStderr(`Error: --no-session-persistence can only be used with --print mode.`); + process.exit(1); + } + + const effectivePrompt = prompt || ''; + let inputPrompt = await getInputPrompt(effectivePrompt, (inputFormat ?? 'text') as 'text' | 'stream-json'); + profileCheckpoint('action_after_input_prompt'); + + // Activate proactive mode BEFORE getTools() so SleepTool.isEnabled() + // (which returns isProactiveActive()) passes and Sleep is included. + // The later REPL-path maybeActivateProactive() calls are idempotent. + maybeActivateProactive(options); + + let tools = getTools(toolPermissionContext); + + // Apply coordinator mode tool filtering for headless path + // (mirrors useMergedTools.ts filtering for REPL/interactive path) + if (feature('COORDINATOR_MODE') && isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)) { + const { applyCoordinatorToolFilter } = await import('./utils/toolPool.js'); + tools = applyCoordinatorToolFilter(tools); + } + + profileCheckpoint('action_tools_loaded'); + + let jsonSchema: ToolInputJSONSchema | undefined; + if (isSyntheticOutputToolEnabled({ isNonInteractiveSession }) && options.jsonSchema) { + jsonSchema = jsonParse(options.jsonSchema) as ToolInputJSONSchema; + } + + if (jsonSchema) { + const syntheticOutputResult = createSyntheticOutputTool(jsonSchema); + if ('tool' in syntheticOutputResult) { + // Add SyntheticOutputTool to the tools array AFTER getTools() filtering. + // This tool is excluded from normal filtering (see tools.ts) because it's + // an implementation detail for structured output, not a user-controlled tool. + tools = [...tools, syntheticOutputResult.tool]; + + logEvent('tengu_structured_output_enabled', { + schema_property_count: Object.keys((jsonSchema.properties as Record) || {}) + .length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + has_required_fields: Boolean( + jsonSchema.required, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + } else { + logEvent('tengu_structured_output_failure', { + error: 'Invalid JSON schema' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + } + } + + // IMPORTANT: setup() must be called before any other code that depends on the cwd or worktree setup + profileCheckpoint('action_before_setup'); + logForDebugging('[STARTUP] Running setup()...'); + const setupStart = Date.now(); + const { setup } = await import('./setup.js'); + const messagingSocketPath = feature('UDS_INBOX') + ? (options as { messagingSocketPath?: string }).messagingSocketPath + : undefined; + // Parallelize setup() with commands+agents loading. setup()'s ~28ms is + // mostly startUdsMessaging (socket bind, ~20ms) — not disk-bound, so it + // doesn't contend with getCommands' file reads. Gated on !worktreeEnabled + // since --worktree makes setup() process.chdir() (setup.ts:203), and + // commands/agents need the post-chdir cwd. + const preSetupCwd = getCwd(); + // Register bundled skills/plugins before kicking getCommands() — they're + // pure in-memory array pushes (<1ms, zero I/O) that getBundledSkills() + // reads synchronously. Previously ran inside setup() after ~20ms of + // await points, so the parallel getCommands() memoized an empty list. + if (process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent') { + initBuiltinPlugins(); + initBundledSkills(); + } + const setupPromise = setup( + preSetupCwd, + permissionMode, + allowDangerouslySkipPermissions, + worktreeEnabled, + worktreeName, + tmuxEnabled, + sessionId ? validateUuid(sessionId) : undefined, + worktreePRNumber, + messagingSocketPath, + ); + const commandsPromise = worktreeEnabled ? null : getCommands(preSetupCwd); + const agentDefsPromise = worktreeEnabled ? null : getAgentDefinitionsWithOverrides(preSetupCwd); + // Suppress transient unhandledRejection if these reject during the + // ~28ms setupPromise await before Promise.all joins them below. + commandsPromise?.catch(() => {}); + agentDefsPromise?.catch(() => {}); + await setupPromise; + logForDebugging(`[STARTUP] setup() completed in ${Date.now() - setupStart}ms`); + profileCheckpoint('action_after_setup'); + + // Replay user messages into stream-json only when the socket was + // explicitly requested. The auto-generated socket is passive — it + // lets tools inject if they want to, but turning it on by default + // shouldn't reshape stream-json for SDK consumers who never touch it. + // Callers who inject and also want those injections visible in the + // stream pass --messaging-socket-path explicitly (or --replay-user-messages). + let effectiveReplayUserMessages = !!options.replayUserMessages; + if (feature('UDS_INBOX')) { + if (!effectiveReplayUserMessages && outputFormat === 'stream-json') { + effectiveReplayUserMessages = !!(options as { messagingSocketPath?: string }).messagingSocketPath; + } + } + + if (getIsNonInteractiveSession()) { + // Apply full merged settings env now (including project-scoped + // .claude/settings.json PATH/GIT_DIR/GIT_WORK_TREE) so gitExe() and + // the git spawn below see it. Trust is implicit in -p mode; the + // docstring at managedEnv.ts:96-97 says this applies "potentially + // dangerous environment variables such as LD_PRELOAD, PATH" from all + // sources. The later call in the isNonInteractiveSession block below + // is idempotent (Object.assign, configureGlobalAgents ejects prior + // interceptor) and picks up any plugin-contributed env after plugin + // init. Project settings are already loaded here: + // applySafeConfigEnvironmentVariables in init() called + // getSettings_DEPRECATED at managedEnv.ts:86 which merges all enabled + // sources including projectSettings/localSettings. + applyConfigEnvironmentVariables(); + + // Spawn git status/log/branch now so the subprocess execution overlaps + // with the getCommands await below and startDeferredPrefetches. After + // setup() so cwd is final (setup.ts:254 may process.chdir(worktreePath) + // for --worktree) and after the applyConfigEnvironmentVariables above + // so PATH/GIT_DIR/GIT_WORK_TREE from all sources (trusted + project) + // are applied. getSystemContext is memoized; the + // prefetchSystemContextIfSafe call in startDeferredPrefetches becomes + // a cache hit. The microtask from await getIsGit() drains at the + // getCommands Promise.all await below. Trust is implicit in -p mode + // (same gate as prefetchSystemContextIfSafe). + void getSystemContext(); + // Kick getUserContext now too — its first await (fs.readFile in + // getMemoryFiles) yields naturally, so the CLAUDE.md directory walk + // runs during the ~280ms overlap window before the context + // Promise.all join in print.ts. The void getUserContext() in + // startDeferredPrefetches becomes a memoize cache-hit. + void getUserContext(); + // Kick ensureModelStringsInitialized now — for Bedrock this triggers + // a 100-200ms profile fetch that was awaited serially at + // print.ts:739. updateBedrockModelStrings is sequential()-wrapped so + // the await joins the in-flight fetch. Non-Bedrock is a sync + // early-return (zero-cost). + void ensureModelStringsInitialized(); + } + + // Apply --name: cache-only so no orphan file is created before the + // session ID is finalized by --continue/--resume. materializeSessionFile + // persists it on the first user message; REPL's useTerminalTitle reads it + // via getCurrentSessionTitle. + const sessionNameArg = options.name?.trim(); + if (sessionNameArg) { + cacheSessionTitle(sessionNameArg); + } + + // Ant model aliases (capybara-fast etc.) resolve via the + // tengu_ant_model_override GrowthBook flag. _CACHED_MAY_BE_STALE reads + // disk synchronously; disk is populated by a fire-and-forget write. On a + // cold cache, parseUserSpecifiedModel returns the unresolved alias, the + // API 404s, and -p exits before the async write lands — crashloop on + // fresh pods. Awaiting init here populates the in-memory payload map that + // _CACHED_MAY_BE_STALE now checks first. Gated so the warm path stays + // non-blocking: + // - explicit model via --model or ANTHROPIC_MODEL (both feed alias resolution) + // - no env override (which short-circuits _CACHED_MAY_BE_STALE before disk) + // - flag absent from disk (== null also catches pre-#22279 poisoned null) + const explicitModel = options.model || process.env.ANTHROPIC_MODEL; + if ( + process.env.USER_TYPE === 'ant' && + explicitModel && + explicitModel !== 'default' && + !hasGrowthBookEnvOverride('tengu_ant_model_override') && + getGlobalConfig().cachedGrowthBookFeatures?.['tengu_ant_model_override'] == null + ) { + await initializeGrowthBook(); + } + + // Special case the default model with the null keyword + // NOTE: Model resolution happens after setup() to ensure trust is established before AWS auth + const userSpecifiedModel = options.model === 'default' ? getDefaultMainLoopModel() : options.model; + const userSpecifiedFallbackModel = fallbackModel === 'default' ? getDefaultMainLoopModel() : fallbackModel; + + // Reuse preSetupCwd unless setup() chdir'd (worktreeEnabled). Saves a + // getCwd() syscall in the common path. + const currentCwd = worktreeEnabled ? getCwd() : preSetupCwd; + logForDebugging('[STARTUP] Loading commands and agents...'); + const commandsStart = Date.now(); + // Join the promises kicked before setup() (or start fresh if + // worktreeEnabled gated the early kick). Both memoized by cwd. + const [commands, agentDefinitionsResult] = await Promise.all([ + commandsPromise ?? getCommands(currentCwd), + agentDefsPromise ?? getAgentDefinitionsWithOverrides(currentCwd), + ]); + logForDebugging(`[STARTUP] Commands and agents loaded in ${Date.now() - commandsStart}ms`); + profileCheckpoint('action_commands_loaded'); + + // Parse CLI agents if provided via --agents flag + let cliAgents: typeof agentDefinitionsResult.activeAgents = []; + if (agentsJson) { + try { + const parsedAgents = safeParseJSON(agentsJson); + if (parsedAgents) { + cliAgents = parseAgentsFromJson(parsedAgents, 'flagSettings'); + } + } catch (error) { + logError(error); + } + } + + // Merge CLI agents with existing ones + const allAgents = [...agentDefinitionsResult.allAgents, ...cliAgents]; + const agentDefinitions = { + ...agentDefinitionsResult, + allAgents, + activeAgents: getActiveAgentsFromList(allAgents), + }; + + // Look up main thread agent from CLI flag or settings + const agentSetting = agentCli ?? getInitialSettings().agent; + let mainThreadAgentDefinition: (typeof agentDefinitions.activeAgents)[number] | undefined; + if (agentSetting) { + mainThreadAgentDefinition = agentDefinitions.activeAgents.find(agent => agent.agentType === agentSetting); + if (!mainThreadAgentDefinition) { + logForDebugging( + `Warning: agent "${agentSetting}" not found. ` + + `Available agents: ${agentDefinitions.activeAgents.map(a => a.agentType).join(', ')}. ` + + `Using default behavior.`, + ); + } + } + + // Store the main thread agent type in bootstrap state so hooks can access it + setMainThreadAgentType(mainThreadAgentDefinition?.agentType); + + // Log agent flag usage — only log agent name for built-in agents to avoid leaking custom agent names + if (mainThreadAgentDefinition) { + logEvent('tengu_agent_flag', { + agentType: isBuiltInAgent(mainThreadAgentDefinition) + ? (mainThreadAgentDefinition.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + : ('custom' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS), + ...(agentCli && { + source: 'cli' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + }); + } + + // Persist agent setting to session transcript for resume view display and restoration + if (mainThreadAgentDefinition?.agentType) { + saveAgentSetting(mainThreadAgentDefinition.agentType); + } + + // Apply the agent's system prompt for non-interactive sessions + // (interactive mode uses buildEffectiveSystemPrompt instead) + if ( + isNonInteractiveSession && + mainThreadAgentDefinition && + !systemPrompt && + !isBuiltInAgent(mainThreadAgentDefinition) + ) { + const agentSystemPrompt = mainThreadAgentDefinition.getSystemPrompt(); + if (agentSystemPrompt) { + systemPrompt = agentSystemPrompt; + } + } + + // initialPrompt goes first so its slash command (if any) is processed; + // user-provided text becomes trailing context. + // Only concatenate when inputPrompt is a string. When it's an + // AsyncIterable (SDK stream-json mode), template interpolation would + // call .toString() producing "[object Object]". The AsyncIterable case + // is handled in print.ts via structuredIO.prependUserMessage(). + if (mainThreadAgentDefinition?.initialPrompt) { + if (typeof inputPrompt === 'string') { + inputPrompt = inputPrompt + ? `${mainThreadAgentDefinition.initialPrompt}\n\n${inputPrompt}` + : mainThreadAgentDefinition.initialPrompt; + } else if (!inputPrompt) { + inputPrompt = mainThreadAgentDefinition.initialPrompt; + } + } + + // Compute effective model early so hooks can run in parallel with MCP + // If user didn't specify a model but agent has one, use the agent's model + let effectiveModel = userSpecifiedModel; + if (!effectiveModel && mainThreadAgentDefinition?.model && mainThreadAgentDefinition.model !== 'inherit') { + effectiveModel = parseUserSpecifiedModel(mainThreadAgentDefinition.model); + } + + setMainLoopModelOverride(effectiveModel); + + // Compute resolved model for hooks (use user-specified model at launch) + setInitialMainLoopModel(getUserSpecifiedModelSetting() || null); + const initialMainLoopModel = getInitialMainLoopModel(); + const resolvedInitialModel = parseUserSpecifiedModel(initialMainLoopModel ?? getDefaultMainLoopModel()); + + let advisorModel: string | undefined; + if (isAdvisorEnabled()) { + const advisorOption = canUserConfigureAdvisor() ? (options as { advisor?: string }).advisor : undefined; + if (advisorOption) { + logForDebugging(`[AdvisorTool] --advisor ${advisorOption}`); + if (!modelSupportsAdvisor(resolvedInitialModel)) { + process.stderr.write( + chalk.red(`Error: The model "${resolvedInitialModel}" does not support the advisor tool.\n`), + ); + process.exit(1); + } + const normalizedAdvisorModel = normalizeModelStringForAPI(parseUserSpecifiedModel(advisorOption)); + if (!isValidAdvisorModel(normalizedAdvisorModel)) { + process.stderr.write(chalk.red(`Error: The model "${advisorOption}" cannot be used as an advisor.\n`)); + process.exit(1); + } + } + advisorModel = canUserConfigureAdvisor() ? (advisorOption ?? getInitialAdvisorSetting()) : advisorOption; + if (advisorModel) { + logForDebugging(`[AdvisorTool] Advisor model: ${advisorModel}`); + } + } + + // For tmux teammates with --agent-type, append the custom agent's prompt + if ( + isAgentSwarmsEnabled() && + storedTeammateOpts?.agentId && + storedTeammateOpts?.agentName && + storedTeammateOpts?.teamName && + storedTeammateOpts?.agentType + ) { + // Look up the custom agent definition + const customAgent = agentDefinitions.activeAgents.find(a => a.agentType === storedTeammateOpts.agentType); + if (customAgent) { + // Get the prompt - need to handle both built-in and custom agents + let customPrompt: string | undefined; + if (customAgent.source === 'built-in') { + // Built-in agents have getSystemPrompt that takes toolUseContext + // We can't access full toolUseContext here, so skip for now + logForDebugging( + `[teammate] Built-in agent ${storedTeammateOpts.agentType} - skipping custom prompt (not supported)`, + ); + } else { + // Custom agents have getSystemPrompt that takes no args + customPrompt = customAgent.getSystemPrompt(); + } + + // Log agent memory loaded event for tmux teammates + if (customAgent.memory) { + logEvent('tengu_agent_memory_loaded', { + ...(process.env.USER_TYPE === 'ant' && { + agent_type: customAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + scope: customAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: 'teammate' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + } + + if (customPrompt) { + const customInstructions = `\n# Custom Agent Instructions\n${customPrompt}`; + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${customInstructions}` + : customInstructions; + } + } else { + logForDebugging(`[teammate] Custom agent ${storedTeammateOpts.agentType} not found in available agents`); + } + } + + maybeActivateBrief(options); + // defaultView: 'chat' is a persisted opt-in — check entitlement and set + // userMsgOptIn so the tool + prompt section activate. Interactive-only: + // defaultView is a display preference; SDK sessions have no display, and + // the assistant installer writes defaultView:'chat' to settings.local.json + // which would otherwise leak into --print sessions in the same directory. + // Runs right after maybeActivateBrief() so all startup opt-in paths fire + // BEFORE any isBriefEnabled() read below (proactive prompt's + // briefVisibility). A persisted 'chat' after a GB kill-switch falls + // through (entitlement fails). + if ( + (feature('KAIROS') || feature('KAIROS_BRIEF')) && + !getIsNonInteractiveSession() && + !getUserMsgOptIn() && + getInitialSettings().defaultView === 'chat' + ) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isBriefEntitled } = + require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js'); + /* eslint-enable @typescript-eslint/no-require-imports */ + if (isBriefEntitled()) { + setUserMsgOptIn(true); + } + } + // Coordinator mode has its own system prompt and filters out Sleep, so + // the generic proactive prompt would tell it to call a tool it can't + // access and conflict with delegation instructions. + if ( + (feature('PROACTIVE') || feature('KAIROS')) && + ((options as { proactive?: boolean }).proactive || isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) && + !coordinatorModeModule?.isCoordinatorMode() + ) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const briefVisibility = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? ( + require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') + ).isBriefEnabled() + ? 'Call SendUserMessage at checkpoints to mark where things stand.' + : 'The user will see any text you output.' + : 'The user will see any text you output.'; + /* eslint-enable @typescript-eslint/no-require-imports */ + const proactivePrompt = `\n# Proactive Mode\n\nYou are in proactive mode. Take initiative — explore, act, and make progress without waiting for instructions.\n\nStart by briefly greeting the user.\n\nYou will receive periodic prompts. These are check-ins. Do whatever seems most useful, or call Sleep if there's nothing to do. ${briefVisibility}`; + appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${proactivePrompt}` : proactivePrompt; + } + + if (feature('KAIROS') && kairosEnabled && assistantModule) { + const assistantAddendum = assistantModule.getAssistantSystemPromptAddendum(); + appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${assistantAddendum}` : assistantAddendum; + } + + // Ink root is only needed for interactive sessions — patchConsole in the + // Ink constructor would swallow console output in headless mode. + let root!: Root; + let getFpsMetrics!: () => FpsMetrics | undefined; + let stats!: StatsStore; + + // Show setup screens after commands are loaded + if (!isNonInteractiveSession) { + const ctx = getRenderContext(false); + getFpsMetrics = ctx.getFpsMetrics; + stats = ctx.stats; + // Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1) + if (process.env.USER_TYPE === 'ant') { + installAsciicastRecorder(); + } + + const { createRoot } = await import('@anthropic/ink'); + root = await createRoot(ctx.renderOptions); + + // Log startup time now, before any blocking dialog renders. Logging + // from REPL's first render (the old location) included however long + // the user sat on trust/OAuth/onboarding/resume-picker — p99 was ~70s + // dominated by dialog-wait time, not code-path startup. + logEvent('tengu_timer', { + event: 'startup' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + durationMs: Math.round(process.uptime() * 1000), + }); + + logForDebugging('[STARTUP] Running showSetupScreens()...'); + const setupScreensStart = Date.now(); + const onboardingShown = await showSetupScreens( + root, + permissionMode, + allowDangerouslySkipPermissions, + commands, + enableClaudeInChrome, + devChannels, + ); + logForDebugging(`[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`); + + // Now that trust is established and GrowthBook has auth headers, + // resolve the --remote-control / --rc entitlement gate. + if (feature('BRIDGE_MODE') && remoteControlOption !== undefined) { + const { getBridgeDisabledReason } = await import('./bridge/bridgeEnabled.js'); + const disabledReason = await getBridgeDisabledReason(); + remoteControl = disabledReason === null; + if (disabledReason) { + process.stderr.write(chalk.yellow(`${disabledReason}\n--rc flag ignored.\n`)); + } + } + + // Check for pending agent memory snapshot updates (only for --agent mode, ant-only) + if ( + feature('AGENT_MEMORY_SNAPSHOT') && + mainThreadAgentDefinition && + isCustomAgent(mainThreadAgentDefinition) && + mainThreadAgentDefinition.memory && + mainThreadAgentDefinition.pendingSnapshotUpdate + ) { + const agentDef = mainThreadAgentDefinition; + const choice = await launchSnapshotUpdateDialog(root, { + agentType: agentDef.agentType, + scope: agentDef.memory!, + snapshotTimestamp: agentDef.pendingSnapshotUpdate!.snapshotTimestamp, + }); + if (choice === 'merge') { + const { buildMergePrompt } = await import('./components/agents/SnapshotUpdateDialog.js'); + const mergePrompt = buildMergePrompt(agentDef.agentType, agentDef.memory!); + inputPrompt = inputPrompt ? `${mergePrompt}\n\n${inputPrompt}` : mergePrompt; + } + agentDef.pendingSnapshotUpdate = undefined; + } + + // Skip executing /login if we just completed onboarding for it + if (onboardingShown && prompt?.trim().toLowerCase() === '/login') { + prompt = ''; + } + + if (onboardingShown) { + // Refresh auth-dependent services now that the user has logged in during onboarding. + // Keep in sync with the post-login logic in src/commands/login.tsx + // void refreshRemoteManagedSettings(); + void refreshPolicyLimits(); + // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials + resetUserCache(); + // Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs) + refreshGrowthBookAfterAuthChange(); + // Clear any stale trusted device token then enroll for Remote Control. + // Both self-gate on tengu_sessions_elevated_auth_enforcement internally + // — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits + // the GrowthBook reinit above), clearTrustedDeviceToken() via the + // sync cached check (acceptable since clear is idempotent). + void import('./bridge/trustedDevice.js').then(m => { + m.clearTrustedDeviceToken(); + return m.enrollTrustedDevice(); + }); + } + + // Validate that the active token's org matches forceLoginOrgUUID (if set + // in managed settings). Runs after onboarding so managed settings and + // login state are fully loaded. + const orgValidation = await validateForceLoginOrg(); + if (!orgValidation.valid) { + await exitWithError(root, (orgValidation as { valid: false; message: string }).message); + } + } + + // If gracefulShutdown was initiated (e.g., user rejected trust dialog), + // process.exitCode will be set. Skip all subsequent operations that could + // trigger code execution before the process exits (e.g. we don't want apiKeyHelper + // to run if trust was not established). + if (process.exitCode !== undefined) { + logForDebugging('Graceful shutdown initiated, skipping further initialization'); + return; + } + + // Initialize LSP manager AFTER trust is established (or in non-interactive mode + // where trust is implicit). This prevents plugin LSP servers from executing + // code in untrusted directories before user consent. + // Must be after inline plugins are set (if any) so --plugin-dir LSP servers are included. + initializeLspServerManager(); + + // Show settings validation errors after trust is established + // MCP config errors don't block settings from loading, so exclude them + if (!isNonInteractiveSession) { + const { errors } = getSettingsWithErrors(); + const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata); + if (nonMcpErrors.length > 0) { + await launchInvalidSettingsDialog(root, { + settingsErrors: nonMcpErrors, + onExit: () => gracefulShutdownSync(1), + }); + } + } + + // Check quota status, fast mode, passes eligibility, and bootstrap data + // after trust is established. These make API calls which could trigger + // apiKeyHelper execution. + // --bare / SIMPLE: skip — these are cache-warms for the REPL's + // first-turn responsiveness (quota, passes, fastMode, bootstrap data). Fast + // mode doesn't apply to the Agent SDK anyway (see getFastModeUnavailableReason). + const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE('tengu_cicada_nap_ms', 0); + const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0; + const skipStartupPrefetches = + isBareMode() || (bgRefreshThrottleMs > 0 && Date.now() - lastPrefetched < bgRefreshThrottleMs); + + if (!skipStartupPrefetches) { + const lastPrefetchedInfo = + lastPrefetched > 0 ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` : ''; + logForDebugging(`Starting background startup prefetches${lastPrefetchedInfo}`); + + checkQuotaStatus().catch(error => logError(error)); + + // Fetch bootstrap data from the server and update all cache values. + void fetchBootstrapData(); + + // TODO: Consolidate other prefetches into a single bootstrap request. + void prefetchPassesEligibility(); + if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_miraculo_the_bard', false)) { + void prefetchFastModeStatus(); + } else { + // Kill switch skips the network call, not org-policy enforcement. + // Resolve from cache so orgStatus doesn't stay 'pending' (which + // getFastModeUnavailableReason treats as permissive). + resolveFastModeStatusFromCache(); + } + if (bgRefreshThrottleMs > 0) { + saveGlobalConfig(current => ({ + ...current, + startupPrefetchedAt: Date.now(), + })); + } + } else { + logForDebugging( + `Skipping startup prefetches, last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago`, + ); + // Resolve fast mode org status from cache (no network) + resolveFastModeStatusFromCache(); + } + + if (!isNonInteractiveSession) { + void refreshExampleCommands(); // Pre-fetch example commands (runs git log, no API call) + } + + // Resolve MCP configs (started early, overlaps with setup/trust dialog work) + const { servers: existingMcpConfigs } = await mcpConfigPromise; + logForDebugging( + `[STARTUP] MCP configs resolved in ${mcpConfigResolvedMs}ms (awaited at +${Date.now() - mcpConfigStart}ms)`, + ); + // CLI flag (--mcp-config) should override file-based configs, matching settings precedence + const allMcpConfigs = { + ...existingMcpConfigs, + ...dynamicMcpConfig, + }; + + // Separate SDK configs from regular MCP configs + const sdkMcpConfigs: Record = {}; + const regularMcpConfigs: Record = {}; + + for (const [name, config] of Object.entries(allMcpConfigs)) { + const typedConfig = config as ScopedMcpServerConfig | McpSdkServerConfig; + if (typedConfig.type === 'sdk') { + sdkMcpConfigs[name] = typedConfig as McpSdkServerConfig; + } else { + regularMcpConfigs[name] = typedConfig as ScopedMcpServerConfig; + } + } + + profileCheckpoint('action_mcp_configs_loaded'); + + // Prefetch MCP resources after trust dialog (this is where execution happens). + // Interactive mode only: print mode defers connects until headlessStore exists + // and pushes per-server (below), so ToolSearch's pending-client handling works + // and one slow server doesn't block the batch. + const localMcpPromise = isNonInteractiveSession + ? Promise.resolve({ clients: [], tools: [], commands: [] }) + : prefetchAllMcpResources(regularMcpConfigs); + const claudeaiMcpPromise = isNonInteractiveSession + ? Promise.resolve({ clients: [], tools: [], commands: [] }) + : claudeaiConfigPromise.then(configs => + Object.keys(configs).length > 0 + ? prefetchAllMcpResources(configs) + : { clients: [], tools: [], commands: [] }, + ); + // Merge with dedup by name: each prefetchAllMcpResources call independently + // adds helper tools (ListMcpResourcesTool, ReadMcpResourceTool) via + // local dedup flags, so merging two calls can yield duplicates. print.ts + // already uniqBy's the final tool pool, but dedup here keeps appState clean. + const mcpPromise = Promise.all([localMcpPromise, claudeaiMcpPromise]).then(([local, claudeai]) => ({ + clients: [...local.clients, ...claudeai.clients], + tools: uniqBy([...local.tools, ...claudeai.tools], 'name'), + commands: uniqBy([...local.commands, ...claudeai.commands], 'name'), + })); + + // Start hooks early so they run in parallel with MCP connections. + // Skip for initOnly/init/maintenance (handled separately), non-interactive + // (handled via setupTrigger), and resume/continue (conversationRecovery.ts + // fires 'resume' instead — without this guard, hooks fire TWICE on /resume + // and the second systemMessage clobbers the first. gh-30825) + const hooksPromise = + initOnly || init || maintenance || isNonInteractiveSession || options.continue || options.resume + ? null + : processSessionStartHooks('startup', { + agentType: mainThreadAgentDefinition?.agentType, + model: resolvedInitialModel, + }); + + // MCP never blocks REPL render OR turn 1 TTFT. useManageMCPConnections + // populates appState.mcp async as servers connect (connectToServer is + // memoized — the prefetch calls above and the hook converge on the same + // connections). getToolUseContext reads store.getState() fresh via + // computeTools(), so turn 1 sees whatever's connected by query time. + // Slow servers populate for turn 2+. Matches interactive-no-prompt + // behavior. Print mode: per-server push into headlessStore (below). + const hookMessages: Awaited> = []; + // Suppress transient unhandledRejection — the prefetch warms the + // memoized connectToServer cache but nobody awaits it in interactive. + mcpPromise.catch(() => {}); + + const mcpClients: Awaited['clients'] = []; + const mcpTools: Awaited['tools'] = []; + const mcpCommands: Awaited['commands'] = []; + + let thinkingEnabled = shouldEnableThinkingByDefault(); + let thinkingConfig: ThinkingConfig = thinkingEnabled !== false ? { type: 'adaptive' } : { type: 'disabled' }; + + if (options.thinking === 'adaptive' || options.thinking === 'enabled') { + thinkingEnabled = true; + thinkingConfig = { type: 'adaptive' }; + } else if (options.thinking === 'disabled') { + thinkingEnabled = false; + thinkingConfig = { type: 'disabled' }; + } else { + const maxThinkingTokens = process.env.MAX_THINKING_TOKENS + ? parseInt(process.env.MAX_THINKING_TOKENS, 10) + : options.maxThinkingTokens; + if (maxThinkingTokens !== undefined) { + if (maxThinkingTokens > 0) { + thinkingEnabled = true; + thinkingConfig = { + type: 'enabled', + budgetTokens: maxThinkingTokens, + }; + } else if (maxThinkingTokens === 0) { + thinkingEnabled = false; + thinkingConfig = { type: 'disabled' }; + } + } + } + + logForDiagnosticsNoPII('info', 'started', { + version: MACRO.VERSION, + is_native_binary: isInBundledMode(), + }); + + registerCleanup(async () => { + logForDiagnosticsNoPII('info', 'exited'); + }); + + void logTenguInit({ + hasInitialPrompt: Boolean(prompt), + hasStdin: Boolean(inputPrompt), + verbose, + debug, + debugToStderr, + print: print ?? false, + outputFormat: outputFormat ?? 'text', + inputFormat: inputFormat ?? 'text', + numAllowedTools: allowedTools.length, + numDisallowedTools: disallowedTools.length, + mcpClientCount: Object.keys(allMcpConfigs).length, + worktreeEnabled, + skipWebFetchPreflight: getInitialSettings().skipWebFetchPreflight, + githubActionInputs: process.env.GITHUB_ACTION_INPUTS, + dangerouslySkipPermissionsPassed: dangerouslySkipPermissions ?? false, + permissionMode, + modeIsBypass: permissionMode === 'bypassPermissions', + allowDangerouslySkipPermissionsPassed: allowDangerouslySkipPermissions, + systemPromptFlag: systemPrompt ? (options.systemPromptFile ? 'file' : 'flag') : undefined, + appendSystemPromptFlag: appendSystemPrompt ? (options.appendSystemPromptFile ? 'file' : 'flag') : undefined, + thinkingConfig, + assistantActivationPath: + feature('KAIROS') && kairosEnabled ? assistantModule?.getAssistantActivationPath() : undefined, + }); + + // Log context metrics once at initialization + void logContextMetrics(regularMcpConfigs, toolPermissionContext); + + void logPermissionContextForAnts(null, 'initialization'); + + logManagedSettings(); + + // Register PID file for concurrent-session detection (~/.claude/sessions/) + // and fire multi-clauding telemetry. Lives here (not init.ts) so only the + // REPL path registers — not subcommands like `claude doctor`. Chained: + // count must run after register's write completes or it misses our own file. + void registerSession().then(registered => { + if (!registered) return; + if (sessionNameArg) { + void updateSessionName(sessionNameArg); + } + void countConcurrentSessions().then(count => { + if (count >= 2) { + logEvent('tengu_concurrent_sessions', { + num_sessions: count, + }); + } + }); + }); + + // Initialize versioned plugins system (triggers V1→V2 migration if + // needed). Then run orphan GC, THEN warm the Grep/Glob exclusion cache. + // Sequencing matters: the warmup scans disk for .orphaned_at markers, + // so it must see the GC's Pass 1 (remove markers from reinstalled + // versions) and Pass 2 (stamp unmarked orphans) already applied. The + // warm also lands before autoupdate (fires on first submit in REPL) + // can orphan this session's active version underneath us. + // --bare / SIMPLE: skip plugin version sync + orphan cleanup. These + // are install/upgrade bookkeeping that scripted calls don't need — + // the next interactive session will reconcile. The await here was + // blocking -p on a marketplace round-trip. + if (isBareMode()) { + // skip — no-op + } else if (isNonInteractiveSession) { + // In headless mode, await to ensure plugin sync completes before CLI exits + await initializeVersionedPlugins(); + profileCheckpoint('action_after_plugins_init'); + void cleanupOrphanedPluginVersionsInBackground().then(() => getGlobExclusionsForPluginCache()); + } else { + // In interactive mode, fire-and-forget — this is purely bookkeeping + // that doesn't affect runtime behavior of the current session + void initializeVersionedPlugins().then(async () => { + profileCheckpoint('action_after_plugins_init'); + await cleanupOrphanedPluginVersionsInBackground(); + void getGlobExclusionsForPluginCache(); + }); + } + + const setupTrigger = initOnly || init ? 'init' : maintenance ? 'maintenance' : null; + if (initOnly) { + applyConfigEnvironmentVariables(); + await processSetupHooks('init', { forceSyncExecution: true }); + await processSessionStartHooks('startup', { + forceSyncExecution: true, + }); + gracefulShutdownSync(0); + return; + } + + // --print mode + if (isNonInteractiveSession) { + if (outputFormat === 'stream-json' || outputFormat === 'json') { + setHasFormattedOutput(true); + } + + // Apply full environment variables in print mode since trust dialog is bypassed + // This includes potentially dangerous environment variables from untrusted sources + // but print mode is considered trusted (as documented in help text) + applyConfigEnvironmentVariables(); + + // Initialize telemetry after env vars are applied so OTEL endpoint env vars and + // otelHeadersHelper (which requires trust to execute) are available. + initializeTelemetryAfterTrust(); + + // Kick SessionStart hooks now so the subprocess spawn overlaps with + // MCP connect + plugin init + print.ts import below. loadInitialMessages + // joins this at print.ts:4397. Guarded same as loadInitialMessages — + // continue/resume/teleport paths don't fire startup hooks (or fire them + // conditionally inside the resume branch, where this promise is + // undefined and the ?? fallback runs). Also skip when setupTrigger is + // set — those paths run setup hooks first (print.ts:544), and session + // start hooks must wait until setup completes. + const sessionStartHooksPromise = + options.continue || options.resume || teleport || setupTrigger + ? undefined + : processSessionStartHooks('startup'); + // Suppress transient unhandledRejection if this rejects before + // loadInitialMessages awaits it. Downstream await still observes the + // rejection — this just prevents the spurious global handler fire. + sessionStartHooksPromise?.catch(() => {}); + + profileCheckpoint('before_validateForceLoginOrg'); + // Validate org restriction for non-interactive sessions + const orgValidation = await validateForceLoginOrg(); + if (!orgValidation.valid) { + process.stderr.write((orgValidation as { valid: false; message: string }).message + '\n'); + process.exit(1); + } + + // Headless mode supports all prompt commands and some local commands + // If disableSlashCommands is true, return empty array + const commandsHeadless = disableSlashCommands + ? [] + : commands.filter( + command => + (command.type === 'prompt' && !command.disableNonInteractive) || + (command.type === 'local' && command.supportsNonInteractive), + ); + + const defaultState = getDefaultAppState(); + const headlessInitialState: AppState = { + ...defaultState, + mcp: { + ...defaultState.mcp, + clients: mcpClients, + commands: mcpCommands, + tools: mcpTools, + }, + // In headless (-p) mode the TUI is never mounted, so + // AskUserQuestionPermissionRequest's useEffect/setTimeout never fires. + // Mark shouldAvoidPermissionPrompts so permissions.ts step 1e-headless + // picks up askUserQuestionTimeoutSeconds from settings and applies the + // configured auto-select timeout instead of hanging indefinitely. + // When --permission-prompt-tool is specified (e.g. serve mode with stdio), + // the stdio protocol handles permission prompts — don't suppress them. + toolPermissionContext: { + ...toolPermissionContext, + shouldAvoidPermissionPrompts: !options.permissionPromptTool, + }, + effortValue: parseEffortValue(options.effort) ?? getInitialEffortSetting(), + ...(isFastModeEnabled() && { + fastMode: getInitialFastModeSetting(effectiveModel ?? null), + }), + ...(isAdvisorEnabled() && advisorModel && { advisorModel }), + // kairosEnabled gates the async fire-and-forget path in + // executeForkedSlashCommand (processSlashCommand.tsx:132) and + // AgentTool's shouldRunAsync. The REPL initialState sets this at + // ~3459; headless was defaulting to false, so the daemon child's + // scheduled tasks and Agent-tool calls ran synchronously — N + // overdue cron tasks on spawn = N serial subagent turns blocking + // user input. Computed at :1620, well before this branch. + ...(feature('KAIROS') ? { kairosEnabled } : {}), + }; + + // Init app state + const headlessStore = createStore(headlessInitialState, onChangeAppState); + + // Async check of auto mode gate — corrects state and disables auto if needed. + if (feature('TRANSCRIPT_CLASSIFIER')) { + void verifyAutoModeGateAccess(toolPermissionContext, headlessStore.getState().fastMode).then( + ({ updateContext }) => { + headlessStore.setState(prev => { + const nextCtx = updateContext(prev.toolPermissionContext); + if (nextCtx === prev.toolPermissionContext) return prev; + return { ...prev, toolPermissionContext: nextCtx }; + }); + }, + ); + } + + // Set global state for session persistence + if (options.sessionPersistence === false) { + setSessionPersistenceDisabled(true); + } + + // Store SDK betas in global state for context window calculation + // Only store allowed betas (filters by allowlist and subscriber status) + setSdkBetas(filterAllowedSdkBetas(betas)); + + // Print-mode MCP: per-server incremental push into headlessStore. + // Mirrors useManageMCPConnections — push pending first (so ToolSearch's + // pending-check at ToolSearchTool.ts:334 sees them), then replace with + // connected/failed as each server settles. + const connectMcpBatch = (configs: Record, label: string): Promise => { + if (Object.keys(configs).length === 0) return Promise.resolve(); + headlessStore.setState(prev => ({ + ...prev, + mcp: { + ...prev.mcp, + clients: [ + ...prev.mcp.clients, + ...Object.entries(configs).map(([name, config]) => ({ + name, + type: 'pending' as const, + config, + })), + ], + }, + })); + return getMcpToolsCommandsAndResources(({ client, tools, commands }) => { + headlessStore.setState(prev => ({ + ...prev, + mcp: { + ...prev.mcp, + clients: prev.mcp.clients.some(c => c.name === client.name) + ? prev.mcp.clients.map(c => (c.name === client.name ? client : c)) + : [...prev.mcp.clients, client], + tools: uniqBy([...prev.mcp.tools, ...tools], 'name'), + commands: uniqBy([...prev.mcp.commands, ...commands], 'name'), + }, + })); + }, configs).catch(err => logForDebugging(`[MCP] ${label} connect error: ${err}`)); + }; + // Await all MCP configs — print mode is often single-turn, so + // "late-connecting servers visible next turn" doesn't help. SDK init + // message and turn-1 tool list both need configured MCP tools present. + // Zero-server case is free via the early return in connectMcpBatch. + // Connectors parallelize inside getMcpToolsCommandsAndResources + // (processBatched with Promise.all). claude.ai is awaited too — its + // fetch was kicked off early (line ~2558) so only residual time blocks + // here. --bare skips claude.ai entirely for perf-sensitive scripts. + profileCheckpoint('before_connectMcp'); + await connectMcpBatch(regularMcpConfigs, 'regular'); + profileCheckpoint('after_connectMcp'); + // Dedup: suppress plugin MCP servers that duplicate a claude.ai + // connector (connector wins), then connect claude.ai servers. + // Bounded wait — #23725 made this blocking so single-turn -p sees + // connectors, but with 40+ slow connectors tengu_startup_perf p99 + // climbed to 76s. If fetch+connect doesn't finish in time, proceed; + // the promise keeps running and updates headlessStore in the + // background so turn 2+ still sees connectors. + const CLAUDE_AI_MCP_TIMEOUT_MS = 5_000; + const claudeaiConnect = claudeaiConfigPromise.then(claudeaiConfigs => { + if (Object.keys(claudeaiConfigs).length > 0) { + const claudeaiSigs = new Set(); + for (const config of Object.values(claudeaiConfigs)) { + const sig = getMcpServerSignature(config); + if (sig) claudeaiSigs.add(sig); + } + const suppressed = new Set(); + for (const [name, config] of Object.entries(regularMcpConfigs)) { + if (!name.startsWith('plugin:')) continue; + const sig = getMcpServerSignature(config); + if (sig && claudeaiSigs.has(sig)) suppressed.add(name); + } + if (suppressed.size > 0) { + logForDebugging( + `[MCP] Lazy dedup: suppressing ${suppressed.size} plugin server(s) that duplicate claude.ai connectors: ${[...suppressed].join(', ')}`, + ); + // Disconnect before filtering from state. Only connected + // servers need cleanup — clearServerCache on a never-connected + // server triggers a real connect just to kill it (memoize + // cache-miss path, see useManageMCPConnections.ts:870). + for (const c of headlessStore.getState().mcp.clients) { + if (!suppressed.has(c.name) || c.type !== 'connected') continue; + c.client.onclose = undefined; + void clearServerCache(c.name, c.config).catch(() => {}); + } + headlessStore.setState(prev => { + let { clients, tools, commands, resources } = prev.mcp; + clients = clients.filter(c => !suppressed.has(c.name)); + tools = tools.filter(t => !t.mcpInfo || !suppressed.has(t.mcpInfo.serverName)); + for (const name of suppressed) { + commands = excludeCommandsByServer(commands, name); + resources = excludeResourcesByServer(resources, name); + } + return { + ...prev, + mcp: { + ...prev.mcp, + clients, + tools, + commands, + resources, + }, + }; + }); + } + } + // Suppress claude.ai connectors that duplicate an enabled + // manual server (URL-signature match). Plugin dedup above only + // handles `plugin:*` keys; this catches manual `.mcp.json` entries. + // plugin:* must be excluded here — step 1 already suppressed + // those (claude.ai wins); leaving them in suppresses the + // connector too, and neither survives (gh-39974). + const nonPluginConfigs = pickBy(regularMcpConfigs, (_, n) => !n.startsWith('plugin:')); + const { servers: dedupedClaudeAi } = dedupClaudeAiMcpServers(claudeaiConfigs, nonPluginConfigs); + return connectMcpBatch(dedupedClaudeAi, 'claudeai'); + }); + let claudeaiTimer: ReturnType | undefined; + const claudeaiTimedOut = await Promise.race([ + claudeaiConnect.then(() => false), + new Promise(resolve => { + claudeaiTimer = setTimeout(r => r(true), CLAUDE_AI_MCP_TIMEOUT_MS, resolve); + }), + ]); + if (claudeaiTimer) clearTimeout(claudeaiTimer); + if (claudeaiTimedOut) { + logForDebugging( + `[MCP] claude.ai connectors not ready after ${CLAUDE_AI_MCP_TIMEOUT_MS}ms — proceeding; background connection continues`, + ); + } + profileCheckpoint('after_connectMcp_claudeai'); + + // In headless mode, start deferred prefetches immediately (no user typing delay) + // --bare / SIMPLE: startDeferredPrefetches early-returns internally. + // backgroundHousekeeping (initExtractMemories, pruneShellSnapshots, + // cleanupOldMessageFiles) and sdkHeapDumpMonitor are all bookkeeping + // that scripted calls don't need — the next interactive session reconciles. + if (!isBareMode()) { + startDeferredPrefetches(); + void import('./utils/backgroundHousekeeping.js').then(m => m.startBackgroundHousekeeping()); + if (process.env.USER_TYPE === 'ant') { + void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor()); + } + } + + logSessionTelemetry(); + profileCheckpoint('before_print_import'); + const { runHeadless } = await import('src/cli/print.js'); + profileCheckpoint('after_print_import'); + void runHeadless( + inputPrompt, + () => headlessStore.getState(), + headlessStore.setState, + commandsHeadless, + tools, + sdkMcpConfigs, + agentDefinitions.activeAgents, + { + continue: options.continue, + resume: options.resume, + verbose: verbose, + outputFormat: outputFormat, + jsonSchema, + permissionPromptToolName: options.permissionPromptTool, + allowedTools, + thinkingConfig, + maxTurns: options.maxTurns, + maxBudgetUsd: options.maxBudgetUsd, + taskBudget: options.taskBudget ? { total: options.taskBudget } : undefined, + systemPrompt, + appendSystemPrompt, + userSpecifiedModel: effectiveModel, + fallbackModel: userSpecifiedFallbackModel, + teleport, + sdkUrl, + replayUserMessages: effectiveReplayUserMessages, + includePartialMessages: effectiveIncludePartialMessages, + forkSession: options.forkSession || false, + resumeSessionAt: options.resumeSessionAt || undefined, + rewindFiles: options.rewindFiles, + enableAuthStatus: options.enableAuthStatus, + agent: agentCli, + workload: options.workload, + setupTrigger: setupTrigger ?? undefined, + sessionStartHooksPromise, + }, + ); + return; + } + + // Log model config at startup + logEvent('tengu_startup_manual_model_config', { + cli_flag: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + env_var: process.env.ANTHROPIC_MODEL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + settings_file: (getInitialSettings() || {}).model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + subscriptionType: getSubscriptionType() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent: agentSetting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + + // Get deprecation warning for the initial model (resolvedInitialModel computed earlier for hooks parallelization) + const deprecationWarning = getModelDeprecationWarning(resolvedInitialModel); + + // Build initial notification queue + const initialNotifications: Array<{ + key: string; + text: string; + color?: 'warning'; + priority: 'high'; + }> = []; + if (permissionModeNotification) { + initialNotifications.push({ + key: 'permission-mode-notification', + text: permissionModeNotification, + priority: 'high', + }); + } + if (deprecationWarning) { + initialNotifications.push({ + key: 'model-deprecation-warning', + text: deprecationWarning, + color: 'warning', + priority: 'high', + }); + } + if (overlyBroadBashPermissions.length > 0) { + const displayList = uniq(overlyBroadBashPermissions.map(p => p.ruleDisplay)); + const displays = displayList.join(', '); + const sources = uniq(overlyBroadBashPermissions.map(p => p.sourceDisplay)).join(', '); + const n = displayList.length; + initialNotifications.push({ + key: 'overly-broad-bash-notification', + text: `${displays} allow ${plural(n, 'rule')} from ${sources} ${plural(n, 'was', 'were')} ignored \u2014 not available for Ants, please use auto-mode instead`, + color: 'warning', + priority: 'high', + }); + } + + const teammateUtils = getTeammateUtils(); + const effectiveToolPermissionContext = { + ...toolPermissionContext, + mode: + isAgentSwarmsEnabled() && teammateUtils?.isPlanModeRequired?.() + ? ('plan' as const) + : toolPermissionContext.mode, + }; + // All startup opt-in paths (--tools, --brief, defaultView) have fired + // above; initialIsBriefOnly just reads the resulting state. + const initialIsBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? getUserMsgOptIn() : false; + const fullRemoteControl = remoteControl || getRemoteControlAtStartup() || kairosEnabled; + let ccrMirrorEnabled = false; + if (feature('CCR_MIRROR') && !fullRemoteControl) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isCcrMirrorEnabled } = + require('./bridge/bridgeEnabled.js') as typeof import('./bridge/bridgeEnabled.js'); + /* eslint-enable @typescript-eslint/no-require-imports */ + ccrMirrorEnabled = isCcrMirrorEnabled(); + } + + const initialState: AppState = { + settings: getInitialSettings(), + tasks: {}, + agentNameRegistry: new Map(), + verbose: verbose ?? getGlobalConfig().verbose ?? false, + mainLoopModel: initialMainLoopModel, + mainLoopModelForSession: null, + isBriefOnly: initialIsBriefOnly, + expandedView: getGlobalConfig().showSpinnerTree + ? 'teammates' + : getGlobalConfig().showExpandedTodos + ? 'tasks' + : 'none', + showTeammateMessagePreview: isAgentSwarmsEnabled() ? false : undefined, + selectedIPAgentIndex: -1, + coordinatorTaskIndex: -1, + viewSelectionMode: 'none', + footerSelection: null, + toolPermissionContext: effectiveToolPermissionContext, + agent: mainThreadAgentDefinition?.agentType, + agentDefinitions, + mcp: { + clients: [], + tools: [], + commands: [], + resources: {}, + pluginReconnectKey: 0, + }, + plugins: { + enabled: [], + disabled: [], + commands: [], + errors: [], + installationStatus: { + marketplaces: [], + plugins: [], + }, + needsRefresh: false, + }, + statusLineText: undefined, + kairosEnabled, + remoteSessionUrl: undefined, + remoteConnectionStatus: 'connecting', + remoteBackgroundTaskCount: 0, + replBridgeEnabled: fullRemoteControl || ccrMirrorEnabled, + replBridgeExplicit: remoteControl, + replBridgeOutboundOnly: ccrMirrorEnabled, + replBridgeConnected: false, + replBridgeSessionActive: false, + replBridgeReconnecting: false, + replBridgeConnectUrl: undefined, + replBridgeSessionUrl: undefined, + replBridgeEnvironmentId: undefined, + replBridgeSessionId: undefined, + replBridgeError: undefined, + replBridgeInitialName: remoteControlName, + showRemoteCallout: false, + notifications: { + current: null, + queue: initialNotifications, + }, + elicitation: { + queue: [], + }, + todos: {}, + remoteAgentTaskSuggestions: [], + fileHistory: { + snapshots: [], + trackedFiles: new Set(), + snapshotSequence: 0, + }, + attribution: createEmptyAttributionState(), + thinkingEnabled, + promptSuggestionEnabled: shouldEnablePromptSuggestion(), + sessionHooks: new Map(), + inbox: { + messages: [], + }, + promptSuggestion: { + text: null, + promptId: null, + shownAt: 0, + acceptedAt: 0, + generationRequestId: null, + }, + speculation: IDLE_SPECULATION_STATE, + speculationSessionTimeSavedMs: 0, + skillImprovement: { + suggestion: null, + }, + workerSandboxPermissions: { + queue: [], + selectedIndex: 0, + }, + pendingWorkerRequest: null, + pendingSandboxRequest: null, + authVersion: 0, + initialMessage: inputPrompt + ? { + message: createUserMessage({ + content: String(inputPrompt), + }), + } + : null, + effortValue: parseEffortValue(options.effort) ?? getInitialEffortSetting(), + activeOverlays: new Set(), + fastMode: getInitialFastModeSetting(resolvedInitialModel), + ...(isAdvisorEnabled() && advisorModel && { advisorModel }), + // Compute teamContext synchronously to avoid useEffect setState during render. + // KAIROS: assistantTeamContext takes precedence — set earlier in the + // KAIROS block so Agent(name: "foo") can spawn in-process teammates + // without TeamCreate. computeInitialTeamContext() is for tmux-spawned + // teammates reading their own identity, not the assistant-mode leader. + teamContext: (feature('KAIROS') + ? (assistantTeamContext ?? computeInitialTeamContext()) + : computeInitialTeamContext()) as AppState['teamContext'], + }; + + // Add CLI initial prompt to history + if (inputPrompt) { + addToHistory(String(inputPrompt)); + } + + const initialTools = mcpTools; + + // Increment numStartups synchronously — first-render readers like + // shouldShowEffortCallout (via useState initializer) need the updated + // value before setImmediate fires. Defer only telemetry. + saveGlobalConfig(current => ({ + ...current, + numStartups: (current.numStartups ?? 0) + 1, + })); + setImmediate(() => { + void logStartupTelemetry(); + logSessionTelemetry(); + }); + + // Set up per-turn session environment data uploader (ant-only build). + // Default-enabled for all ant users when working in an Anthropic-owned + // repo. Captures git/filesystem state (NOT transcripts) at each turn so + // environments can be recreated at any user message index. Gating: + // - Build-time: this import is stubbed in external builds. + // - Runtime: uploader checks github.com/anthropics/* remote + gcloud auth. + // - Safety: CLAUDE_CODE_DISABLE_SESSION_DATA_UPLOAD=1 bypasses (tests set this). + // Import is dynamic + async to avoid adding startup latency. + const sessionUploaderPromise = process.env.USER_TYPE === 'ant' ? import('./utils/sessionDataUploader.js') : null; + + // Defer session uploader resolution to the onTurnComplete callback to avoid + // adding a new top-level await in main.tsx (performance-critical path). + // The per-turn auth logic in sessionDataUploader.ts handles unauthenticated + // state gracefully (re-checks each turn, so auth recovery mid-session works). + const uploaderReady = sessionUploaderPromise + ? sessionUploaderPromise.then(mod => mod.createSessionTurnUploader()).catch(() => null) + : null; + + const sessionConfig = { + debug: debug || debugToStderr, + commands: [...commands, ...mcpCommands], + initialTools, + mcpClients, + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + dynamicMcpConfig, + strictMcpConfig, + systemPrompt, + appendSystemPrompt, + taskListId, + thinkingConfig, + ...(uploaderReady && { + onTurnComplete: (messages: MessageType[]) => { + void uploaderReady.then(uploader => (uploader as ((msgs: MessageType[]) => void) | null)?.(messages)); + }, + }), + }; + + // Shared context for processResumedConversation calls + const resumeContext = { + modeApi: coordinatorModeModule, + mainThreadAgentDefinition, + agentDefinitions, + currentCwd, + cliAgents, + initialState, + }; + + if (options.continue) { + // Continue the most recent conversation directly + let resumeSucceeded = false; + try { + const resumeStart = performance.now(); + + // Clear stale caches before resuming to ensure fresh file/skill discovery + const { clearSessionCaches } = await import('./commands/clear/caches.js'); + clearSessionCaches(); + + const result = await loadConversationForResume(undefined /* sessionId */, undefined /* sourceFile */); + if (!result) { + logEvent('tengu_continue', { + success: false, + }); + return await exitWithError(root, 'No conversation found to continue'); + } + + const loaded = await processResumedConversation( + result, + { + forkSession: !!options.forkSession, + includeAttribution: true, + transcriptPath: result.fullPath, + }, + resumeContext, + ); + + if (loaded.restoredAgentDef) { + mainThreadAgentDefinition = loaded.restoredAgentDef; + } + + maybeActivateProactive(options); + maybeActivateBrief(options); + + logEvent('tengu_continue', { + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }); + resumeSucceeded = true; + + await launchRepl( + root, + { + getFpsMetrics, + stats, + initialState: loaded.initialState, + }, + { + ...sessionConfig, + mainThreadAgentDefinition: loaded.restoredAgentDef ?? mainThreadAgentDefinition, + initialMessages: loaded.messages, + initialFileHistorySnapshots: loaded.fileHistorySnapshots, + initialContentReplacements: loaded.contentReplacements, + initialAgentName: loaded.agentName, + initialAgentColor: loaded.agentColor, + }, + renderAndRun, + ); + } catch (error) { + if (!resumeSucceeded) { + logEvent('tengu_continue', { + success: false, + }); + } + logError(error); + process.exit(1); + } + } else if (feature('DIRECT_CONNECT') && _pendingConnect?.url) { + // `claude connect ` — full interactive TUI connected to a remote server + let directConnectConfig; + try { + const session = await createDirectConnectSession({ + serverUrl: _pendingConnect.url, + authToken: _pendingConnect.authToken, + cwd: getOriginalCwd(), + dangerouslySkipPermissions: _pendingConnect.dangerouslySkipPermissions, + }); + if (session.workDir) { + setOriginalCwd(session.workDir); + setCwdState(session.workDir); + } + setDirectConnectServerUrl(_pendingConnect.url); + directConnectConfig = session.config; + } catch (err) { + return await exitWithError(root, err instanceof DirectConnectError ? err.message : String(err), () => + gracefulShutdown(1), + ); + } + + const connectInfoMessage = createSystemMessage( + `Connected to server at ${_pendingConnect.url}\nSession: ${directConnectConfig.sessionId}`, + 'info', + ); + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + debug: debug || debugToStderr, + commands, + initialTools: [], + initialMessages: [connectInfoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + directConnectConfig, + thinkingConfig, + }, + renderAndRun, + ); + return; + } else if (feature('SSH_REMOTE') && _pendingSSH?.host) { + // `claude ssh [dir]` — probe remote, deploy binary if needed, + // spawn ssh with unix-socket -R forward to a local auth proxy, hand + // the REPL an SSHSession. Tools run remotely, UI renders locally. + // `--local` skips probe/deploy/ssh and spawns the current binary + // directly with the same env — e2e test of the proxy/auth plumbing. + const { createSSHSession, createLocalSSHSession, SSHSessionError } = await import('./ssh/createSSHSession.js'); + let sshSession: import('./ssh/createSSHSession.js').SSHSession | undefined; + try { + if (_pendingSSH.local) { + process.stderr.write('Starting local ssh-proxy test session...\n'); + sshSession = await createLocalSSHSession({ + cwd: _pendingSSH.cwd, + permissionMode: _pendingSSH.permissionMode, + dangerouslySkipPermissions: _pendingSSH.dangerouslySkipPermissions, + }); + } else { + process.stderr.write(`Connecting to ${_pendingSSH.host}…\n`); + // In-place progress: \r + EL0 (erase to end of line). Final \n on + // success so the next message lands on a fresh line. No-op when + // stderr isn't a TTY (piped/redirected) — \r would just emit noise. + const isTTY = process.stderr.isTTY; + let hadProgress = false; + sshSession = await createSSHSession( + { + host: _pendingSSH.host, + cwd: _pendingSSH.cwd, + localVersion: MACRO.VERSION, + permissionMode: _pendingSSH.permissionMode, + dangerouslySkipPermissions: _pendingSSH.dangerouslySkipPermissions, + extraCliArgs: _pendingSSH.extraCliArgs, + remoteBin: _pendingSSH.remoteBin, + }, + isTTY + ? { + onProgress: (msg: string) => { + hadProgress = true; + process.stderr.write(`\r ${msg}\x1b[K`); + }, + } + : {}, + ); + if (hadProgress) process.stderr.write('\n'); + } + setOriginalCwd(sshSession.remoteCwd); + setCwdState(sshSession.remoteCwd); + setDirectConnectServerUrl(_pendingSSH.local ? 'local' : _pendingSSH.host); + } catch (err) { + return await exitWithError(root, err instanceof SSHSessionError ? err.message : String(err), () => + gracefulShutdown(1), + ); + } + + const sshInfoMessage = createSystemMessage( + _pendingSSH.local + ? `Local ssh-proxy test session\ncwd: ${sshSession.remoteCwd}\nAuth: unix socket → local proxy` + : `SSH session to ${_pendingSSH.host}\nRemote cwd: ${sshSession.remoteCwd}\nAuth: unix socket -R → local proxy`, + 'info', + ); + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + debug: debug || debugToStderr, + commands, + initialTools: [], + initialMessages: [sshInfoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + sshSession, + thinkingConfig, + }, + renderAndRun, + ); + return; + } else if ( + feature('KAIROS') && + _pendingAssistantChat && + (_pendingAssistantChat.sessionId || _pendingAssistantChat.discover) + ) { + // `claude assistant [sessionId]` — REPL as a pure viewer client + // of a remote assistant session. The agentic loop runs remotely; this + // process streams live events and POSTs messages. History is lazy- + // loaded by useAssistantHistory on scroll-up (no blocking fetch here). + const { discoverAssistantSessions } = await import('./assistant/sessionDiscovery.js'); + + let targetSessionId = _pendingAssistantChat.sessionId; + + // Discovery flow — list bridge environments, filter sessions + if (!targetSessionId) { + let sessions; + try { + sessions = await discoverAssistantSessions(); + } catch (e) { + return await exitWithError(root, `Failed to discover sessions: ${e instanceof Error ? e.message : e}`, () => + gracefulShutdown(1), + ); + } + if (sessions.length === 0) { + let installedDir: string | null; + try { + installedDir = await launchAssistantInstallWizard(root); + } catch (e) { + return await exitWithError( + root, + `Assistant installation failed: ${e instanceof Error ? e.message : e}`, + () => gracefulShutdown(1), + ); + } + if (installedDir === null) { + await gracefulShutdown(0); + process.exit(0); + } + // The daemon needs a few seconds to spin up its worker and + // establish a bridge session before discovery will find it. + return await exitWithMessage( + root, + `Assistant installed in ${installedDir}. The daemon is starting up — run \`claude assistant\` again in a few seconds to connect.`, + { + exitCode: 0, + beforeExit: () => gracefulShutdown(0), + }, + ); + } + if (sessions.length === 1) { + targetSessionId = sessions[0]!.id; + } else { + const picked = await launchAssistantSessionChooser(root, { + sessions, + }); + if (!picked) { + await gracefulShutdown(0); + process.exit(0); + } + targetSessionId = picked; + } + } + + // Auth — call prepareApiRequest() once for orgUUID, but use a + // getAccessToken closure for the token so reconnects get fresh tokens. + const { checkAndRefreshOAuthTokenIfNeeded, getClaudeAIOAuthTokens } = await import('./utils/auth.js'); + await checkAndRefreshOAuthTokenIfNeeded(); + let apiCreds; + try { + apiCreds = await prepareApiRequest(); + } catch (e) { + return await exitWithError(root, `Error: ${e instanceof Error ? e.message : 'Failed to authenticate'}`, () => + gracefulShutdown(1), + ); + } + const getAccessToken = (): string => getClaudeAIOAuthTokens()?.accessToken ?? apiCreds.accessToken; + + // Brief mode activation: setKairosActive(true) satisfies BOTH opt-in + // and entitlement for isBriefEnabled() (BriefTool.ts:124-132). + setKairosActive(true); + setUserMsgOptIn(true); + setIsRemoteMode(true); + + const remoteSessionConfig = createRemoteSessionConfig( + targetSessionId, + getAccessToken, + apiCreds.orgUUID, + /* hasInitialPrompt */ false, + /* viewerOnly */ true, + ); + + const infoMessage = createSystemMessage( + `Attached to assistant session ${targetSessionId.slice(0, 8)}…`, + 'info', + ); + + const assistantInitialState: AppState = { + ...initialState, + isBriefOnly: true, + kairosEnabled: false, + replBridgeEnabled: false, + }; + + const remoteCommands = filterCommandsForRemoteMode(commands); + await launchRepl( + root, + { + getFpsMetrics, + stats, + initialState: assistantInitialState, + }, + { + debug: debug || debugToStderr, + commands: remoteCommands, + initialTools: [], + initialMessages: [infoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + remoteSessionConfig, + thinkingConfig, + }, + renderAndRun, + ); + return; + } else if (options.resume || options.fromPr || teleport || remote !== null) { + // Handle resume flow - from file (ant-only), session ID, or interactive selector + + // Clear stale caches before resuming to ensure fresh file/skill discovery + const { clearSessionCaches } = await import('./commands/clear/caches.js'); + clearSessionCaches(); + + let messages: MessageType[] | null = null; + let processedResume: ProcessedResume | undefined; + + let maybeSessionId = validateUuid(options.resume); + let searchTerm: string | undefined; + // Store full LogOption when found by custom title (for cross-worktree resume) + let matchedLog: LogOption | null = null; + // PR filter for --from-pr flag + let filterByPr: boolean | number | string | undefined; + + // Handle --from-pr flag + if (options.fromPr) { + if (options.fromPr === true) { + // Show all sessions with linked PRs + filterByPr = true; + } else if (typeof options.fromPr === 'string') { + // Could be a PR number or URL + filterByPr = options.fromPr; + } + } + + // If resume value is not a UUID, try exact match by custom title first + if (options.resume && typeof options.resume === 'string' && !maybeSessionId) { + const trimmedValue = options.resume.trim(); + if (trimmedValue) { + const matches = await searchSessionsByCustomTitle(trimmedValue, { + exact: true, + }); + + if (matches.length === 1) { + // Exact match found - store full LogOption for cross-worktree resume + matchedLog = matches[0]!; + maybeSessionId = getSessionIdFromLog(matchedLog) ?? null; + } else { + // No match or multiple matches - use as search term for picker + searchTerm = trimmedValue; + } + } + } + + // --remote and --teleport both create/resume CoStrict remote sessions. + // Remote Control (--rc) is a separate feature gated in initReplBridge.ts. + if (remote !== null || teleport) { + await waitForPolicyLimitsToLoad(); + if (!isPolicyAllowed('allow_remote_sessions')) { + return await exitWithError(root, "Error: Remote sessions are disabled by your organization's policy.", () => + gracefulShutdown(1), + ); + } + } + + if (remote !== null) { + // Create remote session (optionally with initial prompt) + const hasInitialPrompt = remote.length > 0; + + // Check if TUI mode is enabled - description is only optional in TUI mode + const isRemoteTuiEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_remote_backend', false); + if (!isRemoteTuiEnabled && !hasInitialPrompt) { + return await exitWithError( + root, + 'Error: --remote requires a description.\nUsage: csc --remote "your task description"', + () => gracefulShutdown(1), + ); + } + + logEvent('tengu_remote_create_session', { + has_initial_prompt: String(hasInitialPrompt) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + + // Pass current branch so CCR clones the repo at the right revision + const currentBranch = await getBranch(); + const createdSession = await teleportToRemoteWithErrorHandling( + root, + hasInitialPrompt ? remote : null, + new AbortController().signal, + currentBranch || undefined, + ); + if (!createdSession) { + logEvent('tengu_remote_create_session_error', { + error: 'unable_to_create_session' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + return await exitWithError(root, 'Error: Unable to create remote session', () => gracefulShutdown(1)); + } + logEvent('tengu_remote_create_session_success', { + session_id: createdSession.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + + // Check if new remote TUI mode is enabled via feature gate + if (!isRemoteTuiEnabled) { + // Original behavior: print session info and exit + process.stdout.write(`Created remote session: ${createdSession.title}\n`); + process.stdout.write(`View: ${getRemoteSessionUrl(createdSession.id)}?m=0\n`); + process.stdout.write(`Resume with: claude --teleport ${createdSession.id}\n`); + await gracefulShutdown(0); + process.exit(0); + } + + // New behavior: start local TUI with CCR engine + // Mark that we're in remote mode for command visibility + setIsRemoteMode(true); + switchSession(asSessionId(createdSession.id)); + + // Get OAuth credentials for remote session + let apiCreds: { accessToken: string; orgUUID: string }; + try { + apiCreds = await prepareApiRequest(); + } catch (error) { + logError(toError(error)); + return await exitWithError(root, `Error: ${errorMessage(error) || 'Failed to authenticate'}`, () => + gracefulShutdown(1), + ); + } + + // Create remote session config for the REPL + const { getClaudeAIOAuthTokens: getTokensForRemote } = await import('./utils/auth.js'); + const getAccessTokenForRemote = (): string => getTokensForRemote()?.accessToken ?? apiCreds.accessToken; + const remoteSessionConfig = createRemoteSessionConfig( + createdSession.id, + getAccessTokenForRemote, + apiCreds.orgUUID, + hasInitialPrompt, + ); + + // Add remote session info as initial system message + const remoteSessionUrl = `${getRemoteSessionUrl(createdSession.id)}?m=0`; + const remoteInfoMessage = createSystemMessage( + `/remote-control is active. Code in CLI or at ${remoteSessionUrl}`, + 'info', + ); + + // Create initial user message from the prompt if provided (CCR echoes it back but we ignore that) + const initialUserMessage = hasInitialPrompt ? createUserMessage({ content: remote }) : null; + + // Set remote session URL in app state for footer indicator + const remoteInitialState = { + ...initialState, + remoteSessionUrl, + }; + + // Pre-filter commands to only include remote-safe ones. + // CCR's init response may further refine the list (via handleRemoteInit in REPL). + const remoteCommands = filterCommandsForRemoteMode(commands); + await launchRepl( + root, + { + getFpsMetrics, + stats, + initialState: remoteInitialState, + }, + { + debug: debug || debugToStderr, + commands: remoteCommands, + initialTools: [], + initialMessages: initialUserMessage ? [remoteInfoMessage, initialUserMessage] : [remoteInfoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + remoteSessionConfig, + thinkingConfig, + }, + renderAndRun, + ); + return; + } else if (teleport) { + if (teleport === true || teleport === '') { + // Interactive mode: show task selector and handle resume + logEvent('tengu_teleport_interactive_mode', {}); + logForDebugging('selectAndResumeTeleportTask: Starting teleport flow...'); + const teleportResult = await launchTeleportResumeWrapper(root); + if (!teleportResult) { + // User cancelled or error occurred + await gracefulShutdown(0); + process.exit(0); + } + const { branchError } = await checkOutTeleportedSessionBranch(teleportResult.branch); + messages = processMessagesForTeleportResume(teleportResult.log, branchError); + } else if (typeof teleport === 'string') { + logEvent('tengu_teleport_resume_session', { + mode: 'direct' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + try { + // First, fetch session and validate repository before checking git state + const sessionData = await fetchSession(teleport); + const repoValidation = await validateSessionRepository(sessionData); + + // Handle repo mismatch or not in repo cases + if (repoValidation.status === 'mismatch' || repoValidation.status === 'not_in_repo') { + const sessionRepo = repoValidation.sessionRepo; + if (sessionRepo) { + // Check for known paths + const knownPaths = getKnownPathsForRepo(sessionRepo); + const existingPaths = await filterExistingPaths(knownPaths); + + if (existingPaths.length > 0) { + // Show directory switch dialog + const selectedPath = await launchTeleportRepoMismatchDialog(root, { + targetRepo: sessionRepo, + initialPaths: existingPaths, + }); + + if (selectedPath) { + // Change to the selected directory + process.chdir(selectedPath); + setCwd(selectedPath); + setOriginalCwd(selectedPath); + } else { + // User cancelled + await gracefulShutdown(0); + } + } else { + // No known paths - show original error + throw new TeleportOperationError( + `You must run claude --teleport ${teleport} from a checkout of ${sessionRepo}.`, + chalk.red( + `You must run claude --teleport ${teleport} from a checkout of ${chalk.bold(sessionRepo)}.\n`, + ), + ); + } + } + } else if (repoValidation.status === 'error') { + throw new TeleportOperationError( + repoValidation.errorMessage || 'Failed to validate session', + chalk.red(`Error: ${repoValidation.errorMessage || 'Failed to validate session'}\n`), + ); + } + + await validateGitState(); + + // Use progress UI for teleport + const { teleportWithProgress } = await import('./components/TeleportProgress.js'); + const result = await teleportWithProgress(root, teleport); + // Track teleported session for reliability logging + setTeleportedSessionInfo({ sessionId: teleport }); + messages = result.messages; + } catch (error) { + if (error instanceof TeleportOperationError) { + process.stderr.write(error.formattedMessage + '\n'); + } else { + logError(error); + process.stderr.write(chalk.red(`Error: ${errorMessage(error)}\n`)); + } + await gracefulShutdown(1); + } + } + } + if (process.env.USER_TYPE === 'ant') { + if (options.resume && typeof options.resume === 'string' && !maybeSessionId) { + // Check for ccshare URL (e.g. https://go/ccshare/boris-20260311-211036) + const { parseCcshareId, loadCcshare } = await import('./utils/ccshareResume.js'); + const ccshareId = parseCcshareId(options.resume); + if (ccshareId) { + try { + const resumeStart = performance.now(); + const logOption = await loadCcshare(ccshareId); + const result = await loadConversationForResume(logOption, undefined); + if (result) { + processedResume = await processResumedConversation( + result, + { + forkSession: true, + transcriptPath: result.fullPath, + }, + resumeContext, + ); + if (processedResume.restoredAgentDef) { + mainThreadAgentDefinition = processedResume.restoredAgentDef; + } + logEvent('tengu_session_resumed', { + entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }); + } else { + logEvent('tengu_session_resumed', { + entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + } + } catch (error) { + logEvent('tengu_session_resumed', { + entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + logError(error); + await exitWithError(root, `Unable to resume from ccshare: ${errorMessage(error)}`, () => + gracefulShutdown(1), + ); + } + } else { + const resolvedPath = resolve(options.resume); + try { + const resumeStart = performance.now(); + let logOption; + try { + // Attempt to load as a transcript file; ENOENT falls through to session-ID handling + logOption = await loadTranscriptFromFile(resolvedPath); + } catch (error) { + if (!isENOENT(error)) throw error; + // ENOENT: not a file path — fall through to session-ID handling + } + if (logOption) { + const result = await loadConversationForResume(logOption, undefined /* sourceFile */); + if (result) { + processedResume = await processResumedConversation( + result, + { + forkSession: !!options.forkSession, + transcriptPath: result.fullPath, + }, + resumeContext, + ); + if (processedResume.restoredAgentDef) { + mainThreadAgentDefinition = processedResume.restoredAgentDef; + } + logEvent('tengu_session_resumed', { + entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }); + } else { + logEvent('tengu_session_resumed', { + entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + } + } + } catch (error) { + logEvent('tengu_session_resumed', { + entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + logError(error); + await exitWithError(root, `Unable to load transcript from file: ${options.resume}`, () => + gracefulShutdown(1), + ); + } + } + } + } + + // If not loaded as a file, try as session ID + if (maybeSessionId) { + // Resume specific session by ID + const sessionId = maybeSessionId; + try { + const resumeStart = performance.now(); + // Use matchedLog if available (for cross-worktree resume by custom title) + // Otherwise fall back to sessionId string (for direct UUID resume) + const result = await loadConversationForResume(matchedLog ?? sessionId, undefined); + + if (!result) { + logEvent('tengu_session_resumed', { + entrypoint: 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + return await exitWithError(root, `No conversation found with session ID: ${sessionId}`); + } + + const fullPath = matchedLog?.fullPath ?? result.fullPath; + processedResume = await processResumedConversation( + result, + { + forkSession: !!options.forkSession, + sessionIdOverride: sessionId, + transcriptPath: fullPath, + }, + resumeContext, + ); + + if (processedResume.restoredAgentDef) { + mainThreadAgentDefinition = processedResume.restoredAgentDef; + } + logEvent('tengu_session_resumed', { + entrypoint: 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }); + } catch (error) { + logEvent('tengu_session_resumed', { + entrypoint: 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + logError(error); + await exitWithError(root, `Failed to resume session ${sessionId}`); + } + } + + // Await file downloads before rendering REPL (files must be available) + if (fileDownloadPromise) { + try { + const results = await fileDownloadPromise; + const failedCount = count(results, r => !r.success); + if (failedCount > 0) { + process.stderr.write( + chalk.yellow(`Warning: ${failedCount}/${results.length} file(s) failed to download.\n`), + ); + } + } catch (error) { + return await exitWithError(root, `Error downloading files: ${errorMessage(error)}`); + } + } + + // If we have a processed resume or teleport messages, render the REPL + const resumeData = + processedResume ?? + (Array.isArray(messages) + ? { + messages, + fileHistorySnapshots: undefined, + agentName: undefined, + agentColor: undefined as AgentColorName | undefined, + restoredAgentDef: mainThreadAgentDefinition, + initialState, + contentReplacements: undefined, + } + : undefined); + if (resumeData) { + maybeActivateProactive(options); + maybeActivateBrief(options); + + await launchRepl( + root, + { + getFpsMetrics, + stats, + initialState: resumeData.initialState, + }, + { + ...sessionConfig, + mainThreadAgentDefinition: resumeData.restoredAgentDef ?? mainThreadAgentDefinition, + initialMessages: resumeData.messages, + initialFileHistorySnapshots: resumeData.fileHistorySnapshots, + initialContentReplacements: resumeData.contentReplacements, + initialAgentName: resumeData.agentName, + initialAgentColor: resumeData.agentColor, + }, + renderAndRun, + ); + } else { + // Show interactive selector (includes same-repo worktrees) + // Note: ResumeConversation loads logs internally to ensure proper GC after selection + await launchResumeChooser(root, { getFpsMetrics, stats, initialState }, getWorktreePaths(getOriginalCwd()), { + ...sessionConfig, + initialSearchQuery: searchTerm, + forkSession: options.forkSession, + filterByPr, + }); + } + } else { + // Pass unresolved hooks promise to REPL so it can render immediately + // instead of blocking ~500ms waiting for SessionStart hooks to finish. + // REPL will inject hook messages when they resolve and await them before + // the first API call so the model always sees hook context. + const pendingHookMessages = hooksPromise && hookMessages.length === 0 ? hooksPromise : undefined; + + profileCheckpoint('action_after_hooks'); + maybeActivateProactive(options); + maybeActivateBrief(options); + // Persist the current mode for fresh sessions so future resumes know what mode was used + if (feature('COORDINATOR_MODE')) { + saveMode(coordinatorModeModule?.isCoordinatorMode() ? 'coordinator' : 'normal'); + } + + // If launched via a deep link, show a provenance banner so the user + // knows the session originated externally. Linux xdg-open and + // browsers with "always allow" set dispatch the link with no OS-level + // confirmation, so this is the only signal the user gets that the + // prompt — and the working directory / CLAUDE.md it implies — came + // from an external source rather than something they typed. + let deepLinkBanner: ReturnType | null = null; + if (feature('LODESTONE')) { + if (options.deepLinkOrigin) { + logEvent('tengu_deep_link_opened', { + has_prefill: Boolean(options.prefill), + has_repo: Boolean(options.deepLinkRepo), + }); + deepLinkBanner = createSystemMessage( + buildDeepLinkBanner({ + cwd: getCwd(), + prefillLength: options.prefill?.length, + repo: options.deepLinkRepo, + lastFetch: options.deepLinkLastFetch !== undefined ? new Date(options.deepLinkLastFetch) : undefined, + }), + 'warning', + ); + } else if (options.prefill) { + deepLinkBanner = createSystemMessage( + 'Launched with a pre-filled prompt — review it before pressing Enter.', + 'warning', + ); + } + } + const initialMessages = deepLinkBanner + ? [deepLinkBanner, ...hookMessages] + : hookMessages.length > 0 + ? hookMessages + : undefined; + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + ...sessionConfig, + initialMessages, + pendingHookMessages, + }, + renderAndRun, + ); + } + }) + .version(`${MACRO.VERSION} (CoStrict)`, '-v, --version', 'Output the version number'); + + // Worktree flags + program.addOption( + new Option( + '-w, --worktree [name]', + 'Create a new git worktree for this session (optionally specify a name)', + ).hideHelp(), + ); + program.addOption( + new Option( + '--tmux', + 'Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.', + ).hideHelp(), + ); + + if (canUserConfigureAdvisor()) { + program.addOption( + new Option( + '--advisor ', + 'Enable the server-side advisor tool with the specified model (alias or full ID).', + ).hideHelp(), + ); + } + + if (process.env.USER_TYPE === 'ant') { + program.addOption( + new Option('--delegate-permissions', '[ANT-ONLY] Alias for --permission-mode auto.').implies({ + permissionMode: 'auto', + }), + ); + program.addOption( + new Option( + '--dangerously-skip-permissions-with-classifiers', + '[ANT-ONLY] Deprecated alias for --permission-mode auto.', + ) + .hideHelp() + .implies({ permissionMode: 'auto' }), + ); + program.addOption( + new Option('--afk', '[ANT-ONLY] Deprecated alias for --permission-mode auto.') + .hideHelp() + .implies({ permissionMode: 'auto' }), + ); + program.addOption( + new Option( + '--tasks [id]', + '[ANT-ONLY] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").', + ) + .argParser(String) + .hideHelp(), + ); + program.option('--agent-teams', '[ANT-ONLY] Force Claude to use multi-agent mode for solving problems', () => true); + } + + if (feature('TRANSCRIPT_CLASSIFIER')) { + program.addOption(new Option('--enable-auto-mode', 'Opt in to auto mode').hideHelp()); + } + + if (feature('PROACTIVE') || feature('KAIROS')) { + program.addOption(new Option('--proactive', 'Start in proactive autonomous mode').hideHelp()); + } + + if (feature('UDS_INBOX')) { + program.addOption( + new Option( + '--messaging-socket-path ', + 'Unix domain socket path for the UDS messaging server (defaults to a tmp path)', + ).hideHelp(), + ); + } + + if (feature('KAIROS') || feature('KAIROS_BRIEF')) { + program.addOption(new Option('--brief', 'Enable SendUserMessage tool for agent-to-user communication').hideHelp()); + } + if (feature('KAIROS')) { + program.addOption(new Option('--assistant', 'Force assistant mode (Agent SDK daemon use)').hideHelp()); + } + program.addOption( + new Option( + '--channels ', + 'MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.', + ).hideHelp(), + ); + program.addOption( + new Option( + '--dangerously-load-development-channels ', + 'Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.', + ).hideHelp(), + ); + + // Teammate identity options (set by leader when spawning tmux teammates) + // These replace the CLAUDE_CODE_* environment variables + program.addOption(new Option('--agent-id ', 'Teammate agent ID').hideHelp()); + program.addOption(new Option('--agent-name ', 'Teammate display name').hideHelp()); + program.addOption(new Option('--team-name ', 'Team name for swarm coordination').hideHelp()); + program.addOption(new Option('--agent-color ', 'Teammate UI color').hideHelp()); + program.addOption(new Option('--plan-mode-required', 'Require plan mode before implementation').hideHelp()); + program.addOption(new Option('--parent-session-id ', 'Parent session ID for analytics correlation').hideHelp()); + program.addOption( + new Option('--teammate-mode ', 'How to spawn teammates: "tmux", "in-process", or "auto"') + .choices(['auto', 'tmux', 'in-process']) + .hideHelp(), + ); + program.addOption(new Option('--agent-type ', 'Custom agent type for this teammate').hideHelp()); + + // Enable SDK URL for all builds but hide from help + program.addOption( + new Option( + '--sdk-url ', + 'Use remote WebSocket endpoint for SDK I/O streaming (only with -p and stream-json format)', + ).hideHelp(), + ); + + // Enable teleport/remote flags for all builds but keep them undocumented until GA + program.addOption( + new Option('--teleport [session]', 'Resume a teleport session, optionally specify session ID').hideHelp(), + ); + program.addOption( + new Option('--remote [description]', 'Create a remote session with the given description').hideHelp(), + ); + if (feature('BRIDGE_MODE')) { + program.addOption( + new Option( + '--remote-control [name]', + 'Start an interactive session with Remote Control enabled (optionally named)', + ) + .argParser(value => value || true) + .hideHelp(), + ); + program.addOption( + new Option('--rc [name]', 'Alias for --remote-control').argParser(value => value || true).hideHelp(), + ); + } + + if (feature('HARD_FAIL')) { + program.addOption(new Option('--hard-fail', 'Crash on logError calls instead of silently logging').hideHelp()); + } + + profileCheckpoint('run_main_options_built'); + + // -p/--print mode: skip subcommand registration. The 52 subcommands + // (mcp, auth, plugin, skill, task, config, doctor, update, etc.) are + // never dispatched in print mode — commander routes the prompt to the + // default action. The subcommand registration path was measured at ~65ms + // on baseline — mostly the isBridgeEnabled() call (25ms settings Zod parse + // + 40ms sync keychain subprocess), both hidden by the try/catch that + // always returns false before enableConfigs(). cc:// URLs are rewritten to + // `open` at main() line ~851 BEFORE this runs, so argv check is safe here. + const isPrintMode = process.argv.includes('-p') || process.argv.includes('--print'); + const isCcUrl = process.argv.some(a => a.startsWith('cc://') || a.startsWith('cc+unix://')); + if (isPrintMode && !isCcUrl) { + profileCheckpoint('run_before_parse'); + await program.parseAsync(process.argv); + profileCheckpoint('run_after_parse'); + return program; + } + + // claude mcp + + const mcp = program + .command('mcp') + .description('Configure and manage MCP servers') + .addHelpCommand(false) + .configureHelp(createSortedHelpConfig()) + .enablePositionalOptions(); + + mcp + .command('serve') + .description(`Start the CoStrict MCP server`) + .option('-d, --debug', 'Enable debug mode', () => true) + .option('--verbose', 'Override verbose mode setting from config', () => true) + .action(async ({ debug, verbose }: { debug?: boolean; verbose?: boolean }) => { + const { mcpServeHandler } = await import('./cli/handlers/mcp.js'); + await mcpServeHandler({ debug, verbose }); + }); + + // Register the mcp add subcommand (extracted for testability) + registerMcpAddCommand(mcp); + + if (isXaaEnabled()) { + registerMcpXaaIdpCommand(mcp); + } + + mcp + .command('remove ') + .alias('rm') + .description('Remove an MCP server') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project) - if not specified, removes from whichever scope it exists in', + ) + .action(async (name: string, options: { scope?: string }) => { + const { mcpRemoveHandler } = await import('./cli/handlers/mcp.js'); + await mcpRemoveHandler(name, options); + }); + + mcp + .command('list') + .alias('ls') + .description('List configured MCP servers') + .action(async () => { + const { mcpListHandler } = await import('./cli/handlers/mcp.js'); + await mcpListHandler(); + }); + + mcp + .command('get ') + .description('Get details about an MCP server') + .action(async (name: string) => { + const { mcpGetHandler } = await import('./cli/handlers/mcp.js'); + await mcpGetHandler(name); + }); + + mcp + .command('add-json ') + .description('Add an MCP server (stdio or SSE) with a JSON string') + .option('-s, --scope ', 'Configuration scope (local, user, or project)', 'local') + .option('--client-secret', 'Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)') + .action(async (name: string, json: string, options: { scope?: string; clientSecret?: true }) => { + const { mcpAddJsonHandler } = await import('./cli/handlers/mcp.js'); + await mcpAddJsonHandler(name, json, options); + }); + + mcp + .command('add-from-claude-desktop') + .description('Import MCP servers from Claude Desktop (Mac and WSL only)') + .option('-s, --scope ', 'Configuration scope (local, user, or project)', 'local') + .action(async (options: { scope?: string }) => { + const { mcpAddFromDesktopHandler } = await import('./cli/handlers/mcp.js'); + await mcpAddFromDesktopHandler(options); + }); + + mcp + .command('reset-project-choices') + .description('Reset all approved and rejected project-scoped (.mcp.json) servers within this project') + .action(async () => { + const { mcpResetChoicesHandler } = await import('./cli/handlers/mcp.js'); + await mcpResetChoicesHandler(); + }); + + // csc serve — HTTP API server for IDE plugins and web clients. + // Also accessible via `csc serve` alias. + if (feature('DIRECT_CONNECT')) { + const serveAction = async (opts: { + port: string; + host: string; + authToken?: string; + unix?: string; + workspace?: string; + idleTimeout: string; + maxSessions: string; + }) => { + const { startServer } = await import('./server/server.js'); + const { EventBus } = await import('./server/eventBus.js'); + const { SessionManager } = await import('./server/sessionManager.js'); + const { printBanner } = await import('./server/serverBanner.js'); + const { getScriptArgsForChild, saveChildSpawnPrefix } = await import('./server/sessionHandle.js'); + + await saveChildSpawnPrefix(); + const eventBus = new EventBus(); + const config = { + port: parseInt(opts.port, 10), + host: opts.host, + authToken: undefined, + unix: opts.unix, + workspace: opts.workspace, + idleTimeoutMs: parseInt(opts.idleTimeout, 10), + maxSessions: parseInt(opts.maxSessions, 10), + }; + + const sessionManager = new SessionManager({ + eventBus, + idleTimeoutMs: config.idleTimeoutMs, + maxSessions: config.maxSessions, + workspace: config.workspace, + }); + await sessionManager.init(); + + const server = startServer(config, sessionManager, eventBus); + const actualPort = server.port ?? config.port; + printBanner(config, undefined, actualPort); + + sessionManager.startProbeSession({ + cwd: config.workspace || process.cwd(), + execPath: process.execPath, + scriptArgs: getScriptArgsForChild(), + }); + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + sessionManager.killProbe(); + server.stop(true); + eventBus.destroy(); + await sessionManager.destroyAll(); + process.exit(0); + }; + process.on('SIGINT', () => void shutdown()); + process.on('SIGTERM', () => void shutdown()); + }; + + program + .command('server') + .description('Start a CoStrict session server (alias: serve)') + .option('--port ', 'HTTP port', '0') + .option('--host ', 'Bind address', '127.0.0.1') + .option('--auth-token ', 'Bearer token for auth (currently disabled)') + .option('--unix ', 'Listen on a unix domain socket') + .option('--workspace ', 'Default working directory for sessions that do not specify cwd') + .option('--idle-timeout ', 'Idle timeout for detached sessions in ms (0 = never expire)', '1800000') + .option('--max-sessions ', 'Maximum concurrent sessions (0 = unlimited)', '32') + .action(serveAction); + + program + .command('serve') + .description('Start a CoStrict HTTP API server') + .option('--port ', 'HTTP port', '0') + .option('--host ', 'Bind address', '127.0.0.1') + .option('--auth-token ', 'Bearer token for auth (currently disabled)') + .option('--unix ', 'Listen on a unix domain socket') + .option('--workspace ', 'Default working directory for sessions that do not specify cwd') + .option('--idle-timeout ', 'Idle timeout for detached sessions in ms (0 = never expire)', '1800000') + .option('--max-sessions ', 'Maximum concurrent sessions (0 = unlimited)', '32') + .action(serveAction); + } + + // `claude ssh [dir]` — registered here only so --help shows it. + // The actual interactive flow is handled by early argv rewriting in main() + // (parallels the DIRECT_CONNECT/cc:// pattern above). If commander reaches + // this action it means the argv rewrite didn't fire (e.g. user ran + // `claude ssh` with no host) — just print usage. + if (feature('SSH_REMOTE')) { + program + .command('ssh [dir]') + .description( + 'Run CoStrict on a remote host over SSH. Deploys the binary and ' + + 'tunnels API auth back through your local machine — no remote setup needed.', + ) + .option('--permission-mode ', 'Permission mode for the remote session') + .option('--dangerously-skip-permissions', 'Skip all permission prompts on the remote (dangerous)') + .option( + '--remote-bin ', + 'Custom remote binary command (skips probe/deploy). ' + + "Example: --remote-bin 'bun /path/to/project/dist/cli.js'", + ) + .option( + '--local', + 'e2e test mode — spawn the child CLI locally (skip ssh/deploy). ' + + 'Exercises the auth proxy and unix-socket plumbing without a remote host.', + ) + .action(async () => { + // Argv rewriting in main() should have consumed `ssh ` before + // commander runs. Reaching here means host was missing or the + // rewrite predicate didn't match. + process.stderr.write( + 'Usage: csc ssh [dir]\n\n' + + "Runs CoStrict on a remote Linux host. You don't need to install\n" + + 'anything on the remote or run `csc auth login` there — the binary is\n' + + 'deployed over SSH and API auth tunnels back through your local machine.\n', + ); + process.exit(1); + }); + } + + // claude connect — subcommand only handles -p (headless) mode. + // Interactive mode (without -p) is handled by early argv rewriting in main() + // which redirects to the main command with full TUI support. + if (feature('DIRECT_CONNECT')) { + program + .command('open ') + .description('Connect to a CoStrict server (internal — use cc:// URLs)') + .option('-p, --print [prompt]', 'Print mode (headless)') + .option('--output-format ', 'Output format: text, json, stream-json', 'text') + .action( + async ( + ccUrl: string, + opts: { + print?: string | true; + outputFormat?: string; + }, + ) => { + const { parseConnectUrl } = await import('./server/parseConnectUrl.js'); + const { serverUrl, authToken } = parseConnectUrl(ccUrl); + + let connectConfig; + try { + const session = await createDirectConnectSession({ + serverUrl, + authToken, + cwd: getOriginalCwd(), + dangerouslySkipPermissions: _pendingConnect?.dangerouslySkipPermissions, + }); + if (session.workDir) { + setOriginalCwd(session.workDir); + setCwdState(session.workDir); + } + setDirectConnectServerUrl(serverUrl); + connectConfig = session.config; + } catch (err) { + console.error(err instanceof DirectConnectError ? err.message : String(err)); + process.exit(1); + } + + const { runConnectHeadless } = await import('./server/connectHeadless.js'); + + const prompt = typeof opts.print === 'string' ? opts.print : ''; + const interactive = opts.print === true; + await runConnectHeadless(connectConfig, prompt, opts.outputFormat, interactive); + }, + ); + } + + // claude auth + + const auth = program + .command('auth') + .description('Manage authentication') + .addHelpCommand(false) + .configureHelp(createSortedHelpConfig()); + + auth + .command('login') + .description('Sign in to your CoStrict account') + .option('--email ', 'Pre-populate email address on the login page (Anthropic OAuth only)') + .option('--sso', 'Force SSO login flow (Anthropic OAuth only)') + .option('--console', 'Use Anthropic Console (API usage billing) instead of CoStrict') + .option('--claudeai', 'Use Claude subscription instead of CoStrict') + .action( + async ({ + email, + sso, + console: useConsole, + claudeai, + }: { + email?: string; + sso?: boolean; + console?: boolean; + claudeai?: boolean; + }) => { + const { authLogin } = await import('./cli/handlers/auth.js'); + await authLogin({ email, sso, console: useConsole, claudeai }); + }, + ); + + auth + .command('status') + .description('Show authentication status') + .option('--json', 'Output as JSON (default)') + .option('--text', 'Output as human-readable text') + .action(async (opts: { json?: boolean; text?: boolean }) => { + const { authStatus } = await import('./cli/handlers/auth.js'); + await authStatus(opts); + }); + + auth + .command('logout') + .description('Log out from your CoStrict account') + .action(async () => { + const { authLogout } = await import('./cli/handlers/auth.js'); + await authLogout(); + }); + + /** + * Helper function to handle marketplace command errors consistently. + * Logs the error and exits the process with status 1. + * @param error The error that occurred + * @param action Description of the action that failed + */ + // Hidden flag on all plugin/marketplace subcommands to target cowork_plugins. + const coworkOption = () => new Option('--cowork', 'Use cowork_plugins directory').hideHelp(); + + // Plugin validate command + const pluginCmd = program + .command('plugin') + .alias('plugins') + .alias('plug') + .description('Manage CoStrict plugins') + .addHelpCommand(false) + .configureHelp(createSortedHelpConfig()); + + pluginCmd + .command('validate ') + .description('Validate a plugin or marketplace manifest') + .addOption(coworkOption()) + .action(async (manifestPath: string, options: { cowork?: boolean }) => { + const { pluginValidateHandler } = await import('./cli/handlers/plugins.js'); + await pluginValidateHandler(manifestPath, options); + }); + + // Plugin list command + pluginCmd + .command('list') + .alias('ls') + .description('List installed plugins') + .option('--json', 'Output as JSON') + .option('--available', 'Include available plugins from marketplaces (requires --json)') + .addOption(coworkOption()) + .action(async (options: { json?: boolean; available?: boolean; cowork?: boolean }) => { + const { pluginListHandler } = await import('./cli/handlers/plugins.js'); + await pluginListHandler(options); + }); + + // Marketplace subcommands + const marketplaceCmd = pluginCmd + .command('marketplace') + .description('Manage CoStrict marketplaces') + .configureHelp(createSortedHelpConfig()); + + marketplaceCmd + .command('add ') + .description('Add a marketplace from a URL, path, or GitHub repo') + .addOption(coworkOption()) + .option( + '--sparse ', + 'Limit checkout to specific directories via git sparse-checkout (for monorepos). Example: --sparse .claude-plugin plugins', + ) + .option('--scope ', 'Where to declare the marketplace: user (default), project, or local') + .action( + async ( + source: string, + options: { + cowork?: boolean; + sparse?: string[]; + scope?: string; + }, + ) => { + const { marketplaceAddHandler } = await import('./cli/handlers/plugins.js'); + await marketplaceAddHandler(source, options); + }, + ); + + marketplaceCmd + .command('list') + .description('List all configured marketplaces') + .option('--json', 'Output as JSON') + .addOption(coworkOption()) + .action(async (options: { json?: boolean; cowork?: boolean }) => { + const { marketplaceListHandler } = await import('./cli/handlers/plugins.js'); + await marketplaceListHandler(options); + }); + + marketplaceCmd + .command('remove ') + .alias('rm') + .description('Remove a configured marketplace') + .addOption(coworkOption()) + .action(async (name: string, options: { cowork?: boolean }) => { + const { marketplaceRemoveHandler } = await import('./cli/handlers/plugins.js'); + await marketplaceRemoveHandler(name, options); + }); + + marketplaceCmd + .command('update [name]') + .description('Update marketplace(s) from their source - updates all if no name specified') + .addOption(coworkOption()) + .action(async (name: string | undefined, options: { cowork?: boolean }) => { + const { marketplaceUpdateHandler } = await import('./cli/handlers/plugins.js'); + await marketplaceUpdateHandler(name, options); + }); + + // Plugin install command + pluginCmd + .command('install ') + .alias('i') + .description('Install a plugin from available marketplaces (use plugin@marketplace for specific marketplace)') + .option('-s, --scope ', 'Installation scope: user, project, or local', 'user') + .addOption(coworkOption()) + .action(async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginInstallHandler } = await import('./cli/handlers/plugins.js'); + await pluginInstallHandler(plugin, options); + }); + + // Plugin uninstall command + pluginCmd + .command('uninstall ') + .alias('remove') + .alias('rm') + .description('Uninstall an installed plugin') + .option('-s, --scope ', 'Uninstall from scope: user, project, or local', 'user') + .option('--keep-data', "Preserve the plugin's persistent data directory (~/.claude/plugins/data/{id}/)") + .addOption(coworkOption()) + .action( + async ( + plugin: string, + options: { + scope?: string; + cowork?: boolean; + keepData?: boolean; + }, + ) => { + const { pluginUninstallHandler } = await import('./cli/handlers/plugins.js'); + await pluginUninstallHandler(plugin, options); + }, + ); + + // Plugin enable command + pluginCmd + .command('enable ') + .description('Enable a disabled plugin') + .option('-s, --scope ', `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`) + .addOption(coworkOption()) + .action(async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginEnableHandler } = await import('./cli/handlers/plugins.js'); + await pluginEnableHandler(plugin, options); + }); + + // Plugin disable command + pluginCmd + .command('disable [plugin]') + .description('Disable an enabled plugin') + .option('-a, --all', 'Disable all enabled plugins') + .option('-s, --scope ', `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`) + .addOption(coworkOption()) + .action(async (plugin: string | undefined, options: { scope?: string; cowork?: boolean; all?: boolean }) => { + const { pluginDisableHandler } = await import('./cli/handlers/plugins.js'); + await pluginDisableHandler(plugin, options); + }); + + // Plugin update command + pluginCmd + .command('update ') + .description('Update a plugin to the latest version (restart required to apply)') + .option('-s, --scope ', `Installation scope: ${VALID_UPDATE_SCOPES.join(', ')} (default: user)`) + .addOption(coworkOption()) + .action(async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginUpdateHandler } = await import('./cli/handlers/plugins.js'); + await pluginUpdateHandler(plugin, options); + }); + // END ANT-ONLY // Setup token command program .command('setup-token') - .description( - 'Set up a long-lived authentication token (requires Claude subscription)', - ) + .description('Set up a long-lived authentication token (requires Claude subscription)') .action(async () => { const [{ setupTokenHandler }, { createRoot }] = await Promise.all([ import('./cli/handlers/util.js'), import('@anthropic/ink'), - ]) - const root = await createRoot(getBaseRenderOptions(false)) - await setupTokenHandler(root) - }) + ]); + const root = await createRoot(getBaseRenderOptions(false)); + await setupTokenHandler(root); + }); - // Agents command - list configured agents - program - .command("agents") - .description("List configured agents") - .option( - "--setting-sources ", - "Comma-separated list of setting sources to load (user, project, local).", - ) - .action(async () => { - const { agentsHandler } = await import("./cli/handlers/agents.js"); - await agentsHandler(); - process.exit(0); - }); + // Agents command - list configured agents + program + .command('agents') + .description('List configured agents') + .option('--setting-sources ', 'Comma-separated list of setting sources to load (user, project, local).') + .action(async () => { + const { agentsHandler } = await import('./cli/handlers/agents.js'); + await agentsHandler(); + process.exit(0); + }); - if (feature("TRANSCRIPT_CLASSIFIER")) { - // Skip when tengu_auto_mode_config.enabled === 'disabled' (circuit breaker). - // Reads from disk cache — GrowthBook isn't initialized at registration time. - if (getAutoModeEnabledStateIfCached() !== "disabled") { - const autoModeCmd = program - .command("auto-mode", { hidden: true }) - .description("Inspect auto mode classifier configuration"); + if (feature('TRANSCRIPT_CLASSIFIER')) { + // Skip when tengu_auto_mode_config.enabled === 'disabled' (circuit breaker). + // Reads from disk cache — GrowthBook isn't initialized at registration time. + if (getAutoModeEnabledStateIfCached() !== 'disabled') { + const autoModeCmd = program + .command('auto-mode', { hidden: true }) + .description('Inspect auto mode classifier configuration'); - autoModeCmd - .command("defaults") - .description( - "Print the default auto mode environment, allow, and deny rules as JSON", - ) - .action(async () => { - const { autoModeDefaultsHandler } = - await import("./cli/handlers/autoMode.js"); - autoModeDefaultsHandler(); - process.exit(0); - }); + autoModeCmd + .command('defaults') + .description('Print the default auto mode environment, allow, and deny rules as JSON') + .action(async () => { + const { autoModeDefaultsHandler } = await import('./cli/handlers/autoMode.js'); + autoModeDefaultsHandler(); + process.exit(0); + }); - autoModeCmd - .command("config") - .description( - "Print the effective auto mode config as JSON: your settings where set, defaults otherwise", - ) - .action(async () => { - const { autoModeConfigHandler } = - await import("./cli/handlers/autoMode.js"); - autoModeConfigHandler(); - process.exit(0); - }); + autoModeCmd + .command('config') + .description('Print the effective auto mode config as JSON: your settings where set, defaults otherwise') + .action(async () => { + const { autoModeConfigHandler } = await import('./cli/handlers/autoMode.js'); + autoModeConfigHandler(); + process.exit(0); + }); - autoModeCmd - .command("critique") - .description("Get AI feedback on your custom auto mode rules") - .option("--model ", "Override which model is used") - .action(async (options) => { - const { autoModeCritiqueHandler } = - await import("./cli/handlers/autoMode.js"); - await autoModeCritiqueHandler(options); - process.exit(); - }); - } - } + autoModeCmd + .command('critique') + .description('Get AI feedback on your custom auto mode rules') + .option('--model ', 'Override which model is used') + .action(async options => { + const { autoModeCritiqueHandler } = await import('./cli/handlers/autoMode.js'); + await autoModeCritiqueHandler(options); + process.exit(); + }); + } + } - // claude autonomy — CLI subcommands mirroring /autonomy slash command - { - const autonomyCmd = program - .command("autonomy") - .description("Inspect and manage automatic autonomy runs and flows"); + // claude autonomy — CLI subcommands mirroring /autonomy slash command + { + const autonomyCmd = program.command('autonomy').description('Inspect and manage automatic autonomy runs and flows'); - autonomyCmd - .command("status") - .description("Print autonomy run, flow, team, pipe, and remote-control status") - .option("--deep", "Include teams, pipes, daemon, and remote-control sections") - .action(async (options: { deep?: boolean }) => { - const { autonomyStatusHandler } = await import("./cli/handlers/autonomy.js"); - await autonomyStatusHandler(options); - process.exit(0); - }); + autonomyCmd + .command('status') + .description('Print autonomy run, flow, team, pipe, and remote-control status') + .option('--deep', 'Include teams, pipes, daemon, and remote-control sections') + .action(async (options: { deep?: boolean }) => { + const { autonomyStatusHandler } = await import('./cli/handlers/autonomy.js'); + await autonomyStatusHandler(options); + process.exit(0); + }); - autonomyCmd - .command("runs [limit]") - .description("List recent autonomy runs") - .action(async (limit?: string) => { - const { autonomyRunsHandler } = await import("./cli/handlers/autonomy.js"); - await autonomyRunsHandler(limit); - process.exit(0); - }); + autonomyCmd + .command('runs [limit]') + .description('List recent autonomy runs') + .action(async (limit?: string) => { + const { autonomyRunsHandler } = await import('./cli/handlers/autonomy.js'); + await autonomyRunsHandler(limit); + process.exit(0); + }); - autonomyCmd - .command("flows [limit]") - .description("List recent autonomy flows") - .action(async (limit?: string) => { - const { autonomyFlowsHandler } = await import("./cli/handlers/autonomy.js"); - await autonomyFlowsHandler(limit); - process.exit(0); - }); + autonomyCmd + .command('flows [limit]') + .description('List recent autonomy flows') + .action(async (limit?: string) => { + const { autonomyFlowsHandler } = await import('./cli/handlers/autonomy.js'); + await autonomyFlowsHandler(limit); + process.exit(0); + }); - const flowCmd = autonomyCmd - .command("flow ") - .description("Inspect a single autonomy flow") - .action(async (flowId: string) => { - const { autonomyFlowHandler } = await import("./cli/handlers/autonomy.js"); - await autonomyFlowHandler(flowId); - process.exit(0); - }); + const flowCmd = autonomyCmd + .command('flow ') + .description('Inspect a single autonomy flow') + .action(async (flowId: string) => { + const { autonomyFlowHandler } = await import('./cli/handlers/autonomy.js'); + await autonomyFlowHandler(flowId); + process.exit(0); + }); - flowCmd - .command("cancel ") - .description("Cancel a queued, waiting, or running autonomy flow") - .action(async (flowId: string) => { - const { autonomyFlowCancelHandler } = await import("./cli/handlers/autonomy.js"); - await autonomyFlowCancelHandler(flowId); - process.exit(0); - }); + flowCmd + .command('cancel ') + .description('Cancel a queued, waiting, or running autonomy flow') + .action(async (flowId: string) => { + const { autonomyFlowCancelHandler } = await import('./cli/handlers/autonomy.js'); + await autonomyFlowCancelHandler(flowId); + process.exit(0); + }); - flowCmd - .command("resume ") - .description("Resume a waiting autonomy flow") - .action(async (flowId: string) => { - const { autonomyFlowResumeHandler } = await import("./cli/handlers/autonomy.js"); - await autonomyFlowResumeHandler(flowId); - process.exit(0); - }); - } + flowCmd + .command('resume ') + .description('Resume a waiting autonomy flow') + .action(async (flowId: string) => { + const { autonomyFlowResumeHandler } = await import('./cli/handlers/autonomy.js'); + await autonomyFlowResumeHandler(flowId); + process.exit(0); + }); + } - // Remote Control command — connect local environment to claude.ai/code. - // The actual command is intercepted by the fast-path in cli.tsx before - // Commander.js runs, so this registration exists only for help output. - // Always hidden: isBridgeEnabled() at this point (before enableConfigs) - // would throw inside isClaudeAISubscriber → getGlobalConfig and return - // false via the try/catch — but not before paying ~65ms of side effects - // (25ms settings Zod parse + 40ms sync `security` keychain subprocess). - // The dynamic visibility never worked; the command was always hidden. - if (feature("BRIDGE_MODE")) { - program - .command("remote-control", { hidden: true }) - .alias("rc") - .description( - "Connect your local environment for remote-control sessions via costrict.ai", - ) - .action(async () => { - // Unreachable — cli.tsx fast-path handles this command before main.tsx loads. - // If somehow reached, delegate to bridgeMain. - const { bridgeMain } = await import("./bridge/bridgeMain.js"); - await bridgeMain(process.argv.slice(3)); - }); - } + // Remote Control command — connect local environment to claude.ai/code. + // The actual command is intercepted by the fast-path in cli.tsx before + // Commander.js runs, so this registration exists only for help output. + // Always hidden: isBridgeEnabled() at this point (before enableConfigs) + // would throw inside isClaudeAISubscriber → getGlobalConfig and return + // false via the try/catch — but not before paying ~65ms of side effects + // (25ms settings Zod parse + 40ms sync `security` keychain subprocess). + // The dynamic visibility never worked; the command was always hidden. + if (feature('BRIDGE_MODE')) { + program + .command('remote-control', { hidden: true }) + .alias('rc') + .description('Connect your local environment for remote-control sessions via costrict.ai') + .action(async () => { + // Unreachable — cli.tsx fast-path handles this command before main.tsx loads. + // If somehow reached, delegate to bridgeMain. + const { bridgeMain } = await import('./bridge/bridgeMain.js'); + await bridgeMain(process.argv.slice(3)); + }); + } - if (feature("KAIROS")) { - program - .command("assistant [sessionId]", { hidden: true }) - .description( - "Attach the REPL as a client to a running bridge session. Discovers sessions via API if no sessionId given.", - ) - .action(() => { - // Argv rewriting above should have consumed `assistant [id]` - // before commander runs. Reaching here means a root flag came first - // (e.g. `--debug assistant`) and the position-0 predicate - // didn't match. Print usage like the ssh stub does. - process.stderr.write( - "Usage: csc assistant [sessionId]\n\n" + - "Attach the REPL as a viewer client to a running bridge session.\n" + - "Omit sessionId to discover and pick from available sessions.\n", - ); - process.exit(1); - }); - } + if (feature('KAIROS')) { + program + .command('assistant [sessionId]', { hidden: true }) + .description( + 'Attach the REPL as a client to a running bridge session. Discovers sessions via API if no sessionId given.', + ) + .action(() => { + // Argv rewriting above should have consumed `assistant [id]` + // before commander runs. Reaching here means a root flag came first + // (e.g. `--debug assistant`) and the position-0 predicate + // didn't match. Print usage like the ssh stub does. + process.stderr.write( + 'Usage: csc assistant [sessionId]\n\n' + + 'Attach the REPL as a viewer client to a running bridge session.\n' + + 'Omit sessionId to discover and pick from available sessions.\n', + ); + process.exit(1); + }); + } // Cloud command - manage cs-cloud daemon (download binary + forward commands) program @@ -6654,9 +5448,9 @@ async function run(): Promise { .description('Manage cloud daemon (register device and connect via WebSocket tunnel)') .allowUnknownOption() .action(async (args: string[]) => { - const { cloudHandler } = await import('./cli/handlers/cloud.js') - await cloudHandler(args) - }) + const { cloudHandler } = await import('./cli/handlers/cloud.js'); + await cloudHandler(args); + }); // Update command - check and install updates program @@ -6664,501 +5458,398 @@ async function run(): Promise { .alias('upgrade') .description('Check for updates and install the latest version') .action(async () => { - const { update } = await import('./cli/update.js') - await update() - }) + const { update } = await import('./cli/update.js'); + await update(); + }); // Doctor command - check installation health program .command('doctor') - .description( - 'Check the health of your CoStrict auto-updater', - ) + .description('Check the health of your CoStrict auto-updater') .action(async () => { const [{ doctorHandler }, { createRoot }] = await Promise.all([ import('./cli/handlers/util.js'), import('@anthropic/ink'), - ]) - const root = await createRoot(getBaseRenderOptions(false)) - await doctorHandler(root) - }) + ]); + const root = await createRoot(getBaseRenderOptions(false)); + await doctorHandler(root); + }); + // claude up — run the project's CLAUDE.md "# claude up" setup instructions. + if (process.env.USER_TYPE === 'ant') { + program + .command('up') + .description( + '[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md', + ) + .action(async () => { + const { up } = await import('src/cli/up.js'); + await up(); + }); + } - // claude up — run the project's CLAUDE.md "# claude up" setup instructions. - if (process.env.USER_TYPE === "ant") { - program - .command("up") - .description( - '[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md', - ) - .action(async () => { - const { up } = await import("src/cli/up.js"); - await up(); - }); - } + // claude rollback (ant-only) + // Rolls back to previous releases + if (process.env.USER_TYPE === 'ant') { + program + .command('rollback [target]') + .description( + '[ANT-ONLY] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version', + ) + .option('-l, --list', 'List recent published versions with ages') + .option('--dry-run', 'Show what would be installed without installing') + .option('--safe', 'Roll back to the server-pinned safe version (set by oncall during incidents)') + .action( + async ( + target?: string, + options?: { + list?: boolean; + dryRun?: boolean; + safe?: boolean; + }, + ) => { + const { rollback } = await import('src/cli/rollback.js'); + await rollback(target, options); + }, + ); + } - // claude rollback (ant-only) - // Rolls back to previous releases - if (process.env.USER_TYPE === "ant") { - program - .command("rollback [target]") - .description( - "[ANT-ONLY] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version", - ) - .option("-l, --list", "List recent published versions with ages") - .option( - "--dry-run", - "Show what would be installed without installing", - ) - .option( - "--safe", - "Roll back to the server-pinned safe version (set by oncall during incidents)", - ) - .action( - async ( - target?: string, - options?: { - list?: boolean; - dryRun?: boolean; - safe?: boolean; - }, - ) => { - const { rollback } = await import("src/cli/rollback.js"); - await rollback(target, options); - }, - ); - } + // claude install + program + .command('install [target]') + .description('Install CoStrict native build. Use [target] to specify version (stable, latest, or specific version)') + .option('--force', 'Force installation even if already installed') + .action(async (target: string | undefined, options: { force?: boolean }) => { + const { installHandler } = await import('./cli/handlers/util.js'); + await installHandler(target, options); + }); - // claude install - program - .command("install [target]") - .description( - "Install CoStrict native build. Use [target] to specify version (stable, latest, or specific version)", - ) - .option("--force", "Force installation even if already installed") - .action( - async ( - target: string | undefined, - options: { force?: boolean }, - ) => { - const { installHandler } = - await import("./cli/handlers/util.js"); - await installHandler(target, options); - }, - ); + // ant-only commands + if (process.env.USER_TYPE === 'ant') { + const validateLogId = (value: string) => { + const maybeSessionId = validateUuid(value); + if (maybeSessionId) return maybeSessionId; + return Number(value); + }; + // claude log + program + .command('log') + .description('[ANT-ONLY] Manage conversation logs.') + .argument( + '[number|sessionId]', + 'A number (0, 1, 2, etc.) to display a specific log, or the sesssion ID (uuid) of a log', + validateLogId, + ) + .action(async (logId: string | number | undefined) => { + const { logHandler } = await import('./cli/handlers/ant.js'); + await logHandler(logId); + }); + // claude error + program + .command('error') + .description( + '[ANT-ONLY] View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.', + ) + .argument('[number]', 'A number (0, 1, 2, etc.) to display a specific log', parseInt) + .action(async (number: number | undefined) => { + const { errorHandler } = await import('./cli/handlers/ant.js'); + await errorHandler(number); + }); - // ant-only commands - if (process.env.USER_TYPE === "ant") { - const validateLogId = (value: string) => { - const maybeSessionId = validateUuid(value); - if (maybeSessionId) return maybeSessionId; - return Number(value); - }; - // claude log - program - .command("log") - .description("[ANT-ONLY] Manage conversation logs.") - .argument( - "[number|sessionId]", - "A number (0, 1, 2, etc.) to display a specific log, or the sesssion ID (uuid) of a log", - validateLogId, - ) - .action(async (logId: string | number | undefined) => { - const { logHandler } = await import("./cli/handlers/ant.js"); - await logHandler(logId); - }); - - // claude error - program - .command("error") - .description( - "[ANT-ONLY] View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.", - ) - .argument( - "[number]", - "A number (0, 1, 2, etc.) to display a specific log", - parseInt, - ) - .action(async (number: number | undefined) => { - const { errorHandler } = await import("./cli/handlers/ant.js"); - await errorHandler(number); - }); - - // claude export - program - .command("export") - .description("[ANT-ONLY] Export a conversation to a text file.") - .usage(" ") - .argument( - "", - "Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file", - ) - .argument("", "Output file path for the exported text") - .addHelpText( - "after", - ` + // claude export + program + .command('export') + .description('[ANT-ONLY] Export a conversation to a text file.') + .usage(' ') + .argument('', 'Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file') + .argument('', 'Output file path for the exported text') + .addHelpText( + 'after', + ` Examples: $ claude export 0 conversation.txt Export conversation at log index 0 $ claude export conversation.txt Export conversation by session ID $ claude export input.json output.txt Render JSON log file to text $ claude export .jsonl output.txt Render JSONL session file to text`, - ) - .action(async (source: string, outputFile: string) => { - const { exportHandler } = await import("./cli/handlers/ant.js"); - await exportHandler(source, outputFile); - }); + ) + .action(async (source: string, outputFile: string) => { + const { exportHandler } = await import('./cli/handlers/ant.js'); + await exportHandler(source, outputFile); + }); - if (process.env.USER_TYPE === "ant") { - const taskCmd = program - .command("task") - .description("[ANT-ONLY] Manage task list tasks"); + if (process.env.USER_TYPE === 'ant') { + const taskCmd = program.command('task').description('[ANT-ONLY] Manage task list tasks'); - taskCmd - .command("create ") - .description("Create a new task") - .option("-d, --description ", "Task description") - .option( - "-l, --list ", - 'Task list ID (defaults to "tasklist")', - ) - .action( - async ( - subject: string, - opts: { description?: string; list?: string }, - ) => { - const { taskCreateHandler } = - await import("./cli/handlers/ant.js"); - await taskCreateHandler(subject, opts); - }, - ); + taskCmd + .command('create ') + .description('Create a new task') + .option('-d, --description ', 'Task description') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action(async (subject: string, opts: { description?: string; list?: string }) => { + const { taskCreateHandler } = await import('./cli/handlers/ant.js'); + await taskCreateHandler(subject, opts); + }); - taskCmd - .command("list") - .description("List all tasks") - .option( - "-l, --list ", - 'Task list ID (defaults to "tasklist")', - ) - .option("--pending", "Show only pending tasks") - .option("--json", "Output as JSON") - .action( - async (opts: { - list?: string; - pending?: boolean; - json?: boolean; - }) => { - const { taskListHandler } = - await import("./cli/handlers/ant.js"); - await taskListHandler(opts); - }, - ); + taskCmd + .command('list') + .description('List all tasks') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .option('--pending', 'Show only pending tasks') + .option('--json', 'Output as JSON') + .action(async (opts: { list?: string; pending?: boolean; json?: boolean }) => { + const { taskListHandler } = await import('./cli/handlers/ant.js'); + await taskListHandler(opts); + }); - taskCmd - .command("get ") - .description("Get details of a task") - .option( - "-l, --list ", - 'Task list ID (defaults to "tasklist")', - ) - .action(async (id: string, opts: { list?: string }) => { - const { taskGetHandler } = - await import("./cli/handlers/ant.js"); - await taskGetHandler(id, opts); - }); + taskCmd + .command('get ') + .description('Get details of a task') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action(async (id: string, opts: { list?: string }) => { + const { taskGetHandler } = await import('./cli/handlers/ant.js'); + await taskGetHandler(id, opts); + }); - taskCmd - .command("update ") - .description("Update a task") - .option( - "-l, --list ", - 'Task list ID (defaults to "tasklist")', - ) - .option( - "-s, --status ", - `Set status (${TASK_STATUSES.join(", ")})`, - ) - .option("--subject ", "Update subject") - .option("-d, --description ", "Update description") - .option("--owner ", "Set owner") - .option("--clear-owner", "Clear owner") - .action( - async ( - id: string, - opts: { - list?: string; - status?: string; - subject?: string; - description?: string; - owner?: string; - clearOwner?: boolean; - }, - ) => { - const { taskUpdateHandler } = - await import("./cli/handlers/ant.js"); - await taskUpdateHandler(id, opts); - }, - ); + taskCmd + .command('update ') + .description('Update a task') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .option('-s, --status ', `Set status (${TASK_STATUSES.join(', ')})`) + .option('--subject ', 'Update subject') + .option('-d, --description ', 'Update description') + .option('--owner ', 'Set owner') + .option('--clear-owner', 'Clear owner') + .action( + async ( + id: string, + opts: { + list?: string; + status?: string; + subject?: string; + description?: string; + owner?: string; + clearOwner?: boolean; + }, + ) => { + const { taskUpdateHandler } = await import('./cli/handlers/ant.js'); + await taskUpdateHandler(id, opts); + }, + ); - taskCmd - .command("dir") - .description("Show the tasks directory path") - .option( - "-l, --list ", - 'Task list ID (defaults to "tasklist")', - ) - .action(async (opts: { list?: string }) => { - const { taskDirHandler } = - await import("./cli/handlers/ant.js"); - await taskDirHandler(opts); - }); - } + taskCmd + .command('dir') + .description('Show the tasks directory path') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action(async (opts: { list?: string }) => { + const { taskDirHandler } = await import('./cli/handlers/ant.js'); + await taskDirHandler(opts); + }); + } - // claude completion - program - .command("completion ", { hidden: true }) - .description( - "Generate shell completion script (bash, zsh, or fish)", - ) - .option( - "--output ", - "Write completion script directly to a file instead of stdout", - ) - .action(async (shell: string, opts: { output?: string }) => { - const { completionHandler } = - await import("./cli/handlers/ant.js"); - await completionHandler(shell, opts, program); - }); - } + // claude completion + program + .command('completion ', { hidden: true }) + .description('Generate shell completion script (bash, zsh, or fish)') + .option('--output ', 'Write completion script directly to a file instead of stdout') + .action(async (shell: string, opts: { output?: string }) => { + const { completionHandler } = await import('./cli/handlers/ant.js'); + await completionHandler(shell, opts, program); + }); + } - profileCheckpoint("run_before_parse"); - await program.parseAsync(process.argv); - profileCheckpoint("run_after_parse"); + profileCheckpoint('run_before_parse'); + await program.parseAsync(process.argv); + profileCheckpoint('run_after_parse'); - // Record final checkpoint for total_time calculation - profileCheckpoint("main_after_run"); + // Record final checkpoint for total_time calculation + profileCheckpoint('main_after_run'); - // Log startup perf to Statsig (sampled) and output detailed report if enabled - profileReport(); + // Log startup perf to Statsig (sampled) and output detailed report if enabled + profileReport(); - return program; + return program; } async function logTenguInit({ - hasInitialPrompt, - hasStdin, - verbose, - debug, - debugToStderr, - print, - outputFormat, - inputFormat, - numAllowedTools, - numDisallowedTools, - mcpClientCount, - worktreeEnabled, - skipWebFetchPreflight, - githubActionInputs, - dangerouslySkipPermissionsPassed, - permissionMode, - modeIsBypass, - allowDangerouslySkipPermissionsPassed, - systemPromptFlag, - appendSystemPromptFlag, - thinkingConfig, - assistantActivationPath, + hasInitialPrompt, + hasStdin, + verbose, + debug, + debugToStderr, + print, + outputFormat, + inputFormat, + numAllowedTools, + numDisallowedTools, + mcpClientCount, + worktreeEnabled, + skipWebFetchPreflight, + githubActionInputs, + dangerouslySkipPermissionsPassed, + permissionMode, + modeIsBypass, + allowDangerouslySkipPermissionsPassed, + systemPromptFlag, + appendSystemPromptFlag, + thinkingConfig, + assistantActivationPath, }: { - hasInitialPrompt: boolean; - hasStdin: boolean; - verbose: boolean; - debug: boolean; - debugToStderr: boolean; - print: boolean; - outputFormat: string; - inputFormat: string; - numAllowedTools: number; - numDisallowedTools: number; - mcpClientCount: number; - worktreeEnabled: boolean; - skipWebFetchPreflight: boolean | undefined; - githubActionInputs: string | undefined; - dangerouslySkipPermissionsPassed: boolean; - permissionMode: string; - modeIsBypass: boolean; - allowDangerouslySkipPermissionsPassed: boolean; - systemPromptFlag: "file" | "flag" | undefined; - appendSystemPromptFlag: "file" | "flag" | undefined; - thinkingConfig: ThinkingConfig; - assistantActivationPath: string | undefined; + hasInitialPrompt: boolean; + hasStdin: boolean; + verbose: boolean; + debug: boolean; + debugToStderr: boolean; + print: boolean; + outputFormat: string; + inputFormat: string; + numAllowedTools: number; + numDisallowedTools: number; + mcpClientCount: number; + worktreeEnabled: boolean; + skipWebFetchPreflight: boolean | undefined; + githubActionInputs: string | undefined; + dangerouslySkipPermissionsPassed: boolean; + permissionMode: string; + modeIsBypass: boolean; + allowDangerouslySkipPermissionsPassed: boolean; + systemPromptFlag: 'file' | 'flag' | undefined; + appendSystemPromptFlag: 'file' | 'flag' | undefined; + thinkingConfig: ThinkingConfig; + assistantActivationPath: string | undefined; }): Promise { - try { - logEvent("tengu_init", { - entrypoint: - "claude" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - hasInitialPrompt, - hasStdin, - verbose, - debug, - debugToStderr, - print, - outputFormat: - outputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - inputFormat: - inputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - numAllowedTools, - numDisallowedTools, - mcpClientCount, - worktree: worktreeEnabled, - skipWebFetchPreflight, - ...(githubActionInputs && { - githubActionInputs: - githubActionInputs as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - dangerouslySkipPermissionsPassed, - permissionMode: - permissionMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - modeIsBypass, - inProtectedNamespace: isInProtectedNamespace(), - allowDangerouslySkipPermissionsPassed, - thinkingType: - thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ...(thinkingConfig.type === "enabled" && { - thinkingBudgetTokens: thinkingConfig.budgetTokens, - }), - ...(systemPromptFlag && { - systemPromptFlag: - systemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - ...(appendSystemPromptFlag && { - appendSystemPromptFlag: - appendSystemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - is_simple: isBareMode() || undefined, - is_coordinator: - feature("COORDINATOR_MODE") && - coordinatorModeModule?.isCoordinatorMode() - ? true - : undefined, - ...(assistantActivationPath && { - assistantActivationPath: - assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - autoUpdatesChannel: (getInitialSettings().autoUpdatesChannel ?? - "latest") as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ...(process.env.USER_TYPE === "ant" - ? (() => { - const cwd = getCwd(); - const gitRoot = findGitRoot(cwd); - const rp = gitRoot - ? relative(gitRoot, cwd) || "." - : undefined; - return rp - ? { - relativeProjectPath: - rp as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}; - })() - : {}), - }); - } catch (error) { - logError(error); - } + try { + logEvent('tengu_init', { + entrypoint: 'claude' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + hasInitialPrompt, + hasStdin, + verbose, + debug, + debugToStderr, + print, + outputFormat: outputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + inputFormat: inputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + numAllowedTools, + numDisallowedTools, + mcpClientCount, + worktree: worktreeEnabled, + skipWebFetchPreflight, + ...(githubActionInputs && { + githubActionInputs: githubActionInputs as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + dangerouslySkipPermissionsPassed, + permissionMode: permissionMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + modeIsBypass, + inProtectedNamespace: isInProtectedNamespace(), + allowDangerouslySkipPermissionsPassed, + thinkingType: thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(thinkingConfig.type === 'enabled' && { + thinkingBudgetTokens: thinkingConfig.budgetTokens, + }), + ...(systemPromptFlag && { + systemPromptFlag: systemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + ...(appendSystemPromptFlag && { + appendSystemPromptFlag: appendSystemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + is_simple: isBareMode() || undefined, + is_coordinator: feature('COORDINATOR_MODE') && coordinatorModeModule?.isCoordinatorMode() ? true : undefined, + ...(assistantActivationPath && { + assistantActivationPath: assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + autoUpdatesChannel: (getInitialSettings().autoUpdatesChannel ?? + 'latest') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(process.env.USER_TYPE === 'ant' + ? (() => { + const cwd = getCwd(); + const gitRoot = findGitRoot(cwd); + const rp = gitRoot ? relative(gitRoot, cwd) || '.' : undefined; + return rp + ? { + relativeProjectPath: rp as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}; + })() + : {}), + }); + } catch (error) { + logError(error); + } } function maybeActivateProactive(options: unknown): void { - if ( - (feature("PROACTIVE") || feature("KAIROS")) && - ((options as { proactive?: boolean }).proactive || - isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) - ) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const proactiveModule = require("./proactive/index.js"); - if (!proactiveModule.isProactiveActive()) { - proactiveModule.activateProactive("command"); - } - } + if ( + (feature('PROACTIVE') || feature('KAIROS')) && + ((options as { proactive?: boolean }).proactive || isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) + ) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const proactiveModule = require('./proactive/index.js'); + if (!proactiveModule.isProactiveActive()) { + proactiveModule.activateProactive('command'); + } + } } function maybeActivateBrief(options: unknown): void { - if (!(feature("KAIROS") || feature("KAIROS_BRIEF"))) return; - const briefFlag = (options as { brief?: boolean }).brief; - const briefEnv = isEnvTruthy(process.env.CLAUDE_CODE_BRIEF); - if (!briefFlag && !briefEnv) return; - // --brief / CLAUDE_CODE_BRIEF are explicit opt-ins: check entitlement, - // then set userMsgOptIn to activate the tool + prompt section. The env - // var also grants entitlement (isBriefEntitled() reads it), so setting - // CLAUDE_CODE_BRIEF=1 alone force-enables for dev/testing — no GB gate - // needed. initialIsBriefOnly reads getUserMsgOptIn() directly. - // Conditional require: static import would leak the tool name string - // into external builds via BriefTool.ts → prompt.ts. - /* eslint-disable @typescript-eslint/no-require-imports */ - const { isBriefEntitled } = - require("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js") as typeof import("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js"); - /* eslint-enable @typescript-eslint/no-require-imports */ - const entitled = isBriefEntitled(); - if (entitled) { - setUserMsgOptIn(true); - } - // Fire unconditionally once intent is seen: enabled=false captures the - // "user tried but was gated" failure mode in Datadog. - logEvent("tengu_brief_mode_enabled", { - enabled: entitled, - gated: !entitled, - source: (briefEnv - ? "env" - : "flag") as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); + if (!(feature('KAIROS') || feature('KAIROS_BRIEF'))) return; + const briefFlag = (options as { brief?: boolean }).brief; + const briefEnv = isEnvTruthy(process.env.CLAUDE_CODE_BRIEF); + if (!briefFlag && !briefEnv) return; + // --brief / CLAUDE_CODE_BRIEF are explicit opt-ins: check entitlement, + // then set userMsgOptIn to activate the tool + prompt section. The env + // var also grants entitlement (isBriefEntitled() reads it), so setting + // CLAUDE_CODE_BRIEF=1 alone force-enables for dev/testing — no GB gate + // needed. initialIsBriefOnly reads getUserMsgOptIn() directly. + // Conditional require: static import would leak the tool name string + // into external builds via BriefTool.ts → prompt.ts. + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isBriefEntitled } = + require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js'); + /* eslint-enable @typescript-eslint/no-require-imports */ + const entitled = isBriefEntitled(); + if (entitled) { + setUserMsgOptIn(true); + } + // Fire unconditionally once intent is seen: enabled=false captures the + // "user tried but was gated" failure mode in Datadog. + logEvent('tengu_brief_mode_enabled', { + enabled: entitled, + gated: !entitled, + source: (briefEnv ? 'env' : 'flag') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } function resetCursor() { - const terminal = process.stderr.isTTY - ? process.stderr - : process.stdout.isTTY - ? process.stdout - : undefined; - terminal?.write(SHOW_CURSOR); + const terminal = process.stderr.isTTY ? process.stderr : process.stdout.isTTY ? process.stdout : undefined; + terminal?.write(SHOW_CURSOR); } type TeammateOptions = { - agentId?: string; - agentName?: string; - teamName?: string; - agentColor?: string; - planModeRequired?: boolean; - parentSessionId?: string; - teammateMode?: "auto" | "tmux" | "in-process"; - agentType?: string; + agentId?: string; + agentName?: string; + teamName?: string; + agentColor?: string; + planModeRequired?: boolean; + parentSessionId?: string; + teammateMode?: 'auto' | 'tmux' | 'in-process'; + agentType?: string; }; function extractTeammateOptions(options: unknown): TeammateOptions { - if (typeof options !== "object" || options === null) { - return {}; - } - const opts = options as Record; - const teammateMode = opts.teammateMode; - return { - agentId: typeof opts.agentId === "string" ? opts.agentId : undefined, - agentName: - typeof opts.agentName === "string" ? opts.agentName : undefined, - teamName: typeof opts.teamName === "string" ? opts.teamName : undefined, - agentColor: - typeof opts.agentColor === "string" ? opts.agentColor : undefined, - planModeRequired: - typeof opts.planModeRequired === "boolean" - ? opts.planModeRequired - : undefined, - parentSessionId: - typeof opts.parentSessionId === "string" - ? opts.parentSessionId - : undefined, - teammateMode: - teammateMode === "auto" || - teammateMode === "tmux" || - teammateMode === "in-process" - ? teammateMode - : undefined, - agentType: - typeof opts.agentType === "string" ? opts.agentType : undefined, - }; + if (typeof options !== 'object' || options === null) { + return {}; + } + const opts = options as Record; + const teammateMode = opts.teammateMode; + return { + agentId: typeof opts.agentId === 'string' ? opts.agentId : undefined, + agentName: typeof opts.agentName === 'string' ? opts.agentName : undefined, + teamName: typeof opts.teamName === 'string' ? opts.teamName : undefined, + agentColor: typeof opts.agentColor === 'string' ? opts.agentColor : undefined, + planModeRequired: typeof opts.planModeRequired === 'boolean' ? opts.planModeRequired : undefined, + parentSessionId: typeof opts.parentSessionId === 'string' ? opts.parentSessionId : undefined, + teammateMode: + teammateMode === 'auto' || teammateMode === 'tmux' || teammateMode === 'in-process' ? teammateMode : undefined, + agentType: typeof opts.agentType === 'string' ? opts.agentType : undefined, + }; } diff --git a/src/server/sessionHandle.ts b/src/server/sessionHandle.ts index 8e6ba3ad9..238b5e222 100644 --- a/src/server/sessionHandle.ts +++ b/src/server/sessionHandle.ts @@ -3,10 +3,24 @@ import { createInterface } from 'readline' import { jsonParse, jsonStringify } from '../utils/slowOperations.js' import { logError } from '../utils/log.js' import type { EventBus } from './eventBus.js' -import { INIT_TIMEOUT_MS, getScriptArgsForChild, loadChildSpawnPrefix } from './childSpawn.js' +import { + INIT_TIMEOUT_MS, + getScriptArgsForChild, + loadChildSpawnPrefix, +} from './childSpawn.js' import { ControlChannel } from './sessionControlChannel.js' -import { routeMessage, type StdoutMessage, type MessageRouterCtx } from './sessionMessageRouter.js' -import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js' +import { + routeMessage, + type StdoutMessage, + type MessageRouterCtx, +} from './sessionMessageRouter.js' +import type { + SessionBusyStatus, + SessionState, + InitData, + PendingPermission, + PendingQuestion, +} from './types.js' import type { DisposableChildProcess } from '../utils/killOnDrop.js' import type { SessionMessage } from './transcriptReader.js' @@ -169,13 +183,15 @@ export class SessionHandle implements DisposableChildProcess { this.eventBus = opts.eventBus this._model = opts.model this._agent = opts.agent - this._permissionMode = opts.permissionMode ?? 'acceptEdits' + this._permissionMode = opts.permissionMode ?? 'default' this.verbose = opts.verbose ?? false } onMessage(listener: MessageListener): () => void { this.listeners.add(listener) - return () => { this.listeners.delete(listener) } + return () => { + this.listeners.delete(listener) + } } spawn(): void { @@ -214,7 +230,12 @@ export class SessionHandle implements DisposableChildProcess { const saved = loadChildSpawnPrefix() const defineArgs = saved?.defineArgs ?? [] const featureArgs = saved?.featureArgs ?? [] - const spawnArgs = [...defineArgs, ...featureArgs, ...this.opts.scriptArgs, ...printArgs.filter((x): x is string => x != null)] + const spawnArgs = [ + ...defineArgs, + ...featureArgs, + ...this.opts.scriptArgs, + ...printArgs.filter((x): x is string => x != null), + ] this._spawnCwd = this.opts.cwd this.child = spawn(this.opts.execPath, spawnArgs, { @@ -244,7 +265,9 @@ export class SessionHandle implements DisposableChildProcess { const reject = this.initReject this.initResolve = null this.initReject = null - process.stderr.write(`[serve:${this.sessionId}] exit_code=${code} cwd=${this.cwd} stderr:\n${this.lastStderr.slice(-5).join('\n')}\n`) + process.stderr.write( + `[serve:${this.sessionId}] exit_code=${code} cwd=${this.cwd} stderr:\n${this.lastStderr.slice(-5).join('\n')}\n`, + ) reject(new Error(`Process exited with code ${code}`)) } if (this.promptReject) { @@ -363,7 +386,10 @@ export class SessionHandle implements DisposableChildProcess { this.eventBus.publishSessionEvent(this.sessionId, event, data) } - private emitOpencodeEvent(event: string, properties: Record): void { + private emitOpencodeEvent( + event: string, + properties: Record, + ): void { if (this.opts.silent) return if (properties.type === event && 'properties' in properties) { this.eventBus.publish(event, properties as Record) @@ -410,27 +436,47 @@ export class SessionHandle implements DisposableChildProcess { getProviderId: () => this._providerId, getInitRequestId: () => this.initRequestId, getLastMessageUuid: () => this._lastMessageUuid, - setStatus: (s) => { this._status = s }, - setBusyStatus: (s) => { this._busyStatus = s }, - setModel: (m) => { this._model = m }, - setProviderId: (id) => { this._providerId = id }, - setInitData: (d) => { this._initData = d }, - setLastMessageUuid: (u) => { this._lastMessageUuid = u }, - setLastActiveAt: (t) => { this.lastActiveAt = t }, - addCost: (c) => { this._costUsd += c }, - addInputTokens: (t) => { this._inputTokens += t }, - addOutputTokens: (t) => { this._outputTokens += t }, + setStatus: s => { + this._status = s + }, + setBusyStatus: s => { + this._busyStatus = s + }, + setModel: m => { + this._model = m + }, + setProviderId: id => { + this._providerId = id + }, + setInitData: d => { + this._initData = d + }, + setLastMessageUuid: u => { + this._lastMessageUuid = u + }, + setLastActiveAt: t => { + this.lastActiveAt = t + }, + addCost: c => { + this._costUsd += c + }, + addInputTokens: t => { + this._inputTokens += t + }, + addOutputTokens: t => { + this._outputTokens += t + }, getControlChannel: () => this._controlChannel, getPendingPermissions: () => this.pendingPermissions, getPendingQuestions: () => this.pendingQuestions, - resolveInit: (data) => { + resolveInit: data => { const resolve = this.initResolve this.initResolve = null this.initReject = null resolve?.(data) this.opts.onInit?.(data) }, - resolvePrompt: (value) => { + resolvePrompt: value => { this._busyStatus = { type: 'idle' } this.emitBusyStatus() if (this.promptResolve) { @@ -442,22 +488,28 @@ export class SessionHandle implements DisposableChildProcess { this._titleGenerationAttempted = true const content = this._firstPromptContent void this.sendControlRequest( - { subtype: 'generate_session_title', description: content, persist: true }, + { + subtype: 'generate_session_title', + description: content, + persist: true, + }, 30_000, - ).then((res) => { - const title = (res.title as string) || content.slice(0, 100) - this.setTitle(title) - }).catch(() => { - this.setTitle(content.slice(0, 100)) - }) + ) + .then(res => { + const title = (res.title as string) || content.slice(0, 100) + this.setTitle(title) + }) + .catch(() => { + this.setTitle(content.slice(0, 100)) + }) } }, emitEvent: (event, data) => this.emitEvent(event, data), emitOpencodeEvent: (event, props) => this.emitOpencodeEvent(event, props), emitBusyStatus: () => this.emitBusyStatus(), - emitMessage: (msg) => this.emitMessage(msg), - writeStdin: (data) => this.writeStdin(data), - pushBufferMessage: (msg) => this.pushMessage(msg), + emitMessage: msg => this.emitMessage(msg), + writeStdin: data => this.writeStdin(data), + pushBufferMessage: msg => this.pushMessage(msg), } } @@ -501,11 +553,12 @@ export class SessionHandle implements DisposableChildProcess { return (response.mcpServers as unknown[]) ?? [] } - async prompt(content: string, opts?: { parts?: Array>; messageID?: string }): Promise<{ done: boolean; error?: string }> { + async prompt( + content: string, + opts?: { parts?: Array>; messageID?: string }, + ): Promise<{ done: boolean; error?: string }> { if (this._status === 'stopped') { - throw new Error( - `Session ${this.sessionId} is stopped`, - ) + throw new Error(`Session ${this.sessionId} is stopped`) } if (this._prompting) { throw new Error( @@ -558,11 +611,11 @@ export class SessionHandle implements DisposableChildProcess { } return new Promise((resolve, reject) => { - this.promptResolve = (value) => { + this.promptResolve = value => { this._prompting = false resolve(value) } - this.promptReject = (reason) => { + this.promptReject = reason => { this._prompting = false reject(reason) } @@ -590,7 +643,7 @@ export class SessionHandle implements DisposableChildProcess { resolve() }, 2000) const originalResolve = this.promptResolve - this.promptResolve = (value) => { + this.promptResolve = value => { clearTimeout(timeout) originalResolve?.(value) resolve() @@ -642,7 +695,8 @@ export class SessionHandle implements DisposableChildProcess { behavior, }) if (toolName === 'AskUserQuestion') { - const eventType = behavior === 'allow' ? 'question.replied' : 'question.rejected' + const eventType = + behavior === 'allow' ? 'question.replied' : 'question.rejected' this.emitOpencodeEvent(eventType, { sessionID: this.sessionId, requestID: requestId, @@ -677,7 +731,8 @@ export class SessionHandle implements DisposableChildProcess { request_id: requestId, action, }) - const eventType = action === 'accept' ? 'question.replied' : 'question.rejected' + const eventType = + action === 'accept' ? 'question.replied' : 'question.rejected' this.emitOpencodeEvent(eventType, { sessionID: this.sessionId, requestID: requestId,