basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import {8 AuthType,9 type Config,10 InputFormat,11 isDebugLoggingDegraded,12 isBareMode,13 logUserPrompt,14 QWEN_CODE_SIMPLE_ENV_VAR,15 Storage,16 SessionService,17 setStartupEventSink,18 createDebugLogger,19 persistSessionUsage,20 uiTelemetryService,21} from '@qwen-code/qwen-code-core';22import dns from 'node:dns';23import os from 'node:os';24import path from 'node:path';25import v8 from 'node:v8';26import { validateAuthMethod } from './config/auth.js';27import * as cliConfig from './config/config.js';28import {29 buildDisabledSkillNamesProvider,30 loadCliConfig,31 parseArguments,32} from './config/config.js';33import type { DnsResolutionOrder } from './config/settings.js';34import {35 ENV_CORRUPTED_PATH,36 ENV_WAS_RECOVERED,37 createMinimalSettings,38 getSettingsWarnings,39 loadSettings,40 preResolveHomeEnvOverrides,41} from './config/settings.js';42import { SettingsWatcher } from './config/settingsWatcher.js';43import { registerMcpHotReload } from './config/hot-reload.js';44import { LspConfigWatcher } from './config/lsp-config-watcher.js';45import { initializeI18n, resolveLanguageSetting } from './i18n/index.js';46import {47 setupStartupWorktree,48 persistStartupWorktreeSidecar,49 buildStartupWorktreeNotice,50 type StartupWorktreeContext,51} from './startup/worktreeStartup.js';52import { startEarlyStartupPrefetches } from './startup/startup-prefetch.js';53import {54 cleanupCheckpoints,55 registerCleanup,56 runExitCleanup,57} from './utils/cleanup.js';58import { AppEvent, appEvents } from './utils/events.js';59import { readStdin } from './utils/readStdin.js';60import {61 profileCheckpoint,62 recordStartupEvent,63 setInteractiveMode,64 finalizeStartupProfile,65 isStartupProfilerEnabled,66} from './utils/startupProfiler.js';67import {68 relaunchAppInChildProcess,69 relaunchOnExitCode,70} from './utils/relaunch.js';71import { start_sandbox } from './utils/sandbox.js';72import { getStartupWarnings } from './utils/startupWarnings.js';73import { getUserStartupWarnings } from './utils/userStartupWarnings.js';74import { initializeWarningHandler } from './utils/warningHandler.js';75import { writeStderrLine } from './utils/stdioHelpers.js';76import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js';77import { initializeLlmOutputLanguage } from './utils/languageUtils.js';78 79const debugLogger = createDebugLogger('STARTUP');80 81function clearCorruptionEnvVars(): void {82 delete process.env[ENV_CORRUPTED_PATH];83 delete process.env[ENV_WAS_RECOVERED];84}85 86export function validateDnsResolutionOrder(87 order: string | undefined,88): DnsResolutionOrder {89 const defaultValue: DnsResolutionOrder = 'ipv4first';90 if (order === undefined) {91 return defaultValue;92 }93 if (order === 'ipv4first' || order === 'verbatim') {94 return order;95 }96 // We don't want to throw here, just warn and use the default.97 writeStderrLine(98 `Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`,99 );100 return defaultValue;101}102 103function getNodeMemoryArgs(isDebugMode: boolean): string[] {104 const totalMemoryMB = os.totalmem() / (1024 * 1024);105 const heapStats = v8.getHeapStatistics();106 const currentMaxOldSpaceSizeMb = Math.floor(107 heapStats.heap_size_limit / 1024 / 1024,108 );109 110 // Set target to 50% of total memory111 const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5);112 if (isDebugMode) {113 writeStderrLine(114 `Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`,115 );116 }117 118 if (process.env['QWEN_CODE_NO_RELAUNCH']) {119 return [];120 }121 122 if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {123 if (isDebugMode) {124 writeStderrLine(125 `Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`,126 );127 }128 return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];129 }130 131 return [];132}133 134import { loadSandboxConfig } from './config/sandboxConfig.js';135 136export function setupUnhandledRejectionHandler() {137 let unhandledRejectionOccurred = false;138 process.on('unhandledRejection', (reason, _promise) => {139 const errorMessage = `=========================================140This is an unexpected error. Please file a bug report using the /bug tool.141CRITICAL: Unhandled Promise Rejection!142=========================================143Reason: ${reason}${144 reason instanceof Error && reason.stack145 ? `146Stack trace:147${reason.stack}`148 : ''149 }`;150 appEvents.emit(AppEvent.LogError, errorMessage);151 if (!unhandledRejectionOccurred) {152 unhandledRejectionOccurred = true;153 appEvents.emit(AppEvent.OpenDebugConsole);154 }155 });156}157 158function getSignalExitCode(signal: NodeJS.Signals): number {159 return signal === 'SIGINT' ? 130 : 143;160}161 162function installInteractiveSignalHandlers(wasRaw: boolean): () => void {163 let cleanupStarted = false;164 165 const handleSignal = (signal: NodeJS.Signals) => {166 if (process.stdin.isTTY) {167 process.stdin.setRawMode(wasRaw);168 }169 170 if (cleanupStarted) {171 return;172 }173 cleanupStarted = true;174 175 void runExitCleanup()176 .catch((error) => {177 debugLogger.error(`Error during ${signal} cleanup:`, error);178 })179 .finally(() => {180 process.exit(getSignalExitCode(signal));181 });182 };183 184 const handleSigterm = () => {185 handleSignal('SIGTERM');186 };187 const handleSigint = () => {188 handleSignal('SIGINT');189 };190 191 process.once('SIGTERM', handleSigterm);192 process.once('SIGINT', handleSigint);193 194 return () => {195 process.removeListener('SIGTERM', handleSigterm);196 process.removeListener('SIGINT', handleSigint);197 };198}199 200export async function main() {201 profileCheckpoint('main_entry');202 // Bridge core-package startup events (Config.initialize, MCP discovery,203 // GeminiClient.setTools) into the cli's startup profiler. Gated on204 // `isStartupProfilerEnabled()` so that when QWEN_CODE_PROFILE_STARTUP is205 // unset (the common case) every core-side `recordStartupEvent()` call206 // sees a null sink and short-circuits at the first comparison, instead207 // of going through this arrow wrapper and the profiler's own enabled208 // check.209 if (isStartupProfilerEnabled()) {210 setStartupEventSink((name, attrs) => recordStartupEvent(name, attrs));211 }212 setupUnhandledRejectionHandler();213 initializeWarningHandler();214 215 if (process.argv.includes('--bare')) {216 process.env[QWEN_CODE_SIMPLE_ENV_VAR] = '1';217 }218 219 // Run before yargs parses subcommands — handlers like `channel status`/`stop`220 // call `process.exit` before `loadSettings()` would otherwise bootstrap.221 preResolveHomeEnvOverrides();222 223 let argv = await parseArguments();224 profileCheckpoint('after_parse_arguments');225 226 if (isBareMode(argv.bare)) {227 process.env[QWEN_CODE_SIMPLE_ENV_VAR] = '1';228 }229 230 // Load user settings — bare mode uses minimal config, normal mode loads full.231 const settings = isBareMode(argv.bare)232 ? createMinimalSettings()233 : loadSettings();234 235 // Propagate corruption state to child process via env vars so236 // relaunchAppInChildProcess() doesn't lose the marker.237 if (settings.corruptedPath) {238 process.env[ENV_CORRUPTED_PATH] = settings.corruptedPath;239 process.env[ENV_WAS_RECOVERED] = settings.wasRecovered ? '1' : '0';240 }241 await cleanupCheckpoints();242 // Performance checkpoint243 profileCheckpoint('after_load_settings');244 245 // Emit settings warnings early so the parent process surfaces them246 // before relaunchAppInChildProcess() exits (the child has empty247 // migrationWarnings because the parent already renamed the file).248 const settingsWarnings = getSettingsWarnings(settings);249 for (const warning of settingsWarnings) {250 writeStderrLine(warning);251 }252 // Corruption notification no longer goes through migrationWarnings —253 // check corruptedPath directly to keep stderr visible in relaunch.254 if (settings.corruptedPath) {255 writeStderrLine(256 'Warning: Settings file had invalid JSON and was reset. ' +257 'A copy of the corrupted file has been saved at: ' +258 settings.corruptedPath,259 );260 }261 262 if (argv.listExtensions) {263 await initializeI18n(264 resolveLanguageSetting(settings.merged.general?.language as string),265 );266 const { handleList: handleListExtensions } = await import(267 './commands/extensions/list.js'268 );269 await handleListExtensions();270 process.exit(0);271 }272 273 // Check for invalid input combinations early to prevent crashes274 if (argv.promptInteractive && !process.stdin.isTTY) {275 writeStderrLine(276 'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.',277 );278 process.exit(1);279 }280 281 const isDebugMode = cliConfig.isDebugMode(argv);282 283 dns.setDefaultResultOrder(284 validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),285 );286 287 const { themeManager, AUTO_THEME_NAME } = await import(288 './ui/themes/theme-manager.js'289 );290 // Load custom themes from settings291 themeManager.loadCustomThemes(settings.merged.ui?.customThemes);292 293 const configuredTheme = settings.merged.ui?.theme;294 if (configuredTheme && configuredTheme !== AUTO_THEME_NAME) {295 if (!themeManager.setActiveTheme(configuredTheme)) {296 // If the theme is not found during initial load, log a warning and continue.297 // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog.298 writeStderrLine(`Warning: Theme "${configuredTheme}" not found.`);299 }300 } else {301 // 'auto' or unset: resolve a synchronous baseline (COLORFGBG + macOS)302 // so non-interactive runs and any pre-render UI (e.g. the --resume303 // session picker) already have a sensible theme. The interactive304 // startup block refines this with an OSC 11 probe later on, which is305 // intentionally deferred to run inside the early-capture window so306 // terminal response bytes cannot leak into the TUI input.307 themeManager.setActiveTheme(AUTO_THEME_NAME);308 }309 310 // hop into sandbox if we are outside and sandboxing is enabled311 if (!process.env['SANDBOX']) {312 const memoryArgs = settings.merged.advanced?.autoConfigureMemory313 ? getNodeMemoryArgs(isDebugMode)314 : [];315 const sandboxConfig = await loadSandboxConfig(settings.merged, argv);316 // We intentially omit the list of extensions here because extensions317 // should not impact auth or setting up the sandbox.318 // TODO(jacobr): refactor loadCliConfig so there is a minimal version319 // that only initializes enough config to enable refreshAuth or find320 // another way to decouple refreshAuth from requiring a config.321 322 if (sandboxConfig) {323 const partialConfig = await loadCliConfig(324 settings.merged,325 argv,326 undefined,327 [],328 // Pass separated hooks for proper source attribution329 {330 userHooks: settings.getUserHooks(),331 projectHooks: settings.getProjectHooks(),332 },333 buildDisabledSkillNamesProvider(settings),334 );335 336 if (!settings.merged.security?.auth?.useExternal) {337 // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.338 try {339 const authType = partialConfig.getModelsConfig().getCurrentAuthType();340 // Fresh users may not have selected/persisted an authType yet.341 // In that case, defer auth prompting/selection to the main interactive flow.342 if (authType) {343 const err = validateAuthMethod(authType, partialConfig);344 if (err) {345 throw new Error(err);346 }347 348 await partialConfig.refreshAuth(authType);349 }350 } catch (err) {351 writeStderrLine(`Error authenticating: ${err}`);352 process.exit(1);353 }354 }355 // For stream-json and ACP modes, don't read stdin here — stdin carries356 // protocol data (not a user prompt) and should be forwarded to the sandbox357 // intact via stdio: 'inherit'.358 const inputFormat = argv.inputFormat as string | undefined;359 const isAcpMode = argv.acp || argv.experimentalAcp;360 let stdinData = '';361 if (!process.stdin.isTTY && inputFormat !== 'stream-json' && !isAcpMode) {362 stdinData = await readStdin();363 }364 365 // This function is a copy of the one from sandbox.ts366 // It is moved here to decouple sandbox.ts from the CLI's argument structure.367 const injectStdinIntoArgs = (368 args: string[],369 stdinData?: string,370 ): string[] => {371 const finalArgs = [...args];372 if (stdinData) {373 const promptIndex = finalArgs.findIndex(374 (arg) => arg === '--prompt' || arg === '-p',375 );376 if (promptIndex > -1 && finalArgs.length > promptIndex + 1) {377 // If there's a prompt argument, prepend stdin to it378 finalArgs[promptIndex + 1] =379 `${stdinData}\n\n${finalArgs[promptIndex + 1]}`;380 } else {381 // If there's no prompt argument, add stdin as the prompt382 finalArgs.push('--prompt', stdinData);383 }384 }385 return finalArgs;386 };387 388 const injectSandboxSessionIdIntoArgs = (389 args: string[],390 sessionId: string,391 ): string[] => {392 const separatorIndex = args.indexOf('--');393 const cliArgs =394 separatorIndex < 0 ? args : args.slice(0, separatorIndex);395 const hasArg = (names: string[]) =>396 cliArgs.some((arg) =>397 names.some((name) => arg === name || arg.startsWith(`${name}=`)),398 );399 if (400 hasArg(['--session-id', '--sandbox-session-id']) ||401 hasArg(['--continue', '-c']) ||402 hasArg(['--resume', '-r'])403 ) {404 return args;405 }406 407 const sessionArgs = ['--sandbox-session-id', sessionId];408 if (separatorIndex < 0) {409 return [...args, ...sessionArgs];410 }411 412 return [...cliArgs, ...sessionArgs, ...args.slice(separatorIndex)];413 };414 415 const sessionId = partialConfig.getSessionId();416 const sandboxArgs = sessionId417 ? injectSandboxSessionIdIntoArgs(418 injectStdinIntoArgs(process.argv, stdinData),419 sessionId,420 )421 : injectStdinIntoArgs(process.argv, stdinData);422 423 await relaunchOnExitCode(() =>424 start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs),425 );426 process.exit(0);427 } else {428 // Relaunch app so we always have a child process that can be internally429 // restarted if needed.430 await relaunchAppInChildProcess(memoryArgs, [], {431 afterSpawn: clearCorruptionEnvVars,432 });433 }434 }435 436 // When --worktree is going to chdir us into a worktree below, resolve437 // any relative-path argv fields to absolute paths now — BEFORE the438 // chdir. Otherwise downstream `fs.existsSync('./mcp.json')` calls in439 // `loadCliConfig` re-resolve against the worktree dir, where the file440 // doesn't exist. Only touches values that look like paths (mcpConfig441 // also accepts inline JSON — skip those).442 //443 // The list of fields below is hand-maintained. If you add a new444 // CLI flag that takes a relative path, register it here too,445 // otherwise --worktree silently breaks for that flag.446 if (argv.worktree !== undefined) {447 const launchCwdForPaths = process.cwd();448 const looksLikeInlineJson = (v: string): boolean => {449 const t = v.trim();450 return t.startsWith('{') || t.startsWith('[');451 };452 const resolveIfPath = (v: string | undefined): string | undefined => {453 if (typeof v !== 'string' || v.length === 0) return v;454 if (looksLikeInlineJson(v)) return v;455 return path.resolve(launchCwdForPaths, v);456 };457 argv.mcpConfig = resolveIfPath(argv.mcpConfig);458 argv.openaiLoggingDir = resolveIfPath(argv.openaiLoggingDir);459 argv.jsonFile = resolveIfPath(argv.jsonFile);460 argv.inputFile = resolveIfPath(argv.inputFile);461 argv.telemetryOutfile = resolveIfPath(argv.telemetryOutfile);462 if (Array.isArray(argv.includeDirectories)) {463 argv.includeDirectories = argv.includeDirectories.map((d) =>464 typeof d === 'string' && d.length > 0465 ? path.resolve(launchCwdForPaths, d)466 : d,467 );468 }469 // `--json-schema` accepts either an inline schema or `@<path>`. The470 // `@`-prefixed form is read from disk inside `resolveJsonSchemaArg`471 // (`packages/cli/src/config/config.ts`), AFTER chdir, so a relative472 // value would resolve against the worktree — fix the prefix path473 // here.474 if (typeof argv.jsonSchema === 'string') {475 const trimmedSchema = argv.jsonSchema.trim();476 if (trimmedSchema.startsWith('@')) {477 const rel = trimmedSchema.slice(1);478 if (rel.length > 0 && !path.isAbsolute(rel)) {479 argv.jsonSchema = '@' + path.resolve(launchCwdForPaths, rel);480 }481 }482 }483 }484 485 // Phase D-1: process --worktree before the resume picker so the picker486 // (which uses process.cwd() to scope its session search) finds sessions487 // saved inside the target worktree. Creates the worktree directory on488 // disk and chdirs into it; on failure we emit to stderr and exit before489 // any expensive initialization runs.490 //491 // ACP mode is exempt: the ACP host (Zed, etc.) supplies its own per-session492 // cwd, and the startup-level chdir would not propagate. Reject the493 // combination with a clear error rather than silently dropping --worktree.494 let startupWorktreeContext: StartupWorktreeContext | null = null;495 if (argv.worktree !== undefined && (argv.acp || argv.experimentalAcp)) {496 writeStderrLine(497 '--worktree cannot be combined with --acp / --experimental-acp. ' +498 'Pass the worktree path as the cwd of the ACP loadSession / newSession ' +499 'request instead.',500 );501 process.exit(1);502 }503 {504 const startupRes = await setupStartupWorktree(argv.worktree, {505 symlinkDirectories: settings.merged.worktree?.symlinkDirectories,506 });507 if (startupRes !== null) {508 if (!startupRes.ok) {509 writeStderrLine(startupRes.error);510 process.exit(1);511 }512 startupWorktreeContext = startupRes.context;513 }514 }515 516 // Handle --resume without a session ID, or with a custom title, by showing517 // the session picker. Set the runtime output dir early so the picker can find518 // sessions stored under a custom runtimeOutputDir (setRuntimeBaseDir is519 // idempotent and will be called again inside loadCliConfig).520 if (argv.resume !== undefined) {521 Storage.setRuntimeBaseDir(522 settings.merged.advanced?.runtimeOutputDir,523 process.cwd(),524 );525 526 let resolvedSessionId: string | undefined;527 528 if (argv.resume === '') {529 // No argument — show picker530 const { showResumeSessionPicker } = await import(531 './ui/components/StandaloneSessionPicker.js'532 );533 resolvedSessionId = await showResumeSessionPicker();534 } else if (!cliConfig.isValidSessionId(argv.resume)) {535 // Non-UUID argument — treat as custom title search536 const sessionService = new SessionService(process.cwd());537 const matches = await sessionService.findSessionsByTitle(argv.resume);538 if (matches.length === 1) {539 resolvedSessionId = matches[0].sessionId;540 } else if (matches.length > 1) {541 // Multiple matches — show picker to let user choose542 writeStderrLine(543 `Multiple sessions found with title "${argv.resume}". Please select one:`,544 );545 const { showResumeSessionPicker } = await import(546 './ui/components/StandaloneSessionPicker.js'547 );548 resolvedSessionId = await showResumeSessionPicker(549 process.cwd(),550 matches,551 );552 }553 // matches.length === 0 → resolvedSessionId stays undefined, handled below554 }555 556 if (resolvedSessionId !== undefined) {557 argv = { ...argv, resume: resolvedSessionId };558 } else if (argv.resume === '' || !cliConfig.isValidSessionId(argv.resume)) {559 // User cancelled the picker or no sessions found for the title560 if (argv.resume !== '') {561 writeStderrLine(`No saved session found with title "${argv.resume}".`);562 process.exit(1);563 } else {564 process.exit(0);565 }566 }567 // else: argv.resume is already a valid UUID, pass through to loadCliConfig568 }569 570 // We are now past the logic handling potentially launching a child process571 // to run Qwen Code. It is now safe to perform expensive initialization that572 // may have side effects.573 profileCheckpoint('after_sandbox_check');574 575 // Initialize output language file before config loads to ensure it's included in context576 if (!isBareMode(argv.bare)) {577 initializeLlmOutputLanguage(settings.merged.general?.outputLanguage);578 }579 580 {581 // Start settings file watcher (skip in bare mode)582 const settingsWatcher = isBareMode(argv.bare)583 ? undefined584 : new SettingsWatcher(settings);585 settingsWatcher?.startWatching();586 587 const config = await loadCliConfig(588 settings.merged,589 argv,590 process.cwd(),591 argv.extensions,592 // Pass separated hooks for proper source attribution593 {594 userHooks: settings.getUserHooks(),595 projectHooks: settings.getProjectHooks(),596 },597 buildDisabledSkillNamesProvider(settings),598 undefined,599 settingsWatcher,600 );601 profileCheckpoint('after_load_cli_config');602 603 // Subscribe the running Config to settings changes so MCP servers604 // reconnect / disconnect / restart without a session restart (#3696,605 // sub-task 3). Skipped in bare mode (no watcher).606 if (settingsWatcher) {607 const disposeMcpHotReload = registerMcpHotReload(608 settingsWatcher,609 settings,610 config,611 config.getTopTierMcpServers(),612 );613 registerCleanup(disposeMcpHotReload);614 }615 616 registerLspHotReload(config, registerCleanup);617 618 // Phase D-1: persist the WorktreeSession sidecar so Phase C's restore619 // machinery on a subsequent `--resume` picks the worktree back up, and620 // capture any override of a previously-resumed session's worktree so621 // we can emit a one-shot notice on the model's first prompt.622 //623 // The notice is set BEFORE the persist attempt and AGAIN inside the624 // try block (so the override addendum can be appended on success).625 // A persist failure must NOT silently drop the notice — the cwd is626 // already switched, and the model needs to know which worktree it's627 // operating in regardless of whether the sidecar landed.628 if (startupWorktreeContext) {629 config.setPendingStartupWorktreeNotice(630 buildStartupWorktreeNotice(startupWorktreeContext),631 );632 try {633 const startupWorktreePersist = await persistStartupWorktreeSidecar(634 config,635 startupWorktreeContext,636 );637 if (startupWorktreePersist.overrodeResumedWorktree) {638 writeStderrLine(639 `--worktree overrode the resumed session's previous worktree ` +640 `"${startupWorktreePersist.overriddenSlug ?? '(unknown)'}". ` +641 `That worktree directory was left intact on disk.`,642 );643 }644 // Refresh the notice with the override addendum (if any). When645 // there is no override this is a no-op text-wise; on override it646 // gives the model the "you overrode <previous-slug>" hint. TUI647 // and headless consume this via Config.consumePendingStartupWorktreeNotice();648 // ACP is excluded above (`--worktree` × `--acp` is mutually649 // exclusive — see the mutex check earlier in this function).650 config.setPendingStartupWorktreeNotice(651 buildStartupWorktreeNotice(652 startupWorktreeContext,653 startupWorktreePersist,654 ),655 );656 } catch (error) {657 debugLogger.warn(658 `--worktree sidecar persist failed (non-fatal, notice preserved): ${error instanceof Error ? error.message : String(error)}`,659 );660 }661 }662 663 // Persist session usage for cross-session reports (must run before664 // config.shutdown() which clears telemetry state).665 // sessionStartTime is read from uiTelemetryService so it stays correct666 // after /clear resets the session (reset() updates the internal timestamp).667 registerCleanup(() => {668 try {669 const metrics = uiTelemetryService.getMetrics();670 const hasActivity = Object.values(metrics.models).some(671 (m) => m.api.totalRequests > 0,672 );673 if (!hasActivity) return;674 persistSessionUsage({675 sessionId: config.getSessionId(),676 startTime: uiTelemetryService.getSessionStartTime(),677 endTime: new Date(),678 project: config.getProjectRoot(),679 metrics,680 });681 } catch {682 // Best-effort — don't block shutdown683 }684 });685 686 // Register cleanup for MCP clients as early as possible687 // This ensures MCP server subprocesses are properly terminated on exit688 registerCleanup(() => config.shutdown());689 690 startEarlyStartupPrefetches(config);691 692 const wasRaw = process.stdin.isRaw;693 let kittyProtocolDetectionComplete: Promise<boolean> | undefined;694 let themeAutoDetectionComplete: Promise<void> | undefined;695 if (config.isInteractive()) {696 registerCleanup(installInteractiveSignalHandlers(wasRaw));697 }698 if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {699 const { startEarlyInputCapture, stopAndGetCapturedInput } = await import(700 './utils/earlyInputCapture.js'701 );702 const { detectAndEnableKittyProtocol } = await import(703 './ui/utils/kittyProtocolDetector.js'704 );705 // Set this as early as possible to avoid spurious characters from706 // input showing up in the output.707 process.stdin.setRawMode(true);708 709 // Startup optimization: start early input capture710 startEarlyInputCapture();711 // Ensure the stdin listener is removed on any exit path (error, signal, etc.)712 registerCleanup(() => stopAndGetCapturedInput());713 714 // Detect and enable Kitty keyboard protocol once at startup.715 kittyProtocolDetectionComplete = detectAndEnableKittyProtocol();716 717 // Auto-detect theme (OSC 11 + COLORFGBG + macOS) when the user has718 // opted into 'auto' or has not configured a theme at all. Kicked off719 // here without awaiting so the OSC 11 timeout overlaps with the720 // heavier startup work below (initializeApp, warnings) instead of721 // blocking the critical path. The synchronous baseline picked above722 // keeps the active theme valid in the meantime; this probe only723 // refines it. Running inside the early-capture window is deliberate:724 // the filter in startEarlyInputCapture absorbs the OSC 11 response725 // bytes so they cannot leak into the TUI input, even though our726 // probe attaches its own listener to parse the RGB value.727 if (!configuredTheme || configuredTheme === AUTO_THEME_NAME) {728 themeAutoDetectionComplete = themeManager729 .resolveAutoThemeAsync()730 .catch((err) => {731 debugLogger.warn('Async theme auto-detection failed:', err);732 });733 }734 }735 736 if (config.isInteractive()) {737 const { setMaxSizedBoxDebugging } = await import(738 './ui/components/shared/MaxSizedBox.js'739 );740 setMaxSizedBoxDebugging(isDebugMode);741 }742 743 // Check input format early to determine initialization flow744 // In TTY mode, ignore stream-json input format to prevent process from hanging745 const inputFormat = process.stdin.isTTY746 ? InputFormat.TEXT747 : typeof config.getInputFormat === 'function'748 ? config.getInputFormat()749 : InputFormat.TEXT;750 751 // For stream-json mode, defer config.initialize() until after the initialize control request752 // For other modes, initialize normally753 const { initializeApp } = await import('./core/initializer.js');754 let input = config.getQuestion();755 const hasRemoteInput = Boolean(config.getInputFile?.());756 const deferIdeConnection =757 config.isInteractive() &&758 !config.getExperimentalZedIntegration() &&759 !input &&760 !hasRemoteInput;761 const initializationResult = await initializeApp(config, settings, {762 deferIdeConnection,763 });764 profileCheckpoint('after_initialize_app');765 766 if (config.getExperimentalZedIntegration()) {767 const { runAcpAgent } = await import('./acp-integration/acpAgent.js');768 await runAcpAgent(config, settings, argv);769 // Clean up child processes and force exit, matching other non-interactive modes770 await runExitCleanup();771 process.exit(0);772 }773 774 const startupWarnings = [775 ...new Set([776 ...(config.isSafeMode()777 ? [778 '⚠ SAFE MODE — all customizations disabled (hooks, extensions, skills, MCP servers, QWEN.md). Restart without --safe-mode to resume normal operation.',779 ]780 : []),781 ...(await getStartupWarnings()),782 ...(await getUserStartupWarnings({783 workspaceRoot: process.cwd(),784 useRipgrep: settings.merged.tools?.useRipgrep ?? true,785 useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,786 })),787 ...getSettingsWarnings(settings),788 ...config.getWarnings(),789 ...(config.getModelsConfig().getCurrentAuthType() ===790 AuthType.QWEN_OAUTH791 ? [792 'Qwen OAuth free tier was discontinued on 2026-04-15. Run /auth to switch to Coding Plan or another provider.',793 ]794 : []),795 ]),796 ];797 const emittedStartupWarnings = new Set(startupWarnings);798 799 // Surface critical startup warnings (corrupted settings, recovery, etc.)800 // to stderr so they are visible regardless of UI mode. In interactive801 // mode the TUI's Notifications component also renders them, but the802 // onboarding flow can obscure the notification area, leaving users803 // unaware that their settings were reset. Writing to stderr before804 // the TUI takes over ensures the message is visible in the terminal805 // scrollback. In non-interactive mode this is the *only* channel.806 for (const warning of startupWarnings) {807 writeStderrLine(warning);808 }809 810 // Render UI, passing necessary config values. Check that there is no command line question.811 profileCheckpoint('before_render');812 813 if (config.isInteractive()) {814 // --json-schema is a headless-only contract: the synthetic815 // structured_output tool only terminates the run inside816 // runNonInteractive's main/drain loops. In TUI mode the same call817 // would just emit "Structured output accepted." and keep the chat818 // alive, which silently strands the user's run. Parse-time gating819 // can't catch this case (`qwen --json-schema '...'` on a TTY with820 // no prompt routes to interactive only after stdin TTY detection),821 // so reject here before the UI launches.822 if (config.getJsonSchema?.()) {823 writeStderrLine(824 'Error: --json-schema is a headless-only flag. Provide a one-shot prompt via -p / --prompt or pipe one in via stdin.',825 );826 // Run cleanup so MCP subprocesses + telemetry exporters that the827 // earlier initializeApp() / loadCliConfig() registered get shut828 // down — process.exit() doesn't drain them on its own.829 await runExitCleanup();830 process.exit(1);831 }832 // For the interactive path, the profile is finalized by AppContainer833 // after `config.initialize()` and `input_enabled` are recorded — that's834 // the only way `first_paint`, `config_initialize_*`, `input_enabled`,835 // and the MCP events are captured. See AppContainer's mount effect.836 setInteractiveMode(true);837 // Need kitty detection to be complete before we can start the interactive UI.838 await kittyProtocolDetectionComplete;839 // Drain the auto-theme probe before render so the OSC 11 response is840 // absorbed by the early-capture filter (which is closed inside841 // startInteractiveUI) and so the first paint uses the refined theme842 // when the probe finishes in time.843 await themeAutoDetectionComplete;844 const { startInteractiveUI } = await import('./ui/startInteractiveUI.js');845 await startInteractiveUI(846 config,847 settings,848 startupWarnings,849 process.cwd(),850 initializationResult!,851 {852 postRenderConnectIde: deferIdeConnection,853 },854 );855 // Clean up corruption env vars so subsequent relaunch children856 // and subprocesses don't inherit stale state.857 clearCorruptionEnvVars();858 return;859 }860 861 // Also clean up env vars for non-interactive paths so that862 // subprocesses don't inherit stale state.863 clearCorruptionEnvVars();864 865 // Non-interactive: defer finalize until after `config.initialize()` runs866 // so MCP discovery events (mcp_first_tool_registered, mcp_all_servers_settled,867 // gemini_tools_updated) are captured in the profile.868 869 // Print debug mode notice to stderr for non-interactive mode870 if (config.getDebugMode()) {871 writeStderrLine('Debug mode enabled');872 writeStderrLine(873 `Logging to: ${Storage.getDebugLogPath(config.getSessionId())}`,874 );875 if (isDebugLoggingDegraded()) {876 writeStderrLine(877 'Warning: Debug logging is degraded (write failures occurred)',878 );879 }880 }881 882 // Headless + YOLO without a sandbox lets the model auto-approve and883 // execute shell / write / edit tools at the current process's884 // privilege level. Emit a one-line stderr warning so unattended runs885 // have at least an observable signal. Interactive runs are excluded886 // because the user is at the keyboard and the TUI shows approval887 // state directly. See issue #4103.888 if (!config.isInteractive()) {889 const yoloWarning = getHeadlessYoloSafetyWarning(config);890 if (yoloWarning) writeStderrLine(yoloWarning);891 }892 893 // For non-stream-json mode, initialize config here. Stream-json defers894 // `config.initialize()` to inside `Session.ensureConfigInitialized`895 // because the initial control_request may register SDK MCP servers896 // that must be in place before discovery runs (see session.ts).897 if (inputFormat !== InputFormat.STREAM_JSON) {898 profileCheckpoint('config_initialize_start');899 await config.initialize();900 for (const warning of config.getWarnings()) {901 if (emittedStartupWarnings.has(warning)) continue;902 emittedStartupWarnings.add(warning);903 writeStderrLine(warning);904 }905 profileCheckpoint('config_initialize_end');906 907 // Non-interactive paths feed a prompt to the model immediately after908 // init. Under PR-A's progressive MCP availability,909 // `config.initialize()` returns BEFORE MCP servers settle, so910 // without this wait the first sendMessage would see only built-in911 // tools — a silent regression versus the legacy synchronous912 // behavior. Interactive paths skip this (AppContainer's batch-flush913 // subscriber updates the tool list as MCP servers come online).914 await config.waitForMcpReady();915 // Surface MCP server failures on stderr so non-interactive runs916 // (--prompt / piped stdin / scripts) don't silently regress to917 // built-in-tools-only when a server cannot connect. The legacy918 // synchronous MCP path was visibly noisy on failures because919 // per-server errors logged to stderr during the blocking920 // `discoverAllMcpTools` call; PR-A moves discovery to a921 // background promise whose per-server errors are caught inside922 // `discoverAllMcpToolsIncremental` and never reach a TTY. This923 // helper closes that gap without re-introducing blocking.924 // Defensive against tests that pass a stubbed Config without925 // `getFailedMcpServerNames` — the warning is best-effort visibility926 // and never gates startup.927 const failedMcpServers =928 typeof config.getFailedMcpServerNames === 'function'929 ? config.getFailedMcpServerNames()930 : [];931 if (failedMcpServers.length > 0) {932 writeStderrLine(933 `Warning: MCP server(s) failed to start: ${failedMcpServers.join(', ')}. ` +934 `Continuing with built-in tools and any servers that did connect. ` +935 `Re-run with QWEN_CODE_DEBUG=1 to see per-server reasons.`,936 );937 }938 // Finalize the non-interactive startup profile here so MCP events939 // emitted during initialize() / waitForMcpReady() are captured.940 // Subsequent stdin reads / auth checks / prompt execution are not941 // part of the "first-screen" budget.942 //943 // For stream-json we deliberately do NOT finalize here: the profile944 // is finalized inside Session.ensureConfigInitialized() after MCP945 // settles, so its `config_initialize_*` and MCP events make it into946 // the file. Finalizing here would write an empty profile and the947 // module-level `finalized` guard would suppress every subsequent948 // event.949 finalizeStartupProfile(config.getSessionId());950 }951 952 // Only read stdin if NOT in stream-json mode953 // In stream-json mode, stdin is used for protocol messages (control requests, etc.)954 // and should be consumed by StreamJsonInputReader instead955 if (inputFormat !== InputFormat.STREAM_JSON && !process.stdin.isTTY) {956 const stdinData = await readStdin();957 if (stdinData) {958 input = `${stdinData}\n\n${input}`;959 }960 }961 962 const { validateNonInteractiveAuth } = await import(963 './validateNonInterActiveAuth.js'964 );965 const nonInteractiveConfig = await validateNonInteractiveAuth(966 settings.merged.security?.auth?.useExternal,967 config,968 settings,969 );970 971 const prompt_id = createNonInteractivePromptId(config.getSessionId());972 973 if (inputFormat === InputFormat.STREAM_JSON) {974 const trimmedInput = (input ?? '').trim();975 const { runNonInteractiveStreamJson } = await import(976 './nonInteractive/session.js'977 );978 979 await runNonInteractiveStreamJson(980 nonInteractiveConfig,981 trimmedInput.length > 0 ? trimmedInput : '',982 settings,983 );984 await runExitCleanup();985 // `runNonInteractiveStreamJson` doesn't return an explicit exit986 // code yet, so a cleanup task that mutates `process.exitCode`987 // could clobber a non-zero failure signal. This is currently safe988 // because `--json-schema` is rejected at parse time when combined989 // with `--input-format stream-json` (see the yargs `.check` in990 // resolveCliGenerationConfig), so structured-output failures991 // never reach this branch. If a future stream-json equivalent of992 // structured output is added, plumb the exit code through the993 // function's return value the way `runNonInteractive` below does.994 process.exit(process.exitCode ?? 0);995 }996 997 if (!input) {998 writeStderrLine(999 `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,1000 );1001 process.exit(1);1002 }1003 1004 logUserPrompt(config, {1005 'event.name': 'user_prompt',1006 'event.timestamp': new Date().toISOString(),1007 prompt: input,1008 prompt_id,1009 auth_type: config.getContentGeneratorConfig()?.authType,1010 prompt_length: input.length,1011 });1012 1013 debugLogger.debug(`Session ID: ${config.getSessionId()}`);1014 1015 const { runNonInteractive } = await import('./nonInteractiveCli.js');1016 const exitCode = await runNonInteractive(1017 nonInteractiveConfig,1018 settings,1019 input,1020 prompt_id,1021 );1022 // Call cleanup before process.exit, which causes cleanup to not run.1023 // Capture the exit code BEFORE cleanup so any cleanup task that1024 // mutates process.exitCode can't silently turn a structured-output1025 // failure (or other explicit non-zero return from runNonInteractive)1026 // into a zero exit.1027 await runExitCleanup();1028 process.exit(exitCode);1029 }1030}1031 1032export function createNonInteractivePromptId(sessionId: string): string {1033 return `${sessionId}########0`;1034}1035 1036/**1037 * Watches `.lsp.json` for changes and reconciles running LSP servers1038 * (add / remove / restart) without requiring a session restart.1039 *1040 * Silently no-ops when LSP is disabled or the active client does not1041 * support runtime reinitialization.1042 *1043 * Emits {@link AppEvent.LspStatusChanged} after every successful reload1044 * so the UI can reflect the new server state.1045 */1046export function registerLspHotReload(1047 config: Config,1048 registerCleanup: (fn: () => void | Promise<void>) => void,1049): void {1050 if (1051 config.isLspEnabled?.() !== true ||1052 !config.getLspClient?.()?.reinitialize1053 ) {1054 return;1055 }1056 const lspConfigWatcher = new LspConfigWatcher(config.getProjectRoot());1057 debugLogger.info(1058 `Registering LSP config hot reload watcher for ${config.getProjectRoot()}`,1059 );1060 lspConfigWatcher.startWatching(async (event) => {1061 if (event.changeType === 'invalid') {1062 debugLogger.warn(`Invalid LSP config file ${event.path}: ${event.error}`);1063 appEvents.emit(AppEvent.LogError, event.error);1064 return;1065 }1066 debugLogger.info(1067 `Reloading LSP server settings: changeType=${event.changeType}, path=${event.path}`,1068 );1069 let errorReported = false;1070 try {1071 const result = await config.reinitializeLsp();1072 if (result) {1073 const failedServers = getRuntimeReloadFailedNames(result.reconcile);1074 debugLogger.info(1075 `Reloaded LSP server settings: added=${formatRuntimeReloadNames(1076 result.reconcile.added,1077 )}, removed=${formatRuntimeReloadNames(1078 result.reconcile.removed,1079 )}, restarted=${formatRuntimeReloadNames(1080 result.reconcile.restarted,1081 )}, unchanged=${formatRuntimeReloadNames(1082 result.reconcile.unchanged,1083 )}, failed=${formatRuntimeReloadNames(1084 failedServers,1085 )}, skipped=${formatRuntimeReloadNames(1086 result.skipped.map((server) => server.name),1087 )}`,1088 );1089 if (failedServers.length > 0) {1090 appEvents.emit(AppEvent.LspStatusChanged);1091 const changedServers = [1092 ...result.reconcile.added,1093 ...result.reconcile.removed,1094 ...result.reconcile.restarted,1095 ];1096 const message = `LSP reload partially completed: changed=${formatRuntimeReloadNames(1097 changedServers,1098 )}, failed=${formatRuntimeReloadNames(1099 failedServers,1100 )}. Run with --debug for details.`;1101 appEvents.emit(AppEvent.LogError, message);1102 errorReported = true;1103 throw new Error(message);1104 }1105 } else {1106 debugLogger.info(1107 'Skipped LSP server settings reload because LSP is disabled or no client is available',1108 );1109 }1110 appEvents.emit(AppEvent.LspStatusChanged);1111 } catch (error) {1112 debugLogger.warn('Failed to reload LSP server settings:', error);1113 if (!errorReported) {1114 const message =1115 error instanceof Error1116 ? `Failed to reload LSP server settings: ${error.message}. Some LSP servers may have been partially updated. Run with --debug for details.`1117 : 'Failed to reload LSP server settings; some LSP servers may have been partially updated. Run with --debug for details.';1118 appEvents.emit(AppEvent.LogError, message);1119 }1120 throw error;1121 }1122 });1123 registerCleanup(() => lspConfigWatcher.stopWatching());1124}1125 1126function formatRuntimeReloadNames(names: readonly string[]): string {1127 return names.length === 0 ? '<none>' : names.join(',');1128}1129 1130/**1131 * Reads the optional failed bucket defensively because the CLI may typecheck1132 * against stale core dist declarations during local development.1133 */1134function getRuntimeReloadFailedNames(reconcile: {1135 failed?: readonly string[];1136}): readonly string[] {1137 return reconcile.failed ?? [];1138}1139 