Files
claude-code/src/main.tsx
claude-code-best 0e541af24b style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files)
纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 22:50:29 +08:00

6604 lines
249 KiB
TypeScript

// These side-effects must run before all other imports:
// 1. profileCheckpoint marks entry before heavy module evaluation begins
// 2. startMdmRawRead fires MDM subprocesses (plutil/reg query) so they run in
// parallel with the remaining ~135ms of imports below
// 3. startKeychainPrefetch fires both macOS keychain reads (OAuth + legacy API
// 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'
// eslint-disable-next-line custom-rules/no-top-level-side-effects
profileCheckpoint('main_tsx_entry')
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'
// eslint-disable-next-line custom-rules/no-top-level-side-effects
startKeychainPrefetch()
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 './ink.js'
import { launchRepl } from './replLauncher.js'
import {
hasGrowthBookEnvOverride,
initializeGrowthBook,
refreshGrowthBookAfterAuthChange,
} from './services/analytics/growthbook.js'
import { fetchBootstrapData } from './services/api/bootstrap.js'
import {
type DownloadResult,
downloadSessionFiles,
type FilesApiConfig,
parseFileSpecs,
} from './services/api/filesApi.js'
import { prefetchPassesEligibility } from './services/api/referral.js'
import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js'
import type {
McpSdkServerConfig,
McpServerConfig,
ScopedMcpServerConfig,
} from './services/mcp/types.js'
import {
isPolicyAllowed,
loadPolicyLimits,
refreshPolicyLimits,
waitForPolicyLimitsToLoad,
} from './services/policyLimits/index.js'
import {
loadRemoteManagedSettings,
refreshRemoteManagedSettings,
} from './services/remoteManagedSettings/index.js'
import type { ToolInputJSONSchema } from './Tool.js'
import {
createSyntheticOutputTool,
isSyntheticOutputToolEnabled,
} from './tools/SyntheticOutputTool/SyntheticOutputTool.js'
import { getTools } from './tools.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'
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'
// 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 getTeammatePromptAddendum = () =>
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')
/* 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
/* 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
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'
import {
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,
launchInvalidSettingsDialog,
launchResumeChooser,
launchSnapshotUpdateDialog,
launchTeleportRepoMismatchDialog,
launchTeleportResumeWrapper,
} from './dialogLaunchers.js'
import { SHOW_CURSOR } from './ink/termio/dec.js'
import {
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 {
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 './tools/AgentTool/agentColorManager.js'
import {
getActiveAgentsFromList,
getAgentDefinitionsWithOverrides,
isBuiltInAgent,
isCustomAgent,
parseAgentsFromJson,
} from './tools/AgentTool/loadAgentsDir.js'
import type { LogOption } from './types/logs.js'
import type { Message as MessageType } from './types/message.js'
import { assertMinVersion } from './utils/autoUpdater.js'
import {
CLAUDE_IN_CHROME_SKILL_HINT,
CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER,
} from './utils/claudeInChrome/prompt.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'
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 {
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 {
checkAndDisableBypassPermissions,
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'
// 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 {
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 {
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
// TeleportRepoMismatchDialog, TeleportResumeWrapper dynamically imported at call sites
import { migrateAutoUpdatesToSettings } from './migrations/migrateAutoUpdatesToSettings.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 {
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')
/**
* Log managed settings keys to Statsig for analytics.
* This is called after init() completes to ensure settings are 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
}
}
// Check if running in debug/inspection mode
function isBeingDebugged() {
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 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
}
}
// Exit if we detect node debugging or inspection
if ("external" !== 'ant' && isBeingDebugged()) {
// Use process.exit directly here since we're in the top-level code before imports
// and gracefulShutdown is not yet available
// eslint-disable-next-line custom-rules/no-top-level-side-effects
process.exit(1)
}
/**
* Per-session skill/plugin telemetry. Called from both the interactive path
* and the headless -p path (before runHeadless) — both go through
* main.tsx but branch before the interactive startup path, so it needs two
* 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))
}
function getCertEnvVarTelemetry(): Record<string, boolean> {
const result: Record<string, boolean> = {}
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<void> {
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(),
})
}
// @[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) {
migrateAutoUpdatesToSettings()
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
})
}
/**
* Prefetch system context (including git status) only when it's safe to do so.
* Git commands can execute arbitrary code via hooks and config (e.g., core.fsmonitor,
* diff.external), so we must only run them after trust is established or in
* non-interactive mode where trust is implicit.
*/
function prefetchSystemContextIfSafe(): void {
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 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
}
/**
* Start background prefetches and housekeeping that are NOT needed before first render.
* These are deferred from setup() to reduce event loop contention and child process
* spawning during the critical startup path.
* 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
}
// 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()
void prefetchOfficialMcpUrls()
void refreshModelCapabilities()
// 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(),
)
}
}
function loadSettingsFromFlag(settingsFile: string): void {
try {
const trimmedSettings = settingsFile.trim()
const looksLikeJson =
trimmedSettings.startsWith('{') && trimmedSettings.endsWith('}')
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)
}
// 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)
}
}
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)
}
}
/**
* Parse and load settings flags early, before init()
* 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)
}
// 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
}
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
}
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)
// Set based on interactive status
process.env.CLAUDE_CODE_ENTRYPOINT = isNonInteractive ? 'sdk-cli' : 'cli'
}
// Set by early argv processing when `claude open <url>` is detected (interactive mode only)
type PendingConnect = {
url: string | undefined
authToken: string | undefined
dangerouslySkipPermissions: boolean
}
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
// `claude ssh <host> [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[]
}
const _pendingSSH: PendingSSH | undefined = feature('SSH_REMOTE')
? {
host: undefined,
cwd: undefined,
permissionMode: undefined,
dangerouslySkipPermissions: false,
local: false,
extraCliArgs: [],
}
: undefined
export async function main() {
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'
// 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')
// 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]
}
}
}
// 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)
}
}
// `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 <host> [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 <uuid> operate on the REMOTE session history
// (which persists under the remote's ~/.claude/projects/<cwd>/).
// --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)
}
}
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
}
// 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 isNonInteractive =
hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY
// Stop capturing early input for non-interactive modes
if (isNonInteractive) {
stopCapturingEarlyInput()
}
// 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)
// 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'
}
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')
}
// 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')
// Parse and load settings flags early, before init()
eagerLoadSettings()
profileCheckpoint('main_before_run')
await run()
profileCheckpoint('main_after_run')
}
async function getInputPrompt(
prompt: string,
inputFormat: 'text' | 'stream-json',
): Promise<string | AsyncIterable<string>> {
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<CommanderCommand> {
profileCheckpoint('run_function_start')
// Create help config that sorts options by long option name.
// Commander supports compareOptions at runtime but @commander-js/extra-typings
// doesn't include it in the type definitions, so we use Object.assign to add it.
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)),
},
)
}
const program = new CommanderCommand()
.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 = 'claude'
}
// 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('claude')
.description(
`Claude Code - starts an interactive session by default, use -p/--print for non-interactive output`,
)
.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('-d2e, --debug-to-stderr', 'Enable debug mode (to stderr)')
.argParser(Boolean)
.hideHelp(),
)
.option(
'--debug-file <path>',
'Write debug logs to a specific file path (implicitly enables debug mode)',
() => true,
)
.option(
'--verbose',
'Override verbose mode setting from config',
() => true,
)
.option(
'-p, --print',
'Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when Claude is run with the -p mode. Only use this flag in directories you trust.',
() => true,
)
.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.',
() => true,
)
.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 <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 <schema>',
'JSON Schema for structured output validation. ' +
'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}',
).argParser(String),
)
.option(
'--include-hook-events',
'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)',
() => true,
)
.option(
'--include-partial-messages',
'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)',
() => true,
)
.addOption(
new Option(
'--input-format <format>',
'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)',
).choices(['text', 'stream-json']),
)
.option(
'--mcp-debug',
'[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)',
() => true,
)
.option(
'--dangerously-skip-permissions',
'Bypass all permission checks. Recommended only for sandboxes with no internet access.',
() => true,
)
.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.',
() => true,
)
.addOption(
new Option(
'--thinking <mode>',
'Thinking mode: enabled (equivalent to adaptive), disabled',
)
.choices(['enabled', 'adaptive', 'disabled'])
.hideHelp(),
)
.addOption(
new Option(
'--max-thinking-tokens <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 <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 <amount>',
'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
}),
)
.addOption(
new Option(
'--task-budget <tokens>',
'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(),
)
.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)',
() => true,
)
.addOption(
new Option(
'--enable-auth-status',
'Enable auth status messages in SDK mode',
)
.default(false)
.hideHelp(),
)
.option(
'--allowedTools, --allowed-tools <tools...>',
'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")',
)
.option(
'--tools <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").',
)
.option(
'--disallowedTools, --disallowed-tools <tools...>',
'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")',
)
.option(
'--mcp-config <configs...>',
'Load MCP servers from JSON files or strings (space-separated)',
)
.addOption(
new Option(
'--permission-prompt-tool <tool>',
'MCP tool to use for permission prompts (only works with --print)',
)
.argParser(String)
.hideHelp(),
)
.addOption(
new Option(
'--system-prompt <prompt>',
'System prompt to use for the session',
).argParser(String),
)
.addOption(
new Option(
'--system-prompt-file <file>',
'Read system prompt from a file',
)
.argParser(String)
.hideHelp(),
)
.addOption(
new Option(
'--append-system-prompt <prompt>',
'Append a system prompt to the default system prompt',
).argParser(String),
)
.addOption(
new Option(
'--append-system-prompt-file <file>',
'Read system prompt from a file and append to the default system prompt',
)
.argParser(String)
.hideHelp(),
)
.addOption(
new Option(
'--permission-mode <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,
)
.option(
'--fork-session',
'When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)',
() => true,
)
.addOption(
new Option(
'--prefill <text>',
'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 <slug>',
'Repo slug the deep link ?repo= parameter resolved to the current cwd',
).hideHelp(),
)
.addOption(
new Option(
'--deep-link-last-fetch <ms>',
'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(),
)
.option(
'--from-pr [value]',
'Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term',
value => value || true,
)
.option(
'--no-session-persistence',
'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)',
)
.addOption(
new Option(
'--resume-session-at <message id>',
'When resuming, only messages up to and including the assistant message with <message.id> (use with --resume in print mode)',
)
.argParser(String)
.hideHelp(),
)
.addOption(
new Option(
'--rewind-files <user-message-id>',
'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>',
`Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`,
)
.addOption(
new Option(
'--effort <level>',
`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
}),
)
.option(
'--agent <agent>',
`Agent for the current session. Overrides the 'agent' setting.`,
)
.option(
'--betas <betas...>',
'Beta headers to include in API requests (API key users only)',
)
.option(
'--fallback-model <model>',
'Enable automatic fallback to specified model when default model is overloaded (only works with --print)',
)
.addOption(
new Option(
'--workload <tag>',
'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 <file-or-json>',
'Path to a settings JSON file or a JSON string to load additional settings from',
)
.option(
'--add-dir <directories...>',
'Additional directories to allow tool access to',
)
.option(
'--ide',
'Automatically connect to IDE on startup if exactly one valid IDE is available',
() => true,
)
.option(
'--strict-mcp-config',
'Only use MCP servers from --mcp-config, ignoring all other MCP configurations',
() => true,
)
.option(
'--session-id <uuid>',
'Use a specific session ID for the conversation (must be a valid UUID)',
)
.option(
'-n, --name <name>',
'Set a display name for this session (shown in /resume and terminal title)',
)
.option(
'--agents <json>',
'JSON object defining custom agents (e.g. \'{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}\')',
)
.option(
'--setting-sources <sources>',
'Comma-separated list of setting sources to load (user, project, local).',
)
// gh-33508: <paths...> (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.
.option(
'--plugin-dir <path>',
'Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B)',
(val: string, prev: string[]) => [...prev, val],
[] as string[],
)
.option('--disable-slash-commands', 'Disable all skills', () => true)
.option('--chrome', 'Enable Claude in Chrome integration')
.option('--no-chrome', 'Disable Claude in Chrome integration')
.option(
'--file <specs...>',
'File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png)',
)
.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', {})
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.warn(
chalk.yellow('Tip: You can launch Claude Code with just `claude`'),
)
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?.isAssistantMode() &&
// Spawned teammates share the leader's cwd + settings.json, so
// isAssistantMode() 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()) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
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<DownloadResult[]> | 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<string, ScopedMcpServerConfig> = {}
if (mcpConfig && mcpConfig.length > 0) {
// Process mcpConfig array
const processedConfigs = mcpConfig
.map(config => config.trim())
.filter(config => config.length > 0)
let allConfigs: Record<string, McpServerConfig> = {}
const allErrors: ValidationError[] = []
for (const configItem of processedConfigs) {
let configs: Record<string, McpServerConfig> | 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 }
}
}
// 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)
// biome-ignore lint/suspicious/noConsole:: intentional console output
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() === 'macos' &&
!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
if (feature('KAIROS') || feature('KAIROS_CHANNELS')) {
// 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:<name>@<marketplace> — plugin-provided channel (allowlist enforced)\n` +
` server:<name> — 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('./tools/BriefTool/prompt.js') as typeof import('./tools/BriefTool/prompt.js')
const { isBriefEntitled } =
require('./tools/BriefTool/BriefTool.js') as typeof import('./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 => {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(warning)
})
void assertMinVersion()
// 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<string, ScopedMcpServerConfig>
> =
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: claude.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'
) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`Error: Invalid input format "${inputFormat}".`)
process.exit(1)
}
if (inputFormat === 'stream-json' && outputFormat !== 'stream-json') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
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') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
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') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
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('./tools/BriefTool/BriefTool.js') as typeof import('./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('./tools/BriefTool/BriefTool.js') as typeof import('./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 <tick> 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('./ink.js')
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.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<string, McpSdkServerConfig> = {}
const regularMcpConfigs: Record<string, ScopedMcpServerConfig> = {}
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<NonNullable<typeof hooksPromise>> = []
// Suppress transient unhandledRejection — the prefetch warms the
// memoized connectToServer cache but nobody awaits it in interactive.
mcpPromise.catch(() => {})
const mcpClients: Awaited<typeof mcpPromise>['clients'] = []
const mcpTools: Awaited<typeof mcpPromise>['tools'] = []
const mcpCommands: Awaited<typeof mcpPromise>['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.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,
},
toolPermissionContext,
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,
)
// Check if bypassPermissions should be disabled based on Statsig gate
// This runs in parallel to the code below, to avoid blocking the main loop.
if (
toolPermissionContext.mode === 'bypassPermissions' ||
allowDangerouslySkipPermissions
) {
void checkAndDisableBypassPermissions(toolPermissionContext)
}
// Async check of auto mode gate — corrects state and disables auto if needed.
// Gated on TRANSCRIPT_CLASSIFIER (not USER_TYPE) so GrowthBook kill switch runs for external builds too.
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<string, ScopedMcpServerConfig>,
label: string,
): Promise<void> => {
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<string>()
for (const config of Object.values(claudeaiConfigs)) {
const sig = getMcpServerSignature(config)
if (sig) claudeaiSigs.add(sig)
}
const suppressed = new Set<string>()
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<typeof setTimeout> | undefined
const claudeaiTimedOut = await Promise.race([
claudeaiConnect.then(() => false),
new Promise<boolean>(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 effectiveToolPermissionContext = {
...toolPermissionContext,
mode:
isAgentSwarmsEnabled() && getTeammateUtils().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<string>(),
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?.(),
}
// 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?.(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 <url>` — 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 <host> [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
try {
if (_pendingSSH.local) {
process.stderr.write('Starting local ssh-proxy test session...\n')
sshSession = 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,
},
isTTY
? {
onProgress: msg => {
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 = undefined
let maybeSessionId = validateUuid(options.resume)
let searchTerm: string | undefined = 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 = 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 Claude Code Web (CCR) 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: claude --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} (Claude Code)`,
'-v, --version',
'Output the version number',
)
// Worktree flags
program.option(
'-w, --worktree [name]',
'Create a new git worktree for this session (optionally specify a name)',
)
program.option(
'--tmux',
'Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.',
)
if (canUserConfigureAdvisor()) {
program.addOption(
new Option(
'--advisor <model>',
'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'),
)
}
if (feature('UDS_INBOX')) {
program.addOption(
new Option(
'--messaging-socket-path <path>',
'Unix domain socket path for the UDS messaging server (defaults to a tmp path)',
),
)
}
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
program.addOption(
new Option(
'--brief',
'Enable SendUserMessage tool for agent-to-user communication',
),
)
}
if (feature('KAIROS')) {
program.addOption(
new Option(
'--assistant',
'Force assistant mode (Agent SDK daemon use)',
).hideHelp(),
)
}
if (feature('KAIROS') || feature('KAIROS_CHANNELS')) {
program.addOption(
new Option(
'--channels <servers...>',
'MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.',
).hideHelp(),
)
program.addOption(
new Option(
'--dangerously-load-development-channels <servers...>',
'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 <id>', 'Teammate agent ID').hideHelp(),
)
program.addOption(
new Option('--agent-name <name>', 'Teammate display name').hideHelp(),
)
program.addOption(
new Option(
'--team-name <name>',
'Team name for swarm coordination',
).hideHelp(),
)
program.addOption(
new Option('--agent-color <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 <id>',
'Parent session ID for analytics correlation',
).hideHelp(),
)
program.addOption(
new Option(
'--teammate-mode <mode>',
'How to spawn teammates: "tmux", "in-process", or "auto"',
)
.choices(['auto', 'tmux', 'in-process'])
.hideHelp(),
)
program.addOption(
new Option(
'--agent-type <type>',
'Custom agent type for this teammate',
).hideHelp(),
)
// Enable SDK URL for all builds but hide from help
program.addOption(
new Option(
'--sdk-url <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')
.configureHelp(createSortedHelpConfig())
.enablePositionalOptions()
mcp
.command('serve')
.description(`Start the Claude Code 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 <name>')
.description('Remove an MCP server')
.option(
'-s, --scope <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')
.description(
'List configured MCP servers. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.',
)
.action(async () => {
const { mcpListHandler } = await import('./cli/handlers/mcp.js')
await mcpListHandler()
})
mcp
.command('get <name>')
.description(
'Get details about an MCP server. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.',
)
.action(async (name: string) => {
const { mcpGetHandler } = await import('./cli/handlers/mcp.js')
await mcpGetHandler(name)
})
mcp
.command('add-json <name> <json>')
.description('Add an MCP server (stdio or SSE) with a JSON string')
.option(
'-s, --scope <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 <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()
})
// claude server
if (feature('DIRECT_CONNECT')) {
program
.command('server')
.description('Start a Claude Code session server')
.option('--port <number>', 'HTTP port', '0')
.option('--host <string>', 'Bind address', '0.0.0.0')
.option('--auth-token <token>', 'Bearer token for auth')
.option('--unix <path>', 'Listen on a unix domain socket')
.option(
'--workspace <dir>',
'Default working directory for sessions that do not specify cwd',
)
.option(
'--idle-timeout <ms>',
'Idle timeout for detached sessions in ms (0 = never expire)',
'600000',
)
.option(
'--max-sessions <n>',
'Maximum concurrent sessions (0 = unlimited)',
'32',
)
.action(
async (opts: {
port: string
host: string
authToken?: string
unix?: string
workspace?: string
idleTimeout: string
maxSessions: string
}) => {
const { randomBytes } = await import('crypto')
const { startServer } = await import('./server/server.js')
const { SessionManager } = await import('./server/sessionManager.js')
const { DangerousBackend } = await import(
'./server/backends/dangerousBackend.js'
)
const { printBanner } = await import('./server/serverBanner.js')
const { createServerLogger } = await import('./server/serverLog.js')
const { writeServerLock, removeServerLock, probeRunningServer } =
await import('./server/lockfile.js')
const existing = await probeRunningServer()
if (existing) {
process.stderr.write(
`A claude server is already running (pid ${existing.pid}) at ${existing.httpUrl}\n`,
)
process.exit(1)
}
const authToken =
opts.authToken ??
`sk-ant-cc-${randomBytes(16).toString('base64url')}`
const config = {
port: parseInt(opts.port, 10),
host: opts.host,
authToken,
unix: opts.unix,
workspace: opts.workspace,
idleTimeoutMs: parseInt(opts.idleTimeout, 10),
maxSessions: parseInt(opts.maxSessions, 10),
}
const backend = new DangerousBackend()
const sessionManager = new SessionManager(backend, {
idleTimeoutMs: config.idleTimeoutMs,
maxSessions: config.maxSessions,
})
const logger = createServerLogger()
const server = startServer(config, sessionManager, logger)
const actualPort = server.port ?? config.port
printBanner(config, authToken, actualPort)
await writeServerLock({
pid: process.pid,
port: actualPort,
host: config.host,
httpUrl: config.unix
? `unix:${config.unix}`
: `http://${config.host}:${actualPort}`,
startedAt: Date.now(),
})
let shuttingDown = false
const shutdown = async () => {
if (shuttingDown) return
shuttingDown = true
// Stop accepting new connections before tearing down sessions.
server.stop(true)
await sessionManager.destroyAll()
await removeServerLock()
process.exit(0)
}
process.once('SIGINT', () => void shutdown())
process.once('SIGTERM', () => void shutdown())
},
)
}
// `claude ssh <host> [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 <host> [dir]')
.description(
'Run Claude Code 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 <mode>',
'Permission mode for the remote session',
)
.option(
'--dangerously-skip-permissions',
'Skip all permission prompts on the remote (dangerous)',
)
.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 <host>` before
// commander runs. Reaching here means host was missing or the
// rewrite predicate didn't match.
process.stderr.write(
'Usage: claude ssh <user@host | ssh-config-alias> [dir]\n\n' +
"Runs Claude Code on a remote Linux host. You don't need to install\n" +
'anything on the remote or run `claude 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 <cc-url>')
.description(
'Connect to a Claude Code server (internal — use cc:// URLs)',
)
.option('-p, --print [prompt]', 'Print mode (headless)')
.option(
'--output-format <format>',
'Output format: text, json, stream-json',
'text',
)
.action(
async (
ccUrl: string,
opts: {
print?: string | boolean
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) {
// biome-ignore lint/suspicious/noConsole: intentional error output
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')
.configureHelp(createSortedHelpConfig())
auth
.command('login')
.description('Sign in to your Anthropic account')
.option('--email <email>', 'Pre-populate email address on the login page')
.option('--sso', 'Force SSO login flow')
.option(
'--console',
'Use Anthropic Console (API usage billing) instead of Claude subscription',
)
.option('--claudeai', 'Use Claude subscription (default)')
.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 Anthropic 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')
.description('Manage Claude Code plugins')
.configureHelp(createSortedHelpConfig())
pluginCmd
.command('validate <path>')
.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')
.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 Claude Code marketplaces')
.configureHelp(createSortedHelpConfig())
marketplaceCmd
.command('add <source>')
.description('Add a marketplace from a URL, path, or GitHub repo')
.addOption(coworkOption())
.option(
'--sparse <paths...>',
'Limit checkout to specific directories via git sparse-checkout (for monorepos). Example: --sparse .claude-plugin plugins',
)
.option(
'--scope <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 <name>')
.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 <plugin>')
.alias('i')
.description(
'Install a plugin from available marketplaces (use plugin@marketplace for specific marketplace)',
)
.option(
'-s, --scope <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 <plugin>')
.alias('remove')
.alias('rm')
.description('Uninstall an installed plugin')
.option(
'-s, --scope <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 <plugin>')
.description('Enable a disabled plugin')
.option(
'-s, --scope <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 <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 <plugin>')
.description(
'Update a plugin to the latest version (restart required to apply)',
)
.option(
'-s, --scope <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)',
)
.action(async () => {
const [{ setupTokenHandler }, { createRoot }] = await Promise.all([
import('./cli/handlers/util.js'),
import('./ink.js'),
])
const root = await createRoot(getBaseRenderOptions(false))
await setupTokenHandler(root)
})
// Agents command - list configured agents
program
.command('agents')
.description('List configured agents')
.option(
'--setting-sources <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')
.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('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 <model>', 'Override which model is used')
.action(async options => {
const { autoModeCritiqueHandler } = await import(
'./cli/handlers/autoMode.js'
)
await autoModeCritiqueHandler(options)
process.exit()
})
}
}
// 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 claude.ai/code',
)
.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]')
.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: claude 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)
})
}
// Doctor command - check installation health
program
.command('doctor')
.description(
'Check the health of your Claude Code auto-updater. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.',
)
.action(async () => {
const [{ doctorHandler }, { createRoot }] = await Promise.all([
import('./cli/handlers/util.js'),
import('./ink.js'),
])
const root = await createRoot(getBaseRenderOptions(false))
await doctorHandler(root)
})
// claude update
//
// For SemVer-compliant versioning with build metadata (X.X.X+SHA):
// - We perform exact string comparison (including SHA) to detect any change
// - This ensures users always get the latest build, even when only the SHA changes
// - UI shows both versions including build metadata for clarity
program
.command('update')
.alias('upgrade')
.description('Check for updates and install if available')
.action(async () => {
const { update } = await import('src/cli/update.js')
await update()
})
// 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 install
program
.command('install [target]')
.description(
'Install Claude Code 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)
})
// claude export
program
.command('export')
.description('[ANT-ONLY] Export a conversation to a text file.')
.usage('<source> <outputFile>')
.argument(
'<source>',
'Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file',
)
.argument('<outputFile>', 'Output file path for the exported text')
.addHelpText(
'after',
`
Examples:
$ claude export 0 conversation.txt Export conversation at log index 0
$ claude export <uuid> conversation.txt Export conversation by session ID
$ claude export input.json output.txt Render JSON log file to text
$ claude export <uuid>.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)
})
if (process.env.USER_TYPE === 'ant') {
const taskCmd = program
.command('task')
.description('[ANT-ONLY] Manage task list tasks')
taskCmd
.command('create <subject>')
.description('Create a new task')
.option('-d, --description <text>', 'Task description')
.option('-l, --list <id>', '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 <id>', '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 <id>')
.description('Get details of a task')
.option('-l, --list <id>', '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 <id>')
.description('Update a task')
.option('-l, --list <id>', 'Task list ID (defaults to "tasklist")')
.option(
'-s, --status <status>',
`Set status (${TASK_STATUSES.join(', ')})`,
)
.option('--subject <text>', 'Update subject')
.option('-d, --description <text>', 'Update description')
.option('--owner <agentId>', '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 <id>', 'Task list ID (defaults to "tasklist")')
.action(async (opts: { list?: string }) => {
const { taskDirHandler } = await import('./cli/handlers/ant.js')
await taskDirHandler(opts)
})
}
// claude completion <shell>
program
.command('completion <shell>', { hidden: true })
.description('Generate shell completion script (bash, zsh, or fish)')
.option(
'--output <file>',
'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')
// Record final checkpoint for total_time calculation
profileCheckpoint('main_after_run')
// Log startup perf to Statsig (sampled) and output detailed report if enabled
profileReport()
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: 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<void> {
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,
...(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')
}
}
}
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('./tools/BriefTool/BriefTool.js') as typeof import('./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)
}
type TeammateOptions = {
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<string, unknown>
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,
}
}