mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
6971 lines
230 KiB
TypeScript
6971 lines
230 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 '@anthropic/ink'
|
|
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 '@anthropic/ink'
|
|
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 {
|
|
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 { 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;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* 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) {
|
|
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("--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() !== "unknown" &&
|
|
!getIsNonInteractiveSession()
|
|
) {
|
|
try {
|
|
const { getChicagoEnabled } =
|
|
await import("src/utils/computerUse/gates.js");
|
|
if (getChicagoEnabled()) {
|
|
const { setupComputerUseMCP } =
|
|
await import("src/utils/computerUse/setup.js");
|
|
const { mcpConfig, allowedTools: cuTools } =
|
|
setupComputerUseMCP();
|
|
dynamicMcpConfig = {
|
|
...dynamicMcpConfig,
|
|
...mcpConfig,
|
|
};
|
|
allowedTools.push(...cuTools);
|
|
}
|
|
} catch (error) {
|
|
logForDebugging(
|
|
`[Computer Use MCP] Setup failed: ${errorMessage(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Store additional directories for CLAUDE.md loading (controlled by env var)
|
|
setAdditionalDirectoriesForClaudeMd(addDir);
|
|
|
|
// Channel server allowlist from --channels flag — servers whose
|
|
// inbound push notifications should register this session. The option
|
|
// is added inside a feature() block so TS doesn't know about it
|
|
// on the options type — same pattern as --assistant at main.tsx:1824.
|
|
// devChannels is deferred: showSetupScreens shows a confirmation dialog
|
|
// and only appends to allowedChannels on accept.
|
|
let devChannels: ChannelEntry[] | undefined;
|
|
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);
|
|
});
|
|
|
|
|
|
// 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('@anthropic/ink')
|
|
root = await createRoot(ctx.renderOptions)
|
|
|
|
// Log startup time now, before any blocking dialog renders. Logging
|
|
// from REPL's first render (the old location) included however long
|
|
// the user sat on trust/OAuth/onboarding/resume-picker — p99 was ~70s
|
|
// dominated by dialog-wait time, not code-path startup.
|
|
logEvent("tengu_timer", {
|
|
event: "startup" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
durationMs: Math.round(process.uptime() * 1000),
|
|
});
|
|
|
|
logForDebugging("[STARTUP] Running showSetupScreens()...");
|
|
const setupScreensStart = Date.now();
|
|
const onboardingShown = await showSetupScreens(
|
|
root,
|
|
permissionMode,
|
|
allowDangerouslySkipPermissions,
|
|
commands,
|
|
enableClaudeInChrome,
|
|
devChannels,
|
|
);
|
|
logForDebugging(
|
|
`[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`,
|
|
);
|
|
|
|
// Now that trust is established and GrowthBook has auth headers,
|
|
// resolve the --remote-control / --rc entitlement gate.
|
|
if (
|
|
feature("BRIDGE_MODE") &&
|
|
remoteControlOption !== undefined
|
|
) {
|
|
const { getBridgeDisabledReason } =
|
|
await import("./bridge/bridgeEnabled.js");
|
|
const disabledReason = await getBridgeDisabledReason();
|
|
remoteControl = disabledReason === null;
|
|
if (disabledReason) {
|
|
process.stderr.write(
|
|
chalk.yellow(
|
|
`${disabledReason}\n--rc flag ignored.\n`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check for pending agent memory snapshot updates (only for --agent mode, ant-only)
|
|
if (
|
|
feature("AGENT_MEMORY_SNAPSHOT") &&
|
|
mainThreadAgentDefinition &&
|
|
isCustomAgent(mainThreadAgentDefinition) &&
|
|
mainThreadAgentDefinition.memory &&
|
|
mainThreadAgentDefinition.pendingSnapshotUpdate
|
|
) {
|
|
const agentDef = mainThreadAgentDefinition;
|
|
const choice = await launchSnapshotUpdateDialog(root, {
|
|
agentType: agentDef.agentType,
|
|
scope: agentDef.memory!,
|
|
snapshotTimestamp:
|
|
agentDef.pendingSnapshotUpdate!.snapshotTimestamp,
|
|
});
|
|
if (choice === "merge") {
|
|
const { buildMergePrompt } =
|
|
await import("./components/agents/SnapshotUpdateDialog.js");
|
|
const mergePrompt = buildMergePrompt(
|
|
agentDef.agentType,
|
|
agentDef.memory!,
|
|
);
|
|
inputPrompt = inputPrompt
|
|
? `${mergePrompt}\n\n${inputPrompt}`
|
|
: mergePrompt;
|
|
}
|
|
agentDef.pendingSnapshotUpdate = undefined;
|
|
}
|
|
|
|
// Skip executing /login if we just completed onboarding for it
|
|
if (
|
|
onboardingShown &&
|
|
prompt?.trim().toLowerCase() === "/login"
|
|
) {
|
|
prompt = "";
|
|
}
|
|
|
|
if (onboardingShown) {
|
|
// Refresh auth-dependent services now that the user has logged in during onboarding.
|
|
// Keep in sync with the post-login logic in src/commands/login.tsx
|
|
void refreshRemoteManagedSettings();
|
|
void refreshPolicyLimits();
|
|
// Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials
|
|
resetUserCache();
|
|
// Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs)
|
|
refreshGrowthBookAfterAuthChange();
|
|
// Clear any stale trusted device token then enroll for Remote Control.
|
|
// Both self-gate on tengu_sessions_elevated_auth_enforcement internally
|
|
// — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits
|
|
// the GrowthBook reinit above), clearTrustedDeviceToken() via the
|
|
// sync cached check (acceptable since clear is idempotent).
|
|
void import("./bridge/trustedDevice.js").then((m) => {
|
|
m.clearTrustedDeviceToken();
|
|
return m.enrollTrustedDevice();
|
|
});
|
|
}
|
|
|
|
// Validate that the active token's org matches forceLoginOrgUUID (if set
|
|
// in managed settings). Runs after onboarding so managed settings and
|
|
// login state are fully loaded.
|
|
const orgValidation = await validateForceLoginOrg();
|
|
if (!orgValidation.valid) {
|
|
await exitWithError(root, orgValidation.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;
|
|
|
|
let maybeSessionId = validateUuid(options.resume);
|
|
let searchTerm: string | undefined;
|
|
// Store full LogOption when found by custom title (for cross-worktree resume)
|
|
let matchedLog: LogOption | null = null;
|
|
// PR filter for --from-pr flag
|
|
let filterByPr: boolean | number | string | undefined;
|
|
|
|
// Handle --from-pr flag
|
|
if (options.fromPr) {
|
|
if (options.fromPr === true) {
|
|
// Show all sessions with linked PRs
|
|
filterByPr = true;
|
|
} else if (typeof options.fromPr === "string") {
|
|
// Could be a PR number or URL
|
|
filterByPr = options.fromPr;
|
|
}
|
|
}
|
|
|
|
// If resume value is not a UUID, try exact match by custom title first
|
|
if (
|
|
options.resume &&
|
|
typeof options.resume === "string" &&
|
|
!maybeSessionId
|
|
) {
|
|
const trimmedValue = options.resume.trim();
|
|
if (trimmedValue) {
|
|
const matches = await searchSessionsByCustomTitle(
|
|
trimmedValue,
|
|
{
|
|
exact: true,
|
|
},
|
|
);
|
|
|
|
if (matches.length === 1) {
|
|
// Exact match found - store full LogOption for cross-worktree resume
|
|
matchedLog = matches[0]!;
|
|
maybeSessionId =
|
|
getSessionIdFromLog(matchedLog) ?? null;
|
|
} else {
|
|
// No match or multiple matches - use as search term for picker
|
|
searchTerm = trimmedValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --remote and --teleport both create/resume 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('@anthropic/ink'),
|
|
])
|
|
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('@anthropic/ink'),
|
|
])
|
|
const root = await createRoot(getBaseRenderOptions(false))
|
|
await doctorHandler(root)
|
|
})
|
|
|
|
|
|
// claude up — run the project's CLAUDE.md "# claude up" setup instructions.
|
|
if (process.env.USER_TYPE === "ant") {
|
|
program
|
|
.command("up")
|
|
.description(
|
|
'[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md',
|
|
)
|
|
.action(async () => {
|
|
const { up } = await import("src/cli/up.js");
|
|
await up();
|
|
});
|
|
}
|
|
|
|
// claude 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,
|
|
};
|
|
}
|