basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6import { AuthType, InputFormat, isDebugLoggingDegraded, isBareMode, logUserPrompt, QWEN_CODE_SIMPLE_ENV_VAR, Storage, SessionService, setStartupEventSink, createDebugLogger, persistSessionUsage, uiTelemetryService, } from '@qwen-code/qwen-code-core';7import dns from 'node:dns';8import os from 'node:os';9import path from 'node:path';10import v8 from 'node:v8';11import { validateAuthMethod } from './config/auth.js';12import * as cliConfig from './config/config.js';13import { buildDisabledSkillNamesProvider, loadCliConfig, parseArguments, } from './config/config.js';14import { ENV_CORRUPTED_PATH, ENV_WAS_RECOVERED, createMinimalSettings, getSettingsWarnings, loadSettings, preResolveHomeEnvOverrides, } from './config/settings.js';15import { SettingsWatcher } from './config/settingsWatcher.js';16import { registerMcpHotReload } from './config/hot-reload.js';17import { LspConfigWatcher } from './config/lsp-config-watcher.js';18import { initializeI18n, resolveLanguageSetting } from './i18n/index.js';19import { setupStartupWorktree, persistStartupWorktreeSidecar, buildStartupWorktreeNotice, } from './startup/worktreeStartup.js';20import { startEarlyStartupPrefetches } from './startup/startup-prefetch.js';21import { cleanupCheckpoints, registerCleanup, runExitCleanup, } from './utils/cleanup.js';22import { AppEvent, appEvents } from './utils/events.js';23import { readStdin } from './utils/readStdin.js';24import { profileCheckpoint, recordStartupEvent, setInteractiveMode, finalizeStartupProfile, isStartupProfilerEnabled, } from './utils/startupProfiler.js';25import { relaunchAppInChildProcess, relaunchOnExitCode, } from './utils/relaunch.js';26import { start_sandbox } from './utils/sandbox.js';27import { getStartupWarnings } from './utils/startupWarnings.js';28import { getUserStartupWarnings } from './utils/userStartupWarnings.js';29import { initializeWarningHandler } from './utils/warningHandler.js';30import { writeStderrLine } from './utils/stdioHelpers.js';31import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js';32import { initializeLlmOutputLanguage } from './utils/languageUtils.js';33const debugLogger = createDebugLogger('STARTUP');34function clearCorruptionEnvVars() {35 delete process.env[ENV_CORRUPTED_PATH];36 delete process.env[ENV_WAS_RECOVERED];37}38export function validateDnsResolutionOrder(order) {39 const defaultValue = 'ipv4first';40 if (order === undefined) {41 return defaultValue;42 }43 if (order === 'ipv4first' || order === 'verbatim') {44 return order;45 }46 // We don't want to throw here, just warn and use the default.47 writeStderrLine(`Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`);48 return defaultValue;49}50function getNodeMemoryArgs(isDebugMode) {51 const totalMemoryMB = os.totalmem() / (1024 * 1024);52 const heapStats = v8.getHeapStatistics();53 const currentMaxOldSpaceSizeMb = Math.floor(heapStats.heap_size_limit / 1024 / 1024);54 // Set target to 50% of total memory55 const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5);56 if (isDebugMode) {57 writeStderrLine(`Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`);58 }59 if (process.env['QWEN_CODE_NO_RELAUNCH']) {60 return [];61 }62 if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {63 if (isDebugMode) {64 writeStderrLine(`Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`);65 }66 return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];67 }68 return [];69}70import { loadSandboxConfig } from './config/sandboxConfig.js';71export function setupUnhandledRejectionHandler() {72 let unhandledRejectionOccurred = false;73 process.on('unhandledRejection', (reason, _promise) => {74 const errorMessage = `=========================================75This is an unexpected error. Please file a bug report using the /bug tool.76CRITICAL: Unhandled Promise Rejection!77=========================================78Reason: ${reason}${reason instanceof Error && reason.stack79 ? `80Stack trace:81${reason.stack}`82 : ''}`;83 appEvents.emit(AppEvent.LogError, errorMessage);84 if (!unhandledRejectionOccurred) {85 unhandledRejectionOccurred = true;86 appEvents.emit(AppEvent.OpenDebugConsole);87 }88 });89}90function getSignalExitCode(signal) {91 return signal === 'SIGINT' ? 130 : 143;92}93function installInteractiveSignalHandlers(wasRaw) {94 let cleanupStarted = false;95 const handleSignal = (signal) => {96 if (process.stdin.isTTY) {97 process.stdin.setRawMode(wasRaw);98 }99 if (cleanupStarted) {100 return;101 }102 cleanupStarted = true;103 void runExitCleanup()104 .catch((error) => {105 debugLogger.error(`Error during ${signal} cleanup:`, error);106 })107 .finally(() => {108 process.exit(getSignalExitCode(signal));109 });110 };111 const handleSigterm = () => {112 handleSignal('SIGTERM');113 };114 const handleSigint = () => {115 handleSignal('SIGINT');116 };117 process.once('SIGTERM', handleSigterm);118 process.once('SIGINT', handleSigint);119 return () => {120 process.removeListener('SIGTERM', handleSigterm);121 process.removeListener('SIGINT', handleSigint);122 };123}124export async function main() {125 profileCheckpoint('main_entry');126 // Bridge core-package startup events (Config.initialize, MCP discovery,127 // GeminiClient.setTools) into the cli's startup profiler. Gated on128 // `isStartupProfilerEnabled()` so that when QWEN_CODE_PROFILE_STARTUP is129 // unset (the common case) every core-side `recordStartupEvent()` call130 // sees a null sink and short-circuits at the first comparison, instead131 // of going through this arrow wrapper and the profiler's own enabled132 // check.133 if (isStartupProfilerEnabled()) {134 setStartupEventSink((name, attrs) => recordStartupEvent(name, attrs));135 }136 setupUnhandledRejectionHandler();137 initializeWarningHandler();138 if (process.argv.includes('--bare')) {139 process.env[QWEN_CODE_SIMPLE_ENV_VAR] = '1';140 }141 // Run before yargs parses subcommands — handlers like `channel status`/`stop`142 // call `process.exit` before `loadSettings()` would otherwise bootstrap.143 preResolveHomeEnvOverrides();144 let argv = await parseArguments();145 profileCheckpoint('after_parse_arguments');146 if (isBareMode(argv.bare)) {147 process.env[QWEN_CODE_SIMPLE_ENV_VAR] = '1';148 }149 // Load user settings — bare mode uses minimal config, normal mode loads full.150 const settings = isBareMode(argv.bare)151 ? createMinimalSettings()152 : loadSettings();153 // Propagate corruption state to child process via env vars so154 // relaunchAppInChildProcess() doesn't lose the marker.155 if (settings.corruptedPath) {156 process.env[ENV_CORRUPTED_PATH] = settings.corruptedPath;157 process.env[ENV_WAS_RECOVERED] = settings.wasRecovered ? '1' : '0';158 }159 await cleanupCheckpoints();160 // Performance checkpoint161 profileCheckpoint('after_load_settings');162 // Emit settings warnings early so the parent process surfaces them163 // before relaunchAppInChildProcess() exits (the child has empty164 // migrationWarnings because the parent already renamed the file).165 const settingsWarnings = getSettingsWarnings(settings);166 for (const warning of settingsWarnings) {167 writeStderrLine(warning);168 }169 // Corruption notification no longer goes through migrationWarnings —170 // check corruptedPath directly to keep stderr visible in relaunch.171 if (settings.corruptedPath) {172 writeStderrLine('Warning: Settings file had invalid JSON and was reset. ' +173 'A copy of the corrupted file has been saved at: ' +174 settings.corruptedPath);175 }176 if (argv.listExtensions) {177 await initializeI18n(resolveLanguageSetting(settings.merged.general?.language));178 const { handleList: handleListExtensions } = await import('./commands/extensions/list.js');179 await handleListExtensions();180 process.exit(0);181 }182 // Check for invalid input combinations early to prevent crashes183 if (argv.promptInteractive && !process.stdin.isTTY) {184 writeStderrLine('Error: The --prompt-interactive flag cannot be used when input is piped from stdin.');185 process.exit(1);186 }187 const isDebugMode = cliConfig.isDebugMode(argv);188 dns.setDefaultResultOrder(validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder));189 const { themeManager, AUTO_THEME_NAME } = await import('./ui/themes/theme-manager.js');190 // Load custom themes from settings191 themeManager.loadCustomThemes(settings.merged.ui?.customThemes);192 const configuredTheme = settings.merged.ui?.theme;193 if (configuredTheme && configuredTheme !== AUTO_THEME_NAME) {194 if (!themeManager.setActiveTheme(configuredTheme)) {195 // If the theme is not found during initial load, log a warning and continue.196 // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog.197 writeStderrLine(`Warning: Theme "${configuredTheme}" not found.`);198 }199 }200 else {201 // 'auto' or unset: resolve a synchronous baseline (COLORFGBG + macOS)202 // so non-interactive runs and any pre-render UI (e.g. the --resume203 // session picker) already have a sensible theme. The interactive204 // startup block refines this with an OSC 11 probe later on, which is205 // intentionally deferred to run inside the early-capture window so206 // terminal response bytes cannot leak into the TUI input.207 themeManager.setActiveTheme(AUTO_THEME_NAME);208 }209 // hop into sandbox if we are outside and sandboxing is enabled210 if (!process.env['SANDBOX']) {211 const memoryArgs = settings.merged.advanced?.autoConfigureMemory212 ? getNodeMemoryArgs(isDebugMode)213 : [];214 const sandboxConfig = await loadSandboxConfig(settings.merged, argv);215 // We intentially omit the list of extensions here because extensions216 // should not impact auth or setting up the sandbox.217 // TODO(jacobr): refactor loadCliConfig so there is a minimal version218 // that only initializes enough config to enable refreshAuth or find219 // another way to decouple refreshAuth from requiring a config.220 if (sandboxConfig) {221 const partialConfig = await loadCliConfig(settings.merged, argv, undefined, [], 222 // Pass separated hooks for proper source attribution223 {224 userHooks: settings.getUserHooks(),225 projectHooks: settings.getProjectHooks(),226 }, buildDisabledSkillNamesProvider(settings));227 if (!settings.merged.security?.auth?.useExternal) {228 // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.229 try {230 const authType = partialConfig.getModelsConfig().getCurrentAuthType();231 // Fresh users may not have selected/persisted an authType yet.232 // In that case, defer auth prompting/selection to the main interactive flow.233 if (authType) {234 const err = validateAuthMethod(authType, partialConfig);235 if (err) {236 throw new Error(err);237 }238 await partialConfig.refreshAuth(authType);239 }240 }241 catch (err) {242 writeStderrLine(`Error authenticating: ${err}`);243 process.exit(1);244 }245 }246 // For stream-json and ACP modes, don't read stdin here — stdin carries247 // protocol data (not a user prompt) and should be forwarded to the sandbox248 // intact via stdio: 'inherit'.249 const inputFormat = argv.inputFormat;250 const isAcpMode = argv.acp || argv.experimentalAcp;251 let stdinData = '';252 if (!process.stdin.isTTY && inputFormat !== 'stream-json' && !isAcpMode) {253 stdinData = await readStdin();254 }255 // This function is a copy of the one from sandbox.ts256 // It is moved here to decouple sandbox.ts from the CLI's argument structure.257 const injectStdinIntoArgs = (args, stdinData) => {258 const finalArgs = [...args];259 if (stdinData) {260 const promptIndex = finalArgs.findIndex((arg) => arg === '--prompt' || arg === '-p');261 if (promptIndex > -1 && finalArgs.length > promptIndex + 1) {262 // If there's a prompt argument, prepend stdin to it263 finalArgs[promptIndex + 1] =264 `${stdinData}\n\n${finalArgs[promptIndex + 1]}`;265 }266 else {267 // If there's no prompt argument, add stdin as the prompt268 finalArgs.push('--prompt', stdinData);269 }270 }271 return finalArgs;272 };273 const injectSandboxSessionIdIntoArgs = (args, sessionId) => {274 const separatorIndex = args.indexOf('--');275 const cliArgs = separatorIndex < 0 ? args : args.slice(0, separatorIndex);276 const hasArg = (names) => cliArgs.some((arg) => names.some((name) => arg === name || arg.startsWith(`${name}=`)));277 if (hasArg(['--session-id', '--sandbox-session-id']) ||278 hasArg(['--continue', '-c']) ||279 hasArg(['--resume', '-r'])) {280 return args;281 }282 const sessionArgs = ['--sandbox-session-id', sessionId];283 if (separatorIndex < 0) {284 return [...args, ...sessionArgs];285 }286 return [...cliArgs, ...sessionArgs, ...args.slice(separatorIndex)];287 };288 const sessionId = partialConfig.getSessionId();289 const sandboxArgs = sessionId290 ? injectSandboxSessionIdIntoArgs(injectStdinIntoArgs(process.argv, stdinData), sessionId)291 : injectStdinIntoArgs(process.argv, stdinData);292 await relaunchOnExitCode(() => start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs));293 process.exit(0);294 }295 else {296 // Relaunch app so we always have a child process that can be internally297 // restarted if needed.298 await relaunchAppInChildProcess(memoryArgs, [], {299 afterSpawn: clearCorruptionEnvVars,300 });301 }302 }303 // When --worktree is going to chdir us into a worktree below, resolve304 // any relative-path argv fields to absolute paths now — BEFORE the305 // chdir. Otherwise downstream `fs.existsSync('./mcp.json')` calls in306 // `loadCliConfig` re-resolve against the worktree dir, where the file307 // doesn't exist. Only touches values that look like paths (mcpConfig308 // also accepts inline JSON — skip those).309 //310 // The list of fields below is hand-maintained. If you add a new311 // CLI flag that takes a relative path, register it here too,312 // otherwise --worktree silently breaks for that flag.313 if (argv.worktree !== undefined) {314 const launchCwdForPaths = process.cwd();315 const looksLikeInlineJson = (v) => {316 const t = v.trim();317 return t.startsWith('{') || t.startsWith('[');318 };319 const resolveIfPath = (v) => {320 if (typeof v !== 'string' || v.length === 0)321 return v;322 if (looksLikeInlineJson(v))323 return v;324 return path.resolve(launchCwdForPaths, v);325 };326 argv.mcpConfig = resolveIfPath(argv.mcpConfig);327 argv.openaiLoggingDir = resolveIfPath(argv.openaiLoggingDir);328 argv.jsonFile = resolveIfPath(argv.jsonFile);329 argv.inputFile = resolveIfPath(argv.inputFile);330 argv.telemetryOutfile = resolveIfPath(argv.telemetryOutfile);331 if (Array.isArray(argv.includeDirectories)) {332 argv.includeDirectories = argv.includeDirectories.map((d) => typeof d === 'string' && d.length > 0333 ? path.resolve(launchCwdForPaths, d)334 : d);335 }336 // `--json-schema` accepts either an inline schema or `@<path>`. The337 // `@`-prefixed form is read from disk inside `resolveJsonSchemaArg`338 // (`packages/cli/src/config/config.ts`), AFTER chdir, so a relative339 // value would resolve against the worktree — fix the prefix path340 // here.341 if (typeof argv.jsonSchema === 'string') {342 const trimmedSchema = argv.jsonSchema.trim();343 if (trimmedSchema.startsWith('@')) {344 const rel = trimmedSchema.slice(1);345 if (rel.length > 0 && !path.isAbsolute(rel)) {346 argv.jsonSchema = '@' + path.resolve(launchCwdForPaths, rel);347 }348 }349 }350 }351 // Phase D-1: process --worktree before the resume picker so the picker352 // (which uses process.cwd() to scope its session search) finds sessions353 // saved inside the target worktree. Creates the worktree directory on354 // disk and chdirs into it; on failure we emit to stderr and exit before355 // any expensive initialization runs.356 //357 // ACP mode is exempt: the ACP host (Zed, etc.) supplies its own per-session358 // cwd, and the startup-level chdir would not propagate. Reject the359 // combination with a clear error rather than silently dropping --worktree.360 let startupWorktreeContext = null;361 if (argv.worktree !== undefined && (argv.acp || argv.experimentalAcp)) {362 writeStderrLine('--worktree cannot be combined with --acp / --experimental-acp. ' +363 'Pass the worktree path as the cwd of the ACP loadSession / newSession ' +364 'request instead.');365 process.exit(1);366 }367 {368 const startupRes = await setupStartupWorktree(argv.worktree, {369 symlinkDirectories: settings.merged.worktree?.symlinkDirectories,370 });371 if (startupRes !== null) {372 if (!startupRes.ok) {373 writeStderrLine(startupRes.error);374 process.exit(1);375 }376 startupWorktreeContext = startupRes.context;377 }378 }379 // Handle --resume without a session ID, or with a custom title, by showing380 // the session picker. Set the runtime output dir early so the picker can find381 // sessions stored under a custom runtimeOutputDir (setRuntimeBaseDir is382 // idempotent and will be called again inside loadCliConfig).383 if (argv.resume !== undefined) {384 Storage.setRuntimeBaseDir(settings.merged.advanced?.runtimeOutputDir, process.cwd());385 let resolvedSessionId;386 if (argv.resume === '') {387 // No argument — show picker388 const { showResumeSessionPicker } = await import('./ui/components/StandaloneSessionPicker.js');389 resolvedSessionId = await showResumeSessionPicker();390 }391 else if (!cliConfig.isValidSessionId(argv.resume)) {392 // Non-UUID argument — treat as custom title search393 const sessionService = new SessionService(process.cwd());394 const matches = await sessionService.findSessionsByTitle(argv.resume);395 if (matches.length === 1) {396 resolvedSessionId = matches[0].sessionId;397 }398 else if (matches.length > 1) {399 // Multiple matches — show picker to let user choose400 writeStderrLine(`Multiple sessions found with title "${argv.resume}". Please select one:`);401 const { showResumeSessionPicker } = await import('./ui/components/StandaloneSessionPicker.js');402 resolvedSessionId = await showResumeSessionPicker(process.cwd(), matches);403 }404 // matches.length === 0 → resolvedSessionId stays undefined, handled below405 }406 if (resolvedSessionId !== undefined) {407 argv = { ...argv, resume: resolvedSessionId };408 }409 else if (argv.resume === '' || !cliConfig.isValidSessionId(argv.resume)) {410 // User cancelled the picker or no sessions found for the title411 if (argv.resume !== '') {412 writeStderrLine(`No saved session found with title "${argv.resume}".`);413 process.exit(1);414 }415 else {416 process.exit(0);417 }418 }419 // else: argv.resume is already a valid UUID, pass through to loadCliConfig420 }421 // We are now past the logic handling potentially launching a child process422 // to run Qwen Code. It is now safe to perform expensive initialization that423 // may have side effects.424 profileCheckpoint('after_sandbox_check');425 // Initialize output language file before config loads to ensure it's included in context426 if (!isBareMode(argv.bare)) {427 initializeLlmOutputLanguage(settings.merged.general?.outputLanguage);428 }429 {430 // Start settings file watcher (skip in bare mode)431 const settingsWatcher = isBareMode(argv.bare)432 ? undefined433 : new SettingsWatcher(settings);434 settingsWatcher?.startWatching();435 const config = await loadCliConfig(settings.merged, argv, process.cwd(), argv.extensions, 436 // Pass separated hooks for proper source attribution437 {438 userHooks: settings.getUserHooks(),439 projectHooks: settings.getProjectHooks(),440 }, buildDisabledSkillNamesProvider(settings), undefined, settingsWatcher);441 profileCheckpoint('after_load_cli_config');442 // Subscribe the running Config to settings changes so MCP servers443 // reconnect / disconnect / restart without a session restart (#3696,444 // sub-task 3). Skipped in bare mode (no watcher).445 if (settingsWatcher) {446 const disposeMcpHotReload = registerMcpHotReload(settingsWatcher, settings, config, config.getTopTierMcpServers());447 registerCleanup(disposeMcpHotReload);448 }449 registerLspHotReload(config, registerCleanup);450 // Phase D-1: persist the WorktreeSession sidecar so Phase C's restore451 // machinery on a subsequent `--resume` picks the worktree back up, and452 // capture any override of a previously-resumed session's worktree so453 // we can emit a one-shot notice on the model's first prompt.454 //455 // The notice is set BEFORE the persist attempt and AGAIN inside the456 // try block (so the override addendum can be appended on success).457 // A persist failure must NOT silently drop the notice — the cwd is458 // already switched, and the model needs to know which worktree it's459 // operating in regardless of whether the sidecar landed.460 if (startupWorktreeContext) {461 config.setPendingStartupWorktreeNotice(buildStartupWorktreeNotice(startupWorktreeContext));462 try {463 const startupWorktreePersist = await persistStartupWorktreeSidecar(config, startupWorktreeContext);464 if (startupWorktreePersist.overrodeResumedWorktree) {465 writeStderrLine(`--worktree overrode the resumed session's previous worktree ` +466 `"${startupWorktreePersist.overriddenSlug ?? '(unknown)'}". ` +467 `That worktree directory was left intact on disk.`);468 }469 // Refresh the notice with the override addendum (if any). When470 // there is no override this is a no-op text-wise; on override it471 // gives the model the "you overrode <previous-slug>" hint. TUI472 // and headless consume this via Config.consumePendingStartupWorktreeNotice();473 // ACP is excluded above (`--worktree` × `--acp` is mutually474 // exclusive — see the mutex check earlier in this function).475 config.setPendingStartupWorktreeNotice(buildStartupWorktreeNotice(startupWorktreeContext, startupWorktreePersist));476 }477 catch (error) {478 debugLogger.warn(`--worktree sidecar persist failed (non-fatal, notice preserved): ${error instanceof Error ? error.message : String(error)}`);479 }480 }481 // Persist session usage for cross-session reports (must run before482 // config.shutdown() which clears telemetry state).483 // sessionStartTime is read from uiTelemetryService so it stays correct484 // after /clear resets the session (reset() updates the internal timestamp).485 registerCleanup(() => {486 try {487 const metrics = uiTelemetryService.getMetrics();488 const hasActivity = Object.values(metrics.models).some((m) => m.api.totalRequests > 0);489 if (!hasActivity)490 return;491 persistSessionUsage({492 sessionId: config.getSessionId(),493 startTime: uiTelemetryService.getSessionStartTime(),494 endTime: new Date(),495 project: config.getProjectRoot(),496 metrics,497 });498 }499 catch {500 // Best-effort — don't block shutdown501 }502 });503 // Register cleanup for MCP clients as early as possible504 // This ensures MCP server subprocesses are properly terminated on exit505 registerCleanup(() => config.shutdown());506 startEarlyStartupPrefetches(config);507 const wasRaw = process.stdin.isRaw;508 let kittyProtocolDetectionComplete;509 let themeAutoDetectionComplete;510 if (config.isInteractive()) {511 registerCleanup(installInteractiveSignalHandlers(wasRaw));512 }513 if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {514 const { startEarlyInputCapture, stopAndGetCapturedInput } = await import('./utils/earlyInputCapture.js');515 const { detectAndEnableKittyProtocol } = await import('./ui/utils/kittyProtocolDetector.js');516 // Set this as early as possible to avoid spurious characters from517 // input showing up in the output.518 process.stdin.setRawMode(true);519 // Startup optimization: start early input capture520 startEarlyInputCapture();521 // Ensure the stdin listener is removed on any exit path (error, signal, etc.)522 registerCleanup(() => stopAndGetCapturedInput());523 // Detect and enable Kitty keyboard protocol once at startup.524 kittyProtocolDetectionComplete = detectAndEnableKittyProtocol();525 // Auto-detect theme (OSC 11 + COLORFGBG + macOS) when the user has526 // opted into 'auto' or has not configured a theme at all. Kicked off527 // here without awaiting so the OSC 11 timeout overlaps with the528 // heavier startup work below (initializeApp, warnings) instead of529 // blocking the critical path. The synchronous baseline picked above530 // keeps the active theme valid in the meantime; this probe only531 // refines it. Running inside the early-capture window is deliberate:532 // the filter in startEarlyInputCapture absorbs the OSC 11 response533 // bytes so they cannot leak into the TUI input, even though our534 // probe attaches its own listener to parse the RGB value.535 if (!configuredTheme || configuredTheme === AUTO_THEME_NAME) {536 themeAutoDetectionComplete = themeManager537 .resolveAutoThemeAsync()538 .catch((err) => {539 debugLogger.warn('Async theme auto-detection failed:', err);540 });541 }542 }543 if (config.isInteractive()) {544 const { setMaxSizedBoxDebugging } = await import('./ui/components/shared/MaxSizedBox.js');545 setMaxSizedBoxDebugging(isDebugMode);546 }547 // Check input format early to determine initialization flow548 // In TTY mode, ignore stream-json input format to prevent process from hanging549 const inputFormat = process.stdin.isTTY550 ? InputFormat.TEXT551 : typeof config.getInputFormat === 'function'552 ? config.getInputFormat()553 : InputFormat.TEXT;554 // For stream-json mode, defer config.initialize() until after the initialize control request555 // For other modes, initialize normally556 const { initializeApp } = await import('./core/initializer.js');557 let input = config.getQuestion();558 const hasRemoteInput = Boolean(config.getInputFile?.());559 const deferIdeConnection = config.isInteractive() &&560 !config.getExperimentalZedIntegration() &&561 !input &&562 !hasRemoteInput;563 const initializationResult = await initializeApp(config, settings, {564 deferIdeConnection,565 });566 profileCheckpoint('after_initialize_app');567 if (config.getExperimentalZedIntegration()) {568 const { runAcpAgent } = await import('./acp-integration/acpAgent.js');569 await runAcpAgent(config, settings, argv);570 // Clean up child processes and force exit, matching other non-interactive modes571 await runExitCleanup();572 process.exit(0);573 }574 const startupWarnings = [575 ...new Set([576 ...(config.isSafeMode()577 ? [578 '⚠ SAFE MODE — all customizations disabled (hooks, extensions, skills, MCP servers, QWEN.md). Restart without --safe-mode to resume normal operation.',579 ]580 : []),581 ...(await getStartupWarnings()),582 ...(await getUserStartupWarnings({583 workspaceRoot: process.cwd(),584 useRipgrep: settings.merged.tools?.useRipgrep ?? true,585 useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,586 })),587 ...getSettingsWarnings(settings),588 ...config.getWarnings(),589 ...(config.getModelsConfig().getCurrentAuthType() ===590 AuthType.QWEN_OAUTH591 ? [592 'Qwen OAuth free tier was discontinued on 2026-04-15. Run /auth to switch to Coding Plan or another provider.',593 ]594 : []),595 ]),596 ];597 const emittedStartupWarnings = new Set(startupWarnings);598 // Surface critical startup warnings (corrupted settings, recovery, etc.)599 // to stderr so they are visible regardless of UI mode. In interactive600 // mode the TUI's Notifications component also renders them, but the601 // onboarding flow can obscure the notification area, leaving users602 // unaware that their settings were reset. Writing to stderr before603 // the TUI takes over ensures the message is visible in the terminal604 // scrollback. In non-interactive mode this is the *only* channel.605 for (const warning of startupWarnings) {606 writeStderrLine(warning);607 }608 // Render UI, passing necessary config values. Check that there is no command line question.609 profileCheckpoint('before_render');610 if (config.isInteractive()) {611 // --json-schema is a headless-only contract: the synthetic612 // structured_output tool only terminates the run inside613 // runNonInteractive's main/drain loops. In TUI mode the same call614 // would just emit "Structured output accepted." and keep the chat615 // alive, which silently strands the user's run. Parse-time gating616 // can't catch this case (`qwen --json-schema '...'` on a TTY with617 // no prompt routes to interactive only after stdin TTY detection),618 // so reject here before the UI launches.619 if (config.getJsonSchema?.()) {620 writeStderrLine('Error: --json-schema is a headless-only flag. Provide a one-shot prompt via -p / --prompt or pipe one in via stdin.');621 // Run cleanup so MCP subprocesses + telemetry exporters that the622 // earlier initializeApp() / loadCliConfig() registered get shut623 // down — process.exit() doesn't drain them on its own.624 await runExitCleanup();625 process.exit(1);626 }627 // For the interactive path, the profile is finalized by AppContainer628 // after `config.initialize()` and `input_enabled` are recorded — that's629 // the only way `first_paint`, `config_initialize_*`, `input_enabled`,630 // and the MCP events are captured. See AppContainer's mount effect.631 setInteractiveMode(true);632 // Need kitty detection to be complete before we can start the interactive UI.633 await kittyProtocolDetectionComplete;634 // Drain the auto-theme probe before render so the OSC 11 response is635 // absorbed by the early-capture filter (which is closed inside636 // startInteractiveUI) and so the first paint uses the refined theme637 // when the probe finishes in time.638 await themeAutoDetectionComplete;639 const { startInteractiveUI } = await import('./ui/startInteractiveUI.js');640 await startInteractiveUI(config, settings, startupWarnings, process.cwd(), initializationResult, {641 postRenderConnectIde: deferIdeConnection,642 });643 // Clean up corruption env vars so subsequent relaunch children644 // and subprocesses don't inherit stale state.645 clearCorruptionEnvVars();646 return;647 }648 // Also clean up env vars for non-interactive paths so that649 // subprocesses don't inherit stale state.650 clearCorruptionEnvVars();651 // Non-interactive: defer finalize until after `config.initialize()` runs652 // so MCP discovery events (mcp_first_tool_registered, mcp_all_servers_settled,653 // gemini_tools_updated) are captured in the profile.654 // Print debug mode notice to stderr for non-interactive mode655 if (config.getDebugMode()) {656 writeStderrLine('Debug mode enabled');657 writeStderrLine(`Logging to: ${Storage.getDebugLogPath(config.getSessionId())}`);658 if (isDebugLoggingDegraded()) {659 writeStderrLine('Warning: Debug logging is degraded (write failures occurred)');660 }661 }662 // Headless + YOLO without a sandbox lets the model auto-approve and663 // execute shell / write / edit tools at the current process's664 // privilege level. Emit a one-line stderr warning so unattended runs665 // have at least an observable signal. Interactive runs are excluded666 // because the user is at the keyboard and the TUI shows approval667 // state directly. See issue #4103.668 if (!config.isInteractive()) {669 const yoloWarning = getHeadlessYoloSafetyWarning(config);670 if (yoloWarning)671 writeStderrLine(yoloWarning);672 }673 // For non-stream-json mode, initialize config here. Stream-json defers674 // `config.initialize()` to inside `Session.ensureConfigInitialized`675 // because the initial control_request may register SDK MCP servers676 // that must be in place before discovery runs (see session.ts).677 if (inputFormat !== InputFormat.STREAM_JSON) {678 profileCheckpoint('config_initialize_start');679 await config.initialize();680 for (const warning of config.getWarnings()) {681 if (emittedStartupWarnings.has(warning))682 continue;683 emittedStartupWarnings.add(warning);684 writeStderrLine(warning);685 }686 profileCheckpoint('config_initialize_end');687 // Non-interactive paths feed a prompt to the model immediately after688 // init. Under PR-A's progressive MCP availability,689 // `config.initialize()` returns BEFORE MCP servers settle, so690 // without this wait the first sendMessage would see only built-in691 // tools — a silent regression versus the legacy synchronous692 // behavior. Interactive paths skip this (AppContainer's batch-flush693 // subscriber updates the tool list as MCP servers come online).694 await config.waitForMcpReady();695 // Surface MCP server failures on stderr so non-interactive runs696 // (--prompt / piped stdin / scripts) don't silently regress to697 // built-in-tools-only when a server cannot connect. The legacy698 // synchronous MCP path was visibly noisy on failures because699 // per-server errors logged to stderr during the blocking700 // `discoverAllMcpTools` call; PR-A moves discovery to a701 // background promise whose per-server errors are caught inside702 // `discoverAllMcpToolsIncremental` and never reach a TTY. This703 // helper closes that gap without re-introducing blocking.704 // Defensive against tests that pass a stubbed Config without705 // `getFailedMcpServerNames` — the warning is best-effort visibility706 // and never gates startup.707 const failedMcpServers = typeof config.getFailedMcpServerNames === 'function'708 ? config.getFailedMcpServerNames()709 : [];710 if (failedMcpServers.length > 0) {711 writeStderrLine(`Warning: MCP server(s) failed to start: ${failedMcpServers.join(', ')}. ` +712 `Continuing with built-in tools and any servers that did connect. ` +713 `Re-run with QWEN_CODE_DEBUG=1 to see per-server reasons.`);714 }715 // Finalize the non-interactive startup profile here so MCP events716 // emitted during initialize() / waitForMcpReady() are captured.717 // Subsequent stdin reads / auth checks / prompt execution are not718 // part of the "first-screen" budget.719 //720 // For stream-json we deliberately do NOT finalize here: the profile721 // is finalized inside Session.ensureConfigInitialized() after MCP722 // settles, so its `config_initialize_*` and MCP events make it into723 // the file. Finalizing here would write an empty profile and the724 // module-level `finalized` guard would suppress every subsequent725 // event.726 finalizeStartupProfile(config.getSessionId());727 }728 // Only read stdin if NOT in stream-json mode729 // In stream-json mode, stdin is used for protocol messages (control requests, etc.)730 // and should be consumed by StreamJsonInputReader instead731 if (inputFormat !== InputFormat.STREAM_JSON && !process.stdin.isTTY) {732 const stdinData = await readStdin();733 if (stdinData) {734 input = `${stdinData}\n\n${input}`;735 }736 }737 const { validateNonInteractiveAuth } = await import('./validateNonInterActiveAuth.js');738 const nonInteractiveConfig = await validateNonInteractiveAuth(settings.merged.security?.auth?.useExternal, config, settings);739 const prompt_id = createNonInteractivePromptId(config.getSessionId());740 if (inputFormat === InputFormat.STREAM_JSON) {741 const trimmedInput = (input ?? '').trim();742 const { runNonInteractiveStreamJson } = await import('./nonInteractive/session.js');743 await runNonInteractiveStreamJson(nonInteractiveConfig, trimmedInput.length > 0 ? trimmedInput : '', settings);744 await runExitCleanup();745 // `runNonInteractiveStreamJson` doesn't return an explicit exit746 // code yet, so a cleanup task that mutates `process.exitCode`747 // could clobber a non-zero failure signal. This is currently safe748 // because `--json-schema` is rejected at parse time when combined749 // with `--input-format stream-json` (see the yargs `.check` in750 // resolveCliGenerationConfig), so structured-output failures751 // never reach this branch. If a future stream-json equivalent of752 // structured output is added, plumb the exit code through the753 // function's return value the way `runNonInteractive` below does.754 process.exit(process.exitCode ?? 0);755 }756 if (!input) {757 writeStderrLine(`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`);758 process.exit(1);759 }760 logUserPrompt(config, {761 'event.name': 'user_prompt',762 'event.timestamp': new Date().toISOString(),763 prompt: input,764 prompt_id,765 auth_type: config.getContentGeneratorConfig()?.authType,766 prompt_length: input.length,767 });768 debugLogger.debug(`Session ID: ${config.getSessionId()}`);769 const { runNonInteractive } = await import('./nonInteractiveCli.js');770 const exitCode = await runNonInteractive(nonInteractiveConfig, settings, input, prompt_id);771 // Call cleanup before process.exit, which causes cleanup to not run.772 // Capture the exit code BEFORE cleanup so any cleanup task that773 // mutates process.exitCode can't silently turn a structured-output774 // failure (or other explicit non-zero return from runNonInteractive)775 // into a zero exit.776 await runExitCleanup();777 process.exit(exitCode);778 }779}780export function createNonInteractivePromptId(sessionId) {781 return `${sessionId}########0`;782}783/**784 * Watches `.lsp.json` for changes and reconciles running LSP servers785 * (add / remove / restart) without requiring a session restart.786 *787 * Silently no-ops when LSP is disabled or the active client does not788 * support runtime reinitialization.789 *790 * Emits {@link AppEvent.LspStatusChanged} after every successful reload791 * so the UI can reflect the new server state.792 */793export function registerLspHotReload(config, registerCleanup) {794 if (config.isLspEnabled?.() !== true ||795 !config.getLspClient?.()?.reinitialize) {796 return;797 }798 const lspConfigWatcher = new LspConfigWatcher(config.getProjectRoot());799 debugLogger.info(`Registering LSP config hot reload watcher for ${config.getProjectRoot()}`);800 lspConfigWatcher.startWatching(async (event) => {801 if (event.changeType === 'invalid') {802 debugLogger.warn(`Invalid LSP config file ${event.path}: ${event.error}`);803 appEvents.emit(AppEvent.LogError, event.error);804 return;805 }806 debugLogger.info(`Reloading LSP server settings: changeType=${event.changeType}, path=${event.path}`);807 let errorReported = false;808 try {809 const result = await config.reinitializeLsp();810 if (result) {811 const failedServers = getRuntimeReloadFailedNames(result.reconcile);812 debugLogger.info(`Reloaded LSP server settings: added=${formatRuntimeReloadNames(result.reconcile.added)}, removed=${formatRuntimeReloadNames(result.reconcile.removed)}, restarted=${formatRuntimeReloadNames(result.reconcile.restarted)}, unchanged=${formatRuntimeReloadNames(result.reconcile.unchanged)}, failed=${formatRuntimeReloadNames(failedServers)}, skipped=${formatRuntimeReloadNames(result.skipped.map((server) => server.name))}`);813 if (failedServers.length > 0) {814 appEvents.emit(AppEvent.LspStatusChanged);815 const changedServers = [816 ...result.reconcile.added,817 ...result.reconcile.removed,818 ...result.reconcile.restarted,819 ];820 const message = `LSP reload partially completed: changed=${formatRuntimeReloadNames(changedServers)}, failed=${formatRuntimeReloadNames(failedServers)}. Run with --debug for details.`;821 appEvents.emit(AppEvent.LogError, message);822 errorReported = true;823 throw new Error(message);824 }825 }826 else {827 debugLogger.info('Skipped LSP server settings reload because LSP is disabled or no client is available');828 }829 appEvents.emit(AppEvent.LspStatusChanged);830 }831 catch (error) {832 debugLogger.warn('Failed to reload LSP server settings:', error);833 if (!errorReported) {834 const message = error instanceof Error835 ? `Failed to reload LSP server settings: ${error.message}. Some LSP servers may have been partially updated. Run with --debug for details.`836 : 'Failed to reload LSP server settings; some LSP servers may have been partially updated. Run with --debug for details.';837 appEvents.emit(AppEvent.LogError, message);838 }839 throw error;840 }841 });842 registerCleanup(() => lspConfigWatcher.stopWatching());843}844function formatRuntimeReloadNames(names) {845 return names.length === 0 ? '<none>' : names.join(',');846}847/**848 * Reads the optional failed bucket defensively because the CLI may typecheck849 * against stale core dist declarations during local development.850 */851function getRuntimeReloadFailedNames(reconcile) {852 return reconcile.failed ?? [];853}854//# sourceMappingURL=gemini.js.map