From 0e541af24befc4ad1491207eef633ad5b9cec117 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 4 Apr 2026 22:50:29 +0800 Subject: [PATCH] =?UTF-8?q?style(B1-6):=20=E6=A0=BC=E5=BC=8F=E5=8C=96=20ma?= =?UTF-8?q?in/entrypoints/utils/moreright=20(21=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 --- src/dialogLaunchers.tsx | 213 +- src/entrypoints/cli.tsx | 261 +- src/interactiveHelpers.tsx | 480 +- src/main.tsx | 9783 ++++++++++------- src/moreright/useMoreRight.tsx | 23 +- src/replLauncher.tsx | 46 +- src/utils/autoRunIssue.tsx | 138 +- src/utils/claudeInChrome/toolRendering.tsx | 322 +- src/utils/computerUse/toolRendering.tsx | 163 +- src/utils/computerUse/wrapper.tsx | 433 +- src/utils/exportRenderer.tsx | 139 +- src/utils/highlightMatch.tsx | 34 +- src/utils/plugins/performStartupChecks.tsx | 56 +- src/utils/preflightChecks.tsx | 200 +- .../processUserInput/processBashCommand.tsx | 230 +- .../processUserInput/processSlashCommand.tsx | 1371 ++- src/utils/staticRender.tsx | 121 +- src/utils/status.tsx | 521 +- src/utils/statusNoticeDefinitions.tsx | 258 +- src/utils/swarm/It2SetupPrompt.tsx | 734 +- src/utils/teleport.tsx | 1462 ++- 21 files changed, 10100 insertions(+), 6888 deletions(-) diff --git a/src/dialogLaunchers.tsx b/src/dialogLaunchers.tsx index 0c1befb7c..3d2e01e14 100644 --- a/src/dialogLaunchers.tsx +++ b/src/dialogLaunchers.tsx @@ -6,62 +6,91 @@ * Part of the main.tsx React/JSX extraction effort. See sibling PRs * perf/extract-interactive-helpers and perf/launch-repl. */ -import React from 'react'; -import type { AssistantSession } from './assistant/sessionDiscovery.js'; -import type { StatsStore } from './context/stats.js'; -import type { Root } from './ink.js'; -import { renderAndRun, showSetupDialog } from './interactiveHelpers.js'; -import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'; -import type { AppState } from './state/AppStateStore.js'; -import type { AgentMemoryScope } from './tools/AgentTool/agentMemory.js'; -import type { TeleportRemoteResponse } from './utils/conversationRecovery.js'; -import type { FpsMetrics } from './utils/fpsTracker.js'; -import type { ValidationError } from './utils/settings/validation.js'; +import React from 'react' +import type { AssistantSession } from './assistant/sessionDiscovery.js' +import type { StatsStore } from './context/stats.js' +import type { Root } from './ink.js' +import { renderAndRun, showSetupDialog } from './interactiveHelpers.js' +import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js' +import type { AppState } from './state/AppStateStore.js' +import type { AgentMemoryScope } from './tools/AgentTool/agentMemory.js' +import type { TeleportRemoteResponse } from './utils/conversationRecovery.js' +import type { FpsMetrics } from './utils/fpsTracker.js' +import type { ValidationError } from './utils/settings/validation.js' // Type-only access to ResumeConversation's Props via the module type. // No runtime cost - erased at compile time. -type ResumeConversationProps = React.ComponentProps; +type ResumeConversationProps = React.ComponentProps< + typeof import('./screens/ResumeConversation.js').ResumeConversation +> /** * Site ~3173: SnapshotUpdateDialog (agent memory snapshot update prompt). * Original callback wiring: onComplete={done}, onCancel={() => done('keep')}. */ -export async function launchSnapshotUpdateDialog(root: Root, props: { - agentType: string; - scope: AgentMemoryScope; - snapshotTimestamp: string; -}): Promise<'merge' | 'keep' | 'replace'> { - const { - SnapshotUpdateDialog - } = await import('./components/agents/SnapshotUpdateDialog.js'); - return showSetupDialog<'merge' | 'keep' | 'replace'>(root, done => done('keep')} />); +export async function launchSnapshotUpdateDialog( + root: Root, + props: { + agentType: string + scope: AgentMemoryScope + snapshotTimestamp: string + }, +): Promise<'merge' | 'keep' | 'replace'> { + const { SnapshotUpdateDialog } = await import( + './components/agents/SnapshotUpdateDialog.js' + ) + return showSetupDialog<'merge' | 'keep' | 'replace'>(root, done => ( + done('keep')} + /> + )) } /** * Site ~3250: InvalidSettingsDialog (settings validation errors). * Original callback wiring: onContinue={done}, onExit passed through from caller. */ -export async function launchInvalidSettingsDialog(root: Root, props: { - settingsErrors: ValidationError[]; - onExit: () => void; -}): Promise { - const { - InvalidSettingsDialog - } = await import('./components/InvalidSettingsDialog.js'); - return showSetupDialog(root, done => ); +export async function launchInvalidSettingsDialog( + root: Root, + props: { + settingsErrors: ValidationError[] + onExit: () => void + }, +): Promise { + const { InvalidSettingsDialog } = await import( + './components/InvalidSettingsDialog.js' + ) + return showSetupDialog(root, done => ( + + )) } /** * Site ~4229: AssistantSessionChooser (pick a bridge session to attach to). * Original callback wiring: onSelect={id => done(id)}, onCancel={() => done(null)}. */ -export async function launchAssistantSessionChooser(root: Root, props: { - sessions: AssistantSession[]; -}): Promise { - const { - AssistantSessionChooser - } = await import('./assistant/AssistantSessionChooser.js'); - return showSetupDialog(root, done => done(id)} onCancel={() => done(null)} />); +export async function launchAssistantSessionChooser( + root: Root, + props: { sessions: AssistantSession[] }, +): Promise { + const { AssistantSessionChooser } = await import( + './assistant/AssistantSessionChooser.js' + ) + return showSetupDialog(root, done => ( + done(id)} + onCancel={() => done(null)} + /> + )) } /** @@ -70,43 +99,71 @@ export async function launchAssistantSessionChooser(root: Root, props: { * success, null on cancel. Rejects on install failure so the caller can * distinguish errors from user cancellation. */ -export async function launchAssistantInstallWizard(root: Root): Promise { - const { - NewInstallWizard, - computeDefaultInstallDir - } = await import('./commands/assistant/assistant.js'); - const defaultDir = await computeDefaultInstallDir(); - let rejectWithError: (reason: Error) => void; +export async function launchAssistantInstallWizard( + root: Root, +): Promise { + const { NewInstallWizard, computeDefaultInstallDir } = await import( + './commands/assistant/assistant.js' + ) + const defaultDir = await computeDefaultInstallDir() + let rejectWithError: (reason: Error) => void const errorPromise = new Promise((_, reject) => { - rejectWithError = reject; - }); - const resultPromise = showSetupDialog(root, done => done(dir)} onCancel={() => done(null)} onError={message => rejectWithError(new Error(`Installation failed: ${message}`))} />); - return Promise.race([resultPromise, errorPromise]); + rejectWithError = reject + }) + const resultPromise = showSetupDialog(root, done => ( + done(dir)} + onCancel={() => done(null)} + onError={message => + rejectWithError(new Error(`Installation failed: ${message}`)) + } + /> + )) + return Promise.race([resultPromise, errorPromise]) } /** * Site ~4549: TeleportResumeWrapper (interactive teleport session picker). * Original callback wiring: onComplete={done}, onCancel={() => done(null)}, source="cliArg". */ -export async function launchTeleportResumeWrapper(root: Root): Promise { - const { - TeleportResumeWrapper - } = await import('./components/TeleportResumeWrapper.js'); - return showSetupDialog(root, done => done(null)} source="cliArg" />); +export async function launchTeleportResumeWrapper( + root: Root, +): Promise { + const { TeleportResumeWrapper } = await import( + './components/TeleportResumeWrapper.js' + ) + return showSetupDialog(root, done => ( + done(null)} + source="cliArg" + /> + )) } /** * Site ~4597: TeleportRepoMismatchDialog (pick a local checkout of the target repo). * Original callback wiring: onSelectPath={done}, onCancel={() => done(null)}. */ -export async function launchTeleportRepoMismatchDialog(root: Root, props: { - targetRepo: string; - initialPaths: string[]; -}): Promise { - const { - TeleportRepoMismatchDialog - } = await import('./components/TeleportRepoMismatchDialog.js'); - return showSetupDialog(root, done => done(null)} />); +export async function launchTeleportRepoMismatchDialog( + root: Root, + props: { + targetRepo: string + initialPaths: string[] + }, +): Promise { + const { TeleportRepoMismatchDialog } = await import( + './components/TeleportRepoMismatchDialog.js' + ) + return showSetupDialog(root, done => ( + done(null)} + /> + )) } /** @@ -114,19 +171,31 @@ export async function launchTeleportRepoMismatchDialog(root: Root, props: { * Uses renderAndRun, NOT showSetupDialog. Wraps in . * Preserves original Promise.all parallelism between getWorktreePaths and imports. */ -export async function launchResumeChooser(root: Root, appProps: { - getFpsMetrics: () => FpsMetrics | undefined; - stats: StatsStore; - initialState: AppState; -}, worktreePathsPromise: Promise, resumeProps: Omit): Promise { - const [worktreePaths, { - ResumeConversation - }, { - App - }] = await Promise.all([worktreePathsPromise, import('./screens/ResumeConversation.js'), import('./components/App.js')]); - await renderAndRun(root, +export async function launchResumeChooser( + root: Root, + appProps: { + getFpsMetrics: () => FpsMetrics | undefined + stats: StatsStore + initialState: AppState + }, + worktreePathsPromise: Promise, + resumeProps: Omit, +): Promise { + const [worktreePaths, { ResumeConversation }, { App }] = await Promise.all([ + worktreePathsPromise, + import('./screens/ResumeConversation.js'), + import('./components/App.js'), + ]) + await renderAndRun( + root, + - ); + , + ) } diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index fa377cbc3..a1f173e6c 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -1,17 +1,19 @@ #!/usr/bin/env bun -import { feature } from 'bun:bundle'; +import { feature } from 'bun:bundle' // Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons // eslint-disable-next-line custom-rules/no-top-level-side-effects -process.env.COREPACK_ENABLE_AUTO_PIN = '0'; +process.env.COREPACK_ENABLE_AUTO_PIN = '0' // Set max heap size for child processes in CCR environments (containers have 16GB) // eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level, custom-rules/safe-env-boolean-check if (process.env.CLAUDE_CODE_REMOTE === 'true') { // eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level - const existing = process.env.NODE_OPTIONS || ''; + const existing = process.env.NODE_OPTIONS || '' // eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level - process.env.NODE_OPTIONS = existing ? `${existing} --max-old-space-size=8192` : '--max-old-space-size=8192'; + process.env.NODE_OPTIONS = existing + ? `${existing} --max-old-space-size=8192` + : '--max-old-space-size=8192' } // Harness-science L0 ablation baseline. Inlined here (not init.ts) because @@ -30,7 +32,7 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) { 'CLAUDE_CODE_DISABLE_BACKGROUND_TASKS', ]) { // eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level - process.env[k] ??= '1'; + process.env[k] ??= '1' } } @@ -40,51 +42,64 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) { * Fast-path for --version has zero imports beyond this file. */ async function main(): Promise { - const args = process.argv.slice(2); + const args = process.argv.slice(2) // Fast-path for --version/-v: zero module loading needed - if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) { + if ( + args.length === 1 && + (args[0] === '--version' || args[0] === '-v' || args[0] === '-V') + ) { // MACRO.VERSION is inlined at build time // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${MACRO.VERSION} (Claude Code)`); - return; + console.log(`${MACRO.VERSION} (Claude Code)`) + return } // For all other paths, load the startup profiler - const { profileCheckpoint } = await import('../utils/startupProfiler.js'); - profileCheckpoint('cli_entry'); + const { profileCheckpoint } = await import('../utils/startupProfiler.js') + profileCheckpoint('cli_entry') // Fast-path for --dump-system-prompt: output the rendered system prompt and exit. // Used by prompt sensitivity evals to extract the system prompt at a specific commit. // Ant-only: eliminated from external builds via feature flag. if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') { - profileCheckpoint('cli_dump_system_prompt_path'); - const { enableConfigs } = await import('../utils/config.js'); - enableConfigs(); - const { getMainLoopModel } = await import('../utils/model/model.js'); - const modelIdx = args.indexOf('--model'); - const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel(); - const { getSystemPrompt } = await import('../constants/prompts.js'); - const prompt = await getSystemPrompt([], model); + profileCheckpoint('cli_dump_system_prompt_path') + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + const { getMainLoopModel } = await import('../utils/model/model.js') + const modelIdx = args.indexOf('--model') + const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel() + const { getSystemPrompt } = await import('../constants/prompts.js') + const prompt = await getSystemPrompt([], model) // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(prompt.join('\n')); - return; + console.log(prompt.join('\n')) + return } + if (process.argv[2] === '--claude-in-chrome-mcp') { - profileCheckpoint('cli_claude_in_chrome_mcp_path'); - const { runClaudeInChromeMcpServer } = await import('../utils/claudeInChrome/mcpServer.js'); - await runClaudeInChromeMcpServer(); - return; + profileCheckpoint('cli_claude_in_chrome_mcp_path') + const { runClaudeInChromeMcpServer } = await import( + '../utils/claudeInChrome/mcpServer.js' + ) + await runClaudeInChromeMcpServer() + return } else if (process.argv[2] === '--chrome-native-host') { - profileCheckpoint('cli_chrome_native_host_path'); - const { runChromeNativeHost } = await import('../utils/claudeInChrome/chromeNativeHost.js'); - await runChromeNativeHost(); - return; - } else if (feature('CHICAGO_MCP') && process.argv[2] === '--computer-use-mcp') { - profileCheckpoint('cli_computer_use_mcp_path'); - const { runComputerUseMcpServer } = await import('../utils/computerUse/mcpServer.js'); - await runComputerUseMcpServer(); - return; + profileCheckpoint('cli_chrome_native_host_path') + const { runChromeNativeHost } = await import( + '../utils/claudeInChrome/chromeNativeHost.js' + ) + await runChromeNativeHost() + return + } else if ( + feature('CHICAGO_MCP') && + process.argv[2] === '--computer-use-mcp' + ) { + profileCheckpoint('cli_computer_use_mcp_path') + const { runComputerUseMcpServer } = await import( + '../utils/computerUse/mcpServer.js' + ) + await runComputerUseMcpServer() + return } // Fast-path for `--daemon-worker=` (internal — supervisor spawns this). @@ -93,9 +108,9 @@ async function main(): Promise { // workers are lean. If a worker kind needs configs/auth (assistant will), // it calls them inside its run() fn. if (feature('DAEMON') && args[0] === '--daemon-worker') { - const { runDaemonWorker } = await import('../daemon/workerRegistry.js'); - await runDaemonWorker(args[1]); - return; + const { runDaemonWorker } = await import('../daemon/workerRegistry.js') + await runDaemonWorker(args[1]) + return } // Fast-path for `claude remote-control` (also accepts legacy `claude remote` / `claude sync` / `claude bridge`): @@ -110,51 +125,59 @@ async function main(): Promise { args[0] === 'sync' || args[0] === 'bridge') ) { - profileCheckpoint('cli_bridge_path'); - const { enableConfigs } = await import('../utils/config.js'); - enableConfigs(); - const { getBridgeDisabledReason, checkBridgeMinVersion } = await import('../bridge/bridgeEnabled.js'); - const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js'); - const { bridgeMain } = await import('../bridge/bridgeMain.js'); - const { exitWithError } = await import('../utils/process.js'); + profileCheckpoint('cli_bridge_path') + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + + const { getBridgeDisabledReason, checkBridgeMinVersion } = await import( + '../bridge/bridgeEnabled.js' + ) + const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js') + const { bridgeMain } = await import('../bridge/bridgeMain.js') + const { exitWithError } = await import('../utils/process.js') // Auth check must come before the GrowthBook gate check — without auth, // GrowthBook has no user context and would return a stale/default false. // getBridgeDisabledReason awaits GB init, so the returned value is fresh // (not the stale disk cache), but init still needs auth headers to work. - const { getClaudeAIOAuthTokens } = await import('../utils/auth.js'); + const { getClaudeAIOAuthTokens } = await import('../utils/auth.js') if (!getClaudeAIOAuthTokens()?.accessToken) { - exitWithError(BRIDGE_LOGIN_ERROR); + exitWithError(BRIDGE_LOGIN_ERROR) } - const disabledReason = await getBridgeDisabledReason(); + const disabledReason = await getBridgeDisabledReason() if (disabledReason) { - exitWithError(`Error: ${disabledReason}`); + exitWithError(`Error: ${disabledReason}`) } - const versionError = checkBridgeMinVersion(); + const versionError = checkBridgeMinVersion() if (versionError) { - exitWithError(versionError); + exitWithError(versionError) } // Bridge is a remote control feature - check policy limits - const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import('../services/policyLimits/index.js'); - await waitForPolicyLimitsToLoad(); + const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import( + '../services/policyLimits/index.js' + ) + await waitForPolicyLimitsToLoad() if (!isPolicyAllowed('allow_remote_control')) { - exitWithError("Error: Remote Control is disabled by your organization's policy."); + exitWithError( + "Error: Remote Control is disabled by your organization's policy.", + ) } - await bridgeMain(args.slice(1)); - return; + + await bridgeMain(args.slice(1)) + return } // Fast-path for `claude daemon [subcommand]`: long-running supervisor. if (feature('DAEMON') && args[0] === 'daemon') { - profileCheckpoint('cli_daemon_path'); - const { enableConfigs } = await import('../utils/config.js'); - enableConfigs(); - const { initSinks } = await import('../utils/sinks.js'); - initSinks(); - const { daemonMain } = await import('../daemon/main.js'); - await daemonMain(args.slice(1)); - return; + profileCheckpoint('cli_daemon_path') + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + const { initSinks } = await import('../utils/sinks.js') + initSinks() + const { daemonMain } = await import('../daemon/main.js') + await daemonMain(args.slice(1)) + return } // Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`. @@ -169,103 +192,117 @@ async function main(): Promise { args.includes('--bg') || args.includes('--background')) ) { - profileCheckpoint('cli_bg_path'); - const { enableConfigs } = await import('../utils/config.js'); - enableConfigs(); - const bg = await import('../cli/bg.js'); + profileCheckpoint('cli_bg_path') + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + const bg = await import('../cli/bg.js') switch (args[0]) { case 'ps': - await bg.psHandler(args.slice(1)); - break; + await bg.psHandler(args.slice(1)) + break case 'logs': - await bg.logsHandler(args[1]); - break; + await bg.logsHandler(args[1]) + break case 'attach': - await bg.attachHandler(args[1]); - break; + await bg.attachHandler(args[1]) + break case 'kill': - await bg.killHandler(args[1]); - break; + await bg.killHandler(args[1]) + break default: - await bg.handleBgFlag(args); + await bg.handleBgFlag(args) } - return; + return } // Fast-path for template job commands. - if (feature('TEMPLATES') && (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')) { - profileCheckpoint('cli_templates_path'); - const { templatesMain } = await import('../cli/handlers/templateJobs.js'); - await templatesMain(args); + if ( + feature('TEMPLATES') && + (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply') + ) { + profileCheckpoint('cli_templates_path') + const { templatesMain } = await import('../cli/handlers/templateJobs.js') + await templatesMain(args) // process.exit (not return) — mountFleetView's Ink TUI can leave event // loop handles that prevent natural exit. // eslint-disable-next-line custom-rules/no-process-exit - process.exit(0); + process.exit(0) } // Fast-path for `claude environment-runner`: headless BYOC runner. // feature() must stay inline for build-time dead code elimination. if (feature('BYOC_ENVIRONMENT_RUNNER') && args[0] === 'environment-runner') { - profileCheckpoint('cli_environment_runner_path'); - const { environmentRunnerMain } = await import('../environment-runner/main.js'); - await environmentRunnerMain(args.slice(1)); - return; + profileCheckpoint('cli_environment_runner_path') + const { environmentRunnerMain } = await import( + '../environment-runner/main.js' + ) + await environmentRunnerMain(args.slice(1)) + return } // Fast-path for `claude self-hosted-runner`: headless self-hosted-runner // targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS // heartbeat). feature() must stay inline for build-time dead code elimination. if (feature('SELF_HOSTED_RUNNER') && args[0] === 'self-hosted-runner') { - profileCheckpoint('cli_self_hosted_runner_path'); - const { selfHostedRunnerMain } = await import('../self-hosted-runner/main.js'); - await selfHostedRunnerMain(args.slice(1)); - return; + profileCheckpoint('cli_self_hosted_runner_path') + const { selfHostedRunnerMain } = await import( + '../self-hosted-runner/main.js' + ) + await selfHostedRunnerMain(args.slice(1)) + return } // Fast-path for --worktree --tmux: exec into tmux before loading full CLI - const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic'); + const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic') if ( hasTmuxFlag && - (args.includes('-w') || args.includes('--worktree') || args.some(a => a.startsWith('--worktree='))) + (args.includes('-w') || + args.includes('--worktree') || + args.some(a => a.startsWith('--worktree='))) ) { - profileCheckpoint('cli_tmux_worktree_fast_path'); - const { enableConfigs } = await import('../utils/config.js'); - enableConfigs(); - const { isWorktreeModeEnabled } = await import('../utils/worktreeModeEnabled.js'); + profileCheckpoint('cli_tmux_worktree_fast_path') + const { enableConfigs } = await import('../utils/config.js') + enableConfigs() + const { isWorktreeModeEnabled } = await import( + '../utils/worktreeModeEnabled.js' + ) if (isWorktreeModeEnabled()) { - const { execIntoTmuxWorktree } = await import('../utils/worktree.js'); - const result = await execIntoTmuxWorktree(args); + const { execIntoTmuxWorktree } = await import('../utils/worktree.js') + const result = await execIntoTmuxWorktree(args) if (result.handled) { - return; + return } // If not handled (e.g., error), fall through to normal CLI if (result.error) { - const { exitWithError } = await import('../utils/process.js'); - exitWithError(result.error); + const { exitWithError } = await import('../utils/process.js') + exitWithError(result.error) } } } // Redirect common update flag mistakes to the update subcommand - if (args.length === 1 && (args[0] === '--update' || args[0] === '--upgrade')) { - process.argv = [process.argv[0]!, process.argv[1]!, 'update']; + if ( + args.length === 1 && + (args[0] === '--update' || args[0] === '--upgrade') + ) { + process.argv = [process.argv[0]!, process.argv[1]!, 'update'] } // --bare: set SIMPLE early so gates fire during module eval / commander // option building (not just inside the action handler). if (args.includes('--bare')) { - process.env.CLAUDE_CODE_SIMPLE = '1'; + process.env.CLAUDE_CODE_SIMPLE = '1' } // No special flags detected, load and run the full CLI - const { startCapturingEarlyInput } = await import('../utils/earlyInput.js'); - startCapturingEarlyInput(); - profileCheckpoint('cli_before_main_import'); - const { main: cliMain } = await import('../main.jsx'); - profileCheckpoint('cli_after_main_import'); - await cliMain(); - profileCheckpoint('cli_after_main_complete'); + const { startCapturingEarlyInput } = await import('../utils/earlyInput.js') + startCapturingEarlyInput() + profileCheckpoint('cli_before_main_import') + const { main: cliMain } = await import('../main.jsx') + profileCheckpoint('cli_after_main_import') + await cliMain() + profileCheckpoint('cli_after_main_complete') } // eslint-disable-next-line custom-rules/no-top-level-side-effects -void main(); +void main() diff --git a/src/interactiveHelpers.tsx b/src/interactiveHelpers.tsx index 384ca917a..72bf5e7be 100644 --- a/src/interactiveHelpers.tsx +++ b/src/interactiveHelpers.tsx @@ -1,46 +1,76 @@ -import { feature } from 'bun:bundle'; -import { appendFileSync } from 'fs'; -import React from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; -import { type ChannelEntry, getAllowedChannels, setAllowedChannels, setHasDevChannels, setSessionTrustAccepted, setStatsStore } from './bootstrap/state.js'; -import type { Command } from './commands.js'; -import { createStatsStore, type StatsStore } from './context/stats.js'; -import { getSystemContext } from './context.js'; -import { initializeTelemetryAfterTrust } from './entrypoints/init.js'; -import { isSynchronizedOutputSupported } from './ink/terminal.js'; -import type { RenderOptions, Root, TextProps } from './ink.js'; -import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'; -import { startDeferredPrefetches } from './main.js'; -import { checkGate_CACHED_OR_BLOCKING, initializeGrowthBook, resetGrowthBook } from './services/analytics/growthbook.js'; -import { isQualifiedForGrove } from './services/api/grove.js'; -import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js'; -import { AppStateProvider } from './state/AppState.js'; -import { onChangeAppState } from './state/onChangeAppState.js'; -import { normalizeApiKeyForConfig } from './utils/authPortable.js'; -import { getExternalClaudeMdIncludes, getMemoryFiles, shouldShowClaudeMdExternalIncludesWarning } from './utils/claudemd.js'; -import { checkHasTrustDialogAccepted, getCustomApiKeyStatus, getGlobalConfig, saveGlobalConfig } from './utils/config.js'; -import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js'; -import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js'; -import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js'; -import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js'; -import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'; -import type { PermissionMode } from './utils/permissions/PermissionMode.js'; -import { getBaseRenderOptions } from './utils/renderOptions.js'; -import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'; -import { hasAutoModeOptIn, hasSkipDangerousModePermissionPrompt } from './utils/settings/settings.js'; +import { feature } from 'bun:bundle' +import { appendFileSync } from 'fs' +import React from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { + gracefulShutdown, + gracefulShutdownSync, +} from 'src/utils/gracefulShutdown.js' +import { + type ChannelEntry, + getAllowedChannels, + setAllowedChannels, + setHasDevChannels, + setSessionTrustAccepted, + setStatsStore, +} from './bootstrap/state.js' +import type { Command } from './commands.js' +import { createStatsStore, type StatsStore } from './context/stats.js' +import { getSystemContext } from './context.js' +import { initializeTelemetryAfterTrust } from './entrypoints/init.js' +import { isSynchronizedOutputSupported } from './ink/terminal.js' +import type { RenderOptions, Root, TextProps } from './ink.js' +import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js' +import { startDeferredPrefetches } from './main.js' +import { + checkGate_CACHED_OR_BLOCKING, + initializeGrowthBook, + resetGrowthBook, +} from './services/analytics/growthbook.js' +import { isQualifiedForGrove } from './services/api/grove.js' +import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js' +import { AppStateProvider } from './state/AppState.js' +import { onChangeAppState } from './state/onChangeAppState.js' +import { normalizeApiKeyForConfig } from './utils/authPortable.js' +import { + getExternalClaudeMdIncludes, + getMemoryFiles, + shouldShowClaudeMdExternalIncludesWarning, +} from './utils/claudemd.js' +import { + checkHasTrustDialogAccepted, + getCustomApiKeyStatus, + getGlobalConfig, + saveGlobalConfig, +} from './utils/config.js' +import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js' +import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js' +import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js' +import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js' +import { applyConfigEnvironmentVariables } from './utils/managedEnv.js' +import type { PermissionMode } from './utils/permissions/PermissionMode.js' +import { getBaseRenderOptions } from './utils/renderOptions.js' +import { getSettingsWithAllErrors } from './utils/settings/allErrors.js' +import { + hasAutoModeOptIn, + hasSkipDangerousModePermissionPrompt, +} from './utils/settings/settings.js' + export function completeOnboarding(): void { saveGlobalConfig(current => ({ ...current, hasCompletedOnboarding: true, - lastOnboardingVersion: MACRO.VERSION - })); + lastOnboardingVersion: MACRO.VERSION, + })) } -export function showDialog(root: Root, renderer: (done: (result: T) => void) => React.ReactNode): Promise { +export function showDialog( + root: Root, + renderer: (done: (result: T) => void) => React.ReactNode, +): Promise { return new Promise(resolve => { - const done = (result: T): void => void resolve(result); - root.render(renderer(done)); - }); + const done = (result: T): void => void resolve(result) + root.render(renderer(done)) + }) } /** @@ -49,11 +79,12 @@ export function showDialog(root: Root, renderer: (done: (result: T) => * console.error is swallowed by Ink's patchConsole, so we render * through the React tree instead. */ -export async function exitWithError(root: Root, message: string, beforeExit?: () => Promise): Promise { - return exitWithMessage(root, message, { - color: 'error', - beforeExit - }); +export async function exitWithError( + root: Root, + message: string, + beforeExit?: () => Promise, +): Promise { + return exitWithMessage(root, message, { color: 'error', beforeExit }) } /** @@ -62,64 +93,93 @@ export async function exitWithError(root: Root, message: string, beforeExit?: () * console output is swallowed by Ink's patchConsole, so we render * through the React tree instead. */ -export async function exitWithMessage(root: Root, message: string, options?: { - color?: TextProps['color']; - exitCode?: number; - beforeExit?: () => Promise; -}): Promise { - const { - Text - } = await import('./ink.js'); - const color = options?.color; - const exitCode = options?.exitCode ?? 1; - root.render(color ? {message} : {message}); - root.unmount(); - await options?.beforeExit?.(); +export async function exitWithMessage( + root: Root, + message: string, + options?: { + color?: TextProps['color'] + exitCode?: number + beforeExit?: () => Promise + }, +): Promise { + const { Text } = await import('./ink.js') + const color = options?.color + const exitCode = options?.exitCode ?? 1 + root.render( + color ? {message} : {message}, + ) + root.unmount() + await options?.beforeExit?.() // eslint-disable-next-line custom-rules/no-process-exit -- exit after Ink unmount - process.exit(exitCode); + process.exit(exitCode) } /** * Show a setup dialog wrapped in AppStateProvider + KeybindingSetup. * Reduces boilerplate in showSetupScreens() where every dialog needs these wrappers. */ -export function showSetupDialog(root: Root, renderer: (done: (result: T) => void) => React.ReactNode, options?: { - onChangeAppState?: typeof onChangeAppState; -}): Promise { - return showDialog(root, done => +export function showSetupDialog( + root: Root, + renderer: (done: (result: T) => void) => React.ReactNode, + options?: { onChangeAppState?: typeof onChangeAppState }, +): Promise { + return showDialog(root, done => ( + {renderer(done)} - ); + + )) } /** * Render the main UI into the root and wait for it to exit. * Handles the common epilogue: start deferred prefetches, wait for exit, graceful shutdown. */ -export async function renderAndRun(root: Root, element: React.ReactNode): Promise { - root.render(element); - startDeferredPrefetches(); - await root.waitUntilExit(); - await gracefulShutdown(0); +export async function renderAndRun( + root: Root, + element: React.ReactNode, +): Promise { + root.render(element) + startDeferredPrefetches() + await root.waitUntilExit() + await gracefulShutdown(0) } -export async function showSetupScreens(root: Root, permissionMode: PermissionMode, allowDangerouslySkipPermissions: boolean, commands?: Command[], claudeInChrome?: boolean, devChannels?: ChannelEntry[]): Promise { - if (("production" as string) === 'test' || isEnvTruthy(false) || process.env.IS_DEMO // Skip onboarding in demo mode + +export async function showSetupScreens( + root: Root, + permissionMode: PermissionMode, + allowDangerouslySkipPermissions: boolean, + commands?: Command[], + claudeInChrome?: boolean, + devChannels?: ChannelEntry[], +): Promise { + if ( + "production" === 'test' || + isEnvTruthy(false) || + process.env.IS_DEMO // Skip onboarding in demo mode ) { - return false; + return false } - const config = getGlobalConfig(); - let onboardingShown = false; - if (!config.theme || !config.hasCompletedOnboarding // always show onboarding at least once + + const config = getGlobalConfig() + let onboardingShown = false + if ( + !config.theme || + !config.hasCompletedOnboarding // always show onboarding at least once ) { - onboardingShown = true; - const { - Onboarding - } = await import('./components/Onboarding.js'); - await showSetupDialog(root, done => { - completeOnboarding(); - void done(); - }} />, { - onChangeAppState - }); + onboardingShown = true + const { Onboarding } = await import('./components/Onboarding.js') + await showSetupDialog( + root, + done => ( + { + completeOnboarding() + void done() + }} + /> + ), + { onChangeAppState }, + ) } // Always show the trust dialog in interactive sessions, regardless of permission mode. @@ -133,70 +193,83 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod // If it returns true, the TrustDialog would auto-resolve regardless of // security features, so we can skip the dynamic import and render cycle. if (!checkHasTrustDialogAccepted()) { - const { - TrustDialog - } = await import('./components/TrustDialog/TrustDialog.js'); - await showSetupDialog(root, done => ); + const { TrustDialog } = await import( + './components/TrustDialog/TrustDialog.js' + ) + await showSetupDialog(root, done => ( + + )) } // Signal that trust has been verified for this session. // GrowthBook checks this to decide whether to include auth headers. - setSessionTrustAccepted(true); + setSessionTrustAccepted(true) // Reset and reinitialize GrowthBook after trust is established. // Defense for login/logout: clears any prior client so the next init // picks up fresh auth headers. - resetGrowthBook(); - void initializeGrowthBook(); + resetGrowthBook() + void initializeGrowthBook() // Now that trust is established, prefetch system context if it wasn't already - void getSystemContext(); + void getSystemContext() // If settings are valid, check for any mcp.json servers that need approval - const { - errors: allErrors - } = getSettingsWithAllErrors(); + const { errors: allErrors } = getSettingsWithAllErrors() if (allErrors.length === 0) { - await handleMcpjsonServerApprovals(root); + await handleMcpjsonServerApprovals(root) } // Check for claude.md includes that need approval if (await shouldShowClaudeMdExternalIncludesWarning()) { - const externalIncludes = getExternalClaudeMdIncludes(await getMemoryFiles(true)); - const { - ClaudeMdExternalIncludesDialog - } = await import('./components/ClaudeMdExternalIncludesDialog.js'); - await showSetupDialog(root, done => ); + const externalIncludes = getExternalClaudeMdIncludes( + await getMemoryFiles(true), + ) + const { ClaudeMdExternalIncludesDialog } = await import( + './components/ClaudeMdExternalIncludesDialog.js' + ) + await showSetupDialog(root, done => ( + + )) } } // Track current repo path for teleport directory switching (fire-and-forget) // This must happen AFTER trust to prevent untrusted directories from poisoning the mapping - void updateGithubRepoPathMapping(); + void updateGithubRepoPathMapping() if (feature('LODESTONE')) { - updateDeepLinkTerminalPreference(); + updateDeepLinkTerminalPreference() } // Apply full environment variables after trust dialog is accepted OR in bypass mode // In bypass mode (CI/CD, automation), we trust the environment so apply all variables // In normal mode, this happens after the trust dialog is accepted // This includes potentially dangerous environment variables from untrusted sources - applyConfigEnvironmentVariables(); + applyConfigEnvironmentVariables() // Initialize telemetry after env vars are applied so OTEL endpoint env vars and // otelHeadersHelper (which requires trust to execute) are available. // Defer to next tick so the OTel dynamic import resolves after first render // instead of during the pre-render microtask queue. - setImmediate(() => initializeTelemetryAfterTrust()); + setImmediate(() => initializeTelemetryAfterTrust()) + if (await isQualifiedForGrove()) { - const { - GroveDialog - } = await import('src/components/grove/Grove.js'); - const decision = await showSetupDialog(root, done => ); + const { GroveDialog } = await import('src/components/grove/Grove.js') + const decision = await showSetupDialog(root, done => ( + + )) if (decision === 'escape') { - logEvent('tengu_grove_policy_exited', {}); - gracefulShutdownSync(0); - return false; + logEvent('tengu_grove_policy_exited', {}) + gracefulShutdownSync(0) + return false } } @@ -204,33 +277,54 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child // processes but ignored by Claude Code itself (see auth.ts). if (process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()) { - const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); - const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated); + const customApiKeyTruncated = normalizeApiKeyForConfig( + process.env.ANTHROPIC_API_KEY, + ) + const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated) if (keyStatus === 'new') { - const { - ApproveApiKey - } = await import('./components/ApproveApiKey.js'); - await showSetupDialog(root, done => , { - onChangeAppState - }); + const { ApproveApiKey } = await import('./components/ApproveApiKey.js') + await showSetupDialog( + root, + done => ( + + ), + { onChangeAppState }, + ) } } - if ((permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions) && !hasSkipDangerousModePermissionPrompt()) { - const { - BypassPermissionsModeDialog - } = await import('./components/BypassPermissionsModeDialog.js'); - await showSetupDialog(root, done => ); + + if ( + (permissionMode === 'bypassPermissions' || + allowDangerouslySkipPermissions) && + !hasSkipDangerousModePermissionPrompt() + ) { + const { BypassPermissionsModeDialog } = await import( + './components/BypassPermissionsModeDialog.js' + ) + await showSetupDialog(root, done => ( + + )) } + if (feature('TRANSCRIPT_CLASSIFIER')) { // Only show the opt-in dialog if auto mode actually resolved — if the // gate denied it (org not allowlisted, settings disabled), showing // consent for an unavailable feature is pointless. The // verifyAutoModeGateAccess notification will explain why instead. if (permissionMode === 'auto' && !hasAutoModeOptIn()) { - const { - AutoModeOptInDialog - } = await import('./components/AutoModeOptInDialog.js'); - await showSetupDialog(root, done => gracefulShutdownSync(1)} declineExits />); + const { AutoModeOptInDialog } = await import( + './components/AutoModeOptInDialog.js' + ) + await showSetupDialog(root, done => ( + gracefulShutdownSync(1)} + declineExits + /> + )) } } @@ -248,14 +342,15 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod // initializeGrowthBook promise fired earlier). Also warms the // isChannelsEnabled() check in the dev-channels dialog below. if (getAllowedChannels().length > 0 || (devChannels?.length ?? 0) > 0) { - await checkGate_CACHED_OR_BLOCKING('tengu_harbor'); + await checkGate_CACHED_OR_BLOCKING('tengu_harbor') } + if (devChannels && devChannels.length > 0) { - const [{ - isChannelsEnabled - }, { - getClaudeAIOAuthTokens - }] = await Promise.all([import('./services/mcp/channelAllowlist.js'), import('./utils/auth.js')]); + const [{ isChannelsEnabled }, { getClaudeAIOAuthTokens }] = + await Promise.all([ + import('./services/mcp/channelAllowlist.js'), + import('./utils/auth.js'), + ]) // Skip the dialog when channels are blocked (tengu_harbor off or no // OAuth) — accepting then immediately seeing "not available" in // ChannelsNotice is worse than no dialog. Append entries anyway so @@ -264,102 +359,115 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod // (hasNonDev check); the allowlist bypass it also grants is moot // since the gate blocks upstream. if (!isChannelsEnabled() || !getClaudeAIOAuthTokens()?.accessToken) { - setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({ - ...c, - dev: true - }))]); - setHasDevChannels(true); + setAllowedChannels([ + ...getAllowedChannels(), + ...devChannels.map(c => ({ ...c, dev: true })), + ]) + setHasDevChannels(true) } else { - const { - DevChannelsDialog - } = await import('./components/DevChannelsDialog.js'); - await showSetupDialog(root, done => { - // Mark dev entries per-entry so the allowlist bypass doesn't leak - // to --channels entries when both flags are passed. - setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({ - ...c, - dev: true - }))]); - setHasDevChannels(true); - void done(); - }} />); + const { DevChannelsDialog } = await import( + './components/DevChannelsDialog.js' + ) + await showSetupDialog(root, done => ( + { + // Mark dev entries per-entry so the allowlist bypass doesn't leak + // to --channels entries when both flags are passed. + setAllowedChannels([ + ...getAllowedChannels(), + ...devChannels.map(c => ({ ...c, dev: true })), + ]) + setHasDevChannels(true) + void done() + }} + /> + )) } } } // Show Chrome onboarding for first-time Claude in Chrome users - if (claudeInChrome && !getGlobalConfig().hasCompletedClaudeInChromeOnboarding) { - const { - ClaudeInChromeOnboarding - } = await import('./components/ClaudeInChromeOnboarding.js'); - await showSetupDialog(root, done => ); + if ( + claudeInChrome && + !getGlobalConfig().hasCompletedClaudeInChromeOnboarding + ) { + const { ClaudeInChromeOnboarding } = await import( + './components/ClaudeInChromeOnboarding.js' + ) + await showSetupDialog(root, done => ( + + )) } - return onboardingShown; + + return onboardingShown } + export function getRenderContext(exitOnCtrlC: boolean): { - renderOptions: RenderOptions; - getFpsMetrics: () => FpsMetrics | undefined; - stats: StatsStore; + renderOptions: RenderOptions + getFpsMetrics: () => FpsMetrics | undefined + stats: StatsStore } { - let lastFlickerTime = 0; - const baseOptions = getBaseRenderOptions(exitOnCtrlC); + let lastFlickerTime = 0 + const baseOptions = getBaseRenderOptions(exitOnCtrlC) // Log analytics event when stdin override is active if (baseOptions.stdin) { - logEvent('tengu_stdin_interactive', {}); + logEvent('tengu_stdin_interactive', {}) } - const fpsTracker = new FpsTracker(); - const stats = createStatsStore(); - setStatsStore(stats); + + const fpsTracker = new FpsTracker() + const stats = createStatsStore() + setStatsStore(stats) // Bench mode: when set, append per-frame phase timings as JSONL for // offline analysis by bench/repl-scroll.ts. Captures the full TUI // render pipeline (yoga → screen buffer → diff → optimize → stdout) // so perf work on any phase can be validated against real user flows. - const frameTimingLogPath = process.env.CLAUDE_CODE_FRAME_TIMING_LOG; + const frameTimingLogPath = process.env.CLAUDE_CODE_FRAME_TIMING_LOG return { getFpsMetrics: () => fpsTracker.getMetrics(), stats, renderOptions: { ...baseOptions, onFrame: event => { - fpsTracker.record(event.durationMs); - stats.observe('frame_duration_ms', event.durationMs); + fpsTracker.record(event.durationMs) + stats.observe('frame_duration_ms', event.durationMs) if (frameTimingLogPath && event.phases) { // Bench-only env-var-gated path: sync write so no frames dropped // on abrupt exit. ~100 bytes at ≤60fps is negligible. rss/cpu are // single syscalls; cpu is cumulative — bench side computes delta. const line = - // eslint-disable-next-line custom-rules/no-direct-json-operations -- tiny object, hot bench path - JSON.stringify({ - total: event.durationMs, - ...event.phases, - rss: process.memoryUsage.rss(), - cpu: process.cpuUsage() - }) + '\n'; + // eslint-disable-next-line custom-rules/no-direct-json-operations -- tiny object, hot bench path + JSON.stringify({ + total: event.durationMs, + ...event.phases, + rss: process.memoryUsage.rss(), + cpu: process.cpuUsage(), + }) + '\n' // eslint-disable-next-line custom-rules/no-sync-fs -- bench-only, sync so no frames dropped on exit - appendFileSync(frameTimingLogPath, line); + appendFileSync(frameTimingLogPath, line) } // Skip flicker reporting for terminals with synchronized output — // DEC 2026 buffers between BSU/ESU so clear+redraw is atomic. if (isSynchronizedOutputSupported()) { - return; + return } for (const flicker of event.flickers) { if (flicker.reason === 'resize') { - continue; + continue } - const now = Date.now(); + const now = Date.now() if (now - lastFlickerTime < 1000) { logEvent('tengu_flicker', { desiredHeight: flicker.desiredHeight, actualHeight: flicker.availableHeight, - reason: flicker.reason - } as unknown as Record); + reason: flicker.reason, + } as unknown as Record) } - lastFlickerTime = now; + lastFlickerTime = now } - } - } - }; + }, + }, + } } diff --git a/src/main.tsx b/src/main.tsx index 56eb77b26..9fd25e385 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,207 +6,449 @@ // key) in parallel — isRemoteManagedSettingsEligible() otherwise reads them // sequentially via sync spawn inside applySafeConfigEnvironmentVariables() // (~65ms on every macOS startup) -import { profileCheckpoint, profileReport } from './utils/startupProfiler.js'; +import { profileCheckpoint, profileReport } from './utils/startupProfiler.js' // eslint-disable-next-line custom-rules/no-top-level-side-effects -profileCheckpoint('main_tsx_entry'); -import { startMdmRawRead } from './utils/settings/mdm/rawRead.js'; +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'; +startMdmRawRead() + +import { + ensureKeychainPrefetchCompleted, + startKeychainPrefetch, +} from './utils/secureStorage/keychainPrefetch.js' // eslint-disable-next-line custom-rules/no-top-level-side-effects -startKeychainPrefetch(); -import { feature } from 'bun:bundle'; -import { Command as CommanderCommand, InvalidArgumentError, Option } from '@commander-js/extra-typings'; -import chalk from 'chalk'; -import { readFileSync } from 'fs'; -import mapValues from 'lodash-es/mapValues.js'; -import pickBy from 'lodash-es/pickBy.js'; -import uniqBy from 'lodash-es/uniqBy.js'; -import React from 'react'; -import { getOauthConfig } from './constants/oauth.js'; -import { getRemoteSessionUrl } from './constants/product.js'; -import { getSystemContext, getUserContext } from './context.js'; -import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js'; -import { addToHistory } from './history.js'; -import type { Root } from './ink.js'; -import { launchRepl } from './replLauncher.js'; -import { hasGrowthBookEnvOverride, initializeGrowthBook, refreshGrowthBookAfterAuthChange } from './services/analytics/growthbook.js'; -import { fetchBootstrapData } from './services/api/bootstrap.js'; -import { type DownloadResult, downloadSessionFiles, type FilesApiConfig, parseFileSpecs } from './services/api/filesApi.js'; -import { prefetchPassesEligibility } from './services/api/referral.js'; -import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js'; -import type { McpSdkServerConfig, McpServerConfig, ScopedMcpServerConfig } from './services/mcp/types.js'; -import { isPolicyAllowed, loadPolicyLimits, refreshPolicyLimits, waitForPolicyLimitsToLoad } from './services/policyLimits/index.js'; -import { loadRemoteManagedSettings, refreshRemoteManagedSettings } from './services/remoteManagedSettings/index.js'; -import type { ToolInputJSONSchema } from './Tool.js'; -import { createSyntheticOutputTool, isSyntheticOutputToolEnabled } from './tools/SyntheticOutputTool/SyntheticOutputTool.js'; -import { getTools } from './tools.js'; -import { canUserConfigureAdvisor, getInitialAdvisorSetting, isAdvisorEnabled, isValidAdvisorModel, modelSupportsAdvisor } from './utils/advisor.js'; -import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js'; -import { count, uniq } from './utils/array.js'; -import { installAsciicastRecorder } from './utils/asciicast.js'; -import { getSubscriptionType, isClaudeAISubscriber, prefetchAwsCredentialsAndBedRockInfoIfSafe, prefetchGcpCredentialsIfSafe, validateForceLoginOrg } from './utils/auth.js'; -import { checkHasTrustDialogAccepted, getGlobalConfig, getRemoteControlAtStartup, isAutoUpdaterDisabled, saveGlobalConfig } from './utils/config.js'; -import { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js'; -import { getInitialEffortSetting, parseEffortValue } from './utils/effort.js'; -import { getInitialFastModeSetting, isFastModeEnabled, prefetchFastModeStatus, resolveFastModeStatusFromCache } from './utils/fastMode.js'; -import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'; -import { createSystemMessage, createUserMessage } from './utils/messages.js'; -import { getPlatform } from './utils/platform.js'; -import { getBaseRenderOptions } from './utils/renderOptions.js'; -import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js'; -import { settingsChangeDetector } from './utils/settings/changeDetector.js'; -import { skillChangeDetector } from './utils/skills/skillChangeDetector.js'; -import { jsonParse, writeFileSync_DEPRECATED } from './utils/slowOperations.js'; -import { computeInitialTeamContext } from './utils/swarm/reconnection.js'; -import { initializeWarningHandler } from './utils/warningHandler.js'; -import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.js'; +startKeychainPrefetch() + +import { feature } from 'bun:bundle' +import { + Command as CommanderCommand, + InvalidArgumentError, + Option, +} from '@commander-js/extra-typings' +import chalk from 'chalk' +import { readFileSync } from 'fs' +import mapValues from 'lodash-es/mapValues.js' +import pickBy from 'lodash-es/pickBy.js' +import uniqBy from 'lodash-es/uniqBy.js' +import React from 'react' +import { getOauthConfig } from './constants/oauth.js' +import { getRemoteSessionUrl } from './constants/product.js' +import { getSystemContext, getUserContext } from './context.js' +import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js' +import { addToHistory } from './history.js' +import type { Root } from './ink.js' +import { launchRepl } from './replLauncher.js' +import { + hasGrowthBookEnvOverride, + initializeGrowthBook, + refreshGrowthBookAfterAuthChange, +} from './services/analytics/growthbook.js' +import { fetchBootstrapData } from './services/api/bootstrap.js' +import { + type DownloadResult, + downloadSessionFiles, + type FilesApiConfig, + parseFileSpecs, +} from './services/api/filesApi.js' +import { prefetchPassesEligibility } from './services/api/referral.js' +import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js' +import type { + McpSdkServerConfig, + McpServerConfig, + ScopedMcpServerConfig, +} from './services/mcp/types.js' +import { + isPolicyAllowed, + loadPolicyLimits, + refreshPolicyLimits, + waitForPolicyLimitsToLoad, +} from './services/policyLimits/index.js' +import { + loadRemoteManagedSettings, + refreshRemoteManagedSettings, +} from './services/remoteManagedSettings/index.js' +import type { ToolInputJSONSchema } from './Tool.js' +import { + createSyntheticOutputTool, + isSyntheticOutputToolEnabled, +} from './tools/SyntheticOutputTool/SyntheticOutputTool.js' +import { getTools } from './tools.js' +import { + canUserConfigureAdvisor, + getInitialAdvisorSetting, + isAdvisorEnabled, + isValidAdvisorModel, + modelSupportsAdvisor, +} from './utils/advisor.js' +import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js' +import { count, uniq } from './utils/array.js' +import { installAsciicastRecorder } from './utils/asciicast.js' +import { + getSubscriptionType, + isClaudeAISubscriber, + prefetchAwsCredentialsAndBedRockInfoIfSafe, + prefetchGcpCredentialsIfSafe, + validateForceLoginOrg, +} from './utils/auth.js' +import { + checkHasTrustDialogAccepted, + getGlobalConfig, + getRemoteControlAtStartup, + isAutoUpdaterDisabled, + saveGlobalConfig, +} from './utils/config.js' +import { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js' +import { getInitialEffortSetting, parseEffortValue } from './utils/effort.js' +import { + getInitialFastModeSetting, + isFastModeEnabled, + prefetchFastModeStatus, + resolveFastModeStatusFromCache, +} from './utils/fastMode.js' +import { applyConfigEnvironmentVariables } from './utils/managedEnv.js' +import { createSystemMessage, createUserMessage } from './utils/messages.js' +import { getPlatform } from './utils/platform.js' +import { getBaseRenderOptions } from './utils/renderOptions.js' +import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js' +import { settingsChangeDetector } from './utils/settings/changeDetector.js' +import { skillChangeDetector } from './utils/skills/skillChangeDetector.js' +import { jsonParse, writeFileSync_DEPRECATED } from './utils/slowOperations.js' +import { computeInitialTeamContext } from './utils/swarm/reconnection.js' +import { initializeWarningHandler } from './utils/warningHandler.js' +import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.js' // Lazy require to avoid circular dependency: teammate.ts -> AppState.tsx -> ... -> main.tsx /* eslint-disable @typescript-eslint/no-require-imports */ -const getTeammateUtils = () => require('./utils/teammate.js') as typeof import('./utils/teammate.js'); -const getTeammatePromptAddendum = () => require('./utils/swarm/teammatePromptAddendum.js') as typeof import('./utils/swarm/teammatePromptAddendum.js'); -const getTeammateModeSnapshot = () => require('./utils/swarm/backends/teammateModeSnapshot.js') as typeof import('./utils/swarm/backends/teammateModeSnapshot.js'); +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; +const coordinatorModeModule = feature('COORDINATOR_MODE') + ? (require('./coordinator/coordinatorMode.js') as typeof import('./coordinator/coordinatorMode.js')) + : null /* eslint-enable @typescript-eslint/no-require-imports */ // Dead code elimination: conditional import for KAIROS (assistant mode) /* eslint-disable @typescript-eslint/no-require-imports */ -const assistantModule = feature('KAIROS') ? require('./assistant/index.js') as typeof import('./assistant/index.js') : null; -const kairosGate = feature('KAIROS') ? require('./assistant/gate.js') as typeof import('./assistant/gate.js') : null; -import { relative, resolve } from 'path'; -import { isAnalyticsDisabled } from 'src/services/analytics/config.js'; -import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import { initializeAnalyticsGates } from 'src/services/analytics/sink.js'; -import { getOriginalCwd, setAdditionalDirectoriesForClaudeMd, setIsRemoteMode, setMainLoopModelOverride, setMainThreadAgentType, setTeleportedSessionInfo } from './bootstrap/state.js'; -import { filterCommandsForRemoteMode, getCommands } from './commands.js'; -import type { StatsStore } from './context/stats.js'; -import { launchAssistantInstallWizard, launchAssistantSessionChooser, launchInvalidSettingsDialog, launchResumeChooser, launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper } from './dialogLaunchers.js'; -import { SHOW_CURSOR } from './ink/termio/dec.js'; -import { exitWithError, exitWithMessage, getRenderContext, renderAndRun, showSetupScreens } from './interactiveHelpers.js'; -import { initBuiltinPlugins } from './plugins/bundled/index.js'; +const assistantModule = feature('KAIROS') + ? (require('./assistant/index.js') as typeof import('./assistant/index.js')) + : null +const kairosGate = feature('KAIROS') + ? (require('./assistant/gate.js') as typeof import('./assistant/gate.js')) + : null + +import { relative, resolve } from 'path' +import { isAnalyticsDisabled } from 'src/services/analytics/config.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { initializeAnalyticsGates } from 'src/services/analytics/sink.js' +import { + getOriginalCwd, + setAdditionalDirectoriesForClaudeMd, + setIsRemoteMode, + setMainLoopModelOverride, + setMainThreadAgentType, + setTeleportedSessionInfo, +} from './bootstrap/state.js' +import { filterCommandsForRemoteMode, getCommands } from './commands.js' +import type { StatsStore } from './context/stats.js' +import { + launchAssistantInstallWizard, + launchAssistantSessionChooser, + launchInvalidSettingsDialog, + launchResumeChooser, + launchSnapshotUpdateDialog, + launchTeleportRepoMismatchDialog, + launchTeleportResumeWrapper, +} from './dialogLaunchers.js' +import { SHOW_CURSOR } from './ink/termio/dec.js' +import { + exitWithError, + exitWithMessage, + getRenderContext, + renderAndRun, + showSetupScreens, +} from './interactiveHelpers.js' +import { initBuiltinPlugins } from './plugins/bundled/index.js' /* eslint-enable @typescript-eslint/no-require-imports */ -import { checkQuotaStatus } from './services/claudeAiLimits.js'; -import { getMcpToolsCommandsAndResources, prefetchAllMcpResources } from './services/mcp/client.js'; -import { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } from './services/plugins/pluginCliCommands.js'; -import { initBundledSkills } from './skills/bundled/index.js'; -import type { AgentColorName } from './tools/AgentTool/agentColorManager.js'; -import { getActiveAgentsFromList, getAgentDefinitionsWithOverrides, isBuiltInAgent, isCustomAgent, parseAgentsFromJson } from './tools/AgentTool/loadAgentsDir.js'; -import type { LogOption } from './types/logs.js'; -import type { Message as MessageType } from './types/message.js'; -import { assertMinVersion } from './utils/autoUpdater.js'; -import { CLAUDE_IN_CHROME_SKILL_HINT, CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER } from './utils/claudeInChrome/prompt.js'; -import { setupClaudeInChrome, shouldAutoEnableClaudeInChrome, shouldEnableClaudeInChrome } from './utils/claudeInChrome/setup.js'; -import { getContextWindowForModel } from './utils/context.js'; -import { loadConversationForResume } from './utils/conversationRecovery.js'; -import { buildDeepLinkBanner } from './utils/deepLink/banner.js'; -import { hasNodeOption, isBareMode, isEnvTruthy, isInProtectedNamespace } from './utils/envUtils.js'; -import { refreshExampleCommands } from './utils/exampleCommands.js'; -import type { FpsMetrics } from './utils/fpsTracker.js'; -import { getWorktreePaths } from './utils/getWorktreePaths.js'; -import { findGitRoot, getBranch, getIsGit, getWorktreeCount } from './utils/git.js'; -import { getGhAuthStatus } from './utils/github/ghAuthStatus.js'; -import { safeParseJSON } from './utils/json.js'; -import { logError } from './utils/log.js'; -import { getModelDeprecationWarning } from './utils/model/deprecation.js'; -import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel } from './utils/model/model.js'; -import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; -import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; -import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, removeDangerousPermissions, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js'; -import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; -import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js'; -import { getManagedPluginNames } from './utils/plugins/managedPlugins.js'; -import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js'; -import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js'; -import { countFilesRoundedRg } from './utils/ripgrep.js'; -import { processSessionStartHooks, processSetupHooks } from './utils/sessionStart.js'; -import { cacheSessionTitle, getSessionIdFromLog, loadTranscriptFromFile, saveAgentSetting, saveMode, searchSessionsByCustomTitle, sessionIdExists } from './utils/sessionStorage.js'; -import { ensureMdmSettingsLoaded } from './utils/settings/mdm/settings.js'; -import { getInitialSettings, getManagedSettingsKeysForLogging, getSettingsForSource, getSettingsWithErrors } from './utils/settings/settings.js'; -import { resetSettingsCache } from './utils/settings/settingsCache.js'; -import type { ValidationError } from './utils/settings/validation.js'; -import { DEFAULT_TASKS_MODE_TASK_LIST_ID, TASK_STATUSES } from './utils/tasks.js'; -import { logPluginLoadErrors, logPluginsEnabledForSession } from './utils/telemetry/pluginTelemetry.js'; -import { logSkillsLoaded } from './utils/telemetry/skillLoadedEvent.js'; -import { generateTempFilePath } from './utils/tempfile.js'; -import { validateUuid } from './utils/uuid.js'; +import { checkQuotaStatus } from './services/claudeAiLimits.js' +import { + getMcpToolsCommandsAndResources, + prefetchAllMcpResources, +} from './services/mcp/client.js' +import { + VALID_INSTALLABLE_SCOPES, + VALID_UPDATE_SCOPES, +} from './services/plugins/pluginCliCommands.js' +import { initBundledSkills } from './skills/bundled/index.js' +import type { AgentColorName } from './tools/AgentTool/agentColorManager.js' +import { + getActiveAgentsFromList, + getAgentDefinitionsWithOverrides, + isBuiltInAgent, + isCustomAgent, + parseAgentsFromJson, +} from './tools/AgentTool/loadAgentsDir.js' +import type { LogOption } from './types/logs.js' +import type { Message as MessageType } from './types/message.js' +import { assertMinVersion } from './utils/autoUpdater.js' +import { + CLAUDE_IN_CHROME_SKILL_HINT, + CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER, +} from './utils/claudeInChrome/prompt.js' +import { + setupClaudeInChrome, + shouldAutoEnableClaudeInChrome, + shouldEnableClaudeInChrome, +} from './utils/claudeInChrome/setup.js' +import { getContextWindowForModel } from './utils/context.js' +import { loadConversationForResume } from './utils/conversationRecovery.js' +import { buildDeepLinkBanner } from './utils/deepLink/banner.js' +import { + hasNodeOption, + isBareMode, + isEnvTruthy, + isInProtectedNamespace, +} from './utils/envUtils.js' +import { refreshExampleCommands } from './utils/exampleCommands.js' +import type { FpsMetrics } from './utils/fpsTracker.js' +import { getWorktreePaths } from './utils/getWorktreePaths.js' +import { + findGitRoot, + getBranch, + getIsGit, + getWorktreeCount, +} from './utils/git.js' +import { getGhAuthStatus } from './utils/github/ghAuthStatus.js' +import { safeParseJSON } from './utils/json.js' +import { logError } from './utils/log.js' +import { getModelDeprecationWarning } from './utils/model/deprecation.js' +import { + getDefaultMainLoopModel, + getUserSpecifiedModelSetting, + normalizeModelStringForAPI, + parseUserSpecifiedModel, +} from './utils/model/model.js' +import { ensureModelStringsInitialized } from './utils/model/modelStrings.js' +import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js' +import { + checkAndDisableBypassPermissions, + getAutoModeEnabledStateIfCached, + initializeToolPermissionContext, + initialPermissionModeFromCLI, + isDefaultPermissionModeAuto, + parseToolListFromCLI, + removeDangerousPermissions, + stripDangerousPermissionsForAutoMode, + verifyAutoModeGateAccess, +} from './utils/permissions/permissionSetup.js' +import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js' +import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js' +import { getManagedPluginNames } from './utils/plugins/managedPlugins.js' +import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js' +import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js' +import { countFilesRoundedRg } from './utils/ripgrep.js' +import { + processSessionStartHooks, + processSetupHooks, +} from './utils/sessionStart.js' +import { + cacheSessionTitle, + getSessionIdFromLog, + loadTranscriptFromFile, + saveAgentSetting, + saveMode, + searchSessionsByCustomTitle, + sessionIdExists, +} from './utils/sessionStorage.js' +import { ensureMdmSettingsLoaded } from './utils/settings/mdm/settings.js' +import { + getInitialSettings, + getManagedSettingsKeysForLogging, + getSettingsForSource, + getSettingsWithErrors, +} from './utils/settings/settings.js' +import { resetSettingsCache } from './utils/settings/settingsCache.js' +import type { ValidationError } from './utils/settings/validation.js' +import { + DEFAULT_TASKS_MODE_TASK_LIST_ID, + TASK_STATUSES, +} from './utils/tasks.js' +import { + logPluginLoadErrors, + logPluginsEnabledForSession, +} from './utils/telemetry/pluginTelemetry.js' +import { logSkillsLoaded } from './utils/telemetry/skillLoadedEvent.js' +import { generateTempFilePath } from './utils/tempfile.js' +import { validateUuid } from './utils/uuid.js' // Plugin startup checks are now handled non-blockingly in REPL.tsx -import { registerMcpAddCommand } from 'src/commands/mcp/addCommand.js'; -import { registerMcpXaaIdpCommand } from 'src/commands/mcp/xaaIdpCommand.js'; -import { logPermissionContextForAnts } from 'src/services/internalLogging.js'; -import { fetchClaudeAIMcpConfigsIfEligible } from 'src/services/mcp/claudeai.js'; -import { clearServerCache } from 'src/services/mcp/client.js'; -import { areMcpConfigsAllowedWithEnterpriseMcpConfig, dedupClaudeAiMcpServers, doesEnterpriseMcpConfigExist, filterMcpServersByPolicy, getClaudeCodeMcpConfigs, getMcpServerSignature, parseMcpConfig, parseMcpConfigFromFilePath } from 'src/services/mcp/config.js'; -import { excludeCommandsByServer, excludeResourcesByServer } from 'src/services/mcp/utils.js'; -import { isXaaEnabled } from 'src/services/mcp/xaaIdpLogin.js'; -import { getRelevantTips } from 'src/services/tips/tipRegistry.js'; -import { logContextMetrics } from 'src/utils/api.js'; -import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isClaudeInChromeMCPServer } from 'src/utils/claudeInChrome/common.js'; -import { registerCleanup } from 'src/utils/cleanupRegistry.js'; -import { eagerParseCliFlag } from 'src/utils/cliArgs.js'; -import { createEmptyAttributionState } from 'src/utils/commitAttribution.js'; -import { countConcurrentSessions, registerSession, updateSessionName } from 'src/utils/concurrentSessions.js'; -import { getCwd } from 'src/utils/cwd.js'; -import { logForDebugging, setHasFormattedOutput } from 'src/utils/debug.js'; -import { errorMessage, getErrnoCode, isENOENT, TeleportOperationError, toError } from 'src/utils/errors.js'; -import { getFsImplementation, safeResolvePath } from 'src/utils/fsOperations.js'; -import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; -import { setAllHookEventsEnabled } from 'src/utils/hooks/hookEvents.js'; -import { refreshModelCapabilities } from 'src/utils/model/modelCapabilities.js'; -import { peekForStdinData, writeToStderr } from 'src/utils/process.js'; -import { setCwd } from 'src/utils/Shell.js'; -import { type ProcessedResume, processResumedConversation } from 'src/utils/sessionRestore.js'; -import { parseSettingSourcesFlag } from 'src/utils/settings/constants.js'; -import { plural } from 'src/utils/stringUtils.js'; -import { type ChannelEntry, getInitialMainLoopModel, getIsNonInteractiveSession, getSdkBetas, getSessionId, getUserMsgOptIn, setAllowedChannels, setAllowedSettingSources, setChromeFlagOverride, setClientType, setCwdState, setDirectConnectServerUrl, setFlagSettingsPath, setInitialMainLoopModel, setInlinePlugins, setIsInteractive, setKairosActive, setOriginalCwd, setQuestionPreviewFormat, setSdkBetas, setSessionBypassPermissionsMode, setSessionPersistenceDisabled, setSessionSource, setUserMsgOptIn, switchSession } from './bootstrap/state.js'; +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; +const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') + ? (require('./utils/permissions/autoModeState.js') as typeof import('./utils/permissions/autoModeState.js')) + : null // TeleportRepoMismatchDialog, TeleportResumeWrapper dynamically imported at call sites -import { migrateAutoUpdatesToSettings } from './migrations/migrateAutoUpdatesToSettings.js'; -import { migrateBypassPermissionsAcceptedToSettings } from './migrations/migrateBypassPermissionsAcceptedToSettings.js'; -import { migrateEnableAllProjectMcpServersToSettings } from './migrations/migrateEnableAllProjectMcpServersToSettings.js'; -import { migrateFennecToOpus } from './migrations/migrateFennecToOpus.js'; -import { migrateLegacyOpusToCurrent } from './migrations/migrateLegacyOpusToCurrent.js'; -import { migrateOpusToOpus1m } from './migrations/migrateOpusToOpus1m.js'; -import { migrateReplBridgeEnabledToRemoteControlAtStartup } from './migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.js'; -import { migrateSonnet1mToSonnet45 } from './migrations/migrateSonnet1mToSonnet45.js'; -import { migrateSonnet45ToSonnet46 } from './migrations/migrateSonnet45ToSonnet46.js'; -import { resetAutoModeOptInForDefaultOffer } from './migrations/resetAutoModeOptInForDefaultOffer.js'; -import { resetProToOpusDefault } from './migrations/resetProToOpusDefault.js'; -import { createRemoteSessionConfig } from './remote/RemoteSessionManager.js'; +import { migrateAutoUpdatesToSettings } from './migrations/migrateAutoUpdatesToSettings.js' +import { migrateBypassPermissionsAcceptedToSettings } from './migrations/migrateBypassPermissionsAcceptedToSettings.js' +import { migrateEnableAllProjectMcpServersToSettings } from './migrations/migrateEnableAllProjectMcpServersToSettings.js' +import { migrateFennecToOpus } from './migrations/migrateFennecToOpus.js' +import { migrateLegacyOpusToCurrent } from './migrations/migrateLegacyOpusToCurrent.js' +import { migrateOpusToOpus1m } from './migrations/migrateOpusToOpus1m.js' +import { migrateReplBridgeEnabledToRemoteControlAtStartup } from './migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.js' +import { migrateSonnet1mToSonnet45 } from './migrations/migrateSonnet1mToSonnet45.js' +import { migrateSonnet45ToSonnet46 } from './migrations/migrateSonnet45ToSonnet46.js' +import { resetAutoModeOptInForDefaultOffer } from './migrations/resetAutoModeOptInForDefaultOffer.js' +import { resetProToOpusDefault } from './migrations/resetProToOpusDefault.js' +import { createRemoteSessionConfig } from './remote/RemoteSessionManager.js' /* eslint-enable @typescript-eslint/no-require-imports */ // teleportWithProgress dynamically imported at call site -import { createDirectConnectSession, DirectConnectError } from './server/createDirectConnectSession.js'; -import { initializeLspServerManager } from './services/lsp/manager.js'; -import { shouldEnablePromptSuggestion } from './services/PromptSuggestion/promptSuggestion.js'; -import { type AppState, getDefaultAppState, IDLE_SPECULATION_STATE } from './state/AppStateStore.js'; -import { onChangeAppState } from './state/onChangeAppState.js'; -import { createStore } from './state/store.js'; -import { asSessionId } from './types/ids.js'; -import { filterAllowedSdkBetas } from './utils/betas.js'; -import { isInBundledMode, isRunningWithBun } from './utils/bundledMode.js'; -import { logForDiagnosticsNoPII } from './utils/diagLogs.js'; -import { filterExistingPaths, getKnownPathsForRepo } from './utils/githubRepoPathMapping.js'; -import { clearPluginCache, loadAllPluginsCacheOnly } from './utils/plugins/pluginLoader.js'; -import { migrateChangelogFromConfig } from './utils/releaseNotes.js'; -import { SandboxManager } from './utils/sandbox/sandbox-adapter.js'; -import { fetchSession, prepareApiRequest } from './utils/teleport/api.js'; -import { checkOutTeleportedSessionBranch, processMessagesForTeleportResume, teleportToRemoteWithErrorHandling, validateGitState, validateSessionRepository } from './utils/teleport.js'; -import { shouldEnableThinkingByDefault, type ThinkingConfig } from './utils/thinking.js'; -import { initUser, resetUserCache } from './utils/user.js'; -import { getTmuxInstallInstructions, isTmuxAvailable, parsePRReference } from './utils/worktree.js'; +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'); +profileCheckpoint('main_tsx_imports_loaded') /** * Log managed settings keys to Statsig for analytics. @@ -215,13 +457,15 @@ profileCheckpoint('main_tsx_imports_loaded'); */ function logManagedSettings(): void { try { - const policySettings = getSettingsForSource('policySettings'); + const policySettings = getSettingsForSource('policySettings') if (policySettings) { - const allKeys = getManagedSettingsKeysForLogging(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 - }); + keys: allKeys.join( + ',', + ) as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } } catch { // Silently ignore errors - this is just for analytics @@ -230,7 +474,7 @@ function logManagedSettings(): void { // Check if running in debug/inspection mode function isBeingDebugged() { - const isBun = isRunningWithBun(); + const isBun = isRunningWithBun() // Check for inspect flags in process arguments (including all variants) const hasInspectArg = process.execArgv.some(arg => { @@ -239,33 +483,38 @@ function isBeingDebugged() { // 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); + return /--inspect(-brk)?/.test(arg) } else { // In Node.js, check for both --inspect and legacy --debug flags - return /--inspect(-brk)?|--debug(-brk)?/.test(arg); + 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); + 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; + 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; + return hasInspectArg || hasInspectEnv } } -// Anti-debugging check disabled for local development -// if ((process.env.USER_TYPE) !== 'ant' && isBeingDebugged()) { -// process.exit(1); -// } +// Exit if we detect node debugging or inspection +if ("external" !== 'ant' && isBeingDebugged()) { + // Use process.exit directly here since we're in the top-level code before imports + // and gracefulShutdown is not yet available + // eslint-disable-next-line custom-rules/no-top-level-side-effects + process.exit(1) +} /** * Per-session skill/plugin telemetry. Called from both the interactive path @@ -274,78 +523,90 @@ function isBeingDebugged() { * call sites here rather than one here + one in QueryEngine. */ function logSessionTelemetry(): void { - const model = parseUserSpecifiedModel(getInitialMainLoopModel() ?? getDefaultMainLoopModel()); - void logSkillsLoaded(getCwd(), getContextWindowForModel(model, getSdkBetas())); - void loadAllPluginsCacheOnly().then(({ - enabled, - errors - }) => { - const managedNames = getManagedPluginNames(); - logPluginsEnabledForSession(enabled, managedNames, getPluginSeedDirs()); - logPluginLoadErrors(errors, managedNames); - }).catch(err => logError(err)); + const model = parseUserSpecifiedModel( + getInitialMainLoopModel() ?? getDefaultMainLoopModel(), + ) + void logSkillsLoaded(getCwd(), getContextWindowForModel(model, getSdkBetas())) + void loadAllPluginsCacheOnly() + .then(({ enabled, errors }) => { + const managedNames = getManagedPluginNames() + logPluginsEnabledForSession(enabled, managedNames, getPluginSeedDirs()) + logPluginLoadErrors(errors, managedNames) + }) + .catch(err => logError(err)) } + function getCertEnvVarTelemetry(): Record { - const result: Record = {}; + const result: Record = {} if (process.env.NODE_EXTRA_CA_CERTS) { - result.has_node_extra_ca_certs = true; + result.has_node_extra_ca_certs = true } if (process.env.CLAUDE_CODE_CLIENT_CERT) { - result.has_client_cert = true; + result.has_client_cert = true } if (hasNodeOption('--use-system-ca')) { - result.has_use_system_ca = true; + result.has_use_system_ca = true } if (hasNodeOption('--use-openssl-ca')) { - result.has_use_openssl_ca = true; + result.has_use_openssl_ca = true } - return result; + return result } + async function logStartupTelemetry(): Promise { - if (isAnalyticsDisabled()) return; - const [isGit, worktreeCount, ghAuthStatus] = await Promise.all([getIsGit(), getWorktreeCount(), getGhAuthStatus()]); + if (isAnalyticsDisabled()) return + const [isGit, worktreeCount, ghAuthStatus] = await Promise.all([ + getIsGit(), + getWorktreeCount(), + getGhAuthStatus(), + ]) + logEvent('tengu_startup_telemetry', { is_git: isGit, worktree_count: worktreeCount, - gh_auth_status: ghAuthStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + 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(), + 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() - }); + ...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; +const CURRENT_MIGRATION_VERSION = 11 function runMigrations(): void { if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) { - migrateAutoUpdatesToSettings(); - migrateBypassPermissionsAcceptedToSettings(); - migrateEnableAllProjectMcpServersToSettings(); - resetProToOpusDefault(); - migrateSonnet1mToSonnet45(); - migrateLegacyOpusToCurrent(); - migrateSonnet45ToSonnet46(); - migrateOpusToOpus1m(); - migrateReplBridgeEnabledToRemoteControlAtStartup(); + migrateAutoUpdatesToSettings() + migrateBypassPermissionsAcceptedToSettings() + migrateEnableAllProjectMcpServersToSettings() + resetProToOpusDefault() + migrateSonnet1mToSonnet45() + migrateLegacyOpusToCurrent() + migrateSonnet45ToSonnet46() + migrateOpusToOpus1m() + migrateReplBridgeEnabledToRemoteControlAtStartup() if (feature('TRANSCRIPT_CLASSIFIER')) { - resetAutoModeOptInForDefaultOffer(); + resetAutoModeOptInForDefaultOffer() } - if ((process.env.USER_TYPE) === 'ant') { - migrateFennecToOpus(); + if (process.env.USER_TYPE === 'ant') { + migrateFennecToOpus() } - saveGlobalConfig(prev => prev.migrationVersion === CURRENT_MIGRATION_VERSION ? prev : { - ...prev, - migrationVersion: CURRENT_MIGRATION_VERSION - }); + 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 - }); + }) } /** @@ -355,23 +616,23 @@ function runMigrations(): void { * non-interactive mode where trust is implicit. */ function prefetchSystemContextIfSafe(): void { - const isNonInteractiveSession = getIsNonInteractiveSession(); + const isNonInteractiveSession = getIsNonInteractiveSession() // In non-interactive mode (--print), trust dialog is skipped and // execution is considered trusted (as documented in help text) if (isNonInteractiveSession) { - logForDiagnosticsNoPII('info', 'prefetch_system_context_non_interactive'); - void getSystemContext(); - return; + logForDiagnosticsNoPII('info', 'prefetch_system_context_non_interactive') + void getSystemContext() + return } // In interactive mode, only prefetch if trust has already been established - const hasTrust = checkHasTrustDialogAccepted(); + const hasTrust = checkHasTrustDialogAccepted() if (hasTrust) { - logForDiagnosticsNoPII('info', 'prefetch_system_context_has_trust'); - void getSystemContext(); + logForDiagnosticsNoPII('info', 'prefetch_system_context_has_trust') + void getSystemContext() } else { - logForDiagnosticsNoPII('info', 'prefetch_system_context_skipped_no_trust'); + logForDiagnosticsNoPII('info', 'prefetch_system_context_skipped_no_trust') } // Otherwise, don't prefetch - wait for trust to be established first } @@ -387,56 +648,73 @@ export function startDeferredPrefetches(): void { // 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; + 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(); + 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(); + if ( + isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && + !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH) + ) { + void prefetchGcpCredentialsIfSafe() } - void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []); + void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []) // Analytics and feature flag initialization - void initializeAnalyticsGates(); - void prefetchOfficialMcpUrls(); - void refreshModelCapabilities(); + void initializeAnalyticsGates() + void prefetchOfficialMcpUrls() + + void refreshModelCapabilities() // File change detectors deferred from init() to unblock first render - void settingsChangeDetector.initialize(); + void settingsChangeDetector.initialize() if (!isBareMode()) { - void skillChangeDetector.initialize(); + 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()); + 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; + 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); + const parsedJson = safeParseJSON(trimmedSettings) if (!parsedJson) { - process.stderr.write(chalk.red('Error: Invalid JSON provided to --settings\n')); - process.exit(1); + 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. @@ -449,46 +727,57 @@ function loadSettingsFromFlag(settingsFile: string): void { // 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'); + 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); + const { resolvedPath: resolvedSettingsPath } = safeResolvePath( + getFsImplementation(), + settingsFile, + ) try { - readFileSync(resolvedSettingsPath, 'utf8'); + readFileSync(resolvedSettingsPath, 'utf8') } catch (e) { if (isENOENT(e)) { - process.stderr.write(chalk.red(`Error: Settings file not found: ${resolvedSettingsPath}\n`)); - process.exit(1); + process.stderr.write( + chalk.red( + `Error: Settings file not found: ${resolvedSettingsPath}\n`, + ), + ) + process.exit(1) } - throw e; + throw e } - settingsPath = resolvedSettingsPath; + settingsPath = resolvedSettingsPath } - setFlagSettingsPath(settingsPath); - resetSettingsCache(); + + setFlagSettingsPath(settingsPath) + resetSettingsCache() } catch (error) { if (error instanceof Error) { - logError(error); + logError(error) } - process.stderr.write(chalk.red(`Error processing settings: ${errorMessage(error)}\n`)); - process.exit(1); + 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(); + const sources = parseSettingSourcesFlag(settingSourcesArg) + setAllowedSettingSources(sources) + resetSettingsCache() } catch (error) { if (error instanceof Error) { - logError(error); + logError(error) } - process.stderr.write(chalk.red(`Error processing --setting-sources: ${errorMessage(error)}\n`)); - process.exit(1); + process.stderr.write( + chalk.red(`Error processing --setting-sources: ${errorMessage(error)}\n`), + ) + process.exit(1) } } @@ -497,143 +786,155 @@ function loadSettingSourcesFromFlag(settingSourcesArg: string): void { * This ensures settings are filtered from the start of initialization */ function eagerLoadSettings(): void { - profileCheckpoint('eagerLoadSettings_start'); + profileCheckpoint('eagerLoadSettings_start') // Parse --settings flag early to ensure settings are loaded before init() - const settingsFile = eagerParseCliFlag('--settings'); + const settingsFile = eagerParseCliFlag('--settings') if (settingsFile) { - loadSettingsFromFlag(settingsFile); + loadSettingsFromFlag(settingsFile) } // Parse --setting-sources flag early to control which sources are loaded - const settingSourcesArg = eagerParseCliFlag('--setting-sources'); + const settingSourcesArg = eagerParseCliFlag('--setting-sources') if (settingSourcesArg !== undefined) { - loadSettingSourcesFromFlag(settingSourcesArg); + loadSettingSourcesFromFlag(settingSourcesArg) } - profileCheckpoint('eagerLoadSettings_end'); + 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; + return } - const cliArgs = process.argv.slice(2); + + const cliArgs = process.argv.slice(2) // Check for MCP serve command (handle flags before mcp serve, e.g., --debug mcp serve) - const mcpIndex = cliArgs.indexOf('mcp'); + const mcpIndex = cliArgs.indexOf('mcp') if (mcpIndex !== -1 && cliArgs[mcpIndex + 1] === 'serve') { - process.env.CLAUDE_CODE_ENTRYPOINT = 'mcp'; - return; + process.env.CLAUDE_CODE_ENTRYPOINT = 'mcp' + return } + if (isEnvTruthy(process.env.CLAUDE_CODE_ACTION)) { - process.env.CLAUDE_CODE_ENTRYPOINT = 'claude-code-github-action'; - return; + 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'; + process.env.CLAUDE_CODE_ENTRYPOINT = isNonInteractive ? 'sdk-cli' : 'cli' } // Set by early argv processing when `claude open ` is detected (interactive mode only) type PendingConnect = { - url: string | undefined; - authToken: string | undefined; - dangerouslySkipPermissions: boolean; -}; -const _pendingConnect: PendingConnect | undefined = feature('DIRECT_CONNECT') ? { - url: undefined, - authToken: undefined, - dangerouslySkipPermissions: false -} : undefined; + 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; +type PendingAssistantChat = { sessionId?: string; discover: boolean } +const _pendingAssistantChat: PendingAssistantChat | undefined = feature( + 'KAIROS', +) + ? { sessionId: undefined, discover: false } + : undefined // `claude ssh [dir]` — parsed from argv early (same pattern as // DIRECT_CONNECT above) so the main command path can pick it up and hand // the REPL an SSH-backed session instead of a local one. type PendingSSH = { - host: string | undefined; - cwd: string | undefined; - permissionMode: string | undefined; - dangerouslySkipPermissions: boolean; + 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; + 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; + 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'); + 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'; + process.env.NoDefaultCurrentDirectoryInExePath = '1' // Initialize warning handler early to catch warnings - initializeWarningHandler(); + initializeWarningHandler() + process.on('exit', () => { - resetCursor(); - }); + 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; + return } - process.exit(0); - }); - profileCheckpoint('main_warning_handler_initialized'); + 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://')); + 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'); + 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'); + const stripped = rawCliArgs.filter((_, i) => i !== ccIdx) + const dspIdx = stripped.indexOf('--dangerously-skip-permissions') if (dspIdx !== -1) { - stripped.splice(dspIdx, 1); + stripped.splice(dspIdx, 1) } - process.argv = [process.argv[0]!, process.argv[1]!, 'open', ccUrl, ...stripped]; + 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'); + _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); + stripped.splice(dspIdx, 1) } - process.argv = [process.argv[0]!, process.argv[1]!, ...stripped]; + process.argv = [process.argv[0]!, process.argv[1]!, ...stripped] } } } @@ -642,34 +943,34 @@ export async function main() { // 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'); + 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); + 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); + 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) } } @@ -680,17 +981,17 @@ export async function main() { // (e.g. `--debug assistant`) falls through to the stub, which // prints usage. if (feature('KAIROS') && _pendingAssistantChat) { - const rawArgs = process.argv.slice(2); + const rawArgs = process.argv.slice(2) if (rawArgs[0] === 'assistant') { - const nextArg = rawArgs[1]; + 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]; + _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]; + _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 } @@ -701,7 +1002,7 @@ export async function main() { // ~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); + 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 @@ -709,215 +1010,259 @@ export async function main() { // --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'); + const localIdx = rawCliArgs.indexOf('--local') if (localIdx !== -1) { - _pendingSSH.local = true; - rawCliArgs.splice(localIdx, 1); + _pendingSSH.local = true + rawCliArgs.splice(localIdx, 1) } - const dspIdx = rawCliArgs.indexOf('--dangerously-skip-permissions'); + const dspIdx = rawCliArgs.indexOf('--dangerously-skip-permissions') if (dspIdx !== -1) { - _pendingSSH.dangerouslySkipPermissions = true; - rawCliArgs.splice(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 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=')); + const pmEqIdx = rawCliArgs.findIndex(a => + a.startsWith('--permission-mode='), + ) if (pmEqIdx !== -1) { - _pendingSSH.permissionMode = rawCliArgs[pmEqIdx]!.split('=')[1]; - rawCliArgs.splice(pmEqIdx, 1); + _pendingSSH.permissionMode = rawCliArgs[pmEqIdx]!.split('=')[1] + rawCliArgs.splice(pmEqIdx, 1) } // Forward session-resume + model flags to the remote CLI's initial spawn. // --continue/-c and --resume operate on the REMOTE session history // (which persists under the remote's ~/.claude/projects//). // --model controls which model the remote uses. - const extractFlag = (flag: string, opts: { - hasValue?: boolean; - as?: string; - } = {}) => { - const i = rawCliArgs.indexOf(flag); + 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]; + _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); + _pendingSSH.extraCliArgs.push(val) + rawCliArgs.splice(i, 2) } else { - rawCliArgs.splice(i, 1); + rawCliArgs.splice(i, 1) } } - const eqI = rawCliArgs.findIndex(a => a.startsWith(`${flag}=`)); + 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); + _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 - }); + } + 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]; + if ( + rawCliArgs[0] === 'ssh' && + rawCliArgs[1] && + !rawCliArgs[1].startsWith('-') + ) { + _pendingSSH.host = rawCliArgs[1] // Optional positional cwd. - let consumed = 2; + let consumed = 2 if (rawCliArgs[2] && !rawCliArgs[2].startsWith('-')) { - _pendingSSH.cwd = rawCliArgs[2]; - consumed = 3; + _pendingSSH.cwd = rawCliArgs[2] + consumed = 3 } - const rest = rawCliArgs.slice(consumed); + 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; + 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]; + 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; + 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(); + stopCapturingEarlyInput() } // Set simplified tracking fields - const isInteractive = !isNonInteractive; - setIsInteractive(isInteractive); + const isInteractive = !isNonInteractive + setIsInteractive(isInteractive) // Initialize entrypoint based on mode - needs to be set before any event is logged - initializeEntrypoint(isNonInteractive); + 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'; + 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'; + 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; + + 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'); + 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'); + setSessionSource('remote-control') } - profileCheckpoint('main_client_type_determined'); + + profileCheckpoint('main_client_type_determined') // Parse and load settings flags early, before init() - eagerLoadSettings(); - profileCheckpoint('main_before_run'); - await run(); - profileCheckpoint('main_after_run'); + eagerLoadSettings() + + profileCheckpoint('main_before_run') + + await run() + profileCheckpoint('main_after_run') } -async function getInputPrompt(prompt: string, inputFormat: 'text' | 'stream-json'): Promise> { - if (!process.stdin.isTTY && - // Input hijacking breaks MCP. - !process.argv.includes('mcp')) { + +async function getInputPrompt( + prompt: string, + inputFormat: 'text' | 'stream-json', +): Promise> { + if ( + !process.stdin.isTTY && + // Input hijacking breaks MCP. + !process.argv.includes('mcp') + ) { if (inputFormat === 'stream-json') { - return process.stdin; + return process.stdin } - process.stdin.setEncoding('utf8'); - let data = ''; + process.stdin.setEncoding('utf8') + let data = '' const onData = (chunk: string) => { - data += chunk; - }; - process.stdin.on('data', onData); + 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); + 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'); + 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, data].filter(Boolean).join('\n') } - return prompt; + return prompt } + async function run(): Promise { - profileCheckpoint('run_function_start'); + 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; + 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 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'); + 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'); + 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'); + 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'; + process.title = 'claude' } // Attach logging sinks so subcommand handlers can use logEvent/logError. @@ -925,11 +1270,9 @@ async function run(): Promise { // 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'); + 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 @@ -939,2935 +1282,4121 @@ async function run(): Promise { // 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'); + 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'); + + 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'); + 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 ', '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 ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).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 ', '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 ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', 'Maximum number of agentic turns in non-interactive mode. This will early exit the conversation after the specified number of turns. (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { - const amount = Number(value); - if (isNaN(amount) || amount <= 0) { - throw new Error('--max-budget-usd must be a positive number greater than 0'); - } - return amount; - })).addOption(new Option('--task-budget ', 'API-side task budget in tokens (output_config.task_budget)').argParser(value => { - const tokens = Number(value); - if (isNaN(tokens) || tokens <= 0 || !Number.isInteger(tokens)) { - throw new Error('--task-budget must be a positive integer'); - } - return tokens; - }).hideHelp()).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 ', 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")').option('--tools ', 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read").').option('--disallowedTools, --disallowed-tools ', 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")').option('--mcp-config ', 'Load MCP servers from JSON files or strings (space-separated)').addOption(new Option('--permission-prompt-tool ', 'MCP tool to use for permission prompts (only works with --print)').argParser(String).hideHelp()).addOption(new Option('--system-prompt ', 'System prompt to use for the session').argParser(String)).addOption(new Option('--system-prompt-file ', 'Read system prompt from a file').argParser(String).hideHelp()).addOption(new Option('--append-system-prompt ', 'Append a system prompt to the default system prompt').argParser(String)).addOption(new Option('--append-system-prompt-file ', 'Read system prompt from a file and append to the default system prompt').argParser(String).hideHelp()).addOption(new Option('--permission-mode ', 'Permission mode to use for the session').argParser(String).choices(PERMISSION_MODES)).option('-c, --continue', 'Continue the most recent conversation in the current directory', () => true).option('-r, --resume [value]', 'Resume a conversation by session ID, or open interactive picker with optional search term', value => value || true).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 ', 'Pre-fill the prompt input with text without submitting it').hideHelp()).addOption(new Option('--deep-link-origin', 'Signal that this session was launched from a deep link').hideHelp()).addOption(new Option('--deep-link-repo ', 'Repo slug the deep link ?repo= parameter resolved to the current cwd').hideHelp()).addOption(new Option('--deep-link-last-fetch ', 'FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline').argParser(v => { - const n = Number(v); - return Number.isFinite(n) ? n : undefined; - }).hideHelp()).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 ', 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)').argParser(String).hideHelp()).addOption(new Option('--rewind-files ', 'Restore files to state at the specified user message and exit (requires --resume)').hideHelp()) - // @[MODEL LAUNCH]: Update the example model ID in the --model help text. - .option('--model ', `Model for the current session. Provide 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 ', `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 for the current session. Overrides the 'agent' setting.`).option('--betas ', 'Beta headers to include in API requests (API key users only)').option('--fallback-model ', 'Enable automatic fallback to specified model when default model is overloaded (only works with --print)').addOption(new Option('--workload ', 'Workload tag for billing-header attribution (cc_workload). Process-scoped; set by SDK daemon callers that spawn subprocesses for cron work. (only works with --print)').hideHelp()).option('--settings ', 'Path to a settings JSON file or a JSON string to load additional settings from').option('--add-dir ', 'Additional directories to allow tool access to').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 ', 'Use a specific session ID for the conversation (must be a valid UUID)').option('-n, --name ', 'Set a display name for this session (shown in /resume and terminal title)').option('--agents ', 'JSON object defining custom agents (e.g. \'{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}\')').option('--setting-sources ', 'Comma-separated list of setting sources to load (user, project, local).') - // gh-33508: (variadic) consumed everything until the next - // --flag. `claude --plugin-dir /path mcp add --transport http` swallowed - // `mcp` and `add` as paths, then choked on --transport as an unknown - // top-level option. Single-value + collect accumulator means each - // --plugin-dir takes exactly one arg; repeat the flag for multiple dirs. - .option('--plugin-dir ', '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 ', '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'; + void import('./services/settingsSync/index.js').then(m => + m.uploadUserSettingsInBackground(), + ) } - // 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; - } + profileCheckpoint('preAction_after_settings_sync') + }) - // Log event for any single-word prompt - if (prompt && typeof prompt === 'string' && !/\s/.test(prompt) && prompt.length > 0) { - logEvent('tengu_single_word_prompt', { - length: prompt.length - }); - } - - // Assistant mode: when .claude/settings.json has assistant: true AND - // the tengu_kairos GrowthBook gate is on, force brief on. Permission - // mode is left to the user — settings defaultMode or --permission-mode - // apply as normal. REPL-typed messages already default to 'next' - // priority (messageQueueManager.enqueue) so they drain mid-turn between - // tool calls. SendUserMessage (BriefTool) is enabled via the brief env - // var. SleepTool stays disabled (its isEnabled() gates on proactive). - // kairosEnabled is computed once here and reused at the - // getAssistantSystemPromptAddendum() call site further down. - // - // Trust gate: .claude/settings.json is attacker-controllable in an - // untrusted clone. We run ~1000 lines before showSetupScreens() shows - // the trust dialog, and by then we've already appended - // .claude/agents/assistant.md to the system prompt. Refuse to activate - // until the directory has been explicitly trusted. - let kairosEnabled = false; - let assistantTeamContext: Awaited['initializeAssistantTeam']>> | undefined; - if (feature('KAIROS') && (options as { - assistant?: boolean; - }).assistant && assistantModule) { - // --assistant (Agent SDK daemon mode): force the latch before - // isAssistantMode() runs below. The daemon has already checked - // entitlement — don't make the child re-check tengu_kairos. - assistantModule.markAssistantForced(); - } - if (feature('KAIROS') && assistantModule?.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(); + program + .name('claude') + .description( + `Claude Code - starts an interactive session by default, use -p/--print for non-interactive output`, + ) + .argument('[prompt]', 'Your prompt', String) + // Subcommands inherit helpOption via commander's copyInheritedSettings — + // setting it once here covers mcp, plugin, auth, and all other subcommands. + .helpOption('-h, --help', 'Display help for command') + .option( + '-d, --debug [filter]', + 'Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file")', + (_value: string | true) => { + // If value is provided, it will be the filter string + // If not provided but flag is present, value will be true + // The actual filtering is handled in debug.ts by parsing process.argv + return true + }, + ) + .addOption( + new Option('-d2e, --debug-to-stderr', 'Enable debug mode (to stderr)') + .argParser(Boolean) + .hideHelp(), + ) + .option( + '--debug-file ', + '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 ', + 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)', + ).choices(['text', 'json', 'stream-json']), + ) + .addOption( + new Option( + '--json-schema ', + 'JSON Schema for structured output validation. ' + + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', + ).argParser(String), + ) + .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 ', + '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 ', + 'Thinking mode: enabled (equivalent to adaptive), disabled', + ) + .choices(['enabled', 'adaptive', 'disabled']) + .hideHelp(), + ) + .addOption( + new Option( + '--max-thinking-tokens ', + '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)', + ) + .argParser(Number) + .hideHelp(), + ) + .addOption( + new Option( + '--max-turns ', + 'Maximum number of agentic turns in non-interactive mode. This will early exit the conversation after the specified number of turns. (only works with --print)', + ) + .argParser(Number) + .hideHelp(), + ) + .addOption( + new Option( + '--max-budget-usd ', + 'Maximum dollar amount to spend on API calls (only works with --print)', + ).argParser(value => { + const amount = Number(value) + if (isNaN(amount) || amount <= 0) { + throw new Error( + '--max-budget-usd must be a positive number greater than 0', + ) } - } - } - const { - debug = false, - debugToStderr = false, - dangerouslySkipPermissions, - allowDangerouslySkipPermissions = false, - tools: baseTools = [], - allowedTools = [], - disallowedTools = [], - mcpConfig = [], - permissionMode: permissionModeCli, - addDir = [], - fallbackModel, - betas = [], - ide = false, - sessionId, - includeHookEvents, - includePartialMessages - } = options; - if (options.prefill) { - seedEarlyInput(options.prefill); - } - - // Promise for file downloads - started early, awaited before REPL renders - let fileDownloadPromise: Promise | undefined; - const agentsJson = options.agents; - const agentCli = options.agent; - if (feature('BG_SESSIONS') && agentCli) { - process.env.CLAUDE_CODE_AGENT = agentCli; - } - - // NOTE: LSP manager initialization is intentionally deferred until after - // the trust dialog is accepted. This prevents plugin LSP servers from - // executing code in untrusted directories before user consent. - - // Extract these separately so they can be modified if needed - let outputFormat = options.outputFormat; - let inputFormat = options.inputFormat; - let verbose = options.verbose ?? getGlobalConfig().verbose; - let print = options.print; - const init = options.init ?? false; - const initOnly = options.initOnly ?? false; - const maintenance = options.maintenance ?? false; - - // Extract disable slash commands flag - const disableSlashCommands = options.disableSlashCommands || false; - - // Extract tasks mode options (ant-only) - const tasksOption = (process.env.USER_TYPE) === 'ant' && (options as { - tasks?: boolean | string; - }).tasks; - const taskListId = tasksOption ? typeof tasksOption === 'string' ? tasksOption : DEFAULT_TASKS_MODE_TASK_LIST_ID : undefined; - if ((process.env.USER_TYPE) === 'ant' && taskListId) { - process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId; - } - - // Extract worktree option - // worktree can be true (flag without value) or a string (custom name or PR reference) - const worktreeOption = isWorktreeModeEnabled() ? (options as { - worktree?: boolean | string; - }).worktree : undefined; - let worktreeName = typeof worktreeOption === 'string' ? worktreeOption : undefined; - const worktreeEnabled = worktreeOption !== undefined; - - // Check if worktree name is a PR reference (#N or GitHub PR URL) - let worktreePRNumber: number | undefined; - if (worktreeName) { - const prNum = parsePRReference(worktreeName); - if (prNum !== null) { - worktreePRNumber = prNum; - worktreeName = undefined; // slug will be generated in setup() - } - } - - // Extract tmux option (requires --worktree) - const tmuxEnabled = isWorktreeModeEnabled() && (options as { - tmux?: boolean; - }).tmux === true; - - // Validate tmux option - if (tmuxEnabled) { - if (!worktreeEnabled) { - process.stderr.write(chalk.red('Error: --tmux requires --worktree\n')); - process.exit(1); - } - if (getPlatform() === 'windows') { - process.stderr.write(chalk.red('Error: --tmux is not supported on Windows\n')); - process.exit(1); - } - if (!(await isTmuxAvailable())) { - process.stderr.write(chalk.red(`Error: tmux is not installed.\n${getTmuxInstallInstructions()}\n`)); - process.exit(1); - } - } - - // Extract teammate options (for tmux-spawned agents) - // Declared outside the if block so it's accessible later for system prompt addendum - let storedTeammateOpts: TeammateOptions | undefined; - if (isAgentSwarmsEnabled()) { - // Extract agent identity options (for tmux-spawned agents) - // These replace the CLAUDE_CODE_* environment variables - const teammateOpts = extractTeammateOptions(options); - storedTeammateOpts = teammateOpts; - - // If any teammate identity option is provided, all three required ones must be present - const hasAnyTeammateOpt = teammateOpts.agentId || teammateOpts.agentName || teammateOpts.teamName; - const hasAllRequiredTeammateOpts = teammateOpts.agentId && teammateOpts.agentName && teammateOpts.teamName; - if (hasAnyTeammateOpt && !hasAllRequiredTeammateOpts) { - process.stderr.write(chalk.red('Error: --agent-id, --agent-name, and --team-name must all be provided together\n')); - process.exit(1); - } - - // If teammate identity is provided via CLI, set up dynamicTeamContext - if (teammateOpts.agentId && teammateOpts.agentName && teammateOpts.teamName) { - getTeammateUtils().setDynamicTeamContext?.({ - agentId: teammateOpts.agentId, - agentName: teammateOpts.agentName, - teamName: teammateOpts.teamName, - color: teammateOpts.agentColor, - planModeRequired: teammateOpts.planModeRequired ?? false, - parentSessionId: teammateOpts.parentSessionId - }); - } - - // Set teammate mode CLI override if provided - // This must be done before setup() captures the snapshot - if (teammateOpts.teammateMode) { - getTeammateModeSnapshot().setCliTeammateModeOverride?.(teammateOpts.teammateMode); - } - } - - // Extract remote sdk options - const sdkUrl = (options as { - sdkUrl?: string; - }).sdkUrl ?? undefined; - - // Allow env var to enable partial messages (used by sandbox gateway for baku) - const effectiveIncludePartialMessages = includePartialMessages || isEnvTruthy(process.env.CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES); - - // Enable all hook event types when explicitly requested via SDK option - // or when running in CLAUDE_CODE_REMOTE mode (CCR needs them). - // Without this, only SessionStart and Setup events are emitted. - if (includeHookEvents || isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { - setAllHookEventsEnabled(true); - } - - // Auto-set input/output formats, verbose mode, and print mode when SDK URL is provided - if (sdkUrl) { - // If SDK URL is provided, automatically use stream-json formats unless explicitly set - if (!inputFormat) { - inputFormat = 'stream-json'; - } - if (!outputFormat) { - outputFormat = 'stream-json'; - } - // Auto-enable verbose mode unless explicitly disabled or already set - if (options.verbose === undefined) { - verbose = true; - } - // Auto-enable print mode unless explicitly disabled - if (!options.print) { - print = true; - } - } - - // Extract teleport option - const teleport = (options as { - teleport?: string | true; - }).teleport ?? null; - - // Extract remote option (can be true if no description provided, or a string) - const remoteOption = (options as { - remote?: string | true; - }).remote; - const remote = remoteOption === true ? '' : remoteOption ?? null; - - // Extract --remote-control / --rc flag (enable bridge in interactive session) - const remoteControlOption = (options as { - remoteControl?: string | true; - }).remoteControl ?? (options as { - rc?: string | true; - }).rc; - // Actual bridge check is deferred to after showSetupScreens() so that - // trust is established and GrowthBook has auth headers. - let remoteControl = false; - const remoteControlName = typeof remoteControlOption === 'string' && remoteControlOption.length > 0 ? remoteControlOption : undefined; - - // Validate session ID if provided - if (sessionId) { - // Check for conflicting flags - // --session-id can be used with --continue or --resume when --fork-session is also provided - // (to specify a custom ID for the forked session) - if ((options.continue || options.resume) && !options.forkSession) { - process.stderr.write(chalk.red('Error: --session-id can only be used with --continue or --resume if --fork-session is also specified.\n')); - process.exit(1); - } - - // When --sdk-url is provided (bridge/remote mode), the session ID is a - // server-assigned tagged ID (e.g. "session_local_01...") rather than a - // UUID. Skip UUID validation and local existence checks in that case. - if (!sdkUrl) { - const validatedSessionId = validateUuid(sessionId); - if (!validatedSessionId) { - process.stderr.write(chalk.red('Error: Invalid session ID. Must be a valid UUID.\n')); - process.exit(1); - } - - // Check if session ID already exists - if (sessionIdExists(validatedSessionId)) { - process.stderr.write(chalk.red(`Error: Session ID ${validatedSessionId} is already in use.\n`)); - process.exit(1); - } - } - } - - // Download file resources if specified via --file flag - const fileSpecs = (options as { - file?: string[]; - }).file; - if (fileSpecs && fileSpecs.length > 0) { - // Get session ingress token (provided by EnvManager via CLAUDE_CODE_SESSION_ACCESS_TOKEN) - const sessionToken = getSessionIngressAuthToken(); - if (!sessionToken) { - process.stderr.write(chalk.red('Error: Session token required for file downloads. CLAUDE_CODE_SESSION_ACCESS_TOKEN must be set.\n')); - process.exit(1); - } - - // Resolve session ID: prefer remote session ID, fall back to internal session ID - const fileSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID || getSessionId(); - const files = parseFileSpecs(fileSpecs); - if (files.length > 0) { - // Use ANTHROPIC_BASE_URL if set (by EnvManager), otherwise use OAuth config - // This ensures consistency with session ingress API in all environments - const config: FilesApiConfig = { - baseUrl: process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL, - oauthToken: sessionToken, - sessionId: fileSessionId - }; - - // Start download without blocking startup - await before REPL renders - fileDownloadPromise = downloadSessionFiles(files, config); - } - } - - // Get isNonInteractiveSession from state (was set before init()) - const isNonInteractiveSession = getIsNonInteractiveSession(); - - // Validate that fallback model is different from main model - if (fallbackModel && options.model && fallbackModel === options.model) { - process.stderr.write(chalk.red('Error: Fallback model cannot be the same as the main model. Please specify a different model for --fallback-model.\n')); - process.exit(1); - } - - // Handle system prompt options - let systemPrompt = options.systemPrompt; - if (options.systemPromptFile) { - if (options.systemPrompt) { - process.stderr.write(chalk.red('Error: Cannot use both --system-prompt and --system-prompt-file. Please use only one.\n')); - process.exit(1); - } - try { - const filePath = resolve(options.systemPromptFile); - systemPrompt = readFileSync(filePath, 'utf8'); - } catch (error) { - const code = getErrnoCode(error); - if (code === 'ENOENT') { - process.stderr.write(chalk.red(`Error: System prompt file not found: ${resolve(options.systemPromptFile)}\n`)); - process.exit(1); - } - process.stderr.write(chalk.red(`Error reading system prompt file: ${errorMessage(error)}\n`)); - process.exit(1); - } - } - - // Handle append system prompt options - let appendSystemPrompt = options.appendSystemPrompt; - if (options.appendSystemPromptFile) { - if (options.appendSystemPrompt) { - process.stderr.write(chalk.red('Error: Cannot use both --append-system-prompt and --append-system-prompt-file. Please use only one.\n')); - process.exit(1); - } - try { - const filePath = resolve(options.appendSystemPromptFile); - appendSystemPrompt = readFileSync(filePath, 'utf8'); - } catch (error) { - const code = getErrnoCode(error); - if (code === 'ENOENT') { - process.stderr.write(chalk.red(`Error: Append system prompt file not found: ${resolve(options.appendSystemPromptFile)}\n`)); - process.exit(1); - } - process.stderr.write(chalk.red(`Error reading append system prompt file: ${errorMessage(error)}\n`)); - process.exit(1); - } - } - - // Add teammate-specific system prompt addendum for tmux teammates - if (isAgentSwarmsEnabled() && storedTeammateOpts?.agentId && storedTeammateOpts?.agentName && storedTeammateOpts?.teamName) { - const addendum = getTeammatePromptAddendum().TEAMMATE_SYSTEM_PROMPT_ADDENDUM; - appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${addendum}` : addendum; - } - const { - mode: permissionMode, - notification: permissionModeNotification - } = initialPermissionModeFromCLI({ - permissionModeCli, - dangerouslySkipPermissions - }); - - // Store session bypass permissions mode for trust dialog check - setSessionBypassPermissionsMode(permissionMode === 'bypassPermissions'); - if (feature('TRANSCRIPT_CLASSIFIER')) { - // autoModeFlagCli is the "did the user intend auto this session" signal. - // Set when: --enable-auto-mode, --permission-mode auto, resolved mode - // is auto, OR settings defaultMode is auto but the gate denied it - // (permissionMode resolved to default with no explicit CLI override). - // Used by verifyAutoModeGateAccess to decide whether to notify on - // auto-unavailable, and by tengu_auto_mode_config opt-in carousel. - if ((options as { - enableAutoMode?: boolean; - }).enableAutoMode || permissionModeCli === 'auto' || permissionMode === 'auto' || !permissionModeCli && isDefaultPermissionModeAuto()) { - autoModeStateModule?.setAutoModeFlagCli(true); - } - } - - // Parse the MCP config files/strings if provided - let dynamicMcpConfig: Record = {}; - if (mcpConfig && mcpConfig.length > 0) { - // Process mcpConfig array - const processedConfigs = mcpConfig.map(config => config.trim()).filter(config => config.length > 0); - let allConfigs: Record = {}; - const allErrors: ValidationError[] = []; - for (const configItem of processedConfigs) { - let configs: Record | null = null; - let errors: ValidationError[] = []; - - // First try to parse as JSON string - const parsedJson = safeParseJSON(configItem); - if (parsedJson) { - const result = parseMcpConfig({ - configObject: parsedJson, - filePath: 'command line', - expandVars: true, - scope: 'dynamic' - }); - if (result.config) { - configs = result.config.mcpServers; - } else { - errors = result.errors; + return amount + }), + ) + .addOption( + new Option( + '--task-budget ', + 'API-side task budget in tokens (output_config.task_budget)', + ) + .argParser(value => { + const tokens = Number(value) + if (isNaN(tokens) || tokens <= 0 || !Number.isInteger(tokens)) { + throw new Error('--task-budget must be a positive integer') } - } else { - // Try as file path - const configPath = resolve(configItem); - const result = parseMcpConfigFromFilePath({ - filePath: configPath, - expandVars: true, - scope: 'dynamic' - }); - if (result.config) { - configs = result.config.mcpServers; - } else { - errors = result.errors; - } - } - if (errors.length > 0) { - allErrors.push(...errors); - } else if (configs) { - // Merge configs, later ones override earlier ones - allConfigs = { - ...allConfigs, - ...configs - }; - } - } - if (allErrors.length > 0) { - const formattedErrors = allErrors.map(err => `${err.path ? err.path + ': ' : ''}${err.message}`).join('\n'); - logForDebugging(`--mcp-config validation failed (${allErrors.length} errors): ${formattedErrors}`, { - level: 'error' - }); - process.stderr.write(`Error: Invalid MCP configuration:\n${formattedErrors}\n`); - process.exit(1); - } - if (Object.keys(allConfigs).length > 0) { - // SDK hosts (Nest/Desktop) own their server naming and may reuse - // built-in names — skip reserved-name checks for type:'sdk'. - const nonSdkConfigNames = Object.entries(allConfigs).filter(([, config]) => config.type !== 'sdk').map(([name]) => name); - let reservedNameError: string | null = null; - if (nonSdkConfigNames.some(isClaudeInChromeMCPServer)) { - reservedNameError = `Invalid MCP configuration: "${CLAUDE_IN_CHROME_MCP_SERVER_NAME}" is a reserved MCP name.`; - } else if (feature('CHICAGO_MCP')) { - const { - isComputerUseMCPServer, - COMPUTER_USE_MCP_SERVER_NAME - } = await import('src/utils/computerUse/common.js'); - if (nonSdkConfigNames.some(isComputerUseMCPServer)) { - reservedNameError = `Invalid MCP configuration: "${COMPUTER_USE_MCP_SERVER_NAME}" is a reserved MCP name.`; - } - } - if (reservedNameError) { - // stderr+exit(1) — a throw here becomes a silent unhandled - // rejection in stream-json mode (void main() in cli.tsx). - process.stderr.write(`Error: ${reservedNameError}\n`); - process.exit(1); - } - - // Add dynamic scope to all configs. type:'sdk' entries pass through - // unchanged — they're extracted into sdkMcpConfigs downstream and - // passed to print.ts. The Python SDK relies on this path (it doesn't - // send sdkMcpServers in the initialize message). Dropping them here - // broke Coworker (inc-5122). The policy filter below already exempts - // type:'sdk', and the entries are inert without an SDK transport on - // stdin, so there's no bypass risk from letting them through. - const scopedConfigs = mapValues(allConfigs, config => ({ - ...config, - scope: 'dynamic' as const - })); - - // Enforce managed policy (allowedMcpServers / deniedMcpServers) on - // --mcp-config servers. Without this, the CLI flag bypasses the - // enterprise allowlist that user/project/local configs go through in - // getClaudeCodeMcpConfigs — callers spread dynamicMcpConfig back on - // top of filtered results. Filter here at the source so all - // downstream consumers see the policy-filtered set. - const { - allowed, - blocked - } = filterMcpServersByPolicy(scopedConfigs); - if (blocked.length > 0) { - process.stderr.write(`Warning: MCP ${plural(blocked.length, 'server')} blocked by enterprise policy: ${blocked.join(', ')}\n`); - } - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...allowed - } as Record; - } - } - - // Extract Claude in Chrome option and enforce claude.ai subscriber check (unless user is ant) - const chromeOpts = options as { - chrome?: boolean; - }; - // Store the explicit CLI flag so teammates can inherit it - setChromeFlagOverride(chromeOpts.chrome); - const enableClaudeInChrome = shouldEnableClaudeInChrome(chromeOpts.chrome); - 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') && !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:@ — plugin-provided channel (allowlist enforced)\n` + ` server: — manually configured MCP server\n`)); - process.exit(1); - } - return entries; - }; - const channelOpts = options as { - channels?: string[]; - dangerouslyLoadDevelopmentChannels?: string[]; - }; - const rawChannels = channelOpts.channels; - const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels; - // Always parse + set. ChannelsNotice reads getAllowedChannels() and - // renders the appropriate branch (disabled/noAuth/policyBlocked/ - // listening) in the startup screen. gateChannelServer() enforces. - // --channels works in both interactive and print/SDK modes; dev-channels - // stays interactive-only (requires a confirmation dialog). - let channelEntries: ChannelEntry[] = []; - if (rawChannels && rawChannels.length > 0) { - channelEntries = parseChannelEntries(rawChannels, '--channels'); - setAllowedChannels(channelEntries); - } - if (!isNonInteractiveSession) { - if (rawDev && rawDev.length > 0) { - devChannels = parseChannelEntries(rawDev, '--dangerously-load-development-channels'); - } - } - // Flag-usage telemetry. Plugin identifiers are logged (same tier as - // tengu_plugin_installed — public-registry-style names); server-kind - // names are not (MCP-server-name tier, opt-in-only elsewhere). - // Per-server gate outcomes land in tengu_mcp_channel_gate once - // servers connect. Dev entries go through a confirmation dialog after - // this — dev_plugins captures what was typed, not what was accepted. - if (channelEntries.length > 0 || (devChannels?.length ?? 0) > 0) { - const joinPluginIds = (entries: ChannelEntry[]) => { - const ids = entries.flatMap(e => e.kind === 'plugin' ? [`${e.name}@${e.marketplace}`] : []); - return ids.length > 0 ? ids.sort().join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS : undefined; - }; - logEvent('tengu_mcp_channel_flags', { - channels_count: channelEntries.length, - dev_count: devChannels?.length ?? 0, - plugins: joinPluginIds(channelEntries), - dev_plugins: joinPluginIds(devChannels ?? []) - }); - } - } - - // SDK opt-in for SendUserMessage via --tools. All sessions require - // explicit opt-in; listing it in --tools signals intent. Runs BEFORE - // initializeToolPermissionContext so getToolsForDefaultPreset() sees - // the tool as enabled when computing the base-tools disallow filter. - // Conditional require avoids leaking the tool-name string into - // external builds. - if ((feature('KAIROS') || feature('KAIROS_BRIEF')) && baseTools.length > 0) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { - BRIEF_TOOL_NAME, - LEGACY_BRIEF_TOOL_NAME - } = require('./tools/BriefTool/prompt.js') as typeof import('./tools/BriefTool/prompt.js'); - const { - isBriefEntitled - } = require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js'); - /* eslint-enable @typescript-eslint/no-require-imports */ - const parsed = parseToolListFromCLI(baseTools); - if ((parsed.includes(BRIEF_TOOL_NAME) || parsed.includes(LEGACY_BRIEF_TOOL_NAME)) && isBriefEntitled()) { - setUserMsgOptIn(true); - } - } - - // This await replaces blocking existsSync/statSync calls that were already in - // the startup path. Wall-clock time is unchanged; we just yield to the event - // loop during the fs I/O instead of blocking it. See #19661. - const initResult = await initializeToolPermissionContext({ - allowedToolsCli: allowedTools, - disallowedToolsCli: disallowedTools, - baseToolsCli: baseTools, - permissionMode, - allowDangerouslySkipPermissions, - addDirs: addDir - }); - let toolPermissionContext = initResult.toolPermissionContext; - const { - warnings, - dangerousPermissions, - overlyBroadBashPermissions - } = initResult; - - // Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*)) - if ((process.env.USER_TYPE) === 'ant' && overlyBroadBashPermissions.length > 0) { - for (const permission of overlyBroadBashPermissions) { - logForDebugging(`Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`); - } - toolPermissionContext = removeDangerousPermissions(toolPermissionContext, overlyBroadBashPermissions); - } - if (feature('TRANSCRIPT_CLASSIFIER') && dangerousPermissions.length > 0) { - toolPermissionContext = stripDangerousPermissionsForAutoMode(toolPermissionContext); - } - - // Print any warnings from initialization - warnings.forEach(warning => { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error(warning); - }); - void assertMinVersion(); - - // claude.ai config fetch: -p mode only (interactive uses useManageMCPConnections - // two-phase loading). Kicked off here to overlap with setup(); awaited - // before runHeadless so single-turn -p sees connectors. Skipped under - // enterprise/strict MCP to preserve policy boundaries. - const claudeaiConfigPromise: Promise> = 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 - }) : 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 || {}).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 + return tokens }) - }); - } - - // 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); + .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 ', + 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")', + ) + .option( + '--tools ', + 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read").', + ) + .option( + '--disallowedTools, --disallowed-tools ', + 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")', + ) + .option( + '--mcp-config ', + 'Load MCP servers from JSON files or strings (space-separated)', + ) + .addOption( + new Option( + '--permission-prompt-tool ', + 'MCP tool to use for permission prompts (only works with --print)', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--system-prompt ', + 'System prompt to use for the session', + ).argParser(String), + ) + .addOption( + new Option( + '--system-prompt-file ', + 'Read system prompt from a file', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--append-system-prompt ', + 'Append a system prompt to the default system prompt', + ).argParser(String), + ) + .addOption( + new Option( + '--append-system-prompt-file ', + 'Read system prompt from a file and append to the default system prompt', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--permission-mode ', + 'Permission mode to use for the session', + ) + .argParser(String) + .choices(PERMISSION_MODES), + ) + .option( + '-c, --continue', + 'Continue the most recent conversation in the current directory', + () => true, + ) + .option( + '-r, --resume [value]', + 'Resume a conversation by session ID, or open interactive picker with optional search term', + value => value || true, + ) + .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 ', + 'Pre-fill the prompt input with text without submitting it', + ).hideHelp(), + ) + .addOption( + new Option( + '--deep-link-origin', + 'Signal that this session was launched from a deep link', + ).hideHelp(), + ) + .addOption( + new Option( + '--deep-link-repo ', + 'Repo slug the deep link ?repo= parameter resolved to the current cwd', + ).hideHelp(), + ) + .addOption( + new Option( + '--deep-link-last-fetch ', + 'FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline', + ) + .argParser(v => { + const n = Number(v) + return Number.isFinite(n) ? n : undefined + }) + .hideHelp(), + ) + .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 ', + 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--rewind-files ', + 'Restore files to state at the specified user message and exit (requires --resume)', + ).hideHelp(), + ) + // @[MODEL LAUNCH]: Update the example model ID in the --model help text. + .option( + '--model ', + `Model for the current session. Provide 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 ', + `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(', ')}`, + ) } - 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}`); - } - } + return value + }), + ) + .option( + '--agent ', + `Agent for the current session. Overrides the 'agent' setting.`, + ) + .option( + '--betas ', + 'Beta headers to include in API requests (API key users only)', + ) + .option( + '--fallback-model ', + 'Enable automatic fallback to specified model when default model is overloaded (only works with --print)', + ) + .addOption( + new Option( + '--workload ', + 'Workload tag for billing-header attribution (cc_workload). Process-scoped; set by SDK daemon callers that spawn subprocesses for cron work. (only works with --print)', + ).hideHelp(), + ) + .option( + '--settings ', + 'Path to a settings JSON file or a JSON string to load additional settings from', + ) + .option( + '--add-dir ', + 'Additional directories to allow tool access to', + ) + .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 ', + 'Use a specific session ID for the conversation (must be a valid UUID)', + ) + .option( + '-n, --name ', + 'Set a display name for this session (shown in /resume and terminal title)', + ) + .option( + '--agents ', + 'JSON object defining custom agents (e.g. \'{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}\')', + ) + .option( + '--setting-sources ', + 'Comma-separated list of setting sources to load (user, project, local).', + ) + // gh-33508: (variadic) consumed everything until the next + // --flag. `claude --plugin-dir /path mcp add --transport http` swallowed + // `mcp` and `add` as paths, then choked on --transport as an unknown + // top-level option. Single-value + collect accumulator means each + // --plugin-dir takes exactly one arg; repeat the flag for multiple dirs. + .option( + '--plugin-dir ', + '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 ', + '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') - // 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)`); + // --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['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 { - // 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 prompts. These are check-ins. Do whatever seems most useful, or call Sleep if there's nothing to do. ${briefVisibility}`; - appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${proactivePrompt}` : proactivePrompt; - } - if (feature('KAIROS') && kairosEnabled && assistantModule) { - const assistantAddendum = assistantModule.getAssistantSystemPromptAddendum(); - appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${assistantAddendum}` : assistantAddendum; - } - - // Ink root is only needed for interactive sessions — patchConsole in the - // Ink constructor would swallow console output in headless mode. - let root!: Root; - let getFpsMetrics!: () => FpsMetrics | undefined; - let stats!: StatsStore; - - // Show setup screens after commands are loaded - if (!isNonInteractiveSession) { - const ctx = getRenderContext(false); - getFpsMetrics = ctx.getFpsMetrics; - stats = ctx.stats; - // Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1) - if ((process.env.USER_TYPE) === 'ant') { - installAsciicastRecorder(); - } - const { - createRoot - } = await import('./ink.js'); - root = await createRoot(ctx.renderOptions); - - // Log startup time now, before any blocking dialog renders. Logging - // from REPL's first render (the old location) included however long - // the user sat on trust/OAuth/onboarding/resume-picker — p99 was ~70s - // dominated by dialog-wait time, not code-path startup. - logEvent('tengu_timer', { - event: 'startup' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - durationMs: Math.round(process.uptime() * 1000) - }); - logForDebugging('[STARTUP] Running showSetupScreens()...'); - const setupScreensStart = Date.now(); - const onboardingShown = await showSetupScreens(root, permissionMode, allowDangerouslySkipPermissions, commands, enableClaudeInChrome, devChannels); - logForDebugging(`[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`); - - // Now that trust is established and GrowthBook has auth headers, - // resolve the --remote-control / --rc entitlement gate. - if (feature('BRIDGE_MODE') && remoteControlOption !== undefined) { - const { - getBridgeDisabledReason - } = await import('./bridge/bridgeEnabled.js'); - const disabledReason = await getBridgeDisabledReason(); - remoteControl = disabledReason === null; - if (disabledReason) { - process.stderr.write(chalk.yellow(`${disabledReason}\n--rc flag ignored.\n`)); - } - } - - // Check for pending agent memory snapshot updates (only for --agent mode, ant-only) - if (feature('AGENT_MEMORY_SNAPSHOT') && mainThreadAgentDefinition && isCustomAgent(mainThreadAgentDefinition) && mainThreadAgentDefinition.memory && mainThreadAgentDefinition.pendingSnapshotUpdate) { - const agentDef = mainThreadAgentDefinition; - const choice = await launchSnapshotUpdateDialog(root, { - agentType: agentDef.agentType, - scope: agentDef.memory!, - snapshotTimestamp: agentDef.pendingSnapshotUpdate!.snapshotTimestamp - }); - if (choice === 'merge') { - const { - buildMergePrompt - } = await import('./components/agents/SnapshotUpdateDialog.js'); - const mergePrompt = buildMergePrompt(agentDef.agentType, agentDef.memory!); - inputPrompt = inputPrompt ? `${mergePrompt}\n\n${inputPrompt}` : mergePrompt; - } - agentDef.pendingSnapshotUpdate = undefined; - } - - // Skip executing /login if we just completed onboarding for it - if (onboardingShown && prompt?.trim().toLowerCase() === '/login') { - prompt = ''; - } - if (onboardingShown) { - // Refresh auth-dependent services now that the user has logged in during onboarding. - // Keep in sync with the post-login logic in src/commands/login.tsx - void refreshRemoteManagedSettings(); - void refreshPolicyLimits(); - // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials - resetUserCache(); - // Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs) - refreshGrowthBookAfterAuthChange(); - // Clear any stale trusted device token then enroll for Remote Control. - // Both self-gate on tengu_sessions_elevated_auth_enforcement internally - // — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits - // the GrowthBook reinit above), clearTrustedDeviceToken() via the - // sync cached check (acceptable since clear is idempotent). - void import('./bridge/trustedDevice.js').then(m => { - m.clearTrustedDeviceToken(); - return m.enrollTrustedDevice(); - }); - } - - // Validate that the active token's org matches forceLoginOrgUUID (if set - // in managed settings). Runs after onboarding so managed settings and - // login state are fully loaded. - const orgValidation = await validateForceLoginOrg(); - if (!orgValidation.valid) { - await exitWithError(root, (orgValidation as { valid: false; message: string }).message); - } - } - - // If gracefulShutdown was initiated (e.g., user rejected trust dialog), - // process.exitCode will be set. Skip all subsequent operations that could - // trigger code execution before the process exits (e.g. we don't want apiKeyHelper - // to run if trust was not established). - if (process.exitCode !== undefined) { - logForDebugging('Graceful shutdown initiated, skipping further initialization'); - return; - } - - // Initialize LSP manager AFTER trust is established (or in non-interactive mode - // where trust is implicit). This prevents plugin LSP servers from executing - // code in untrusted directories before user consent. - // Must be after inline plugins are set (if any) so --plugin-dir LSP servers are included. - initializeLspServerManager(); - - // Show settings validation errors after trust is established - // MCP config errors don't block settings from loading, so exclude them - if (!isNonInteractiveSession) { - const { - errors - } = getSettingsWithErrors(); - const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata); - if (nonMcpErrors.length > 0) { - await launchInvalidSettingsDialog(root, { - settingsErrors: nonMcpErrors, - onExit: () => gracefulShutdownSync(1) - }); - } - } - - // Check quota status, fast mode, passes eligibility, and bootstrap data - // after trust is established. These make API calls which could trigger - // apiKeyHelper execution. - // --bare / SIMPLE: skip — these are cache-warms for the REPL's - // first-turn responsiveness (quota, passes, fastMode, bootstrap data). Fast - // mode doesn't apply to the Agent SDK anyway (see getFastModeUnavailableReason). - const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE('tengu_cicada_nap_ms', 0); - const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0; - const skipStartupPrefetches = isBareMode() || bgRefreshThrottleMs > 0 && Date.now() - lastPrefetched < bgRefreshThrottleMs; - if (!skipStartupPrefetches) { - const lastPrefetchedInfo = lastPrefetched > 0 ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` : ''; - logForDebugging(`Starting background startup prefetches${lastPrefetchedInfo}`); - checkQuotaStatus().catch(error => logError(error)); - - // Fetch bootstrap data from the server and update all cache values. - void fetchBootstrapData(); - - // TODO: Consolidate other prefetches into a single bootstrap request. - void prefetchPassesEligibility(); - if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_miraculo_the_bard', false)) { - void prefetchFastModeStatus(); - } else { - // Kill switch skips the network call, not org-policy enforcement. - // Resolve from cache so orgStatus doesn't stay 'pending' (which - // getFastModeUnavailableReason treats as permissive). - resolveFastModeStatusFromCache(); - } - if (bgRefreshThrottleMs > 0) { - saveGlobalConfig(current => ({ - ...current, - startupPrefetchedAt: Date.now() - })); - } - } else { - logForDebugging(`Skipping startup prefetches, last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago`); - // Resolve fast mode org status from cache (no network) - resolveFastModeStatusFromCache(); - } - if (!isNonInteractiveSession) { - void refreshExampleCommands(); // Pre-fetch example commands (runs git log, no API call) - } - - // Resolve MCP configs (started early, overlaps with setup/trust dialog work) - const { - servers: existingMcpConfigs - } = await mcpConfigPromise; - logForDebugging(`[STARTUP] MCP configs resolved in ${mcpConfigResolvedMs}ms (awaited at +${Date.now() - mcpConfigStart}ms)`); - // CLI flag (--mcp-config) should override file-based configs, matching settings precedence - const allMcpConfigs = { - ...existingMcpConfigs, - ...dynamicMcpConfig - }; - - // Separate SDK configs from regular MCP configs - const sdkMcpConfigs: Record = {}; - const regularMcpConfigs: Record = {}; - for (const [name, config] of Object.entries(allMcpConfigs)) { - const typedConfig = config as ScopedMcpServerConfig | McpSdkServerConfig; - if (typedConfig.type === 'sdk') { - sdkMcpConfigs[name] = typedConfig as McpSdkServerConfig; - } else { - regularMcpConfigs[name] = typedConfig as ScopedMcpServerConfig; - } - } - profileCheckpoint('action_mcp_configs_loaded'); - - // Prefetch MCP resources after trust dialog (this is where execution happens). - // Interactive mode only: print mode defers connects until headlessStore exists - // and pushes per-server (below), so ToolSearch's pending-client handling works - // and one slow server doesn't block the batch. - const localMcpPromise = isNonInteractiveSession ? Promise.resolve({ - clients: [], - tools: [], - commands: [] - }) : prefetchAllMcpResources(regularMcpConfigs); - const claudeaiMcpPromise = isNonInteractiveSession ? Promise.resolve({ - clients: [], - tools: [], - commands: [] - }) : claudeaiConfigPromise.then(configs => Object.keys(configs).length > 0 ? prefetchAllMcpResources(configs) : { - clients: [], - tools: [], - commands: [] - }); - // Merge with dedup by name: each prefetchAllMcpResources call independently - // adds helper tools (ListMcpResourcesTool, ReadMcpResourceTool) via - // local dedup flags, so merging two calls can yield duplicates. print.ts - // already uniqBy's the final tool pool, but dedup here keeps appState clean. - const mcpPromise = Promise.all([localMcpPromise, claudeaiMcpPromise]).then(([local, claudeai]) => ({ - clients: [...local.clients, ...claudeai.clients], - tools: uniqBy([...local.tools, ...claudeai.tools], 'name'), - commands: uniqBy([...local.commands, ...claudeai.commands], 'name') - })); - - // Start hooks early so they run in parallel with MCP connections. - // Skip for initOnly/init/maintenance (handled separately), non-interactive - // (handled via setupTrigger), and resume/continue (conversationRecovery.ts - // fires 'resume' instead — without this guard, hooks fire TWICE on /resume - // and the second systemMessage clobbers the first. gh-30825) - const hooksPromise = initOnly || init || maintenance || isNonInteractiveSession || options.continue || options.resume ? null : processSessionStartHooks('startup', { - agentType: mainThreadAgentDefinition?.agentType, - model: resolvedInitialModel - }); - - // MCP never blocks REPL render OR turn 1 TTFT. useManageMCPConnections - // populates appState.mcp async as servers connect (connectToServer is - // memoized — the prefetch calls above and the hook converge on the same - // connections). getToolUseContext reads store.getState() fresh via - // computeTools(), so turn 1 sees whatever's connected by query time. - // Slow servers populate for turn 2+. Matches interactive-no-prompt - // behavior. Print mode: per-server push into headlessStore (below). - const hookMessages: Awaited> = []; - // Suppress transient unhandledRejection — the prefetch warms the - // memoized connectToServer cache but nobody awaits it in interactive. - mcpPromise.catch(() => {}); - const mcpClients: Awaited['clients'] = []; - const mcpTools: Awaited['tools'] = []; - const mcpCommands: Awaited['commands'] = []; - let thinkingEnabled = shouldEnableThinkingByDefault(); - let thinkingConfig: ThinkingConfig = thinkingEnabled !== false ? { - type: 'adaptive' - } : { - type: 'disabled' - }; - if (options.thinking === 'adaptive' || options.thinking === 'enabled') { - thinkingEnabled = true; - thinkingConfig = { - type: 'adaptive' - }; - } else if (options.thinking === 'disabled') { - thinkingEnabled = false; - thinkingConfig = { - type: 'disabled' - }; - } else { - const maxThinkingTokens = process.env.MAX_THINKING_TOKENS ? parseInt(process.env.MAX_THINKING_TOKENS, 10) : options.maxThinkingTokens; - if (maxThinkingTokens !== undefined) { - if (maxThinkingTokens > 0) { - thinkingEnabled = true; - thinkingConfig = { - type: 'enabled', - budgetTokens: maxThinkingTokens - }; - } else if (maxThinkingTokens === 0) { - thinkingEnabled = false; - thinkingConfig = { - type: 'disabled' - }; - } - } - } - logForDiagnosticsNoPII('info', 'started', { - version: MACRO.VERSION, - is_native_binary: isInBundledMode() - }); - registerCleanup(async () => { - logForDiagnosticsNoPII('info', 'exited'); - }); - void logTenguInit({ - hasInitialPrompt: Boolean(prompt), - hasStdin: Boolean(inputPrompt), - verbose, - debug, - debugToStderr, - print: print ?? false, - outputFormat: outputFormat ?? 'text', - inputFormat: inputFormat ?? 'text', - numAllowedTools: allowedTools.length, - numDisallowedTools: disallowedTools.length, - mcpClientCount: Object.keys(allMcpConfigs).length, - worktreeEnabled, - skipWebFetchPreflight: getInitialSettings().skipWebFetchPreflight, - githubActionInputs: process.env.GITHUB_ACTION_INPUTS, - dangerouslySkipPermissionsPassed: dangerouslySkipPermissions ?? false, - permissionMode, - modeIsBypass: permissionMode === 'bypassPermissions', - allowDangerouslySkipPermissionsPassed: allowDangerouslySkipPermissions, - systemPromptFlag: systemPrompt ? options.systemPromptFile ? 'file' : 'flag' : undefined, - appendSystemPromptFlag: appendSystemPrompt ? options.appendSystemPromptFile ? 'file' : 'flag' : undefined, - thinkingConfig, - assistantActivationPath: feature('KAIROS') && kairosEnabled ? assistantModule?.getAssistantActivationPath() : undefined - }); - - // Log context metrics once at initialization - void logContextMetrics(regularMcpConfigs, toolPermissionContext); - void logPermissionContextForAnts(null, 'initialization'); - logManagedSettings(); - - // Register PID file for concurrent-session detection (~/.claude/sessions/) - // and fire multi-clauding telemetry. Lives here (not init.ts) so only the - // REPL path registers — not subcommands like `claude doctor`. Chained: - // count must run after register's write completes or it misses our own file. - void registerSession().then(registered => { - if (!registered) return; - if (sessionNameArg) { - void updateSessionName(sessionNameArg); - } - void countConcurrentSessions().then(count => { - if (count >= 2) { - logEvent('tengu_concurrent_sessions', { - num_sessions: count - }); - } - }); - }); - - // Initialize versioned plugins system (triggers V1→V2 migration if - // needed). Then run orphan GC, THEN warm the Grep/Glob exclusion cache. - // Sequencing matters: the warmup scans disk for .orphaned_at markers, - // so it must see the GC's Pass 1 (remove markers from reinstalled - // versions) and Pass 2 (stamp unmarked orphans) already applied. The - // warm also lands before autoupdate (fires on first submit in REPL) - // can orphan this session's active version underneath us. - // --bare / SIMPLE: skip plugin version sync + orphan cleanup. These - // are install/upgrade bookkeeping that scripted calls don't need — - // the next interactive session will reconcile. The await here was - // blocking -p on a marketplace round-trip. - if (isBareMode()) { - // skip — no-op - } else if (isNonInteractiveSession) { - // In headless mode, await to ensure plugin sync completes before CLI exits - await initializeVersionedPlugins(); - profileCheckpoint('action_after_plugins_init'); - void cleanupOrphanedPluginVersionsInBackground().then(() => getGlobExclusionsForPluginCache()); - } else { - // In interactive mode, fire-and-forget — this is purely bookkeeping - // that doesn't affect runtime behavior of the current session - void initializeVersionedPlugins().then(async () => { - profileCheckpoint('action_after_plugins_init'); - await cleanupOrphanedPluginVersionsInBackground(); - void getGlobExclusionsForPluginCache(); - }); - } - const setupTrigger = initOnly || init ? 'init' : maintenance ? 'maintenance' : null; - if (initOnly) { - applyConfigEnvironmentVariables(); - await processSetupHooks('init', { - forceSyncExecution: true - }); - await processSessionStartHooks('startup', { - forceSyncExecution: true - }); - gracefulShutdownSync(0); - return; - } - - // --print mode - if (isNonInteractiveSession) { - if (outputFormat === 'stream-json' || outputFormat === 'json') { - setHasFormattedOutput(true); - } - - // Apply full environment variables in print mode since trust dialog is bypassed - // This includes potentially dangerous environment variables from untrusted sources - // but print mode is considered trusted (as documented in help text) - applyConfigEnvironmentVariables(); - - // Initialize telemetry after env vars are applied so OTEL endpoint env vars and - // otelHeadersHelper (which requires trust to execute) are available. - initializeTelemetryAfterTrust(); - - // Kick SessionStart hooks now so the subprocess spawn overlaps with - // MCP connect + plugin init + print.ts import below. loadInitialMessages - // joins this at print.ts:4397. Guarded same as loadInitialMessages — - // continue/resume/teleport paths don't fire startup hooks (or fire them - // conditionally inside the resume branch, where this promise is - // undefined and the ?? fallback runs). Also skip when setupTrigger is - // set — those paths run setup hooks first (print.ts:544), and session - // start hooks must wait until setup completes. - const sessionStartHooksPromise = options.continue || options.resume || teleport || setupTrigger ? undefined : processSessionStartHooks('startup'); - // Suppress transient unhandledRejection if this rejects before - // loadInitialMessages awaits it. Downstream await still observes the - // rejection — this just prevents the spurious global handler fire. - sessionStartHooksPromise?.catch(() => {}); - profileCheckpoint('before_validateForceLoginOrg'); - // Validate org restriction for non-interactive sessions - const orgValidation = await validateForceLoginOrg(); - if (!orgValidation.valid) { - process.stderr.write((orgValidation as { valid: false; message: string }).message + '\n'); - process.exit(1); - } - - // Headless mode supports all prompt commands and some local commands - // If disableSlashCommands is true, return empty array - const commandsHeadless = disableSlashCommands ? [] : commands.filter(command => command.type === 'prompt' && !command.disableNonInteractive || command.type === 'local' && command.supportsNonInteractive); - const defaultState = getDefaultAppState(); - const headlessInitialState: AppState = { - ...defaultState, - mcp: { - ...defaultState.mcp, - clients: mcpClients, - commands: mcpCommands, - tools: mcpTools - }, - 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, label: string): Promise => { - if (Object.keys(configs).length === 0) return Promise.resolve(); - headlessStore.setState(prev => ({ - ...prev, - mcp: { - ...prev.mcp, - clients: [...prev.mcp.clients, ...Object.entries(configs).map(([name, config]) => ({ - name, - type: 'pending' as const, - config - }))] + // 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() } - })); - return getMcpToolsCommandsAndResources(({ - client, - tools, - commands - }) => { + } + } + + const { + debug = false, + debugToStderr = false, + dangerouslySkipPermissions, + allowDangerouslySkipPermissions = false, + tools: baseTools = [], + allowedTools = [], + disallowedTools = [], + mcpConfig = [], + permissionMode: permissionModeCli, + addDir = [], + fallbackModel, + betas = [], + ide = false, + sessionId, + includeHookEvents, + includePartialMessages, + } = options + + if (options.prefill) { + seedEarlyInput(options.prefill) + } + + // Promise for file downloads - started early, awaited before REPL renders + let fileDownloadPromise: Promise | undefined + + const agentsJson = options.agents + const agentCli = options.agent + if (feature('BG_SESSIONS') && agentCli) { + process.env.CLAUDE_CODE_AGENT = agentCli + } + + // NOTE: LSP manager initialization is intentionally deferred until after + // the trust dialog is accepted. This prevents plugin LSP servers from + // executing code in untrusted directories before user consent. + + // Extract these separately so they can be modified if needed + let outputFormat = options.outputFormat + let inputFormat = options.inputFormat + let verbose = options.verbose ?? getGlobalConfig().verbose + let print = options.print + const init = options.init ?? false + const initOnly = options.initOnly ?? false + const maintenance = options.maintenance ?? false + + // Extract disable slash commands flag + const disableSlashCommands = options.disableSlashCommands || false + + // Extract tasks mode options (ant-only) + const tasksOption = + process.env.USER_TYPE === 'ant' && + (options as { tasks?: boolean | string }).tasks + const taskListId = tasksOption + ? typeof tasksOption === 'string' + ? tasksOption + : DEFAULT_TASKS_MODE_TASK_LIST_ID + : undefined + if (process.env.USER_TYPE === 'ant' && taskListId) { + process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId + } + + // Extract worktree option + // worktree can be true (flag without value) or a string (custom name or PR reference) + const worktreeOption = isWorktreeModeEnabled() + ? (options as { worktree?: boolean | string }).worktree + : undefined + let worktreeName = + typeof worktreeOption === 'string' ? worktreeOption : undefined + const worktreeEnabled = worktreeOption !== undefined + + // Check if worktree name is a PR reference (#N or GitHub PR URL) + let worktreePRNumber: number | undefined + if (worktreeName) { + const prNum = parsePRReference(worktreeName) + if (prNum !== null) { + worktreePRNumber = prNum + worktreeName = undefined // slug will be generated in setup() + } + } + + // Extract tmux option (requires --worktree) + const tmuxEnabled = + isWorktreeModeEnabled() && (options as { tmux?: boolean }).tmux === true + + // Validate tmux option + if (tmuxEnabled) { + if (!worktreeEnabled) { + process.stderr.write(chalk.red('Error: --tmux requires --worktree\n')) + process.exit(1) + } + if (getPlatform() === 'windows') { + process.stderr.write( + chalk.red('Error: --tmux is not supported on Windows\n'), + ) + process.exit(1) + } + if (!(await isTmuxAvailable())) { + process.stderr.write( + chalk.red( + `Error: tmux is not installed.\n${getTmuxInstallInstructions()}\n`, + ), + ) + process.exit(1) + } + } + + // Extract teammate options (for tmux-spawned agents) + // Declared outside the if block so it's accessible later for system prompt addendum + let storedTeammateOpts: TeammateOptions | undefined + if (isAgentSwarmsEnabled()) { + // Extract agent identity options (for tmux-spawned agents) + // These replace the CLAUDE_CODE_* environment variables + const teammateOpts = extractTeammateOptions(options) + storedTeammateOpts = teammateOpts + + // If any teammate identity option is provided, all three required ones must be present + const hasAnyTeammateOpt = + teammateOpts.agentId || + teammateOpts.agentName || + teammateOpts.teamName + const hasAllRequiredTeammateOpts = + teammateOpts.agentId && + teammateOpts.agentName && + teammateOpts.teamName + + if (hasAnyTeammateOpt && !hasAllRequiredTeammateOpts) { + process.stderr.write( + chalk.red( + 'Error: --agent-id, --agent-name, and --team-name must all be provided together\n', + ), + ) + process.exit(1) + } + + // If teammate identity is provided via CLI, set up dynamicTeamContext + if ( + teammateOpts.agentId && + teammateOpts.agentName && + teammateOpts.teamName + ) { + getTeammateUtils().setDynamicTeamContext?.({ + agentId: teammateOpts.agentId, + agentName: teammateOpts.agentName, + teamName: teammateOpts.teamName, + color: teammateOpts.agentColor, + planModeRequired: teammateOpts.planModeRequired ?? false, + parentSessionId: teammateOpts.parentSessionId, + }) + } + + // Set teammate mode CLI override if provided + // This must be done before setup() captures the snapshot + if (teammateOpts.teammateMode) { + getTeammateModeSnapshot().setCliTeammateModeOverride?.( + teammateOpts.teammateMode, + ) + } + } + + // Extract remote sdk options + const sdkUrl = (options as { sdkUrl?: string }).sdkUrl ?? undefined + + // Allow env var to enable partial messages (used by sandbox gateway for baku) + const effectiveIncludePartialMessages = + includePartialMessages || + isEnvTruthy(process.env.CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES) + + // Enable all hook event types when explicitly requested via SDK option + // or when running in CLAUDE_CODE_REMOTE mode (CCR needs them). + // Without this, only SessionStart and Setup events are emitted. + if (includeHookEvents || isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { + setAllHookEventsEnabled(true) + } + + // Auto-set input/output formats, verbose mode, and print mode when SDK URL is provided + if (sdkUrl) { + // If SDK URL is provided, automatically use stream-json formats unless explicitly set + if (!inputFormat) { + inputFormat = 'stream-json' + } + if (!outputFormat) { + outputFormat = 'stream-json' + } + // Auto-enable verbose mode unless explicitly disabled or already set + if (options.verbose === undefined) { + verbose = true + } + // Auto-enable print mode unless explicitly disabled + if (!options.print) { + print = true + } + } + + // Extract teleport option + const teleport = + (options as { teleport?: string | true }).teleport ?? null + + // Extract remote option (can be true if no description provided, or a string) + const remoteOption = (options as { remote?: string | true }).remote + const remote = remoteOption === true ? '' : (remoteOption ?? null) + + // Extract --remote-control / --rc flag (enable bridge in interactive session) + const remoteControlOption = + (options as { remoteControl?: string | true }).remoteControl ?? + (options as { rc?: string | true }).rc + // Actual bridge check is deferred to after showSetupScreens() so that + // trust is established and GrowthBook has auth headers. + let remoteControl = false + const remoteControlName = + typeof remoteControlOption === 'string' && + remoteControlOption.length > 0 + ? remoteControlOption + : undefined + + // Validate session ID if provided + if (sessionId) { + // Check for conflicting flags + // --session-id can be used with --continue or --resume when --fork-session is also provided + // (to specify a custom ID for the forked session) + if ((options.continue || options.resume) && !options.forkSession) { + process.stderr.write( + chalk.red( + 'Error: --session-id can only be used with --continue or --resume if --fork-session is also specified.\n', + ), + ) + process.exit(1) + } + + // When --sdk-url is provided (bridge/remote mode), the session ID is a + // server-assigned tagged ID (e.g. "session_local_01...") rather than a + // UUID. Skip UUID validation and local existence checks in that case. + if (!sdkUrl) { + const validatedSessionId = validateUuid(sessionId) + if (!validatedSessionId) { + process.stderr.write( + chalk.red('Error: Invalid session ID. Must be a valid UUID.\n'), + ) + process.exit(1) + } + + // Check if session ID already exists + if (sessionIdExists(validatedSessionId)) { + process.stderr.write( + chalk.red( + `Error: Session ID ${validatedSessionId} is already in use.\n`, + ), + ) + process.exit(1) + } + } + } + + // Download file resources if specified via --file flag + const fileSpecs = (options as { file?: string[] }).file + if (fileSpecs && fileSpecs.length > 0) { + // Get session ingress token (provided by EnvManager via CLAUDE_CODE_SESSION_ACCESS_TOKEN) + const sessionToken = getSessionIngressAuthToken() + if (!sessionToken) { + process.stderr.write( + chalk.red( + 'Error: Session token required for file downloads. CLAUDE_CODE_SESSION_ACCESS_TOKEN must be set.\n', + ), + ) + process.exit(1) + } + + // Resolve session ID: prefer remote session ID, fall back to internal session ID + const fileSessionId = + process.env.CLAUDE_CODE_REMOTE_SESSION_ID || getSessionId() + + const files = parseFileSpecs(fileSpecs) + if (files.length > 0) { + // Use ANTHROPIC_BASE_URL if set (by EnvManager), otherwise use OAuth config + // This ensures consistency with session ingress API in all environments + const config: FilesApiConfig = { + baseUrl: + process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL, + oauthToken: sessionToken, + sessionId: fileSessionId, + } + + // Start download without blocking startup - await before REPL renders + fileDownloadPromise = downloadSessionFiles(files, config) + } + } + + // Get isNonInteractiveSession from state (was set before init()) + const isNonInteractiveSession = getIsNonInteractiveSession() + + // Validate that fallback model is different from main model + if (fallbackModel && options.model && fallbackModel === options.model) { + process.stderr.write( + chalk.red( + 'Error: Fallback model cannot be the same as the main model. Please specify a different model for --fallback-model.\n', + ), + ) + process.exit(1) + } + + // Handle system prompt options + let systemPrompt = options.systemPrompt + if (options.systemPromptFile) { + if (options.systemPrompt) { + process.stderr.write( + chalk.red( + 'Error: Cannot use both --system-prompt and --system-prompt-file. Please use only one.\n', + ), + ) + process.exit(1) + } + + try { + const filePath = resolve(options.systemPromptFile) + systemPrompt = readFileSync(filePath, 'utf8') + } catch (error) { + const code = getErrnoCode(error) + if (code === 'ENOENT') { + process.stderr.write( + chalk.red( + `Error: System prompt file not found: ${resolve(options.systemPromptFile)}\n`, + ), + ) + process.exit(1) + } + process.stderr.write( + chalk.red( + `Error reading system prompt file: ${errorMessage(error)}\n`, + ), + ) + process.exit(1) + } + } + + // Handle append system prompt options + let appendSystemPrompt = options.appendSystemPrompt + if (options.appendSystemPromptFile) { + if (options.appendSystemPrompt) { + process.stderr.write( + chalk.red( + 'Error: Cannot use both --append-system-prompt and --append-system-prompt-file. Please use only one.\n', + ), + ) + process.exit(1) + } + + try { + const filePath = resolve(options.appendSystemPromptFile) + appendSystemPrompt = readFileSync(filePath, 'utf8') + } catch (error) { + const code = getErrnoCode(error) + if (code === 'ENOENT') { + process.stderr.write( + chalk.red( + `Error: Append system prompt file not found: ${resolve(options.appendSystemPromptFile)}\n`, + ), + ) + process.exit(1) + } + process.stderr.write( + chalk.red( + `Error reading append system prompt file: ${errorMessage(error)}\n`, + ), + ) + process.exit(1) + } + } + + // Add teammate-specific system prompt addendum for tmux teammates + if ( + isAgentSwarmsEnabled() && + storedTeammateOpts?.agentId && + storedTeammateOpts?.agentName && + storedTeammateOpts?.teamName + ) { + const addendum = + getTeammatePromptAddendum().TEAMMATE_SYSTEM_PROMPT_ADDENDUM + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${addendum}` + : addendum + } + + const { mode: permissionMode, notification: permissionModeNotification } = + initialPermissionModeFromCLI({ + permissionModeCli, + dangerouslySkipPermissions, + }) + + // Store session bypass permissions mode for trust dialog check + setSessionBypassPermissionsMode(permissionMode === 'bypassPermissions') + if (feature('TRANSCRIPT_CLASSIFIER')) { + // autoModeFlagCli is the "did the user intend auto this session" signal. + // Set when: --enable-auto-mode, --permission-mode auto, resolved mode + // is auto, OR settings defaultMode is auto but the gate denied it + // (permissionMode resolved to default with no explicit CLI override). + // Used by verifyAutoModeGateAccess to decide whether to notify on + // auto-unavailable, and by tengu_auto_mode_config opt-in carousel. + if ( + (options as { enableAutoMode?: boolean }).enableAutoMode || + permissionModeCli === 'auto' || + permissionMode === 'auto' || + (!permissionModeCli && isDefaultPermissionModeAuto()) + ) { + autoModeStateModule?.setAutoModeFlagCli(true) + } + } + + // Parse the MCP config files/strings if provided + let dynamicMcpConfig: Record = {} + + if (mcpConfig && mcpConfig.length > 0) { + // Process mcpConfig array + const processedConfigs = mcpConfig + .map(config => config.trim()) + .filter(config => config.length > 0) + + let allConfigs: Record = {} + const allErrors: ValidationError[] = [] + + for (const configItem of processedConfigs) { + let configs: Record | null = null + let errors: ValidationError[] = [] + + // First try to parse as JSON string + const parsedJson = safeParseJSON(configItem) + if (parsedJson) { + const result = parseMcpConfig({ + configObject: parsedJson, + filePath: 'command line', + expandVars: true, + scope: 'dynamic', + }) + if (result.config) { + configs = result.config.mcpServers + } else { + errors = result.errors + } + } else { + // Try as file path + const configPath = resolve(configItem) + const result = parseMcpConfigFromFilePath({ + filePath: configPath, + expandVars: true, + scope: 'dynamic', + }) + if (result.config) { + configs = result.config.mcpServers + } else { + errors = result.errors + } + } + + if (errors.length > 0) { + allErrors.push(...errors) + } else if (configs) { + // Merge configs, later ones override earlier ones + allConfigs = { ...allConfigs, ...configs } + } + } + + if (allErrors.length > 0) { + const formattedErrors = allErrors + .map(err => `${err.path ? err.path + ': ' : ''}${err.message}`) + .join('\n') + logForDebugging( + `--mcp-config validation failed (${allErrors.length} errors): ${formattedErrors}`, + { level: 'error' }, + ) + process.stderr.write( + `Error: Invalid MCP configuration:\n${formattedErrors}\n`, + ) + process.exit(1) + } + + if (Object.keys(allConfigs).length > 0) { + // SDK hosts (Nest/Desktop) own their server naming and may reuse + // built-in names — skip reserved-name checks for type:'sdk'. + const nonSdkConfigNames = Object.entries(allConfigs) + .filter(([, config]) => config.type !== 'sdk') + .map(([name]) => name) + + let reservedNameError: string | null = null + if (nonSdkConfigNames.some(isClaudeInChromeMCPServer)) { + reservedNameError = `Invalid MCP configuration: "${CLAUDE_IN_CHROME_MCP_SERVER_NAME}" is a reserved MCP name.` + } else if (feature('CHICAGO_MCP')) { + const { isComputerUseMCPServer, COMPUTER_USE_MCP_SERVER_NAME } = + await import('src/utils/computerUse/common.js') + if (nonSdkConfigNames.some(isComputerUseMCPServer)) { + reservedNameError = `Invalid MCP configuration: "${COMPUTER_USE_MCP_SERVER_NAME}" is a reserved MCP name.` + } + } + if (reservedNameError) { + // stderr+exit(1) — a throw here becomes a silent unhandled + // rejection in stream-json mode (void main() in cli.tsx). + process.stderr.write(`Error: ${reservedNameError}\n`) + process.exit(1) + } + + // Add dynamic scope to all configs. type:'sdk' entries pass through + // unchanged — they're extracted into sdkMcpConfigs downstream and + // passed to print.ts. The Python SDK relies on this path (it doesn't + // send sdkMcpServers in the initialize message). Dropping them here + // broke Coworker (inc-5122). The policy filter below already exempts + // type:'sdk', and the entries are inert without an SDK transport on + // stdin, so there's no bypass risk from letting them through. + const scopedConfigs = mapValues(allConfigs, config => ({ + ...config, + scope: 'dynamic' as const, + })) + + // Enforce managed policy (allowedMcpServers / deniedMcpServers) on + // --mcp-config servers. Without this, the CLI flag bypasses the + // enterprise allowlist that user/project/local configs go through in + // getClaudeCodeMcpConfigs — callers spread dynamicMcpConfig back on + // top of filtered results. Filter here at the source so all + // downstream consumers see the policy-filtered set. + const { allowed, blocked } = filterMcpServersByPolicy(scopedConfigs) + if (blocked.length > 0) { + process.stderr.write( + `Warning: MCP ${plural(blocked.length, 'server')} blocked by enterprise policy: ${blocked.join(', ')}\n`, + ) + } + dynamicMcpConfig = { ...dynamicMcpConfig, ...allowed } + } + } + + // Extract Claude in Chrome option and enforce claude.ai subscriber check (unless user is ant) + const chromeOpts = options as { chrome?: boolean } + // Store the explicit CLI flag so teammates can inherit it + setChromeFlagOverride(chromeOpts.chrome) + const enableClaudeInChrome = + shouldEnableClaudeInChrome(chromeOpts.chrome) && + (process.env.USER_TYPE === 'ant' || isClaudeAISubscriber()) + const autoEnableClaudeInChrome = + !enableClaudeInChrome && shouldAutoEnableClaudeInChrome() + + if (enableClaudeInChrome) { + const platform = getPlatform() + try { + logEvent('tengu_claude_in_chrome_setup', { + platform: + platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + const { + mcpConfig: chromeMcpConfig, + allowedTools: chromeMcpTools, + systemPrompt: chromeSystemPrompt, + } = setupClaudeInChrome() + dynamicMcpConfig = { ...dynamicMcpConfig, ...chromeMcpConfig } + allowedTools.push(...chromeMcpTools) + if (chromeSystemPrompt) { + appendSystemPrompt = appendSystemPrompt + ? `${chromeSystemPrompt}\n\n${appendSystemPrompt}` + : chromeSystemPrompt + } + } catch (error) { + logEvent('tengu_claude_in_chrome_setup_failed', { + platform: + platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + logForDebugging(`[Claude in Chrome] Error: ${error}`) + logError(error) + // biome-ignore lint/suspicious/noConsole:: intentional console output + console.error(`Error: Failed to run with Claude in Chrome.`) + process.exit(1) + } + } else if (autoEnableClaudeInChrome) { + try { + const { mcpConfig: chromeMcpConfig } = setupClaudeInChrome() + dynamicMcpConfig = { ...dynamicMcpConfig, ...chromeMcpConfig } + + const hint = + feature('WEB_BROWSER_TOOL') && + typeof Bun !== 'undefined' && + 'WebView' in Bun + ? CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER + : CLAUDE_IN_CHROME_SKILL_HINT + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${hint}` + : hint + } catch (error) { + // Silently skip any errors for the auto-enable + logForDebugging(`[Claude in Chrome] Error (auto-enable): ${error}`) + } + } + + // Extract strict MCP config flag + const strictMcpConfig = options.strictMcpConfig || false + + // Check if enterprise MCP configuration exists. When it does, only allow dynamic MCP + // configs that contain special server types (sdk) + if (doesEnterpriseMcpConfigExist()) { + if (strictMcpConfig) { + process.stderr.write( + chalk.red( + 'You cannot use --strict-mcp-config when an enterprise MCP config is present', + ), + ) + process.exit(1) + } + + // For --mcp-config, allow if all servers are internal types (sdk) + if ( + dynamicMcpConfig && + !areMcpConfigsAllowedWithEnterpriseMcpConfig(dynamicMcpConfig) + ) { + process.stderr.write( + chalk.red( + 'You cannot dynamically configure MCP servers when an enterprise MCP config is present', + ), + ) + process.exit(1) + } + } + + // chicago MCP: guarded Computer Use (app allowlist + frontmost gate + + // SCContentFilter screenshots). Ant-only, GrowthBook-gated — failures + // are silent (this is dogfooding). Platform + interactive checks inline + // so non-macOS / print-mode ants skip the heavy @ant/computer-use-mcp + // import entirely. gates.js is light (type-only package import). + // + // Placed AFTER the enterprise-MCP-config check: that check rejects any + // dynamicMcpConfig entry with `type !== 'sdk'`, and our config is + // `type: 'stdio'`. An enterprise-config ant with the GB gate on would + // otherwise process.exit(1). Chrome has the same latent issue but has + // shipped without incident; chicago places itself correctly. + if ( + feature('CHICAGO_MCP') && + getPlatform() === 'macos' && + !getIsNonInteractiveSession() + ) { + try { + const { getChicagoEnabled } = await import( + 'src/utils/computerUse/gates.js' + ) + if (getChicagoEnabled()) { + const { setupComputerUseMCP } = await import( + 'src/utils/computerUse/setup.js' + ) + const { mcpConfig, allowedTools: cuTools } = setupComputerUseMCP() + dynamicMcpConfig = { ...dynamicMcpConfig, ...mcpConfig } + allowedTools.push(...cuTools) + } + } catch (error) { + logForDebugging( + `[Computer Use MCP] Setup failed: ${errorMessage(error)}`, + ) + } + } + + // Store additional directories for CLAUDE.md loading (controlled by env var) + setAdditionalDirectoriesForClaudeMd(addDir) + + // Channel server allowlist from --channels flag — servers whose + // inbound push notifications should register this session. The option + // is added inside a feature() block so TS doesn't know about it + // on the options type — same pattern as --assistant at main.tsx:1824. + // devChannels is deferred: showSetupScreens shows a confirmation dialog + // and only appends to allowedChannels on accept. + let devChannels: ChannelEntry[] | undefined + if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { + // Parse plugin:name@marketplace / server:Y tags into typed entries. + // Tag decides trust model downstream: plugin-kind hits marketplace + // verification + GrowthBook allowlist, server-kind always fails + // allowlist (schema is plugin-only) unless dev flag is set. + // Untagged or marketplace-less plugin entries are hard errors — + // silently not-matching in the gate would look like channels are + // "on" but nothing ever fires. + const parseChannelEntries = ( + raw: string[], + flag: string, + ): ChannelEntry[] => { + const entries: ChannelEntry[] = [] + const bad: string[] = [] + for (const c of raw) { + if (c.startsWith('plugin:')) { + const rest = c.slice(7) + const at = rest.indexOf('@') + if (at <= 0 || at === rest.length - 1) { + bad.push(c) + } else { + entries.push({ + kind: 'plugin', + name: rest.slice(0, at), + marketplace: rest.slice(at + 1), + }) + } + } else if (c.startsWith('server:') && c.length > 7) { + entries.push({ kind: 'server', name: c.slice(7) }) + } else { + bad.push(c) + } + } + if (bad.length > 0) { + process.stderr.write( + chalk.red( + `${flag} entries must be tagged: ${bad.join(', ')}\n` + + ` plugin:@ — plugin-provided channel (allowlist enforced)\n` + + ` server: — manually configured MCP server\n`, + ), + ) + process.exit(1) + } + return entries + } + + const channelOpts = options as { + channels?: string[] + dangerouslyLoadDevelopmentChannels?: string[] + } + const rawChannels = channelOpts.channels + const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels + // Always parse + set. ChannelsNotice reads getAllowedChannels() and + // renders the appropriate branch (disabled/noAuth/policyBlocked/ + // listening) in the startup screen. gateChannelServer() enforces. + // --channels works in both interactive and print/SDK modes; dev-channels + // stays interactive-only (requires a confirmation dialog). + let channelEntries: ChannelEntry[] = [] + if (rawChannels && rawChannels.length > 0) { + channelEntries = parseChannelEntries(rawChannels, '--channels') + setAllowedChannels(channelEntries) + } + if (!isNonInteractiveSession) { + if (rawDev && rawDev.length > 0) { + devChannels = parseChannelEntries( + rawDev, + '--dangerously-load-development-channels', + ) + } + } + // Flag-usage telemetry. Plugin identifiers are logged (same tier as + // tengu_plugin_installed — public-registry-style names); server-kind + // names are not (MCP-server-name tier, opt-in-only elsewhere). + // Per-server gate outcomes land in tengu_mcp_channel_gate once + // servers connect. Dev entries go through a confirmation dialog after + // this — dev_plugins captures what was typed, not what was accepted. + if (channelEntries.length > 0 || (devChannels?.length ?? 0) > 0) { + const joinPluginIds = (entries: ChannelEntry[]) => { + const ids = entries.flatMap(e => + e.kind === 'plugin' ? [`${e.name}@${e.marketplace}`] : [], + ) + return ids.length > 0 + ? (ids + .sort() + .join( + ',', + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + : undefined + } + logEvent('tengu_mcp_channel_flags', { + channels_count: channelEntries.length, + dev_count: devChannels?.length ?? 0, + plugins: joinPluginIds(channelEntries), + dev_plugins: joinPluginIds(devChannels ?? []), + }) + } + } + + // SDK opt-in for SendUserMessage via --tools. All sessions require + // explicit opt-in; listing it in --tools signals intent. Runs BEFORE + // initializeToolPermissionContext so getToolsForDefaultPreset() sees + // the tool as enabled when computing the base-tools disallow filter. + // Conditional require avoids leaking the tool-name string into + // external builds. + if ( + (feature('KAIROS') || feature('KAIROS_BRIEF')) && + baseTools.length > 0 + ) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { BRIEF_TOOL_NAME, LEGACY_BRIEF_TOOL_NAME } = + require('./tools/BriefTool/prompt.js') as typeof import('./tools/BriefTool/prompt.js') + const { isBriefEntitled } = + require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + const parsed = parseToolListFromCLI(baseTools) + if ( + (parsed.includes(BRIEF_TOOL_NAME) || + parsed.includes(LEGACY_BRIEF_TOOL_NAME)) && + isBriefEntitled() + ) { + setUserMsgOptIn(true) + } + } + + // This await replaces blocking existsSync/statSync calls that were already in + // the startup path. Wall-clock time is unchanged; we just yield to the event + // loop during the fs I/O instead of blocking it. See #19661. + const initResult = await initializeToolPermissionContext({ + allowedToolsCli: allowedTools, + disallowedToolsCli: disallowedTools, + baseToolsCli: baseTools, + permissionMode, + allowDangerouslySkipPermissions, + addDirs: addDir, + }) + let toolPermissionContext = initResult.toolPermissionContext + const { warnings, dangerousPermissions, overlyBroadBashPermissions } = + initResult + + // Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*)) + if ( + process.env.USER_TYPE === 'ant' && + overlyBroadBashPermissions.length > 0 + ) { + for (const permission of overlyBroadBashPermissions) { + logForDebugging( + `Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`, + ) + } + toolPermissionContext = removeDangerousPermissions( + toolPermissionContext, + overlyBroadBashPermissions, + ) + } + + if (feature('TRANSCRIPT_CLASSIFIER') && dangerousPermissions.length > 0) { + toolPermissionContext = stripDangerousPermissionsForAutoMode( + toolPermissionContext, + ) + } + + // Print any warnings from initialization + warnings.forEach(warning => { + // biome-ignore lint/suspicious/noConsole:: intentional console output + console.error(warning) + }) + + void assertMinVersion() + + // claude.ai config fetch: -p mode only (interactive uses useManageMCPConnections + // two-phase loading). Kicked off here to overlap with setup(); awaited + // before runHeadless so single-turn -p sees connectors. Skipped under + // enterprise/strict MCP to preserve policy boundaries. + const claudeaiConfigPromise: Promise< + Record + > = + 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, + }) + : 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) || {}, + ) + .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 prompts. These are check-ins. Do whatever seems most useful, or call Sleep if there's nothing to do. ${briefVisibility}` + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${proactivePrompt}` + : proactivePrompt + } + + if (feature('KAIROS') && kairosEnabled && assistantModule) { + const assistantAddendum = + assistantModule.getAssistantSystemPromptAddendum() + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${assistantAddendum}` + : assistantAddendum + } + + // Ink root is only needed for interactive sessions — patchConsole in the + // Ink constructor would swallow console output in headless mode. + let root!: Root + let getFpsMetrics!: () => FpsMetrics | undefined + let stats!: StatsStore + + // Show setup screens after commands are loaded + if (!isNonInteractiveSession) { + const ctx = getRenderContext(false) + getFpsMetrics = ctx.getFpsMetrics + stats = ctx.stats + // Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1) + if (process.env.USER_TYPE === 'ant') { + installAsciicastRecorder() + } + + const { createRoot } = await import('./ink.js') + root = await createRoot(ctx.renderOptions) + + // Log startup time now, before any blocking dialog renders. Logging + // from REPL's first render (the old location) included however long + // the user sat on trust/OAuth/onboarding/resume-picker — p99 was ~70s + // dominated by dialog-wait time, not code-path startup. + logEvent('tengu_timer', { + event: + 'startup' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + durationMs: Math.round(process.uptime() * 1000), + }) + + logForDebugging('[STARTUP] Running showSetupScreens()...') + const setupScreensStart = Date.now() + const onboardingShown = await showSetupScreens( + root, + permissionMode, + allowDangerouslySkipPermissions, + commands, + enableClaudeInChrome, + devChannels, + ) + logForDebugging( + `[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`, + ) + + // Now that trust is established and GrowthBook has auth headers, + // resolve the --remote-control / --rc entitlement gate. + if (feature('BRIDGE_MODE') && remoteControlOption !== undefined) { + const { getBridgeDisabledReason } = await import( + './bridge/bridgeEnabled.js' + ) + const disabledReason = await getBridgeDisabledReason() + remoteControl = disabledReason === null + if (disabledReason) { + process.stderr.write( + chalk.yellow(`${disabledReason}\n--rc flag ignored.\n`), + ) + } + } + + // Check for pending agent memory snapshot updates (only for --agent mode, ant-only) + if ( + feature('AGENT_MEMORY_SNAPSHOT') && + mainThreadAgentDefinition && + isCustomAgent(mainThreadAgentDefinition) && + mainThreadAgentDefinition.memory && + mainThreadAgentDefinition.pendingSnapshotUpdate + ) { + const agentDef = mainThreadAgentDefinition + const choice = await launchSnapshotUpdateDialog(root, { + agentType: agentDef.agentType, + scope: agentDef.memory!, + snapshotTimestamp: + agentDef.pendingSnapshotUpdate!.snapshotTimestamp, + }) + if (choice === 'merge') { + const { buildMergePrompt } = await import( + './components/agents/SnapshotUpdateDialog.js' + ) + const mergePrompt = buildMergePrompt( + agentDef.agentType, + agentDef.memory!, + ) + inputPrompt = inputPrompt + ? `${mergePrompt}\n\n${inputPrompt}` + : mergePrompt + } + agentDef.pendingSnapshotUpdate = undefined + } + + // Skip executing /login if we just completed onboarding for it + if (onboardingShown && prompt?.trim().toLowerCase() === '/login') { + prompt = '' + } + + if (onboardingShown) { + // Refresh auth-dependent services now that the user has logged in during onboarding. + // Keep in sync with the post-login logic in src/commands/login.tsx + void refreshRemoteManagedSettings() + void refreshPolicyLimits() + // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials + resetUserCache() + // Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs) + refreshGrowthBookAfterAuthChange() + // Clear any stale trusted device token then enroll for Remote Control. + // Both self-gate on tengu_sessions_elevated_auth_enforcement internally + // — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits + // the GrowthBook reinit above), clearTrustedDeviceToken() via the + // sync cached check (acceptable since clear is idempotent). + void import('./bridge/trustedDevice.js').then(m => { + m.clearTrustedDeviceToken() + return m.enrollTrustedDevice() + }) + } + + // Validate that the active token's org matches forceLoginOrgUUID (if set + // in managed settings). Runs after onboarding so managed settings and + // login state are fully loaded. + const orgValidation = await validateForceLoginOrg() + if (!orgValidation.valid) { + await exitWithError(root, orgValidation.message) + } + } + + // If gracefulShutdown was initiated (e.g., user rejected trust dialog), + // process.exitCode will be set. Skip all subsequent operations that could + // trigger code execution before the process exits (e.g. we don't want apiKeyHelper + // to run if trust was not established). + if (process.exitCode !== undefined) { + logForDebugging( + 'Graceful shutdown initiated, skipping further initialization', + ) + return + } + + // Initialize LSP manager AFTER trust is established (or in non-interactive mode + // where trust is implicit). This prevents plugin LSP servers from executing + // code in untrusted directories before user consent. + // Must be after inline plugins are set (if any) so --plugin-dir LSP servers are included. + initializeLspServerManager() + + // Show settings validation errors after trust is established + // MCP config errors don't block settings from loading, so exclude them + if (!isNonInteractiveSession) { + const { errors } = getSettingsWithErrors() + const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata) + if (nonMcpErrors.length > 0) { + await launchInvalidSettingsDialog(root, { + settingsErrors: nonMcpErrors, + onExit: () => gracefulShutdownSync(1), + }) + } + } + + // Check quota status, fast mode, passes eligibility, and bootstrap data + // after trust is established. These make API calls which could trigger + // apiKeyHelper execution. + // --bare / SIMPLE: skip — these are cache-warms for the REPL's + // first-turn responsiveness (quota, passes, fastMode, bootstrap data). Fast + // mode doesn't apply to the Agent SDK anyway (see getFastModeUnavailableReason). + const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_cicada_nap_ms', + 0, + ) + const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0 + const skipStartupPrefetches = + isBareMode() || + (bgRefreshThrottleMs > 0 && + Date.now() - lastPrefetched < bgRefreshThrottleMs) + + if (!skipStartupPrefetches) { + const lastPrefetchedInfo = + lastPrefetched > 0 + ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` + : '' + logForDebugging( + `Starting background startup prefetches${lastPrefetchedInfo}`, + ) + + checkQuotaStatus().catch(error => logError(error)) + + // Fetch bootstrap data from the server and update all cache values. + void fetchBootstrapData() + + // TODO: Consolidate other prefetches into a single bootstrap request. + void prefetchPassesEligibility() + if ( + !getFeatureValue_CACHED_MAY_BE_STALE('tengu_miraculo_the_bard', false) + ) { + void prefetchFastModeStatus() + } else { + // Kill switch skips the network call, not org-policy enforcement. + // Resolve from cache so orgStatus doesn't stay 'pending' (which + // getFastModeUnavailableReason treats as permissive). + resolveFastModeStatusFromCache() + } + if (bgRefreshThrottleMs > 0) { + saveGlobalConfig(current => ({ + ...current, + startupPrefetchedAt: Date.now(), + })) + } + } else { + logForDebugging( + `Skipping startup prefetches, last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago`, + ) + // Resolve fast mode org status from cache (no network) + resolveFastModeStatusFromCache() + } + + if (!isNonInteractiveSession) { + void refreshExampleCommands() // Pre-fetch example commands (runs git log, no API call) + } + + // Resolve MCP configs (started early, overlaps with setup/trust dialog work) + const { servers: existingMcpConfigs } = await mcpConfigPromise + logForDebugging( + `[STARTUP] MCP configs resolved in ${mcpConfigResolvedMs}ms (awaited at +${Date.now() - mcpConfigStart}ms)`, + ) + // CLI flag (--mcp-config) should override file-based configs, matching settings precedence + const allMcpConfigs = { ...existingMcpConfigs, ...dynamicMcpConfig } + + // Separate SDK configs from regular MCP configs + const sdkMcpConfigs: Record = {} + const regularMcpConfigs: Record = {} + + for (const [name, config] of Object.entries(allMcpConfigs)) { + const typedConfig = config as ScopedMcpServerConfig | McpSdkServerConfig + if (typedConfig.type === 'sdk') { + sdkMcpConfigs[name] = typedConfig as McpSdkServerConfig + } else { + regularMcpConfigs[name] = typedConfig as ScopedMcpServerConfig + } + } + + profileCheckpoint('action_mcp_configs_loaded') + + // Prefetch MCP resources after trust dialog (this is where execution happens). + // Interactive mode only: print mode defers connects until headlessStore exists + // and pushes per-server (below), so ToolSearch's pending-client handling works + // and one slow server doesn't block the batch. + const localMcpPromise = isNonInteractiveSession + ? Promise.resolve({ clients: [], tools: [], commands: [] }) + : prefetchAllMcpResources(regularMcpConfigs) + const claudeaiMcpPromise = isNonInteractiveSession + ? Promise.resolve({ clients: [], tools: [], commands: [] }) + : claudeaiConfigPromise.then(configs => + Object.keys(configs).length > 0 + ? prefetchAllMcpResources(configs) + : { clients: [], tools: [], commands: [] }, + ) + // Merge with dedup by name: each prefetchAllMcpResources call independently + // adds helper tools (ListMcpResourcesTool, ReadMcpResourceTool) via + // local dedup flags, so merging two calls can yield duplicates. print.ts + // already uniqBy's the final tool pool, but dedup here keeps appState clean. + const mcpPromise = Promise.all([ + localMcpPromise, + claudeaiMcpPromise, + ]).then(([local, claudeai]) => ({ + clients: [...local.clients, ...claudeai.clients], + tools: uniqBy([...local.tools, ...claudeai.tools], 'name'), + commands: uniqBy([...local.commands, ...claudeai.commands], 'name'), + })) + + // Start hooks early so they run in parallel with MCP connections. + // Skip for initOnly/init/maintenance (handled separately), non-interactive + // (handled via setupTrigger), and resume/continue (conversationRecovery.ts + // fires 'resume' instead — without this guard, hooks fire TWICE on /resume + // and the second systemMessage clobbers the first. gh-30825) + const hooksPromise = + initOnly || + init || + maintenance || + isNonInteractiveSession || + options.continue || + options.resume + ? null + : processSessionStartHooks('startup', { + agentType: mainThreadAgentDefinition?.agentType, + model: resolvedInitialModel, + }) + + // MCP never blocks REPL render OR turn 1 TTFT. useManageMCPConnections + // populates appState.mcp async as servers connect (connectToServer is + // memoized — the prefetch calls above and the hook converge on the same + // connections). getToolUseContext reads store.getState() fresh via + // computeTools(), so turn 1 sees whatever's connected by query time. + // Slow servers populate for turn 2+. Matches interactive-no-prompt + // behavior. Print mode: per-server push into headlessStore (below). + const hookMessages: Awaited> = [] + // Suppress transient unhandledRejection — the prefetch warms the + // memoized connectToServer cache but nobody awaits it in interactive. + mcpPromise.catch(() => {}) + + const mcpClients: Awaited['clients'] = [] + const mcpTools: Awaited['tools'] = [] + const mcpCommands: Awaited['commands'] = [] + + let thinkingEnabled = shouldEnableThinkingByDefault() + let thinkingConfig: ThinkingConfig = + thinkingEnabled !== false ? { type: 'adaptive' } : { type: 'disabled' } + + if (options.thinking === 'adaptive' || options.thinking === 'enabled') { + thinkingEnabled = true + thinkingConfig = { type: 'adaptive' } + } else if (options.thinking === 'disabled') { + thinkingEnabled = false + thinkingConfig = { type: 'disabled' } + } else { + const maxThinkingTokens = process.env.MAX_THINKING_TOKENS + ? parseInt(process.env.MAX_THINKING_TOKENS, 10) + : options.maxThinkingTokens + if (maxThinkingTokens !== undefined) { + if (maxThinkingTokens > 0) { + thinkingEnabled = true + thinkingConfig = { + type: 'enabled', + budgetTokens: maxThinkingTokens, + } + } else if (maxThinkingTokens === 0) { + thinkingEnabled = false + thinkingConfig = { type: 'disabled' } + } + } + } + + logForDiagnosticsNoPII('info', 'started', { + version: MACRO.VERSION, + is_native_binary: isInBundledMode(), + }) + + registerCleanup(async () => { + logForDiagnosticsNoPII('info', 'exited') + }) + + void logTenguInit({ + hasInitialPrompt: Boolean(prompt), + hasStdin: Boolean(inputPrompt), + verbose, + debug, + debugToStderr, + print: print ?? false, + outputFormat: outputFormat ?? 'text', + inputFormat: inputFormat ?? 'text', + numAllowedTools: allowedTools.length, + numDisallowedTools: disallowedTools.length, + mcpClientCount: Object.keys(allMcpConfigs).length, + worktreeEnabled, + skipWebFetchPreflight: getInitialSettings().skipWebFetchPreflight, + githubActionInputs: process.env.GITHUB_ACTION_INPUTS, + dangerouslySkipPermissionsPassed: dangerouslySkipPermissions ?? false, + permissionMode, + modeIsBypass: permissionMode === 'bypassPermissions', + allowDangerouslySkipPermissionsPassed: allowDangerouslySkipPermissions, + systemPromptFlag: systemPrompt + ? options.systemPromptFile + ? 'file' + : 'flag' + : undefined, + appendSystemPromptFlag: appendSystemPrompt + ? options.appendSystemPromptFile + ? 'file' + : 'flag' + : undefined, + thinkingConfig, + assistantActivationPath: + feature('KAIROS') && kairosEnabled + ? assistantModule?.getAssistantActivationPath() + : undefined, + }) + + // Log context metrics once at initialization + void logContextMetrics(regularMcpConfigs, toolPermissionContext) + + void logPermissionContextForAnts(null, 'initialization') + + logManagedSettings() + + // Register PID file for concurrent-session detection (~/.claude/sessions/) + // and fire multi-clauding telemetry. Lives here (not init.ts) so only the + // REPL path registers — not subcommands like `claude doctor`. Chained: + // count must run after register's write completes or it misses our own file. + void registerSession().then(registered => { + if (!registered) return + if (sessionNameArg) { + void updateSessionName(sessionNameArg) + } + void countConcurrentSessions().then(count => { + if (count >= 2) { + logEvent('tengu_concurrent_sessions', { num_sessions: count }) + } + }) + }) + + // Initialize versioned plugins system (triggers V1→V2 migration if + // needed). Then run orphan GC, THEN warm the Grep/Glob exclusion cache. + // Sequencing matters: the warmup scans disk for .orphaned_at markers, + // so it must see the GC's Pass 1 (remove markers from reinstalled + // versions) and Pass 2 (stamp unmarked orphans) already applied. The + // warm also lands before autoupdate (fires on first submit in REPL) + // can orphan this session's active version underneath us. + // --bare / SIMPLE: skip plugin version sync + orphan cleanup. These + // are install/upgrade bookkeeping that scripted calls don't need — + // the next interactive session will reconcile. The await here was + // blocking -p on a marketplace round-trip. + if (isBareMode()) { + // skip — no-op + } else if (isNonInteractiveSession) { + // In headless mode, await to ensure plugin sync completes before CLI exits + await initializeVersionedPlugins() + profileCheckpoint('action_after_plugins_init') + void cleanupOrphanedPluginVersionsInBackground().then(() => + getGlobExclusionsForPluginCache(), + ) + } else { + // In interactive mode, fire-and-forget — this is purely bookkeeping + // that doesn't affect runtime behavior of the current session + void initializeVersionedPlugins().then(async () => { + profileCheckpoint('action_after_plugins_init') + await cleanupOrphanedPluginVersionsInBackground() + void getGlobExclusionsForPluginCache() + }) + } + + const setupTrigger = + initOnly || init ? 'init' : maintenance ? 'maintenance' : null + if (initOnly) { + applyConfigEnvironmentVariables() + await processSetupHooks('init', { forceSyncExecution: true }) + await processSessionStartHooks('startup', { forceSyncExecution: true }) + gracefulShutdownSync(0) + return + } + + // --print mode + if (isNonInteractiveSession) { + if (outputFormat === 'stream-json' || outputFormat === 'json') { + setHasFormattedOutput(true) + } + + // Apply full environment variables in print mode since trust dialog is bypassed + // This includes potentially dangerous environment variables from untrusted sources + // but print mode is considered trusted (as documented in help text) + applyConfigEnvironmentVariables() + + // Initialize telemetry after env vars are applied so OTEL endpoint env vars and + // otelHeadersHelper (which requires trust to execute) are available. + initializeTelemetryAfterTrust() + + // Kick SessionStart hooks now so the subprocess spawn overlaps with + // MCP connect + plugin init + print.ts import below. loadInitialMessages + // joins this at print.ts:4397. Guarded same as loadInitialMessages — + // continue/resume/teleport paths don't fire startup hooks (or fire them + // conditionally inside the resume branch, where this promise is + // undefined and the ?? fallback runs). Also skip when setupTrigger is + // set — those paths run setup hooks first (print.ts:544), and session + // start hooks must wait until setup completes. + const sessionStartHooksPromise = + options.continue || options.resume || teleport || setupTrigger + ? undefined + : processSessionStartHooks('startup') + // Suppress transient unhandledRejection if this rejects before + // loadInitialMessages awaits it. Downstream await still observes the + // rejection — this just prevents the spurious global handler fire. + sessionStartHooksPromise?.catch(() => {}) + + profileCheckpoint('before_validateForceLoginOrg') + // Validate org restriction for non-interactive sessions + const orgValidation = await validateForceLoginOrg() + if (!orgValidation.valid) { + process.stderr.write(orgValidation.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, + label: string, + ): Promise => { + if (Object.keys(configs).length === 0) return Promise.resolve() headlessStore.setState(prev => ({ ...prev, mcp: { ...prev.mcp, - clients: prev.mcp.clients.some(c => c.name === client.name) ? prev.mcp.clients.map(c => c.name === client.name ? client : c) : [...prev.mcp.clients, client], - tools: uniqBy([...prev.mcp.tools, ...tools], 'name'), - commands: uniqBy([...prev.mcp.commands, ...commands], 'name') - } - })); - }, configs).catch(err => logForDebugging(`[MCP] ${label} connect error: ${err}`)); - }; - // Await all MCP configs — print mode is often single-turn, so - // "late-connecting servers visible next turn" doesn't help. SDK init - // message and turn-1 tool list both need configured MCP tools present. - // Zero-server case is free via the early return in connectMcpBatch. - // Connectors parallelize inside getMcpToolsCommandsAndResources - // (processBatched with Promise.all). claude.ai is awaited too — its - // fetch was kicked off early (line ~2558) so only residual time blocks - // here. --bare skips claude.ai entirely for perf-sensitive scripts. - profileCheckpoint('before_connectMcp'); - await connectMcpBatch(regularMcpConfigs, 'regular'); - profileCheckpoint('after_connectMcp'); - // Dedup: suppress plugin MCP servers that duplicate a claude.ai - // connector (connector wins), then connect claude.ai servers. - // Bounded wait — #23725 made this blocking so single-turn -p sees - // connectors, but with 40+ slow connectors tengu_startup_perf p99 - // climbed to 76s. If fetch+connect doesn't finish in time, proceed; - // the promise keeps running and updates headlessStore in the - // background so turn 2+ still sees connectors. - const CLAUDE_AI_MCP_TIMEOUT_MS = 5_000; - const claudeaiConnect = claudeaiConfigPromise.then(claudeaiConfigs => { - if (Object.keys(claudeaiConfigs).length > 0) { - const claudeaiSigs = new Set(); - for (const config of Object.values(claudeaiConfigs)) { - const sig = getMcpServerSignature(config); - if (sig) claudeaiSigs.add(sig); - } - const suppressed = new Set(); - for (const [name, config] of Object.entries(regularMcpConfigs)) { - if (!name.startsWith('plugin:')) continue; - const sig = getMcpServerSignature(config); - if (sig && claudeaiSigs.has(sig)) suppressed.add(name); - } - if (suppressed.size > 0) { - logForDebugging(`[MCP] Lazy dedup: suppressing ${suppressed.size} plugin server(s) that duplicate claude.ai connectors: ${[...suppressed].join(', ')}`); - // Disconnect before filtering from state. Only connected - // servers need cleanup — clearServerCache on a never-connected - // server triggers a real connect just to kill it (memoize - // cache-miss path, see useManageMCPConnections.ts:870). - for (const c of headlessStore.getState().mcp.clients) { - if (!suppressed.has(c.name) || c.type !== 'connected') continue; - c.client.onclose = undefined; - void clearServerCache(c.name, c.config).catch(() => {}); - } - headlessStore.setState(prev => { - let { - clients, - tools, - commands, - resources - } = prev.mcp; - clients = clients.filter(c => !suppressed.has(c.name)); - tools = tools.filter(t => !t.mcpInfo || !suppressed.has(t.mcpInfo.serverName)); - for (const name of suppressed) { - commands = excludeCommandsByServer(commands, name); - resources = excludeResourcesByServer(resources, name); - } - return { + 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, - tools, - commands, - resources + clients: prev.mcp.clients.some(c => c.name === client.name) + ? prev.mcp.clients.map(c => + c.name === client.name ? client : c, + ) + : [...prev.mcp.clients, client], + tools: uniqBy([...prev.mcp.tools, ...tools], 'name'), + commands: uniqBy([...prev.mcp.commands, ...commands], 'name'), + }, + })) + }, + configs, + ).catch(err => + logForDebugging(`[MCP] ${label} connect error: ${err}`), + ) + } + // Await all MCP configs — print mode is often single-turn, so + // "late-connecting servers visible next turn" doesn't help. SDK init + // message and turn-1 tool list both need configured MCP tools present. + // Zero-server case is free via the early return in connectMcpBatch. + // Connectors parallelize inside getMcpToolsCommandsAndResources + // (processBatched with Promise.all). claude.ai is awaited too — its + // fetch was kicked off early (line ~2558) so only residual time blocks + // here. --bare skips claude.ai entirely for perf-sensitive scripts. + profileCheckpoint('before_connectMcp') + await connectMcpBatch(regularMcpConfigs, 'regular') + profileCheckpoint('after_connectMcp') + // Dedup: suppress plugin MCP servers that duplicate a claude.ai + // connector (connector wins), then connect claude.ai servers. + // Bounded wait — #23725 made this blocking so single-turn -p sees + // connectors, but with 40+ slow connectors tengu_startup_perf p99 + // climbed to 76s. If fetch+connect doesn't finish in time, proceed; + // the promise keeps running and updates headlessStore in the + // background so turn 2+ still sees connectors. + const CLAUDE_AI_MCP_TIMEOUT_MS = 5_000 + const claudeaiConnect = claudeaiConfigPromise.then(claudeaiConfigs => { + if (Object.keys(claudeaiConfigs).length > 0) { + const claudeaiSigs = new Set() + for (const config of Object.values(claudeaiConfigs)) { + const sig = getMcpServerSignature(config) + if (sig) claudeaiSigs.add(sig) + } + const suppressed = new Set() + for (const [name, config] of Object.entries(regularMcpConfigs)) { + if (!name.startsWith('plugin:')) continue + const sig = getMcpServerSignature(config) + if (sig && claudeaiSigs.has(sig)) suppressed.add(name) + } + if (suppressed.size > 0) { + logForDebugging( + `[MCP] Lazy dedup: suppressing ${suppressed.size} plugin server(s) that duplicate claude.ai connectors: ${[...suppressed].join(', ')}`, + ) + // Disconnect before filtering from state. Only connected + // servers need cleanup — clearServerCache on a never-connected + // server triggers a real connect just to kill it (memoize + // cache-miss path, see useManageMCPConnections.ts:870). + for (const c of headlessStore.getState().mcp.clients) { + if (!suppressed.has(c.name) || c.type !== 'connected') continue + c.client.onclose = undefined + void clearServerCache(c.name, c.config).catch(() => {}) + } + headlessStore.setState(prev => { + let { clients, tools, commands, resources } = prev.mcp + clients = clients.filter(c => !suppressed.has(c.name)) + tools = tools.filter( + t => !t.mcpInfo || !suppressed.has(t.mcpInfo.serverName), + ) + for (const name of suppressed) { + commands = excludeCommandsByServer(commands, name) + resources = excludeResourcesByServer(resources, name) } - }; - }); + return { + ...prev, + mcp: { ...prev.mcp, clients, tools, commands, resources }, + } + }) + } + } + // Suppress claude.ai connectors that duplicate an enabled + // manual server (URL-signature match). Plugin dedup above only + // handles `plugin:*` keys; this catches manual `.mcp.json` entries. + // plugin:* must be excluded here — step 1 already suppressed + // those (claude.ai wins); leaving them in suppresses the + // connector too, and neither survives (gh-39974). + const nonPluginConfigs = pickBy( + regularMcpConfigs, + (_, n) => !n.startsWith('plugin:'), + ) + const { servers: dedupedClaudeAi } = dedupClaudeAiMcpServers( + claudeaiConfigs, + nonPluginConfigs, + ) + return connectMcpBatch(dedupedClaudeAi, 'claudeai') + }) + let claudeaiTimer: ReturnType | undefined + const claudeaiTimedOut = await Promise.race([ + claudeaiConnect.then(() => false), + new Promise(resolve => { + claudeaiTimer = setTimeout( + r => r(true), + CLAUDE_AI_MCP_TIMEOUT_MS, + resolve, + ) + }), + ]) + if (claudeaiTimer) clearTimeout(claudeaiTimer) + if (claudeaiTimedOut) { + logForDebugging( + `[MCP] claude.ai connectors not ready after ${CLAUDE_AI_MCP_TIMEOUT_MS}ms — proceeding; background connection continues`, + ) + } + profileCheckpoint('after_connectMcp_claudeai') + + // In headless mode, start deferred prefetches immediately (no user typing delay) + // --bare / SIMPLE: startDeferredPrefetches early-returns internally. + // backgroundHousekeeping (initExtractMemories, pruneShellSnapshots, + // cleanupOldMessageFiles) and sdkHeapDumpMonitor are all bookkeeping + // that scripted calls don't need — the next interactive session reconciles. + if (!isBareMode()) { + startDeferredPrefetches() + void import('./utils/backgroundHousekeeping.js').then(m => + m.startBackgroundHousekeeping(), + ) + if (process.env.USER_TYPE === 'ant') { + void import('./utils/sdkHeapDumpMonitor.js').then(m => + m.startSdkMemoryMonitor(), + ) } } - // Suppress claude.ai connectors that duplicate an enabled - // manual server (URL-signature match). Plugin dedup above only - // handles `plugin:*` keys; this catches manual `.mcp.json` entries. - // plugin:* must be excluded here — step 1 already suppressed - // those (claude.ai wins); leaving them in suppresses the - // connector too, and neither survives (gh-39974). - const nonPluginConfigs = pickBy(regularMcpConfigs, (_, n) => !n.startsWith('plugin:')); - const { - servers: dedupedClaudeAi - } = dedupClaudeAiMcpServers(claudeaiConfigs, nonPluginConfigs); - return connectMcpBatch(dedupedClaudeAi, 'claudeai'); - }); - let claudeaiTimer: ReturnType | undefined; - const claudeaiTimedOut = await Promise.race([claudeaiConnect.then(() => false), new Promise(resolve => { - claudeaiTimer = setTimeout(r => r(true), CLAUDE_AI_MCP_TIMEOUT_MS, resolve); - })]); - if (claudeaiTimer) clearTimeout(claudeaiTimer); - if (claudeaiTimedOut) { - logForDebugging(`[MCP] claude.ai connectors not ready after ${CLAUDE_AI_MCP_TIMEOUT_MS}ms — proceeding; background connection continues`); - } - profileCheckpoint('after_connectMcp_claudeai'); - // In headless mode, start deferred prefetches immediately (no user typing delay) - // --bare / SIMPLE: startDeferredPrefetches early-returns internally. - // backgroundHousekeeping (initExtractMemories, pruneShellSnapshots, - // cleanupOldMessageFiles) and sdkHeapDumpMonitor are all bookkeeping - // that scripted calls don't need — the next interactive session reconciles. - if (!isBareMode()) { - startDeferredPrefetches(); - void import('./utils/backgroundHousekeeping.js').then(m => m.startBackgroundHousekeeping()); - if ((process.env.USER_TYPE) === 'ant') { - void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor()); - } + logSessionTelemetry() + profileCheckpoint('before_print_import') + const { runHeadless } = await import('src/cli/print.js') + profileCheckpoint('after_print_import') + void runHeadless( + inputPrompt, + () => headlessStore.getState(), + headlessStore.setState, + commandsHeadless, + tools, + sdkMcpConfigs, + agentDefinitions.activeAgents, + { + continue: options.continue, + resume: options.resume, + verbose: verbose, + outputFormat: outputFormat, + jsonSchema, + permissionPromptToolName: options.permissionPromptTool, + allowedTools, + thinkingConfig, + maxTurns: options.maxTurns, + maxBudgetUsd: options.maxBudgetUsd, + taskBudget: options.taskBudget + ? { total: options.taskBudget } + : undefined, + systemPrompt, + appendSystemPrompt, + userSpecifiedModel: effectiveModel, + fallbackModel: userSpecifiedFallbackModel, + teleport, + sdkUrl, + replayUserMessages: effectiveReplayUserMessages, + includePartialMessages: effectiveIncludePartialMessages, + forkSession: options.forkSession || false, + resumeSessionAt: options.resumeSessionAt || undefined, + rewindFiles: options.rewindFiles, + enableAuthStatus: options.enableAuthStatus, + agent: agentCli, + workload: options.workload, + setupTrigger: setupTrigger ?? undefined, + sessionStartHooksPromise, + }, + ) + return } - 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, + + // 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(), + 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, - 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; - } + taskListId, + thinkingConfig, + ...(uploaderReady && { + onTurnComplete: (messages: MessageType[]) => { + void uploaderReady.then(uploader => uploader?.(messages)) + }, + }), + } - // 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 - }); + // Shared context for processResumedConversation calls + const resumeContext = { + modeApi: coordinatorModeModule, + mainThreadAgentDefinition, + agentDefinitions, + currentCwd, + cliAgents, + initialState, + } - // Get deprecation warning for the initial model (resolvedInitialModel computed earlier for hooks parallelization) - const deprecationWarning = getModelDeprecationWarning(resolvedInitialModel); + if (options.continue) { + // Continue the most recent conversation directly + let resumeSucceeded = false + try { + const resumeStart = performance.now() - // 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(), - 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?.()) || undefined - }; + // Clear stale caches before resuming to ensure fresh file/skill discovery + const { clearSessionCaches } = await import( + './commands/clear/caches.js' + ) + clearSessionCaches() - // Add CLI initial prompt to history - if (inputPrompt) { - addToHistory(String(inputPrompt)); - } - const initialTools = mcpTools; + const result = await loadConversationForResume( + undefined /* sessionId */, + undefined /* sourceFile */, + ) + if (!result) { + logEvent('tengu_continue', { + success: false, + }) + return await exitWithError( + root, + 'No conversation found to continue', + ) + } - // 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(); - }); + const loaded = await processResumedConversation( + result, + { + forkSession: !!options.forkSession, + includeAttribution: true, + transcriptPath: result.fullPath, + }, + resumeContext, + ) - // 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; + if (loaded.restoredAgentDef) { + mainThreadAgentDefinition = loaded.restoredAgentDef + } - // 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)); + maybeActivateProactive(options) + maybeActivateBrief(options) + + logEvent('tengu_continue', { + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }) + resumeSucceeded = true + + await launchRepl( + root, + { getFpsMetrics, stats, initialState: loaded.initialState }, + { + ...sessionConfig, + mainThreadAgentDefinition: + loaded.restoredAgentDef ?? mainThreadAgentDefinition, + initialMessages: loaded.messages, + initialFileHistorySnapshots: loaded.fileHistorySnapshots, + initialContentReplacements: loaded.contentReplacements, + initialAgentName: loaded.agentName, + initialAgentColor: loaded.agentColor, + }, + renderAndRun, + ) + } catch (error) { + if (!resumeSucceeded) { + logEvent('tengu_continue', { + success: false, + }) + } + logError(error) + process.exit(1) + } + } else if (feature('DIRECT_CONNECT') && _pendingConnect?.url) { + // `claude connect ` — full interactive TUI connected to a remote server + let directConnectConfig + try { + const session = await createDirectConnectSession({ + serverUrl: _pendingConnect.url, + authToken: _pendingConnect.authToken, + cwd: getOriginalCwd(), + dangerouslySkipPermissions: + _pendingConnect.dangerouslySkipPermissions, + }) + if (session.workDir) { + setOriginalCwd(session.workDir) + setCwdState(session.workDir) + } + setDirectConnectServerUrl(_pendingConnect.url) + directConnectConfig = session.config + } catch (err) { + return await exitWithError( + root, + err instanceof DirectConnectError ? err.message : String(err), + () => gracefulShutdown(1), + ) } - }) - }; - // 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(); + const connectInfoMessage = createSystemMessage( + `Connected to server at ${_pendingConnect.url}\nSession: ${directConnectConfig.sessionId}`, + 'info', + ) + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + debug: debug || debugToStderr, + commands, + initialTools: [], + initialMessages: [connectInfoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + directConnectConfig, + thinkingConfig, + }, + renderAndRun, + ) + return + } else if (feature('SSH_REMOTE') && _pendingSSH?.host) { + // `claude ssh [dir]` — probe remote, deploy binary if needed, + // spawn ssh with unix-socket -R forward to a local auth proxy, hand + // the REPL an SSHSession. Tools run remotely, UI renders locally. + // `--local` skips probe/deploy/ssh and spawns the current binary + // directly with the same env — e2e test of the proxy/auth plumbing. + const { createSSHSession, createLocalSSHSession, SSHSessionError } = + await import('./ssh/createSSHSession.js') + let sshSession + 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(); - const result = await loadConversationForResume(undefined /* sessionId */, undefined /* sourceFile */); - if (!result) { - logEvent('tengu_continue', { - success: false - }); - return await exitWithError(root, 'No conversation found to continue'); - } - const loaded = await processResumedConversation(result, { - forkSession: !!options.forkSession, - includeAttribution: true, - transcriptPath: result.fullPath - }, resumeContext); - if (loaded.restoredAgentDef) { - mainThreadAgentDefinition = loaded.restoredAgentDef; - } - maybeActivateProactive(options); - maybeActivateBrief(options); - logEvent('tengu_continue', { - success: true, - resume_duration_ms: Math.round(performance.now() - resumeStart) - }); - resumeSucceeded = true; - await launchRepl(root, { - getFpsMetrics, - stats, - initialState: loaded.initialState - }, { - ...sessionConfig, - mainThreadAgentDefinition: loaded.restoredAgentDef ?? mainThreadAgentDefinition, - initialMessages: loaded.messages, - initialFileHistorySnapshots: loaded.fileHistorySnapshots, - initialContentReplacements: loaded.contentReplacements, - initialAgentName: loaded.agentName, - initialAgentColor: loaded.agentColor - }, renderAndRun); - } catch (error) { - if (!resumeSucceeded) { - logEvent('tengu_continue', { - success: false - }); - } - logError(error); - process.exit(1); - } - } else if (feature('DIRECT_CONNECT') && _pendingConnect?.url) { - // `claude connect ` — full interactive TUI connected to a remote server - let directConnectConfig; - try { - const session = await createDirectConnectSession({ - serverUrl: _pendingConnect.url, - authToken: _pendingConnect.authToken, - cwd: getOriginalCwd(), - dangerouslySkipPermissions: _pendingConnect.dangerouslySkipPermissions - }); - if (session.workDir) { - setOriginalCwd(session.workDir); - setCwdState(session.workDir); - } - setDirectConnectServerUrl(_pendingConnect.url); - directConnectConfig = session.config; - } catch (err) { - return await exitWithError(root, err instanceof DirectConnectError ? err.message : String(err), () => gracefulShutdown(1)); - } - const connectInfoMessage = createSystemMessage(`Connected to server at ${_pendingConnect.url}\nSession: ${directConnectConfig.sessionId}`, 'info'); - await launchRepl(root, { - getFpsMetrics, - stats, - initialState - }, { - debug: debug || debugToStderr, - commands, - initialTools: [], - initialMessages: [connectInfoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - directConnectConfig, - thinkingConfig - }, renderAndRun); - return; - } else if (feature('SSH_REMOTE') && _pendingSSH?.host) { - // `claude ssh [dir]` — probe remote, deploy binary if needed, - // spawn ssh with unix-socket -R forward to a local auth proxy, hand - // the REPL an SSHSession. Tools run remotely, UI renders locally. - // `--local` skips probe/deploy/ssh and spawns the current binary - // directly with the same env — e2e test of the proxy/auth plumbing. - const { - createSSHSession, - createLocalSSHSession, - SSHSessionError - } = await import('./ssh/createSSHSession.js'); - let sshSession; - 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; + const { clearSessionCaches } = await import( + './commands/clear/caches.js' + ) + clearSessionCaches() - // 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; - } - } + let messages: MessageType[] | null = null + let processedResume: ProcessedResume | undefined = undefined - // 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; + let maybeSessionId = validateUuid(options.resume) + let searchTerm: string | undefined = undefined + // Store full LogOption when found by custom title (for cross-worktree resume) + let matchedLog: LogOption | null = null + // PR filter for --from-pr flag + let filterByPr: boolean | number | string | undefined = undefined - // Brief mode activation: setKairosActive(true) satisfies BOTH opt-in - // and entitlement for isBriefEnabled() (BriefTool.ts:124-132). - setKairosActive(true); - setUserMsgOptIn(true); - setIsRemoteMode(true); - const remoteSessionConfig = createRemoteSessionConfig(targetSessionId, getAccessToken, apiCreds.orgUUID, /* hasInitialPrompt */false, /* viewerOnly */true); - const infoMessage = createSystemMessage(`Attached to assistant session ${targetSessionId.slice(0, 8)}…`, 'info'); - const assistantInitialState: AppState = { - ...initialState, - isBriefOnly: true, - kairosEnabled: false, - replBridgeEnabled: false - }; - const remoteCommands = filterCommandsForRemoteMode(commands); - await launchRepl(root, { - getFpsMetrics, - stats, - initialState: assistantInitialState - }, { - debug: debug || debugToStderr, - commands: remoteCommands, - initialTools: [], - initialMessages: [infoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - remoteSessionConfig, - thinkingConfig - }, renderAndRun); - return; - } else if (options.resume || options.fromPr || teleport || remote !== null) { - // Handle resume flow - from file (ant-only), session ID, or interactive selector - - // Clear stale caches before resuming to ensure fresh file/skill discovery - const { - clearSessionCaches - } = await import('./commands/clear/caches.js'); - clearSessionCaches(); - let messages: MessageType[] | null = null; - let processedResume: ProcessedResume | undefined = undefined; - let maybeSessionId = validateUuid(options.resume); - let searchTerm: string | undefined = undefined; - // Store full LogOption when found by custom title (for cross-worktree resume) - let matchedLog: LogOption | null = null; - // PR filter for --from-pr flag - let filterByPr: boolean | number | string | undefined = undefined; - - // Handle --from-pr flag - if (options.fromPr) { - if (options.fromPr === true) { - // Show all sessions with linked PRs - filterByPr = true; - } else if (typeof options.fromPr === 'string') { - // Could be a PR number or URL - filterByPr = options.fromPr; - } - } - - // If resume value is not a UUID, try exact match by custom title first - if (options.resume && typeof options.resume === 'string' && !maybeSessionId) { - const trimmedValue = options.resume.trim(); - if (trimmedValue) { - const matches = await searchSessionsByCustomTitle(trimmedValue, { - exact: true - }); - if (matches.length === 1) { - // Exact match found - store full LogOption for cross-worktree resume - matchedLog = matches[0]!; - maybeSessionId = getSessionIdFromLog(matchedLog) ?? null; - } else { - // No match or multiple matches - use as search term for picker - searchTerm = trimmedValue; + // 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 } } - } - // --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; + // 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, + }) - // 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]) as MessageType[], - 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'); + if (matches.length === 1) { + // Exact match found - store full LogOption for cross-worktree resume + matchedLog = matches[0]! + maybeSessionId = getSessionIdFromLog(matchedLog) ?? null } else { - logError(error); - process.stderr.write(chalk.red(`Error: ${errorMessage(error)}\n`)); + // No match or multiple matches - use as search term for picker + searchTerm = trimmedValue } - 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)); + + // --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) } - } else { - const resolvedPath = resolve(options.resume); + 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 { - 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 + // 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`, + ), + ) } - if (logOption) { - const result = await loadConversationForResume(logOption, undefined /* sourceFile */); + + 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: !!options.forkSession, - transcriptPath: result.fullPath - }, resumeContext); + processedResume = await processResumedConversation( + result, + { + forkSession: true, + transcriptPath: result.fullPath, + }, + resumeContext, + ) if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = processedResume.restoredAgentDef; + mainThreadAgentDefinition = processedResume.restoredAgentDef } logEvent('tengu_session_resumed', { - entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: + 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: true, - resume_duration_ms: Math.round(performance.now() - resumeStart) - }); + 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 - }); + 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), + ) } - } 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) { + // 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: false - }); - return await exitWithError(root, `No conversation found with session ID: ${sessionId}`); + 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}`) } - 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`)); + // 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)}`, + ) } - } 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); + // 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 { - // 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'); - } + // 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 - // If launched via a deep link, show a provenance banner so the user - // knows the session originated externally. Linux xdg-open and - // browsers with "always allow" set dispatch the link with no OS-level - // confirmation, so this is the only signal the user gets that the - // prompt — and the working directory / CLAUDE.md it implies — came - // from an external source rather than something they typed. - let deepLinkBanner: ReturnType | null = null; - if (feature('LODESTONE')) { - if (options.deepLinkOrigin) { - logEvent('tengu_deep_link_opened', { - has_prefill: Boolean(options.prefill), - has_repo: Boolean(options.deepLinkRepo) - }); - deepLinkBanner = createSystemMessage(buildDeepLinkBanner({ - cwd: getCwd(), - prefillLength: options.prefill?.length, - repo: options.deepLinkRepo, - lastFetch: options.deepLinkLastFetch !== undefined ? new Date(options.deepLinkLastFetch) : undefined - }), 'warning'); - } else if (options.prefill) { - deepLinkBanner = createSystemMessage('Launched with a pre-filled prompt — review it before pressing Enter.', 'warning'); + profileCheckpoint('action_after_hooks') + maybeActivateProactive(options) + maybeActivateBrief(options) + // Persist the current mode for fresh sessions so future resumes know what mode was used + if (feature('COORDINATOR_MODE')) { + saveMode( + coordinatorModeModule?.isCoordinatorMode() + ? 'coordinator' + : 'normal', + ) } + + // If launched via a deep link, show a provenance banner so the user + // knows the session originated externally. Linux xdg-open and + // browsers with "always allow" set dispatch the link with no OS-level + // confirmation, so this is the only signal the user gets that the + // prompt — and the working directory / CLAUDE.md it implies — came + // from an external source rather than something they typed. + let deepLinkBanner: ReturnType | null = null + if (feature('LODESTONE')) { + if (options.deepLinkOrigin) { + logEvent('tengu_deep_link_opened', { + has_prefill: Boolean(options.prefill), + has_repo: Boolean(options.deepLinkRepo), + }) + deepLinkBanner = createSystemMessage( + buildDeepLinkBanner({ + cwd: getCwd(), + prefillLength: options.prefill?.length, + repo: options.deepLinkRepo, + lastFetch: + options.deepLinkLastFetch !== undefined + ? new Date(options.deepLinkLastFetch) + : undefined, + }), + 'warning', + ) + } else if (options.prefill) { + deepLinkBanner = createSystemMessage( + 'Launched with a pre-filled prompt — review it before pressing Enter.', + 'warning', + ) + } + } + const initialMessages = deepLinkBanner + ? [deepLinkBanner, ...hookMessages] + : hookMessages.length > 0 + ? hookMessages + : undefined + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + ...sessionConfig, + initialMessages, + pendingHookMessages, + }, + renderAndRun, + ) } - 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'); + }) + .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.'); + 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 ', 'Enable the server-side advisor tool with the specified model (alias or full ID).').hideHelp()); + program.addOption( + new Option( + '--advisor ', + 'Enable the server-side advisor tool with the specified model (alias or full ID).', + ).hideHelp(), + ) } - if ((process.env.USER_TYPE) === 'ant') { - program.addOption(new Option('--delegate-permissions', '[ANT-ONLY] Alias for --permission-mode auto.').implies({ - permissionMode: 'auto' - })); - program.addOption(new Option('--dangerously-skip-permissions-with-classifiers', '[ANT-ONLY] Deprecated alias for --permission-mode auto.').hideHelp().implies({ - permissionMode: 'auto' - })); - program.addOption(new Option('--afk', '[ANT-ONLY] Deprecated alias for --permission-mode auto.').hideHelp().implies({ - permissionMode: 'auto' - })); - program.addOption(new Option('--tasks [id]', '[ANT-ONLY] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").').argParser(String).hideHelp()); - program.option('--agent-teams', '[ANT-ONLY] Force Claude to use multi-agent mode for solving problems', () => true); + + if (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()); + 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')); + program.addOption( + new Option('--proactive', 'Start in proactive autonomous mode'), + ) } + if (feature('UDS_INBOX')) { - program.addOption(new Option('--messaging-socket-path ', 'Unix domain socket path for the UDS messaging server (defaults to a tmp path)')); + program.addOption( + new Option( + '--messaging-socket-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')); + 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()); + program.addOption( + new Option( + '--assistant', + 'Force assistant mode (Agent SDK daemon use)', + ).hideHelp(), + ) } if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { - program.addOption(new Option('--channels ', 'MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.').hideHelp()); - program.addOption(new Option('--dangerously-load-development-channels ', 'Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.').hideHelp()); + program.addOption( + new Option( + '--channels ', + 'MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.', + ).hideHelp(), + ) + program.addOption( + new Option( + '--dangerously-load-development-channels ', + 'Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.', + ).hideHelp(), + ) } // Teammate identity options (set by leader when spawning tmux teammates) // These replace the CLAUDE_CODE_* environment variables - program.addOption(new Option('--agent-id ', 'Teammate agent ID').hideHelp()); - program.addOption(new Option('--agent-name ', 'Teammate display name').hideHelp()); - program.addOption(new Option('--team-name ', 'Team name for swarm coordination').hideHelp()); - program.addOption(new Option('--agent-color ', 'Teammate UI color').hideHelp()); - program.addOption(new Option('--plan-mode-required', 'Require plan mode before implementation').hideHelp()); - program.addOption(new Option('--parent-session-id ', 'Parent session ID for analytics correlation').hideHelp()); - program.addOption(new Option('--teammate-mode ', 'How to spawn teammates: "tmux", "in-process", or "auto"').choices(['auto', 'tmux', 'in-process']).hideHelp()); - program.addOption(new Option('--agent-type ', 'Custom agent type for this teammate').hideHelp()); + program.addOption( + new Option('--agent-id ', 'Teammate agent ID').hideHelp(), + ) + program.addOption( + new Option('--agent-name ', 'Teammate display name').hideHelp(), + ) + program.addOption( + new Option( + '--team-name ', + 'Team name for swarm coordination', + ).hideHelp(), + ) + program.addOption( + new Option('--agent-color ', 'Teammate UI color').hideHelp(), + ) + program.addOption( + new Option( + '--plan-mode-required', + 'Require plan mode before implementation', + ).hideHelp(), + ) + program.addOption( + new Option( + '--parent-session-id ', + 'Parent session ID for analytics correlation', + ).hideHelp(), + ) + program.addOption( + new Option( + '--teammate-mode ', + 'How to spawn teammates: "tmux", "in-process", or "auto"', + ) + .choices(['auto', 'tmux', 'in-process']) + .hideHelp(), + ) + program.addOption( + new Option( + '--agent-type ', + 'Custom agent type for this teammate', + ).hideHelp(), + ) // Enable SDK URL for all builds but hide from help - program.addOption(new Option('--sdk-url ', 'Use remote WebSocket endpoint for SDK I/O streaming (only with -p and stream-json format)').hideHelp()); + program.addOption( + new Option( + '--sdk-url ', + 'Use remote WebSocket endpoint for SDK I/O streaming (only with -p and stream-json format)', + ).hideHelp(), + ) // Enable teleport/remote flags for all builds but keep them undocumented until GA - program.addOption(new Option('--teleport [session]', 'Resume a teleport session, optionally specify session ID').hideHelp()); - program.addOption(new Option('--remote [description]', 'Create a remote session with the given description').hideHelp()); + 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()); + 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()); + program.addOption( + new Option( + '--hard-fail', + 'Crash on logError calls instead of silently logging', + ).hideHelp(), + ) } - profileCheckpoint('run_main_options_built'); + + profileCheckpoint('run_main_options_built') // -p/--print mode: skip subcommand registration. The 52 subcommands // (mcp, auth, plugin, skill, task, config, doctor, update, etc.) are @@ -3877,161 +5406,228 @@ async function run(): Promise { // + 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://')); + 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; + 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 - }); - }); + 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); + registerMcpAddCommand(mcp) + if (isXaaEnabled()) { - registerMcpXaaIdpCommand(mcp); + registerMcpXaaIdpCommand(mcp) } - mcp.command('remove ').description('Remove an MCP server').option('-s, --scope ', 'Configuration scope (local, user, or project) - if not specified, removes from whichever scope it exists in').action(async (name: string, options: { - scope?: string; - }) => { - const { - mcpRemoveHandler - } = await import('./cli/handlers/mcp.js'); - await mcpRemoveHandler(name, options); - }); - mcp.command('list').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 ').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 ').description('Add an MCP server (stdio or SSE) with a JSON string').option('-s, --scope ', 'Configuration scope (local, user, or project)', 'local').option('--client-secret', 'Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)').action(async (name: string, json: string, options: { - scope?: string; - clientSecret?: true; - }) => { - const { - mcpAddJsonHandler - } = await import('./cli/handlers/mcp.js'); - await mcpAddJsonHandler(name, json, options); - }); - mcp.command('add-from-claude-desktop').description('Import MCP servers from Claude Desktop (Mac and WSL only)').option('-s, --scope ', 'Configuration scope (local, user, or project)', 'local').action(async (options: { - scope?: string; - }) => { - const { - mcpAddFromDesktopHandler - } = await import('./cli/handlers/mcp.js'); - await mcpAddFromDesktopHandler(options); - }); - mcp.command('reset-project-choices').description('Reset all approved and rejected project-scoped (.mcp.json) servers within this project').action(async () => { - const { - mcpResetChoicesHandler - } = await import('./cli/handlers/mcp.js'); - await mcpResetChoicesHandler(); - }); + + mcp + .command('remove ') + .description('Remove an MCP server') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project) - if not specified, removes from whichever scope it exists in', + ) + .action(async (name: string, options: { scope?: string }) => { + const { mcpRemoveHandler } = await import('./cli/handlers/mcp.js') + await mcpRemoveHandler(name, options) + }) + + mcp + .command('list') + .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 ') + .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 ') + .description('Add an MCP server (stdio or SSE) with a JSON string') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project)', + 'local', + ) + .option( + '--client-secret', + 'Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)', + ) + .action( + async ( + name: string, + json: string, + options: { scope?: string; clientSecret?: true }, + ) => { + const { mcpAddJsonHandler } = await import('./cli/handlers/mcp.js') + await mcpAddJsonHandler(name, json, options) + }, + ) + + mcp + .command('add-from-claude-desktop') + .description('Import MCP servers from Claude Desktop (Mac and WSL only)') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project)', + 'local', + ) + .action(async (options: { scope?: string }) => { + const { mcpAddFromDesktopHandler } = await import('./cli/handlers/mcp.js') + await mcpAddFromDesktopHandler(options) + }) + + mcp + .command('reset-project-choices') + .description( + 'Reset all approved and rejected project-scoped (.mcp.json) servers within this project', + ) + .action(async () => { + const { mcpResetChoicesHandler } = await import('./cli/handlers/mcp.js') + await mcpResetChoicesHandler() + }) // claude server if (feature('DIRECT_CONNECT')) { - program.command('server').description('Start a Claude Code session server').option('--port ', 'HTTP port', '0').option('--host ', 'Bind address', '0.0.0.0').option('--auth-token ', 'Bearer token for auth').option('--unix ', 'Listen on a unix domain socket').option('--workspace ', 'Default working directory for sessions that do not specify cwd').option('--idle-timeout ', 'Idle timeout for detached sessions in ms (0 = never expire)', '600000').option('--max-sessions ', '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()); - }); + program + .command('server') + .description('Start a Claude Code session server') + .option('--port ', 'HTTP port', '0') + .option('--host ', 'Bind address', '0.0.0.0') + .option('--auth-token ', 'Bearer token for auth') + .option('--unix ', 'Listen on a unix domain socket') + .option( + '--workspace ', + 'Default working directory for sessions that do not specify cwd', + ) + .option( + '--idle-timeout ', + 'Idle timeout for detached sessions in ms (0 = never expire)', + '600000', + ) + .option( + '--max-sessions ', + '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 [dir]` — registered here only so --help shows it. @@ -4040,97 +5636,157 @@ async function run(): Promise { // this action it means the argv rewrite didn't fire (e.g. user ran // `claude ssh` with no host) — just print usage. if (feature('SSH_REMOTE')) { - program.command('ssh [dir]').description('Run 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 ', '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 ` before - // commander runs. Reaching here means host was missing or the - // rewrite predicate didn't match. - process.stderr.write('Usage: claude ssh [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); - }); + program + .command('ssh [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 ', + '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 ` before + // commander runs. Reaching here means host was missing or the + // rewrite predicate didn't match. + process.stderr.write( + 'Usage: claude ssh [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 ').description('Connect to a Claude Code server (internal — use cc:// URLs)').option('-p, --print [prompt]', 'Print mode (headless)').option('--output-format ', 'Output format: text, json, stream-json', 'text').action(async (ccUrl: string, opts: { - print?: string | true; - outputFormat?: string; - }, _command) => { - 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); - }); + program + .command('open ') + .description( + 'Connect to a Claude Code server (internal — use cc:// URLs)', + ) + .option('-p, --print [prompt]', 'Print mode (headless)') + .option( + '--output-format ', + 'Output format: text, json, stream-json', + 'text', + ) + .action( + async ( + ccUrl: string, + opts: { + print?: string | 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 ', '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(); - }); + const auth = program + .command('auth') + .description('Manage authentication') + .configureHelp(createSortedHelpConfig()) + + auth + .command('login') + .description('Sign in to your Anthropic account') + .option('--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. @@ -4139,172 +5795,300 @@ async function run(): Promise { * @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(); + 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 ').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); - }); + const pluginCmd = program + .command('plugin') + .alias('plugins') + .description('Manage Claude Code plugins') + .configureHelp(createSortedHelpConfig()) + + pluginCmd + .command('validate ') + .description('Validate a plugin or marketplace manifest') + .addOption(coworkOption()) + .action(async (manifestPath: string, options: { cowork?: boolean }) => { + const { pluginValidateHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginValidateHandler(manifestPath, options) + }) // Plugin list command - pluginCmd.command('list').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); - }); + 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 ').description('Add a marketplace from a URL, path, or GitHub repo').addOption(coworkOption()).option('--sparse ', 'Limit checkout to specific directories via git sparse-checkout (for monorepos). Example: --sparse .claude-plugin plugins').option('--scope ', 'Where to declare the marketplace: user (default), project, or local').action(async (source: string, options: { - cowork?: boolean; - sparse?: string[]; - scope?: string; - }) => { - const { - marketplaceAddHandler - } = await import('./cli/handlers/plugins.js'); - await marketplaceAddHandler(source, options); - }); - marketplaceCmd.command('list').description('List all configured marketplaces').option('--json', 'Output as JSON').addOption(coworkOption()).action(async (options: { - json?: boolean; - cowork?: boolean; - }) => { - const { - marketplaceListHandler - } = await import('./cli/handlers/plugins.js'); - await marketplaceListHandler(options); - }); - marketplaceCmd.command('remove ').alias('rm').description('Remove a configured marketplace').addOption(coworkOption()).action(async (name: string, options: { - cowork?: boolean; - }) => { - const { - marketplaceRemoveHandler - } = await import('./cli/handlers/plugins.js'); - await marketplaceRemoveHandler(name, options); - }); - marketplaceCmd.command('update [name]').description('Update marketplace(s) from their source - updates all if no name specified').addOption(coworkOption()).action(async (name: string | undefined, options: { - cowork?: boolean; - }) => { - const { - marketplaceUpdateHandler - } = await import('./cli/handlers/plugins.js'); - await marketplaceUpdateHandler(name, options); - }); + const marketplaceCmd = pluginCmd + .command('marketplace') + .description('Manage Claude Code marketplaces') + .configureHelp(createSortedHelpConfig()) + + marketplaceCmd + .command('add ') + .description('Add a marketplace from a URL, path, or GitHub repo') + .addOption(coworkOption()) + .option( + '--sparse ', + 'Limit checkout to specific directories via git sparse-checkout (for monorepos). Example: --sparse .claude-plugin plugins', + ) + .option( + '--scope ', + 'Where to declare the marketplace: user (default), project, or local', + ) + .action( + async ( + source: string, + options: { cowork?: boolean; sparse?: string[]; scope?: string }, + ) => { + const { marketplaceAddHandler } = await import( + './cli/handlers/plugins.js' + ) + await marketplaceAddHandler(source, options) + }, + ) + + marketplaceCmd + .command('list') + .description('List all configured marketplaces') + .option('--json', 'Output as JSON') + .addOption(coworkOption()) + .action(async (options: { json?: boolean; cowork?: boolean }) => { + const { marketplaceListHandler } = await import( + './cli/handlers/plugins.js' + ) + await marketplaceListHandler(options) + }) + + marketplaceCmd + .command('remove ') + .alias('rm') + .description('Remove a configured marketplace') + .addOption(coworkOption()) + .action(async (name: string, options: { cowork?: boolean }) => { + const { marketplaceRemoveHandler } = await import( + './cli/handlers/plugins.js' + ) + await marketplaceRemoveHandler(name, options) + }) + + marketplaceCmd + .command('update [name]') + .description( + 'Update marketplace(s) from their source - updates all if no name specified', + ) + .addOption(coworkOption()) + .action(async (name: string | undefined, options: { cowork?: boolean }) => { + const { marketplaceUpdateHandler } = await import( + './cli/handlers/plugins.js' + ) + await marketplaceUpdateHandler(name, options) + }) // Plugin install command - pluginCmd.command('install ').alias('i').description('Install a plugin from available marketplaces (use plugin@marketplace for specific marketplace)').option('-s, --scope ', 'Installation scope: user, project, or local', 'user').addOption(coworkOption()).action(async (plugin: string, options: { - scope?: string; - cowork?: boolean; - }) => { - const { - pluginInstallHandler - } = await import('./cli/handlers/plugins.js'); - await pluginInstallHandler(plugin, options); - }); + pluginCmd + .command('install ') + .alias('i') + .description( + 'Install a plugin from available marketplaces (use plugin@marketplace for specific marketplace)', + ) + .option( + '-s, --scope ', + 'Installation scope: user, project, or local', + 'user', + ) + .addOption(coworkOption()) + .action( + async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginInstallHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginInstallHandler(plugin, options) + }, + ) // Plugin uninstall command - pluginCmd.command('uninstall ').alias('remove').alias('rm').description('Uninstall an installed plugin').option('-s, --scope ', 'Uninstall from scope: user, project, or local', 'user').option('--keep-data', "Preserve the plugin's persistent data directory (~/.claude/plugins/data/{id}/)").addOption(coworkOption()).action(async (plugin: string, options: { - scope?: string; - cowork?: boolean; - keepData?: boolean; - }) => { - const { - pluginUninstallHandler - } = await import('./cli/handlers/plugins.js'); - await pluginUninstallHandler(plugin, options); - }); + pluginCmd + .command('uninstall ') + .alias('remove') + .alias('rm') + .description('Uninstall an installed plugin') + .option( + '-s, --scope ', + 'Uninstall from scope: user, project, or local', + 'user', + ) + .option( + '--keep-data', + "Preserve the plugin's persistent data directory (~/.claude/plugins/data/{id}/)", + ) + .addOption(coworkOption()) + .action( + async ( + plugin: string, + options: { scope?: string; cowork?: boolean; keepData?: boolean }, + ) => { + const { pluginUninstallHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginUninstallHandler(plugin, options) + }, + ) // Plugin enable command - pluginCmd.command('enable ').description('Enable a disabled plugin').option('-s, --scope ', `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`).addOption(coworkOption()).action(async (plugin: string, options: { - scope?: string; - cowork?: boolean; - }) => { - const { - pluginEnableHandler - } = await import('./cli/handlers/plugins.js'); - await pluginEnableHandler(plugin, options); - }); + pluginCmd + .command('enable ') + .description('Enable a disabled plugin') + .option( + '-s, --scope ', + `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`, + ) + .addOption(coworkOption()) + .action( + async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginEnableHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginEnableHandler(plugin, options) + }, + ) // Plugin disable command - pluginCmd.command('disable [plugin]').description('Disable an enabled plugin').option('-a, --all', 'Disable all enabled plugins').option('-s, --scope ', `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`).addOption(coworkOption()).action(async (plugin: string | undefined, options: { - scope?: string; - cowork?: boolean; - all?: boolean; - }) => { - const { - pluginDisableHandler - } = await import('./cli/handlers/plugins.js'); - await pluginDisableHandler(plugin, options); - }); + pluginCmd + .command('disable [plugin]') + .description('Disable an enabled plugin') + .option('-a, --all', 'Disable all enabled plugins') + .option( + '-s, --scope ', + `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`, + ) + .addOption(coworkOption()) + .action( + async ( + plugin: string | undefined, + options: { scope?: string; cowork?: boolean; all?: boolean }, + ) => { + const { pluginDisableHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginDisableHandler(plugin, options) + }, + ) // Plugin update command - pluginCmd.command('update ').description('Update a plugin to the latest version (restart required to apply)').option('-s, --scope ', `Installation scope: ${VALID_UPDATE_SCOPES.join(', ')} (default: user)`).addOption(coworkOption()).action(async (plugin: string, options: { - scope?: string; - cowork?: boolean; - }) => { - const { - pluginUpdateHandler - } = await import('./cli/handlers/plugins.js'); - await pluginUpdateHandler(plugin, options); - }); + pluginCmd + .command('update ') + .description( + 'Update a plugin to the latest version (restart required to apply)', + ) + .option( + '-s, --scope ', + `Installation scope: ${VALID_UPDATE_SCOPES.join(', ')} (default: user)`, + ) + .addOption(coworkOption()) + .action( + async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginUpdateHandler } = await import( + './cli/handlers/plugins.js' + ) + await pluginUpdateHandler(plugin, options) + }, + ) // END ANT-ONLY // Setup token command - program.command('setup-token').description('Set up a long-lived authentication token (requires Claude subscription)').action(async () => { - const [{ - setupTokenHandler - }, { - createRoot - }] = await Promise.all([import('./cli/handlers/util.js'), import('./ink.js')]); - const root = await createRoot(getBaseRenderOptions(false)); - await setupTokenHandler(root); - }); + program + .command('setup-token') + .description( + 'Set up a long-lived authentication token (requires Claude subscription)', + ) + .action(async () => { + const [{ setupTokenHandler }, { createRoot }] = await Promise.all([ + import('./cli/handlers/util.js'), + import('./ink.js'), + ]) + const root = await createRoot(getBaseRenderOptions(false)) + await setupTokenHandler(root) + }) // Agents command - list configured agents - program.command('agents').description('List configured agents').option('--setting-sources ', '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); - }); + program + .command('agents') + .description('List configured agents') + .option( + '--setting-sources ', + 'Comma-separated list of setting sources to load (user, project, local).', + ) + .action(async () => { + const { agentsHandler } = await import('./cli/handlers/agents.js') + await agentsHandler() + process.exit(0) + }) + if (feature('TRANSCRIPT_CLASSIFIER')) { // Skip when tengu_auto_mode_config.enabled === 'disabled' (circuit breaker). // Reads from disk cache — GrowthBook isn't initialized at registration time. if (getAutoModeEnabledStateIfCached() !== 'disabled') { - const autoModeCmd = program.command('auto-mode').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 ', 'Override which model is used').action(async options => { - const { - autoModeCritiqueHandler - } = await import('./cli/handlers/autoMode.js'); - await autoModeCritiqueHandler(options); - process.exit(); - }); + 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 ', 'Override which model is used') + .action(async options => { + const { autoModeCritiqueHandler } = await import( + './cli/handlers/autoMode.js' + ) + await autoModeCritiqueHandler(options) + process.exit() + }) } } @@ -4317,38 +6101,54 @@ async function run(): Promise { // (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)); - }); + 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); - }); + program + .command('assistant [sessionId]') + .description( + 'Attach the REPL as a client to a running bridge session. Discovers sessions via API if no sessionId given.', + ) + .action(() => { + // Argv rewriting above should have consumed `assistant [id]` + // before commander runs. Reaching here means a root flag came first + // (e.g. `--debug assistant`) and the position-0 predicate + // didn't match. Print usage like the ssh stub does. + process.stderr.write( + 'Usage: claude assistant [sessionId]\n\n' + + 'Attach the REPL as a viewer client to a running bridge session.\n' + + 'Omit sessionId to discover and pick from available sessions.\n', + ) + process.exit(1) + }) } // Doctor command - check installation health - program.command('doctor').description('Check the health of your Claude Code auto-updater. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.').action(async () => { - const [{ - doctorHandler - }, { - createRoot - }] = await Promise.all([import('./cli/handlers/util.js'), import('./ink.js')]); - const root = await createRoot(getBaseRenderOptions(false)); - await doctorHandler(root); - }); + program + .command('doctor') + .description( + 'Check the health of your Claude Code auto-updater. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.', + ) + .action(async () => { + const [{ doctorHandler }, { createRoot }] = await Promise.all([ + import('./cli/handlers/util.js'), + import('./ink.js'), + ]) + const root = await createRoot(getBaseRenderOptions(false)) + await doctorHandler(root) + }) // claude update // @@ -4356,158 +6156,240 @@ async function run(): Promise { // - We perform exact string comparison (including SHA) to detect any change // - This ensures users always get the latest build, even when only the SHA changes // - UI shows both versions including build metadata for clarity - program.command('update').alias('upgrade').description('Check for updates and install if available').action(async () => { - const { - update - } = await import('src/cli/update.js'); - await update(); - }); + program + .command('update') + .alias('upgrade') + .description('Check for updates and install if available') + .action(async () => { + const { update } = await import('src/cli/update.js') + await update() + }) // claude up — run the project's CLAUDE.md "# claude up" setup instructions. - if ((process.env.USER_TYPE) === 'ant') { - program.command('up').description('[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md').action(async () => { - const { - up - } = await import('src/cli/up.js'); - await up(); - }); + 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); - }); + 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); - }); + 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') { + if (process.env.USER_TYPE === 'ant') { const validateLogId = (value: string) => { - const maybeSessionId = validateUuid(value); - if (maybeSessionId) return maybeSessionId; - return Number(value); - }; + 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); - }); + 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); - }); + program + .command('error') + .description( + '[ANT-ONLY] View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.', + ) + .argument( + '[number]', + 'A number (0, 1, 2, etc.) to display a specific log', + parseInt, + ) + .action(async (number: number | undefined) => { + const { errorHandler } = await import('./cli/handlers/ant.js') + await errorHandler(number) + }) // claude export - program.command('export').description('[ANT-ONLY] Export a conversation to a text file.').usage(' ').argument('', 'Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file').argument('', 'Output file path for the exported text').addHelpText('after', ` + program + .command('export') + .description('[ANT-ONLY] Export a conversation to a text file.') + .usage(' ') + .argument( + '', + 'Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file', + ) + .argument('', 'Output file path for the exported text') + .addHelpText( + 'after', + ` Examples: $ claude export 0 conversation.txt Export conversation at log index 0 $ claude export conversation.txt Export conversation by session ID $ claude export input.json output.txt Render JSON log file to text - $ claude export .jsonl output.txt Render JSONL session file to text`).action(async (source: string, outputFile: string) => { - const { - exportHandler - } = await import('./cli/handlers/ant.js'); - await exportHandler(source, outputFile); - }); - if ((process.env.USER_TYPE) === 'ant') { - const taskCmd = program.command('task').description('[ANT-ONLY] Manage task list tasks'); - taskCmd.command('create ').description('Create a new task').option('-d, --description ', 'Task description').option('-l, --list ', 'Task list ID (defaults to "tasklist")').action(async (subject: string, opts: { - description?: string; - list?: string; - }) => { - const { - taskCreateHandler - } = await import('./cli/handlers/ant.js'); - await taskCreateHandler(subject, opts); - }); - taskCmd.command('list').description('List all tasks').option('-l, --list ', 'Task list ID (defaults to "tasklist")').option('--pending', 'Show only pending tasks').option('--json', 'Output as JSON').action(async (opts: { - list?: string; - pending?: boolean; - json?: boolean; - }) => { - const { - taskListHandler - } = await import('./cli/handlers/ant.js'); - await taskListHandler(opts); - }); - taskCmd.command('get ').description('Get details of a task').option('-l, --list ', 'Task list ID (defaults to "tasklist")').action(async (id: string, opts: { - list?: string; - }) => { - const { - taskGetHandler - } = await import('./cli/handlers/ant.js'); - await taskGetHandler(id, opts); - }); - taskCmd.command('update ').description('Update a task').option('-l, --list ', 'Task list ID (defaults to "tasklist")').option('-s, --status ', `Set status (${TASK_STATUSES.join(', ')})`).option('--subject ', 'Update subject').option('-d, --description ', 'Update description').option('--owner ', 'Set owner').option('--clear-owner', 'Clear owner').action(async (id: string, opts: { - list?: string; - status?: string; - subject?: string; - description?: string; - owner?: string; - clearOwner?: boolean; - }) => { - const { - taskUpdateHandler - } = await import('./cli/handlers/ant.js'); - await taskUpdateHandler(id, opts); - }); - taskCmd.command('dir').description('Show the tasks directory path').option('-l, --list ', 'Task list ID (defaults to "tasklist")').action(async (opts: { - list?: string; - }) => { - const { - taskDirHandler - } = await import('./cli/handlers/ant.js'); - await taskDirHandler(opts); - }); + $ claude export .jsonl output.txt Render JSONL session file to text`, + ) + .action(async (source: string, outputFile: string) => { + const { exportHandler } = await import('./cli/handlers/ant.js') + await exportHandler(source, outputFile) + }) + + if (process.env.USER_TYPE === 'ant') { + const taskCmd = program + .command('task') + .description('[ANT-ONLY] Manage task list tasks') + + taskCmd + .command('create ') + .description('Create a new task') + .option('-d, --description ', 'Task description') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action( + async ( + subject: string, + opts: { description?: string; list?: string }, + ) => { + const { taskCreateHandler } = await import('./cli/handlers/ant.js') + await taskCreateHandler(subject, opts) + }, + ) + + taskCmd + .command('list') + .description('List all tasks') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .option('--pending', 'Show only pending tasks') + .option('--json', 'Output as JSON') + .action( + async (opts: { + list?: string + pending?: boolean + json?: boolean + }) => { + const { taskListHandler } = await import('./cli/handlers/ant.js') + await taskListHandler(opts) + }, + ) + + taskCmd + .command('get ') + .description('Get details of a task') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action(async (id: string, opts: { list?: string }) => { + const { taskGetHandler } = await import('./cli/handlers/ant.js') + await taskGetHandler(id, opts) + }) + + taskCmd + .command('update ') + .description('Update a task') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .option( + '-s, --status ', + `Set status (${TASK_STATUSES.join(', ')})`, + ) + .option('--subject ', 'Update subject') + .option('-d, --description ', 'Update description') + .option('--owner ', 'Set owner') + .option('--clear-owner', 'Clear owner') + .action( + async ( + id: string, + opts: { + list?: string + status?: string + subject?: string + description?: string + owner?: string + clearOwner?: boolean + }, + ) => { + const { taskUpdateHandler } = await import('./cli/handlers/ant.js') + await taskUpdateHandler(id, opts) + }, + ) + + taskCmd + .command('dir') + .description('Show the tasks directory path') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action(async (opts: { list?: string }) => { + const { taskDirHandler } = await import('./cli/handlers/ant.js') + await taskDirHandler(opts) + }) } // claude completion - program.command('completion ', { - hidden: true - }).description('Generate shell completion script (bash, zsh, or fish)').option('--output ', 'Write completion script directly to a file instead of stdout').action(async (shell: string, opts: { - output?: string; - }) => { - const { - completionHandler - } = await import('./cli/handlers/ant.js'); - await completionHandler(shell, opts, program); - }); + program + .command('completion ', { hidden: true }) + .description('Generate shell completion script (bash, zsh, or fish)') + .option( + '--output ', + 'Write completion script directly to a file instead of stdout', + ) + .action(async (shell: string, opts: { output?: string }) => { + const { completionHandler } = await import('./cli/handlers/ant.js') + await completionHandler(shell, opts, program) + }) } - profileCheckpoint('run_before_parse'); - await program.parseAsync(process.argv); - profileCheckpoint('run_after_parse'); + + profileCheckpoint('run_before_parse') + await program.parseAsync(process.argv) + profileCheckpoint('run_after_parse') // Record final checkpoint for total_time calculation - profileCheckpoint('main_after_run'); + profileCheckpoint('main_after_run') // Log startup perf to Statsig (sampled) and output detailed report if enabled - profileReport(); - return program; + profileReport() + + return program } + async function logTenguInit({ hasInitialPrompt, hasStdin, @@ -4530,99 +6412,120 @@ async function logTenguInit({ systemPromptFlag, appendSystemPromptFlag, thinkingConfig, - assistantActivationPath + assistantActivationPath, }: { - hasInitialPrompt: boolean; - hasStdin: boolean; - verbose: boolean; - debug: boolean; - debugToStderr: boolean; - print: boolean; - outputFormat: string; - inputFormat: string; - numAllowedTools: number; - numDisallowedTools: number; - mcpClientCount: number; - worktreeEnabled: boolean; - skipWebFetchPreflight: boolean | undefined; - githubActionInputs: string | undefined; - dangerouslySkipPermissionsPassed: boolean; - permissionMode: string; - modeIsBypass: boolean; - allowDangerouslySkipPermissionsPassed: boolean; - systemPromptFlag: 'file' | 'flag' | undefined; - appendSystemPromptFlag: 'file' | 'flag' | undefined; - thinkingConfig: ThinkingConfig; - assistantActivationPath: string | undefined; + hasInitialPrompt: boolean + hasStdin: boolean + verbose: boolean + debug: boolean + debugToStderr: boolean + print: boolean + outputFormat: string + inputFormat: string + numAllowedTools: number + numDisallowedTools: number + mcpClientCount: number + worktreeEnabled: boolean + skipWebFetchPreflight: boolean | undefined + githubActionInputs: string | undefined + dangerouslySkipPermissionsPassed: boolean + permissionMode: string + modeIsBypass: boolean + allowDangerouslySkipPermissionsPassed: boolean + systemPromptFlag: 'file' | 'flag' | undefined + appendSystemPromptFlag: 'file' | 'flag' | undefined + thinkingConfig: ThinkingConfig + assistantActivationPath: string | undefined }): Promise { try { logEvent('tengu_init', { - entrypoint: 'claude' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + 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, + 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 + 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, + 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, + 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 + 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 + 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, + is_coordinator: + feature('COORDINATOR_MODE') && + coordinatorModeModule?.isCoordinatorMode() + ? true + : undefined, ...(assistantActivationPath && { - assistantActivationPath: assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + 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 - } : {}; - })() : {}) - }); + 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); + logError(error) } } + function maybeActivateProactive(options: unknown): void { - if ((feature('PROACTIVE') || feature('KAIROS')) && ((options as { - proactive?: boolean; - }).proactive || isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE))) { + 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'); + const proactiveModule = require('./proactive/index.js') if (!proactiveModule.isProactiveActive()) { - proactiveModule.activateProactive('command'); + 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; + 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 @@ -4631,50 +6534,70 @@ function maybeActivateBrief(options: unknown): void { // 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'); + const { isBriefEntitled } = + require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js') /* eslint-enable @typescript-eslint/no-require-imports */ - const entitled = isBriefEntitled(); + const entitled = isBriefEntitled() if (entitled) { - setUserMsgOptIn(true); + 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 - }); + source: (briefEnv + ? 'env' + : 'flag') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } + function resetCursor() { - const terminal = process.stderr.isTTY ? process.stderr : process.stdout.isTTY ? process.stdout : undefined; - terminal?.write(SHOW_CURSOR); + const terminal = process.stderr.isTTY + ? process.stderr + : process.stdout.isTTY + ? process.stdout + : undefined + terminal?.write(SHOW_CURSOR) } + type TeammateOptions = { - agentId?: string; - agentName?: string; - teamName?: string; - agentColor?: string; - planModeRequired?: boolean; - parentSessionId?: string; - teammateMode?: 'auto' | 'tmux' | 'in-process'; - agentType?: string; -}; + agentId?: string + agentName?: string + teamName?: string + agentColor?: string + planModeRequired?: boolean + parentSessionId?: string + teammateMode?: 'auto' | 'tmux' | 'in-process' + agentType?: string +} + function extractTeammateOptions(options: unknown): TeammateOptions { if (typeof options !== 'object' || options === null) { - return {}; + return {} } - const opts = options as Record; - const teammateMode = opts.teammateMode; + const opts = options as Record + const teammateMode = opts.teammateMode return { agentId: typeof opts.agentId === 'string' ? opts.agentId : undefined, agentName: typeof opts.agentName === 'string' ? opts.agentName : undefined, teamName: typeof opts.teamName === 'string' ? opts.teamName : undefined, - agentColor: typeof opts.agentColor === 'string' ? opts.agentColor : undefined, - planModeRequired: typeof opts.planModeRequired === 'boolean' ? opts.planModeRequired : undefined, - parentSessionId: typeof opts.parentSessionId === 'string' ? opts.parentSessionId : undefined, - teammateMode: teammateMode === 'auto' || teammateMode === 'tmux' || teammateMode === 'in-process' ? teammateMode : undefined, - agentType: typeof opts.agentType === 'string' ? opts.agentType : undefined - }; + agentColor: + typeof opts.agentColor === 'string' ? opts.agentColor : undefined, + planModeRequired: + typeof opts.planModeRequired === 'boolean' + ? opts.planModeRequired + : undefined, + parentSessionId: + typeof opts.parentSessionId === 'string' + ? opts.parentSessionId + : undefined, + teammateMode: + teammateMode === 'auto' || + teammateMode === 'tmux' || + teammateMode === 'in-process' + ? teammateMode + : undefined, + agentType: typeof opts.agentType === 'string' ? opts.agentType : undefined, + } } diff --git a/src/moreright/useMoreRight.tsx b/src/moreright/useMoreRight.tsx index fab6fa2a6..fb605d9e3 100644 --- a/src/moreright/useMoreRight.tsx +++ b/src/moreright/useMoreRight.tsx @@ -5,21 +5,22 @@ // would resolve to scripts/external-stubs/src/types/ (doesn't exist). // eslint-disable-next-line @typescript-eslint/no-explicit-any -type M = any; +type M = any + export function useMoreRight(_args: { - enabled: boolean; - setMessages: (action: M[] | ((prev: M[]) => M[])) => void; - inputValue: string; - setInputValue: (s: string) => void; - setToolJSX: (args: M) => void; + enabled: boolean + setMessages: (action: M[] | ((prev: M[]) => M[])) => void + inputValue: string + setInputValue: (s: string) => void + setToolJSX: (args: M) => void }): { - onBeforeQuery: (input: string, all: M[], n: number) => Promise; - onTurnComplete: (all: M[], aborted: boolean) => Promise; - render: () => null; + onBeforeQuery: (input: string, all: M[], n: number) => Promise + onTurnComplete: (all: M[], aborted: boolean) => Promise + render: () => null } { return { onBeforeQuery: async () => true, onTurnComplete: async () => {}, - render: () => null - }; + render: () => null, + } } diff --git a/src/replLauncher.tsx b/src/replLauncher.tsx index 2738d8a00..664e95839 100644 --- a/src/replLauncher.tsx +++ b/src/replLauncher.tsx @@ -1,22 +1,28 @@ -import React from 'react'; -import type { StatsStore } from './context/stats.js'; -import type { Root } from './ink.js'; -import type { Props as REPLProps } from './screens/REPL.js'; -import type { AppState } from './state/AppStateStore.js'; -import type { FpsMetrics } from './utils/fpsTracker.js'; +import React from 'react' +import type { StatsStore } from './context/stats.js' +import type { Root } from './ink.js' +import type { Props as REPLProps } from './screens/REPL.js' +import type { AppState } from './state/AppStateStore.js' +import type { FpsMetrics } from './utils/fpsTracker.js' + type AppWrapperProps = { - getFpsMetrics: () => FpsMetrics | undefined; - stats?: StatsStore; - initialState: AppState; -}; -export async function launchRepl(root: Root, appProps: AppWrapperProps, replProps: REPLProps, renderAndRun: (root: Root, element: React.ReactNode) => Promise): Promise { - const { - App - } = await import('./components/App.js'); - const { - REPL - } = await import('./screens/REPL.js'); - await renderAndRun(root, - - ); + getFpsMetrics: () => FpsMetrics | undefined + stats?: StatsStore + initialState: AppState +} + +export async function launchRepl( + root: Root, + appProps: AppWrapperProps, + replProps: REPLProps, + renderAndRun: (root: Root, element: React.ReactNode) => Promise, +): Promise { + const { App } = await import('./components/App.js') + const { REPL } = await import('./screens/REPL.js') + await renderAndRun( + root, + + + , + ) } diff --git a/src/utils/autoRunIssue.tsx b/src/utils/autoRunIssue.tsx index 892fd6fab..971f25635 100644 --- a/src/utils/autoRunIssue.tsx +++ b/src/utils/autoRunIssue.tsx @@ -1,96 +1,72 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useEffect, useRef } from 'react'; -import { KeyboardShortcutHint } from '../components/design-system/KeyboardShortcutHint.js'; -import { Box, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; +import * as React from 'react' +import { useEffect, useRef } from 'react' +import { KeyboardShortcutHint } from '../components/design-system/KeyboardShortcutHint.js' +import { Box, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' + type Props = { - onRun: () => void; - onCancel: () => void; - reason: string; -}; + onRun: () => void + onCancel: () => void + reason: string +} /** * Component that shows a notification about running /issue command * with the ability to cancel via ESC key */ -export function AutoRunIssueNotification(t0) { - const $ = _c(8); - const { - onRun, - onCancel, - reason - } = t0; - const hasRunRef = useRef(false); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", onCancel, t1); - let t2; - let t3; - if ($[1] !== onRun) { - t2 = () => { - if (!hasRunRef.current) { - hasRunRef.current = true; - onRun(); - } - }; - t3 = [onRun]; - $[1] = onRun; - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - useEffect(t2, t3); - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Running feedback capture...; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Press anytime; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== reason) { - t6 = {t4}{t5}Reason: {reason}; - $[6] = reason; - $[7] = t6; - } else { - t6 = $[7]; - } - return t6; +export function AutoRunIssueNotification({ + onRun, + onCancel, + reason, +}: Props): React.ReactNode { + const hasRunRef = useRef(false) + + // Handle ESC key to cancel + useKeybinding('confirm:no', onCancel, { context: 'Confirmation' }) + + // Run /issue immediately on mount + useEffect(() => { + if (!hasRunRef.current) { + hasRunRef.current = true + onRun() + } + }, [onRun]) + + return ( + + + Running feedback capture... + + + + Press anytime + + + + Reason: {reason} + + + ) } -export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good'; + +export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good' /** * Determines if /issue should auto-run for Ant users */ export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean { // Only for Ant users - if ((process.env.USER_TYPE) !== 'ant') { - return false; + if (process.env.USER_TYPE !== 'ant') { + return false } + switch (reason) { case 'feedback_survey_bad': - return false; + return false case 'feedback_survey_good': - return false; + return false default: - return false; + return false } } @@ -100,10 +76,10 @@ export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean { */ export function getAutoRunCommand(reason: AutoRunIssueReason): string { // Only ant builds have the /good-claude command - if ((process.env.USER_TYPE) === 'ant' && reason === 'feedback_survey_good') { - return '/good-claude'; + if (process.env.USER_TYPE === 'ant' && reason === 'feedback_survey_good') { + return '/good-claude' } - return '/issue'; + return '/issue' } /** @@ -112,10 +88,10 @@ export function getAutoRunCommand(reason: AutoRunIssueReason): string { export function getAutoRunIssueReasonText(reason: AutoRunIssueReason): string { switch (reason) { case 'feedback_survey_bad': - return 'You responded "Bad" to the feedback survey'; + return 'You responded "Bad" to the feedback survey' case 'feedback_survey_good': - return 'You responded "Good" to the feedback survey'; + return 'You responded "Good" to the feedback survey' default: - return 'Unknown reason'; + return 'Unknown reason' } } diff --git a/src/utils/claudeInChrome/toolRendering.tsx b/src/utils/claudeInChrome/toolRendering.tsx index 9ae5f449a..d760085fb 100644 --- a/src/utils/claudeInChrome/toolRendering.tsx +++ b/src/utils/claudeInChrome/toolRendering.tsx @@ -1,104 +1,146 @@ -import * as React from 'react'; -import { MessageResponse } from '../../components/MessageResponse.js'; -import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js'; -import { Link, Text } from '../../ink.js'; -import { renderToolResultMessage as renderDefaultMCPToolResultMessage } from '../../tools/MCPTool/UI.js'; -import type { MCPToolResult } from '../../utils/mcpValidation.js'; -import { truncateToWidth } from '../format.js'; -import { trackClaudeInChromeTabId } from './common.js'; -export type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import * as React from 'react' +import { MessageResponse } from '../../components/MessageResponse.js' +import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js' +import { Link, Text } from '../../ink.js' +import { renderToolResultMessage as renderDefaultMCPToolResultMessage } from '../../tools/MCPTool/UI.js' +import type { MCPToolResult } from '../../utils/mcpValidation.js' +import { truncateToWidth } from '../format.js' +import { trackClaudeInChromeTabId } from './common.js' + +export type { Tool } from '@modelcontextprotocol/sdk/types.js' /** * All tool names from BROWSER_TOOLS in @ant/claude-for-chrome-mcp. * Keep in sync with the package's BROWSER_TOOLS array. */ -export type ChromeToolName = 'javascript_tool' | 'read_page' | 'find' | 'form_input' | 'computer' | 'navigate' | 'resize_window' | 'gif_creator' | 'upload_image' | 'get_page_text' | 'tabs_context_mcp' | 'tabs_create_mcp' | 'update_plan' | 'read_console_messages' | 'read_network_requests' | 'shortcuts_list' | 'shortcuts_execute'; -const CHROME_EXTENSION_FOCUS_TAB_URL_BASE = 'https://clau.de/chrome/tab/'; -function renderChromeToolUseMessage(input: Record, toolName: ChromeToolName, verbose: boolean): React.ReactNode { - const tabId = input.tabId; +export type ChromeToolName = + | 'javascript_tool' + | 'read_page' + | 'find' + | 'form_input' + | 'computer' + | 'navigate' + | 'resize_window' + | 'gif_creator' + | 'upload_image' + | 'get_page_text' + | 'tabs_context_mcp' + | 'tabs_create_mcp' + | 'update_plan' + | 'read_console_messages' + | 'read_network_requests' + | 'shortcuts_list' + | 'shortcuts_execute' + +const CHROME_EXTENSION_FOCUS_TAB_URL_BASE = 'https://clau.de/chrome/tab/' + +function renderChromeToolUseMessage( + input: Record, + toolName: ChromeToolName, + verbose: boolean, +): React.ReactNode { + const tabId = input.tabId if (typeof tabId === 'number') { - trackClaudeInChromeTabId(tabId); + trackClaudeInChromeTabId(tabId) } // Build secondary info based on tool type and input - const secondaryInfo: string[] = []; + const secondaryInfo: string[] = [] + switch (toolName) { case 'navigate': if (typeof input.url === 'string') { try { - const url = new URL(input.url); - secondaryInfo.push(url.hostname); + const url = new URL(input.url) + secondaryInfo.push(url.hostname) } catch { - secondaryInfo.push(truncateToWidth(input.url, 30)); + secondaryInfo.push(truncateToWidth(input.url, 30)) } } - break; + break + case 'find': if (typeof input.query === 'string') { - secondaryInfo.push(`pattern: ${truncateToWidth(input.query, 30)}`); + secondaryInfo.push(`pattern: ${truncateToWidth(input.query, 30)}`) } - break; + break + case 'computer': if (typeof input.action === 'string') { - const action = input.action; - if (action === 'left_click' || action === 'right_click' || action === 'double_click' || action === 'middle_click') { + const action = input.action + if ( + action === 'left_click' || + action === 'right_click' || + action === 'double_click' || + action === 'middle_click' + ) { if (typeof input.ref === 'string') { - secondaryInfo.push(`${action} on ${input.ref}`); + secondaryInfo.push(`${action} on ${input.ref}`) } else if (Array.isArray(input.coordinate)) { - secondaryInfo.push(`${action} at (${input.coordinate.join(', ')})`); + secondaryInfo.push(`${action} at (${input.coordinate.join(', ')})`) } else { - secondaryInfo.push(action); + secondaryInfo.push(action) } } else if (action === 'type' && typeof input.text === 'string') { - secondaryInfo.push(`type "${truncateToWidth(input.text, 15)}"`); + secondaryInfo.push(`type "${truncateToWidth(input.text, 15)}"`) } else if (action === 'key' && typeof input.text === 'string') { - secondaryInfo.push(`key ${input.text}`); - } else if (action === 'scroll' && typeof input.scroll_direction === 'string') { - secondaryInfo.push(`scroll ${input.scroll_direction}`); + secondaryInfo.push(`key ${input.text}`) + } else if ( + action === 'scroll' && + typeof input.scroll_direction === 'string' + ) { + secondaryInfo.push(`scroll ${input.scroll_direction}`) } else if (action === 'wait' && typeof input.duration === 'number') { - secondaryInfo.push(`wait ${input.duration}s`); + secondaryInfo.push(`wait ${input.duration}s`) } else if (action === 'left_click_drag') { - secondaryInfo.push('drag'); + secondaryInfo.push('drag') } else { - secondaryInfo.push(action); + secondaryInfo.push(action) } } - break; + break + case 'gif_creator': if (typeof input.action === 'string') { - secondaryInfo.push(`${input.action}`); + secondaryInfo.push(`${input.action}`) } - break; + break + case 'resize_window': if (typeof input.width === 'number' && typeof input.height === 'number') { - secondaryInfo.push(`${input.width}x${input.height}`); + secondaryInfo.push(`${input.width}x${input.height}`) } - break; + break + case 'read_console_messages': if (typeof input.pattern === 'string') { - secondaryInfo.push(`pattern: ${truncateToWidth(input.pattern, 20)}`); + secondaryInfo.push(`pattern: ${truncateToWidth(input.pattern, 20)}`) } if (input.onlyErrors === true) { - secondaryInfo.push('errors only'); + secondaryInfo.push('errors only') } - break; + break + case 'read_network_requests': if (typeof input.urlPattern === 'string') { - secondaryInfo.push(`pattern: ${truncateToWidth(input.urlPattern, 20)}`); + secondaryInfo.push(`pattern: ${truncateToWidth(input.urlPattern, 20)}`) } - break; + break + case 'shortcuts_execute': if (typeof input.shortcutId === 'string') { - secondaryInfo.push(`shortcut_id: ${input.shortcutId}`); + secondaryInfo.push(`shortcut_id: ${input.shortcutId}`) } - break; + break + case 'javascript_tool': // In verbose mode, show the full code if (verbose && typeof input.text === 'string') { - return input.text; + return input.text } // In non-verbose mode, return empty string to preserve View Tab layout - return ''; + return '' + case 'tabs_create_mcp': case 'tabs_context_mcp': case 'form_input': @@ -109,9 +151,10 @@ function renderChromeToolUseMessage(input: Record, toolName: Ch case 'update_plan': // These tools don't have meaningful secondary info to show inline. // Return empty string (not null) to ensure tool header still renders. - return ''; + return '' } - return secondaryInfo.join(', ') || null; + + return secondaryInfo.join(', ') || null } /** @@ -123,22 +166,29 @@ function renderChromeToolUseMessage(input: Record, toolName: Ch */ function renderChromeViewTabLink(input: unknown): React.ReactNode { if (!supportsHyperlinks()) { - return null; + return null } if (typeof input !== 'object' || input === null || !('tabId' in input)) { - return null; + return null } - const tabId = typeof input.tabId === 'number' ? input.tabId : typeof input.tabId === 'string' ? parseInt(input.tabId, 10) : NaN; + const tabId = + typeof input.tabId === 'number' + ? input.tabId + : typeof input.tabId === 'string' + ? parseInt(input.tabId, 10) + : NaN if (isNaN(tabId)) { - return null; + return null } - const linkUrl = `${CHROME_EXTENSION_FOCUS_TAB_URL_BASE}${tabId}`; - return + const linkUrl = `${CHROME_EXTENSION_FOCUS_TAB_URL_BASE}${tabId}` + return ( + {' '} [View Tab] - ; + + ) } /** @@ -146,72 +196,79 @@ function renderChromeViewTabLink(input: unknown): React.ReactNode { * Shows a brief summary for successful results. Errors are handled by * the default renderToolUseErrorMessage when is_error is set. */ -export function renderChromeToolResultMessage(output: MCPToolResult, toolName: ChromeToolName, verbose: boolean): React.ReactNode { +export function renderChromeToolResultMessage( + output: MCPToolResult, + toolName: ChromeToolName, + verbose: boolean, +): React.ReactNode { if (verbose) { - return renderDefaultMCPToolResultMessage(output, [], { - verbose - }); + return renderDefaultMCPToolResultMessage(output, [], { verbose }) } - let summary: string | null = null; + + let summary: string | null = null switch (toolName) { case 'navigate': - summary = 'Navigation completed'; - break; + summary = 'Navigation completed' + break case 'tabs_create_mcp': - summary = 'Tab created'; - break; + summary = 'Tab created' + break case 'tabs_context_mcp': - summary = 'Tabs read'; - break; + summary = 'Tabs read' + break case 'form_input': - summary = 'Input completed'; - break; + summary = 'Input completed' + break case 'computer': - summary = 'Action completed'; - break; + summary = 'Action completed' + break case 'resize_window': - summary = 'Window resized'; - break; + summary = 'Window resized' + break case 'find': - summary = 'Search completed'; - break; + summary = 'Search completed' + break case 'gif_creator': - summary = 'GIF action completed'; - break; + summary = 'GIF action completed' + break case 'read_console_messages': - summary = 'Console messages retrieved'; - break; + summary = 'Console messages retrieved' + break case 'read_network_requests': - summary = 'Network requests retrieved'; - break; + summary = 'Network requests retrieved' + break case 'shortcuts_list': - summary = 'Shortcuts retrieved'; - break; + summary = 'Shortcuts retrieved' + break case 'shortcuts_execute': - summary = 'Shortcut executed'; - break; + summary = 'Shortcut executed' + break case 'javascript_tool': - summary = 'Script executed'; - break; + summary = 'Script executed' + break case 'read_page': - summary = 'Page read'; - break; + summary = 'Page read' + break case 'upload_image': - summary = 'Image uploaded'; - break; + summary = 'Image uploaded' + break case 'get_page_text': - summary = 'Page text retrieved'; - break; + summary = 'Page text retrieved' + break case 'update_plan': - summary = 'Plan updated'; - break; + summary = 'Plan updated' + break } + if (summary) { - return + return ( + {summary} - ; + + ) } - return null; + + return null } /** @@ -219,43 +276,56 @@ export function renderChromeToolResultMessage(output: MCPToolResult, toolName: C * rendering for chrome tools in a single spread operation. */ export function getClaudeInChromeMCPToolOverrides(toolName: string): { - userFacingName: (input?: Record) => string; - renderToolUseMessage: (input: Record, options: { - verbose: boolean; - }) => React.ReactNode; - renderToolUseTag: (input: Partial>) => React.ReactNode; - renderToolResultMessage: (output: string | MCPToolResult, progressMessagesForMessage: unknown[], options: { - verbose: boolean; - }) => React.ReactNode; + userFacingName: (input?: Record) => string + renderToolUseMessage: ( + input: Record, + options: { verbose: boolean }, + ) => React.ReactNode + renderToolUseTag: (input: Partial>) => React.ReactNode + renderToolResultMessage: ( + output: string | MCPToolResult, + progressMessagesForMessage: unknown[], + options: { verbose: boolean }, + ) => React.ReactNode } { return { userFacingName(_input?: Record) { // Trim the _mcp postfix that show up in some of the tool names - const displayName = toolName.replace(/_mcp$/, ''); - return `Claude in Chrome[${displayName}]`; + const displayName = toolName.replace(/_mcp$/, '') + return `Claude in Chrome[${displayName}]` }, - renderToolUseMessage(input: Record, { - verbose - }: { - verbose: boolean; - }): React.ReactNode { - return renderChromeToolUseMessage(input, toolName as ChromeToolName, verbose); + renderToolUseMessage( + input: Record, + { verbose }: { verbose: boolean }, + ): React.ReactNode { + return renderChromeToolUseMessage( + input, + toolName as ChromeToolName, + verbose, + ) }, renderToolUseTag(input: Partial>): React.ReactNode { - return renderChromeViewTabLink(input); + return renderChromeViewTabLink(input) }, - renderToolResultMessage(output: string | MCPToolResult, _progressMessagesForMessage: unknown[], { - verbose - }: { - verbose: boolean; - }): React.ReactNode { + renderToolResultMessage( + output: string | MCPToolResult, + _progressMessagesForMessage: unknown[], + { verbose }: { verbose: boolean }, + ): React.ReactNode { if (!isMCPToolResult(output)) { - return null; + return null } - return renderChromeToolResultMessage(output, toolName as ChromeToolName, verbose); - } - }; + return renderChromeToolResultMessage( + output, + toolName as ChromeToolName, + verbose, + ) + }, + } } -function isMCPToolResult(output: string | MCPToolResult): output is MCPToolResult { - return typeof output === 'object' && output !== null; + +function isMCPToolResult( + output: string | MCPToolResult, +): output is MCPToolResult { + return typeof output === 'object' && output !== null } diff --git a/src/utils/computerUse/toolRendering.tsx b/src/utils/computerUse/toolRendering.tsx index 27b8be969..4b5da230e 100644 --- a/src/utils/computerUse/toolRendering.tsx +++ b/src/utils/computerUse/toolRendering.tsx @@ -1,23 +1,24 @@ -import * as React from 'react'; -import { MessageResponse } from '../../components/MessageResponse.js'; -import { Text } from '../../ink.js'; -import { truncateToWidth } from '../format.js'; -import type { MCPToolResult } from '../mcpValidation.js'; +import * as React from 'react' +import { MessageResponse } from '../../components/MessageResponse.js' +import { Text } from '../../ink.js' +import { truncateToWidth } from '../format.js' +import type { MCPToolResult } from '../mcpValidation.js' + type CuToolInput = Record & { - coordinate?: [number, number]; - start_coordinate?: [number, number]; - text?: string; - apps?: Array<{ - displayName?: string; - }>; - region?: [number, number, number, number]; - direction?: string; - amount?: number; - duration?: number; -}; -function fmtCoord(c: [number, number] | undefined): string { - return c ? `(${c[0]}, ${c[1]})` : ''; + coordinate?: [number, number] + start_coordinate?: [number, number] + text?: string + apps?: Array<{ displayName?: string }> + region?: [number, number, number, number] + direction?: string + amount?: number + duration?: number } + +function fmtCoord(c: [number, number] | undefined): string { + return c ? `(${c[0]}, ${c[1]})` : '' +} + const RESULT_SUMMARY: Readonly>> = { screenshot: 'Captured', zoom: 'Captured', @@ -32,8 +33,8 @@ const RESULT_SUMMARY: Readonly>> = { hold_key: 'Pressed', scroll: 'Scrolled', left_click_drag: 'Dragged', - open_application: 'Opened' -}; + open_application: 'Opened', +} /** * Rendering overrides for `mcp__computer-use__*` tools. Spread into the MCP @@ -41,18 +42,22 @@ const RESULT_SUMMARY: Readonly>> = { * Mirror of `getClaudeInChromeMCPToolOverrides`. */ export function getComputerUseMCPRenderingOverrides(toolName: string): { - userFacingName: () => string; - renderToolUseMessage: (input: Record, options: { - verbose: boolean; - }) => React.ReactNode; - renderToolResultMessage: (output: MCPToolResult, progressMessages: unknown[], options: { - verbose: boolean; - }) => React.ReactNode; + userFacingName: () => string + renderToolUseMessage: ( + input: Record, + options: { verbose: boolean }, + ) => React.ReactNode + renderToolResultMessage: ( + output: MCPToolResult, + progressMessages: unknown[], + options: { verbose: boolean }, + ) => React.ReactNode } { return { userFacingName() { - return `Computer Use[${toolName}]`; + return `Computer Use[${toolName}]` }, + // AssistantToolUseMessage.tsx contract: null hides the ENTIRE row, '' shows // the tool name without "(args)". Every path below returns '' when there's // nothing to show — never null. @@ -64,61 +69,89 @@ export function getComputerUseMCPRenderingOverrides(toolName: string): { case 'cursor_position': case 'list_granted_applications': case 'read_clipboard': - return ''; + return '' + case 'left_click': case 'right_click': case 'middle_click': case 'double_click': case 'triple_click': case 'mouse_move': - return fmtCoord(input.coordinate); + return fmtCoord(input.coordinate) + case 'left_click_drag': - return input.start_coordinate ? `${fmtCoord(input.start_coordinate)} → ${fmtCoord(input.coordinate)}` : `to ${fmtCoord(input.coordinate)}`; + return input.start_coordinate + ? `${fmtCoord(input.start_coordinate)} → ${fmtCoord(input.coordinate)}` + : `to ${fmtCoord(input.coordinate)}` + case 'type': - return typeof input.text === 'string' ? `"${truncateToWidth(input.text, 40)}"` : ''; + return typeof input.text === 'string' + ? `"${truncateToWidth(input.text, 40)}"` + : '' + case 'key': case 'hold_key': - return typeof input.text === 'string' ? input.text : ''; + return typeof input.text === 'string' ? input.text : '' + case 'scroll': - return [input.direction, input.amount && `×${input.amount}`, input.coordinate && `at ${fmtCoord(input.coordinate)}`].filter(Boolean).join(' '); - case 'zoom': - { - const r = input.region; - return Array.isArray(r) && r.length === 4 ? `[${r[0]}, ${r[1]}, ${r[2]}, ${r[3]}]` : ''; - } + return [ + input.direction, + input.amount && `×${input.amount}`, + input.coordinate && `at ${fmtCoord(input.coordinate)}`, + ] + .filter(Boolean) + .join(' ') + + case 'zoom': { + const r = input.region + return Array.isArray(r) && r.length === 4 + ? `[${r[0]}, ${r[1]}, ${r[2]}, ${r[3]}]` + : '' + } + case 'wait': - return typeof input.duration === 'number' ? `${input.duration}s` : ''; + return typeof input.duration === 'number' ? `${input.duration}s` : '' + case 'write_clipboard': - return typeof input.text === 'string' ? `"${truncateToWidth(input.text, 40)}"` : ''; + return typeof input.text === 'string' + ? `"${truncateToWidth(input.text, 40)}"` + : '' + case 'open_application': - return typeof input.bundle_id === 'string' ? String(input.bundle_id) : ''; - case 'request_access': - { - const apps = input.apps; - if (!Array.isArray(apps)) return ''; - const names = apps.map(a => typeof a?.displayName === 'string' ? a.displayName : '').filter(Boolean); - return names.join(', '); - } - case 'computer_batch': - { - const actions = input.actions; - return Array.isArray(actions) ? `${actions.length} actions` : ''; - } + return typeof input.bundle_id === 'string' + ? String(input.bundle_id) + : '' + + case 'request_access': { + const apps = input.apps + if (!Array.isArray(apps)) return '' + const names = apps + .map(a => (typeof a?.displayName === 'string' ? a.displayName : '')) + .filter(Boolean) + return names.join(', ') + } + + case 'computer_batch': { + const actions = input.actions + return Array.isArray(actions) ? `${actions.length} actions` : '' + } + default: - return ''; + return '' } }, - renderToolResultMessage(output, _progress, { - verbose - }) { - if (verbose || typeof output !== 'object' || output === null) return null; + + renderToolResultMessage(output, _progress, { verbose }) { + if (verbose || typeof output !== 'object' || output === null) return null // Non-verbose: one-line dim summary, like Chrome's pattern. - const summary = RESULT_SUMMARY[toolName]; - if (!summary) return null; - return + const summary = RESULT_SUMMARY[toolName] + if (!summary) return null + return ( + {summary} - ; - } - }; + + ) + }, + } } diff --git a/src/utils/computerUse/wrapper.tsx b/src/utils/computerUse/wrapper.tsx index 23917da3a..05a1f81fd 100644 --- a/src/utils/computerUse/wrapper.tsx +++ b/src/utils/computerUse/wrapper.tsx @@ -16,22 +16,35 @@ * GrowthBook gate `tengu_malort_pedway` (see gates.ts). */ -import { bindSessionContext, type ComputerUseSessionContext, type CuCallToolResult, type CuPermissionRequest, type CuPermissionResponse, DEFAULT_GRANT_FLAGS, type ScreenshotDims } from '@ant/computer-use-mcp'; -import * as React from 'react'; -import { getSessionId } from '../../bootstrap/state.js'; -import { ComputerUseApproval } from '../../components/permissions/ComputerUseApproval/ComputerUseApproval.js'; -import type { Tool, ToolUseContext } from '../../Tool.js'; -import { logForDebugging } from '../debug.js'; -import { checkComputerUseLock, tryAcquireComputerUseLock } from './computerUseLock.js'; -import { registerEscHotkey } from './escHotkey.js'; -import { getChicagoCoordinateMode } from './gates.js'; -import { getComputerUseHostAdapter } from './hostAdapter.js'; -import { getComputerUseMCPRenderingOverrides } from './toolRendering.js'; -type CallOverride = Pick['call']; +import { + bindSessionContext, + type ComputerUseSessionContext, + type CuCallToolResult, + type CuPermissionRequest, + type CuPermissionResponse, + DEFAULT_GRANT_FLAGS, + type ScreenshotDims, +} from '@ant/computer-use-mcp' +import * as React from 'react' +import { getSessionId } from '../../bootstrap/state.js' +import { ComputerUseApproval } from '../../components/permissions/ComputerUseApproval/ComputerUseApproval.js' +import type { Tool, ToolUseContext } from '../../Tool.js' +import { logForDebugging } from '../debug.js' +import { + checkComputerUseLock, + tryAcquireComputerUseLock, +} from './computerUseLock.js' +import { registerEscHotkey } from './escHotkey.js' +import { getChicagoCoordinateMode } from './gates.js' +import { getComputerUseHostAdapter } from './hostAdapter.js' +import { getComputerUseMCPRenderingOverrides } from './toolRendering.js' + +type CallOverride = Pick['call'] + type Binding = { - ctx: ComputerUseSessionContext; - dispatch: (name: string, args: unknown) => Promise; -}; + ctx: ComputerUseSessionContext + dispatch: (name: string, args: unknown) => Promise +} /** * Cached binding — built on first `.call()`, reused for process lifetime. @@ -46,35 +59,47 @@ type Binding = { * its internal screenshot blob survives, but `ToolUseContext` is per-call. * Tests will need to either inject the cache or run serially. */ -let binding: Binding | undefined; -let currentToolUseContext: ToolUseContext | undefined; +let binding: Binding | undefined +let currentToolUseContext: ToolUseContext | undefined + function tuc(): ToolUseContext { // Safe: `binding` is only populated when `currentToolUseContext` is set. // Called only from within `ctx` callbacks, which only fire during dispatch. - return currentToolUseContext!; + return currentToolUseContext! } + function formatLockHeld(holder: string): string { - return `Computer use is in use by another Claude session (${holder.slice(0, 8)}…). Wait for that session to finish or run /exit there.`; + return `Computer use is in use by another Claude session (${holder.slice(0, 8)}…). Wait for that session to finish or run /exit there.` } + export function buildSessionContext(): ComputerUseSessionContext { return { // ── Read state fresh via the per-call ref ───────────────────────────── - getAllowedApps: () => tuc().getAppState().computerUseMcpState?.allowedApps ?? [], - getGrantFlags: () => tuc().getAppState().computerUseMcpState?.grantFlags ?? DEFAULT_GRANT_FLAGS, + getAllowedApps: () => + tuc().getAppState().computerUseMcpState?.allowedApps ?? [], + getGrantFlags: () => + tuc().getAppState().computerUseMcpState?.grantFlags ?? + DEFAULT_GRANT_FLAGS, // cc-2 has no Settings page for user-denied apps yet. getUserDeniedBundleIds: () => [], - getSelectedDisplayId: () => tuc().getAppState().computerUseMcpState?.selectedDisplayId, - getDisplayPinnedByModel: () => tuc().getAppState().computerUseMcpState?.displayPinnedByModel ?? false, - getDisplayResolvedForApps: () => tuc().getAppState().computerUseMcpState?.displayResolvedForApps, + getSelectedDisplayId: () => + tuc().getAppState().computerUseMcpState?.selectedDisplayId, + getDisplayPinnedByModel: () => + tuc().getAppState().computerUseMcpState?.displayPinnedByModel ?? false, + getDisplayResolvedForApps: () => + tuc().getAppState().computerUseMcpState?.displayResolvedForApps, getLastScreenshotDims: (): ScreenshotDims | undefined => { - const d = tuc().getAppState().computerUseMcpState?.lastScreenshotDims; - return d ? { - ...d, - displayId: d.displayId ?? 0, - originX: d.originX ?? 0, - originY: d.originY ?? 0 - } : undefined; + const d = tuc().getAppState().computerUseMcpState?.lastScreenshotDims + return d + ? { + ...d, + displayId: d.displayId ?? 0, + originX: d.originX ?? 0, + originY: d.originY ?? 0, + } + : undefined }, + // ── Write-backs ──────────────────────────────────────────────────────── // `setToolJSX` is guaranteed present — the gate in `main.tsx` excludes // non-interactive sessions. The package's `_dialogSignal` (tool-finished @@ -82,122 +107,143 @@ export function buildSessionContext(): ComputerUseSessionContext { // the dialog can't outlive it. Ctrl+C is what matters, and // `runPermissionDialog` wires that from the per-call ref's abortController. onPermissionRequest: (req, _dialogSignal) => runPermissionDialog(req), + // Package does the merge (dedupe + truthy-only flags). We just persist. - onAllowedAppsChanged: (apps, flags) => tuc().setAppState(prev => { - const cu = prev.computerUseMcpState; - const prevApps = cu?.allowedApps; - const prevFlags = cu?.grantFlags; - const sameApps = prevApps?.length === apps.length && apps.every((a, i) => prevApps[i]?.bundleId === a.bundleId); - const sameFlags = prevFlags?.clipboardRead === flags.clipboardRead && prevFlags?.clipboardWrite === flags.clipboardWrite && prevFlags?.systemKeyCombos === flags.systemKeyCombos; - return sameApps && sameFlags ? prev : { - ...prev, - computerUseMcpState: { - ...cu, - allowedApps: [...apps], - grantFlags: flags - } - }; - }), - onAppsHidden: ids => { - if (ids.length === 0) return; + onAllowedAppsChanged: (apps, flags) => tuc().setAppState(prev => { - const cu = prev.computerUseMcpState; - const existing = cu?.hiddenDuringTurn; - if (existing && ids.every(id => existing.has(id))) return prev; + const cu = prev.computerUseMcpState + const prevApps = cu?.allowedApps + const prevFlags = cu?.grantFlags + const sameApps = + prevApps?.length === apps.length && + apps.every((a, i) => prevApps[i]?.bundleId === a.bundleId) + const sameFlags = + prevFlags?.clipboardRead === flags.clipboardRead && + prevFlags?.clipboardWrite === flags.clipboardWrite && + prevFlags?.systemKeyCombos === flags.systemKeyCombos + return sameApps && sameFlags + ? prev + : { + ...prev, + computerUseMcpState: { + ...cu, + allowedApps: [...apps], + grantFlags: flags, + }, + } + }), + + onAppsHidden: ids => { + if (ids.length === 0) return + tuc().setAppState(prev => { + const cu = prev.computerUseMcpState + const existing = cu?.hiddenDuringTurn + if (existing && ids.every(id => existing.has(id))) return prev return { ...prev, computerUseMcpState: { ...cu, - hiddenDuringTurn: new Set([...(existing ?? []), ...ids]) - } - }; - }); + hiddenDuringTurn: new Set([...(existing ?? []), ...ids]), + }, + } + }) }, + // Resolver writeback only fires under a pin when Swift fell back to main // (pinned display unplugged) — the pin is semantically dead, so clear it // and the app-set key so the chase chain runs next time. When autoResolve // was true, onDisplayResolvedForApps re-sets the key in the same tick. - onResolvedDisplayUpdated: id => tuc().setAppState(prev => { - const cu = prev.computerUseMcpState; - if (cu?.selectedDisplayId === id && !cu.displayPinnedByModel && cu.displayResolvedForApps === undefined) { - return prev; - } - return { - ...prev, - computerUseMcpState: { - ...cu, - selectedDisplayId: id, - displayPinnedByModel: false, - displayResolvedForApps: undefined + onResolvedDisplayUpdated: id => + tuc().setAppState(prev => { + const cu = prev.computerUseMcpState + if ( + cu?.selectedDisplayId === id && + !cu.displayPinnedByModel && + cu.displayResolvedForApps === undefined + ) { + return prev } - }; - }), + return { + ...prev, + computerUseMcpState: { + ...cu, + selectedDisplayId: id, + displayPinnedByModel: false, + displayResolvedForApps: undefined, + }, + } + }), + // switch_display(name) pins; switch_display("auto") unpins and clears the // app-set key so the next screenshot auto-resolves fresh. - onDisplayPinned: id => tuc().setAppState(prev => { - const cu = prev.computerUseMcpState; - const pinned = id !== undefined; - const nextResolvedFor = pinned ? cu?.displayResolvedForApps : undefined; - if (cu?.selectedDisplayId === id && cu?.displayPinnedByModel === pinned && cu?.displayResolvedForApps === nextResolvedFor) { - return prev; - } - return { - ...prev, - computerUseMcpState: { - ...cu, - selectedDisplayId: id, - displayPinnedByModel: pinned, - displayResolvedForApps: nextResolvedFor + onDisplayPinned: id => + tuc().setAppState(prev => { + const cu = prev.computerUseMcpState + const pinned = id !== undefined + const nextResolvedFor = pinned ? cu?.displayResolvedForApps : undefined + if ( + cu?.selectedDisplayId === id && + cu?.displayPinnedByModel === pinned && + cu?.displayResolvedForApps === nextResolvedFor + ) { + return prev } - }; - }), - onDisplayResolvedForApps: key => tuc().setAppState(prev => { - const cu = prev.computerUseMcpState; - if (cu?.displayResolvedForApps === key) return prev; - return { - ...prev, - computerUseMcpState: { - ...cu, - displayResolvedForApps: key + return { + ...prev, + computerUseMcpState: { + ...cu, + selectedDisplayId: id, + displayPinnedByModel: pinned, + displayResolvedForApps: nextResolvedFor, + }, } - }; - }), - onScreenshotCaptured: dims => tuc().setAppState(prev => { - const cu = prev.computerUseMcpState; - const p = cu?.lastScreenshotDims; - return p?.width === dims.width && p?.height === dims.height && p?.displayWidth === dims.displayWidth && p?.displayHeight === dims.displayHeight && p?.displayId === dims.displayId && p?.originX === dims.originX && p?.originY === dims.originY ? prev : { - ...prev, - computerUseMcpState: { - ...cu, - lastScreenshotDims: dims + }), + + onDisplayResolvedForApps: key => + tuc().setAppState(prev => { + const cu = prev.computerUseMcpState + if (cu?.displayResolvedForApps === key) return prev + return { + ...prev, + computerUseMcpState: { ...cu, displayResolvedForApps: key }, } - }; - }), + }), + + onScreenshotCaptured: dims => + tuc().setAppState(prev => { + const cu = prev.computerUseMcpState + const p = cu?.lastScreenshotDims + return p?.width === dims.width && + p?.height === dims.height && + p?.displayWidth === dims.displayWidth && + p?.displayHeight === dims.displayHeight && + p?.displayId === dims.displayId && + p?.originX === dims.originX && + p?.originY === dims.originY + ? prev + : { + ...prev, + computerUseMcpState: { ...cu, lastScreenshotDims: dims }, + } + }), + // ── Lock — async, direct file-lock calls ─────────────────────────────── // No `lockHolderForGate` dance: the package's gate is async now. It // awaits `checkCuLock`, and on `holder: undefined` + non-deferring tool // awaits `acquireCuLock`. `defersLockAcquire` is the PACKAGE's set — // the local copy is gone. checkCuLock: async () => { - const c = await checkComputerUseLock(); + const c = await checkComputerUseLock() switch (c.kind) { case 'free': - return { - holder: undefined, - isSelf: false - }; + return { holder: undefined, isSelf: false } case 'held_by_self': - return { - holder: getSessionId(), - isSelf: true - }; + return { holder: getSessionId(), isSelf: true } case 'blocked': - return { - holder: c.by, - isSelf: false - }; + return { holder: c.by, isSelf: false } } }, + // Called only when checkCuLock returned `holder: undefined`. The O_EXCL // acquire is atomic — if another process grabbed it in the gap (rare), // throw so the tool fails instead of proceeding without the lock. @@ -205,9 +251,9 @@ export function buildSessionContext(): ComputerUseSessionContext { // but is possible under parallel tool-use interleaving — don't spam the // notification in that case. acquireCuLock: async () => { - const r = await tryAcquireComputerUseLock(); + const r = await tryAcquireComputerUseLock() if (r.kind === 'blocked') { - throw new Error(formatLockHeld(r.by)); + throw new Error(formatLockHeld(r.by)) } if (r.fresh) { // Global Escape → abort. Consumes the event (PI defense — prompt @@ -215,26 +261,34 @@ export function buildSessionContext(): ComputerUseSessionContext { // CFRunLoopSource is processed by the drainRunLoop pump, so this // holds a pump retain until unregisterEscHotkey() in cleanup.ts. const escRegistered = registerEscHotkey(() => { - logForDebugging('[cu-esc] user escape, aborting turn'); - tuc().abortController.abort(); - }); + logForDebugging('[cu-esc] user escape, aborting turn') + tuc().abortController.abort() + }) tuc().sendOSNotification?.({ - message: escRegistered ? 'Claude is using your computer · press Esc to stop' : 'Claude is using your computer · press Ctrl+C to stop', - notificationType: 'computer_use_enter' - }); + message: escRegistered + ? 'Claude is using your computer · press Esc to stop' + : 'Claude is using your computer · press Ctrl+C to stop', + notificationType: 'computer_use_enter', + }) } }, - formatLockHeldMessage: formatLockHeld - }; + + formatLockHeldMessage: formatLockHeld, + } } + function getOrBind(): Binding { - if (binding) return binding; - const ctx = buildSessionContext(); + if (binding) return binding + const ctx = buildSessionContext() binding = { ctx, - dispatch: bindSessionContext(getComputerUseHostAdapter(), getChicagoCoordinateMode(), ctx) - }; - return binding; + dispatch: bindSessionContext( + getComputerUseHostAdapter(), + getChicagoCoordinateMode(), + ctx, + ), + } + return binding } /** @@ -242,21 +296,25 @@ function getOrBind(): Binding { * tool: rendering overrides from `toolRendering.tsx` plus a `.call()` that * dispatches through the cached binder. */ -type ComputerUseMCPToolOverrides = ReturnType & { - call: CallOverride; -}; -export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCPToolOverrides { +type ComputerUseMCPToolOverrides = ReturnType< + typeof getComputerUseMCPRenderingOverrides +> & { + call: CallOverride +} + +export function getComputerUseMCPToolOverrides( + toolName: string, +): ComputerUseMCPToolOverrides { const call: CallOverride = async (args, context: ToolUseContext) => { - currentToolUseContext = context; - const { - dispatch - } = getOrBind(); - const { - telemetry, - ...result - } = await dispatch(toolName, args); + currentToolUseContext = context + const { dispatch } = getOrBind() + + const { telemetry, ...result } = await dispatch(toolName, args) + if (telemetry?.error_kind) { - logForDebugging(`[Computer Use MCP] ${toolName} error_kind=${telemetry.error_kind}`); + logForDebugging( + `[Computer Use MCP] ${toolName} error_kind=${telemetry.error_kind}`, + ) } // MCP content blocks → Anthropic API blocks. CU only produces text and @@ -265,25 +323,30 @@ export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCP // shape just maps to the API's base64-source shape. The package's result // type admits audio/resource too, but CU's handleToolCall never emits // those; the fallthrough coerces them to empty text. - const data = Array.isArray(result.content) ? result.content.map(item => item.type === 'image' ? { - type: 'image' as const, - source: { - type: 'base64' as const, - media_type: item.mimeType ?? 'image/jpeg', - data: item.data - } - } : { - type: 'text' as const, - text: item.type === 'text' ? item.text : '' - }) : result.content; - return { - data - }; - }; + const data = Array.isArray(result.content) + ? result.content.map(item => + item.type === 'image' + ? { + type: 'image' as const, + source: { + type: 'base64' as const, + media_type: item.mimeType ?? 'image/jpeg', + data: item.data, + }, + } + : { + type: 'text' as const, + text: item.type === 'text' ? item.text : '', + }, + ) + : result.content + return { data } + } + return { ...getComputerUseMCPRenderingOverrides(toolName), - call - }; + call, + } } /** @@ -293,43 +356,43 @@ export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCP * The merge-into-AppState that used to live here (dedupe + truthy-only flags) * is now in the package's `bindSessionContext` → `onAllowedAppsChanged`. */ -async function runPermissionDialog(req: CuPermissionRequest): Promise { - const context = tuc(); - const setToolJSX = context.setToolJSX; +async function runPermissionDialog( + req: CuPermissionRequest, +): Promise { + const context = tuc() + const setToolJSX = context.setToolJSX if (!setToolJSX) { // Shouldn't happen — main.tsx gate excludes non-interactive. Fail safe. - return { - granted: [], - denied: [], - flags: DEFAULT_GRANT_FLAGS - }; + return { granted: [], denied: [], flags: DEFAULT_GRANT_FLAGS } } + try { return await new Promise((resolve, reject) => { - const signal = context.abortController.signal; + const signal = context.abortController.signal // If already aborted, addEventListener won't fire — reject now so the // promise doesn't hang waiting for a user who Ctrl+C'd. if (signal.aborted) { - reject(new Error('Computer Use permission dialog aborted')); - return; + reject(new Error('Computer Use permission dialog aborted')) + return } const onAbort = (): void => { - signal.removeEventListener('abort', onAbort); - reject(new Error('Computer Use permission dialog aborted')); - }; - signal.addEventListener('abort', onAbort); + signal.removeEventListener('abort', onAbort) + reject(new Error('Computer Use permission dialog aborted')) + } + signal.addEventListener('abort', onAbort) + setToolJSX({ jsx: React.createElement(ComputerUseApproval, { request: req, onDone: (resp: CuPermissionResponse) => { - signal.removeEventListener('abort', onAbort); - resolve(resp); - } + signal.removeEventListener('abort', onAbort) + resolve(resp) + }, }), - shouldHidePromptInput: true - }); - }); + shouldHidePromptInput: true, + }) + }) } finally { - setToolJSX(null); + setToolJSX(null) } } diff --git a/src/utils/exportRenderer.tsx b/src/utils/exportRenderer.tsx index 784b119ac..795eb6bfb 100644 --- a/src/utils/exportRenderer.tsx +++ b/src/utils/exportRenderer.tsx @@ -1,13 +1,13 @@ -import React, { useRef } from 'react'; -import stripAnsi from 'strip-ansi'; -import { Messages } from '../components/Messages.js'; -import { KeybindingProvider } from '../keybindings/KeybindingContext.js'; -import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js'; -import type { KeybindingContextName } from '../keybindings/types.js'; -import { AppStateProvider } from '../state/AppState.js'; -import type { Tools } from '../Tool.js'; -import type { Message } from '../types/message.js'; -import { renderToAnsiString } from './staticRender.js'; +import React, { useRef } from 'react' +import stripAnsi from 'strip-ansi' +import { Messages } from '../components/Messages.js' +import { KeybindingProvider } from '../keybindings/KeybindingContext.js' +import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js' +import type { KeybindingContextName } from '../keybindings/types.js' +import { AppStateProvider } from '../state/AppState.js' +import type { Tools } from '../Tool.js' +import type { Message } from '../types/message.js' +import { renderToAnsiString } from './staticRender.js' /** * Minimal keybinding provider for static/headless renders. @@ -15,19 +15,29 @@ import { renderToAnsiString } from './staticRender.js'; * and would hang in headless renders with no stdin). */ function StaticKeybindingProvider({ - children + children, }: { - children: React.ReactNode; + children: React.ReactNode }): React.ReactNode { - const { - bindings - } = loadKeybindingsSyncWithWarnings(); - const pendingChordRef = useRef(null); - const handlerRegistryRef = useRef(new Map()); - const activeContexts = useRef(new Set()).current; - return {}} activeContexts={activeContexts} registerActiveContext={() => {}} unregisterActiveContext={() => {}} handlerRegistryRef={handlerRegistryRef}> + const { bindings } = loadKeybindingsSyncWithWarnings() + const pendingChordRef = useRef(null) + const handlerRegistryRef = useRef(new Map()) + const activeContexts = useRef(new Set()).current + + return ( + {}} + activeContexts={activeContexts} + registerActiveContext={() => {}} + unregisterActiveContext={() => {}} + handlerRegistryRef={handlerRegistryRef} + > {children} - ; + + ) } // Upper-bound how many NormalizedMessages a Message can produce. @@ -35,9 +45,9 @@ function StaticKeybindingProvider({ // NormalizedMessages — 1:1 with block count. String content = 1 block. // AttachmentMessage etc. have no .message and normalize to ≤1. function normalizedUpperBound(m: Message): number { - if (!('message' in m)) return 1; - const c = m.message.content; - return Array.isArray(c) ? c.length : 1; + if (!('message' in m)) return 1 + const c = m.message.content + return Array.isArray(c) ? c.length : 1 } /** @@ -52,35 +62,59 @@ function normalizedUpperBound(m: Message): number { * the full normalized array so tool_use↔tool_result resolves regardless of * which chunk each landed in. */ -export async function streamRenderedMessages(messages: Message[], tools: Tools, sink: (ansiChunk: string) => void | Promise, { - columns, - verbose = false, - chunkSize = 40, - onProgress -}: { - columns?: number; - verbose?: boolean; - chunkSize?: number; - onProgress?: (rendered: number) => void; -} = {}): Promise { - const renderChunk = (range: readonly [number, number]) => renderToAnsiString( +export async function streamRenderedMessages( + messages: Message[], + tools: Tools, + sink: (ansiChunk: string) => void | Promise, + { + columns, + verbose = false, + chunkSize = 40, + onProgress, + }: { + columns?: number + verbose?: boolean + chunkSize?: number + onProgress?: (rendered: number) => void + } = {}, +): Promise { + const renderChunk = (range: readonly [number, number]) => + renderToAnsiString( + - + - , columns); + , + columns, + ) // renderRange indexes into the post-collapse array whose length we can't // see from here — normalize splits each Message into one NormalizedMessage // per content block (unbounded per message), collapse merges some back. // Ceiling is the exact normalize output count + chunkSize so the loop // always reaches the empty slice where break fires (collapse only shrinks). - let ceiling = chunkSize; - for (const m of messages) ceiling += normalizedUpperBound(m); + let ceiling = chunkSize + for (const m of messages) ceiling += normalizedUpperBound(m) for (let offset = 0; offset < ceiling; offset += chunkSize) { - const ansi = await renderChunk([offset, offset + chunkSize]); - if (stripAnsi(ansi).trim() === '') break; - await sink(ansi); - onProgress?.(offset + chunkSize); + const ansi = await renderChunk([offset, offset + chunkSize]) + if (stripAnsi(ansi).trim() === '') break + await sink(ansi) + onProgress?.(offset + chunkSize) } } @@ -88,10 +122,17 @@ export async function streamRenderedMessages(messages: Message[], tools: Tools, * Renders messages to a plain text string suitable for export. * Uses the same React rendering logic as the interactive UI. */ -export async function renderMessagesToPlainText(messages: Message[], tools: Tools = [], columns?: number): Promise { - const parts: string[] = []; - await streamRenderedMessages(messages, tools, chunk => void parts.push(stripAnsi(chunk)), { - columns - }); - return parts.join(''); +export async function renderMessagesToPlainText( + messages: Message[], + tools: Tools = [], + columns?: number, +): Promise { + const parts: string[] = [] + await streamRenderedMessages( + messages, + tools, + chunk => void parts.push(stripAnsi(chunk)), + { columns }, + ) + return parts.join('') } diff --git a/src/utils/highlightMatch.tsx b/src/utils/highlightMatch.tsx index 730426cfb..31ed36045 100644 --- a/src/utils/highlightMatch.tsx +++ b/src/utils/highlightMatch.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; -import { Text } from '../ink.js'; +import * as React from 'react' +import { Text } from '../ink.js' /** * Inverse-highlight every occurrence of `query` in `text` (case-insensitive). @@ -7,21 +7,23 @@ import { Text } from '../ink.js'; * and preview panes. */ export function highlightMatch(text: string, query: string): React.ReactNode { - if (!query) return text; - const queryLower = query.toLowerCase(); - const textLower = text.toLowerCase(); - const parts: React.ReactNode[] = []; - let offset = 0; - let idx = textLower.indexOf(queryLower, offset); - if (idx === -1) return text; + if (!query) return text + const queryLower = query.toLowerCase() + const textLower = text.toLowerCase() + const parts: React.ReactNode[] = [] + let offset = 0 + let idx = textLower.indexOf(queryLower, offset) + if (idx === -1) return text while (idx !== -1) { - if (idx > offset) parts.push(text.slice(offset, idx)); - parts.push( + if (idx > offset) parts.push(text.slice(offset, idx)) + parts.push( + {text.slice(idx, idx + query.length)} - ); - offset = idx + query.length; - idx = textLower.indexOf(queryLower, offset); + , + ) + offset = idx + query.length + idx = textLower.indexOf(queryLower, offset) } - if (offset < text.length) parts.push(text.slice(offset)); - return <>{parts}; + if (offset < text.length) parts.push(text.slice(offset)) + return <>{parts} } diff --git a/src/utils/plugins/performStartupChecks.tsx b/src/utils/plugins/performStartupChecks.tsx index 8efad7a87..f4589dd93 100644 --- a/src/utils/plugins/performStartupChecks.tsx +++ b/src/utils/plugins/performStartupChecks.tsx @@ -1,10 +1,14 @@ -import { performBackgroundPluginInstallations } from '../../services/plugins/PluginInstallationManager.js'; -import type { AppState } from '../../state/AppState.js'; -import { checkHasTrustDialogAccepted } from '../config.js'; -import { logForDebugging } from '../debug.js'; -import { clearMarketplacesCache, registerSeedMarketplaces } from './marketplaceManager.js'; -import { clearPluginCache } from './pluginLoader.js'; -type SetAppState = (f: (prevState: AppState) => AppState) => void; +import { performBackgroundPluginInstallations } from '../../services/plugins/PluginInstallationManager.js' +import type { AppState } from '../../state/AppState.js' +import { checkHasTrustDialogAccepted } from '../config.js' +import { logForDebugging } from '../debug.js' +import { + clearMarketplacesCache, + registerSeedMarketplaces, +} from './marketplaceManager.js' +import { clearPluginCache } from './pluginLoader.js' + +type SetAppState = (f: (prevState: AppState) => AppState) => void /** * Perform plugin startup checks and initiate background installations @@ -21,16 +25,21 @@ type SetAppState = (f: (prevState: AppState) => AppState) => void; * * @param setAppState Function to update app state with installation progress */ -export async function performStartupChecks(setAppState: SetAppState): Promise { - logForDebugging('performStartupChecks called'); +export async function performStartupChecks( + setAppState: SetAppState, +): Promise { + logForDebugging('performStartupChecks called') // Check if the current directory has been trusted if (!checkHasTrustDialogAccepted()) { - logForDebugging('Trust not accepted for current directory - skipping plugin installations'); - return; + logForDebugging( + 'Trust not accepted for current directory - skipping plugin installations', + ) + return } + try { - logForDebugging('Starting background plugin installations'); + logForDebugging('Starting background plugin installations') // Register seed marketplaces (CLAUDE_CODE_PLUGIN_SEED_DIR) before diffing. // Idempotent; no-op if seed not configured. Without this, background install @@ -39,31 +48,30 @@ export async function performStartupChecks(setAppState: SetAppState): Promise { - if (prev.plugins.needsRefresh) return prev; + if (prev.plugins.needsRefresh) return prev return { ...prev, - plugins: { - ...prev.plugins, - needsRefresh: true - } - }; - }); + plugins: { ...prev.plugins, needsRefresh: true }, + } + }) } // Start background installations without waiting // This will update AppState as installations progress - await performBackgroundPluginInstallations(setAppState); + await performBackgroundPluginInstallations(setAppState) } catch (error) { // Even if something fails here, don't block startup - logForDebugging(`Error initiating background plugin installations: ${error}`); + logForDebugging( + `Error initiating background plugin installations: ${error}`, + ) } } diff --git a/src/utils/preflightChecks.tsx b/src/utils/preflightChecks.tsx index 7bf37ba80..445cd12a8 100644 --- a/src/utils/preflightChecks.tsx +++ b/src/utils/preflightChecks.tsx @@ -1,134 +1,152 @@ -import axios from 'axios'; -import React, { useEffect, useState } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { Spinner } from '../components/Spinner.js'; -import { getOauthConfig } from '../constants/oauth.js'; -import { useTimeout } from '../hooks/useTimeout.js'; -import { Box, Text } from '../ink.js'; -import { getSSLErrorHint } from '../services/api/errorUtils.js'; -import { getUserAgent } from './http.js'; -import { logError } from './log.js'; +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { Spinner } from '../components/Spinner.js' +import { getOauthConfig } from '../constants/oauth.js' +import { useTimeout } from '../hooks/useTimeout.js' +import { Box, Text } from '../ink.js' +import { getSSLErrorHint } from '../services/api/errorUtils.js' +import { getUserAgent } from './http.js' +import { logError } from './log.js' + export interface PreflightCheckResult { - success: boolean; - error?: string; - sslHint?: string; + success: boolean + error?: string + sslHint?: string } + async function checkEndpoints(): Promise { try { - const oauthConfig = getOauthConfig(); - const tokenUrl = new URL(oauthConfig.TOKEN_URL); - const endpoints = [`${oauthConfig.BASE_API_URL}/api/hello`, `${tokenUrl.origin}/v1/oauth/hello`]; - const checkEndpoint = async (url: string): Promise => { + const oauthConfig = getOauthConfig() + const tokenUrl = new URL(oauthConfig.TOKEN_URL) + const endpoints = [ + `${oauthConfig.BASE_API_URL}/api/hello`, + `${tokenUrl.origin}/v1/oauth/hello`, + ] + + const checkEndpoint = async ( + url: string, + ): Promise => { try { const response = await axios.get(url, { - headers: { - 'User-Agent': getUserAgent() - } - }); + headers: { 'User-Agent': getUserAgent() }, + }) if (response.status !== 200) { - const hostname = new URL(url).hostname; + const hostname = new URL(url).hostname return { success: false, - error: `Failed to connect to ${hostname}: Status ${response.status}` - }; + error: `Failed to connect to ${hostname}: Status ${response.status}`, + } } - return { - success: true - }; + return { success: true } } catch (error) { - const hostname = new URL(url).hostname; - const sslHint = getSSLErrorHint(error); + const hostname = new URL(url).hostname + const sslHint = getSSLErrorHint(error) return { success: false, error: `Failed to connect to ${hostname}: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`, - sslHint: sslHint ?? undefined - }; + sslHint: sslHint ?? undefined, + } } - }; - const results = await Promise.all(endpoints.map(checkEndpoint)); - const failedResult = results.find(result => !result.success); + } + + const results = await Promise.all(endpoints.map(checkEndpoint)) + const failedResult = results.find(result => !result.success) + if (failedResult) { // Log failure to Statsig logEvent('tengu_preflight_check_failed', { isConnectivityError: false, hasErrorMessage: !!failedResult.error, - isSSLError: !!failedResult.sslHint - }); + isSSLError: !!failedResult.sslHint, + }) } - return failedResult || { - success: true - }; + + return failedResult || { success: true } } catch (error) { - logError(error as Error); + logError(error as Error) // Log to Statsig logEvent('tengu_preflight_check_failed', { - isConnectivityError: true - }); + isConnectivityError: true, + }) + return { success: false, - error: `Connectivity check error: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}` - }; + error: `Connectivity check error: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`, + } } } + interface PreflightStepProps { - onSuccess: () => void; + onSuccess: () => void } -export function PreflightStep({ onSuccess }: PreflightStepProps) { - const [result, setResult] = useState(null); - const [isChecking, setIsChecking] = useState(true); - const showSpinner = useTimeout(1000) && isChecking; + +export function PreflightStep({ + onSuccess, +}: PreflightStepProps): React.ReactNode { + const [result, setResult] = useState(null) + const [isChecking, setIsChecking] = useState(true) + + // delay showing the check since it's so fast that we normally + // want to just immediately show the next step without a flash + const showSpinner = useTimeout(1000) && isChecking useEffect(() => { - checkEndpoints().then((checkResult) => { - setResult(checkResult); - setIsChecking(false); - }); - }, []); + async function run() { + const checkResult = await checkEndpoints() + setResult(checkResult) + setIsChecking(false) + } + void run() + }, []) useEffect(() => { if (result?.success) { - onSuccess(); + onSuccess() } else if (result && !result.success) { - logError(result.error ?? 'Preflight connectivity check failed'); - onSuccess(); + const timer = setTimeout(() => process.exit(1), 100) + return () => clearTimeout(timer) } - }, [result, onSuccess]); - - let content = null; - if (isChecking && showSpinner) { - content = ( - - - Checking connectivity... - - ); - } else if (!result?.success && !isChecking) { - content = ( - - Unable to connect to Anthropic services - {result?.error} - {result?.sslHint ? ( - - {result.sslHint} - See https://code.claude.com/docs/en/network-config - - ) : ( - - Please check your internet connection and network settings. - - Note: Claude Code might not be available in your country. Check supported countries at{" "} - https://anthropic.com/supported-countries - - - )} - - ); - } + }, [result, onSuccess]) return ( - {content} + {isChecking && showSpinner ? ( + + + Checking connectivity... + + ) : ( + !result?.success && + !isChecking && ( + + Unable to connect to Anthropic services + {result?.error} + {result?.sslHint ? ( + + {result.sslHint} + + See https://code.claude.com/docs/en/network-config + + + ) : ( + + + Please check your internet connection and network settings. + + + Note: Claude Code might not be available in your country. + Check supported countries at{' '} + + https://anthropic.com/supported-countries + + + + )} + + ) + )} - ); + ) } diff --git a/src/utils/processUserInput/processBashCommand.tsx b/src/utils/processUserInput/processBashCommand.tsx index 0ea5b8915..a8460d4d0 100644 --- a/src/utils/processUserInput/processBashCommand.tsx +++ b/src/utils/processUserInput/processBashCommand.tsx @@ -1,69 +1,97 @@ -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources'; -import { randomUUID } from 'crypto'; -import * as React from 'react'; -import { BashModeProgress } from 'src/components/BashModeProgress.js'; -import type { SetToolJSXFn } from 'src/Tool.js'; -import { BashTool } from 'src/tools/BashTool/BashTool.js'; -import type { AttachmentMessage, SystemMessage, UserMessage } from 'src/types/message.js'; -import type { ShellProgress } from 'src/types/tools.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { errorMessage, ShellError } from '../errors.js'; -import { createSyntheticUserCaveatMessage, createUserInterruptionMessage, createUserMessage, prepareUserContent } from '../messages.js'; -import { resolveDefaultShell } from '../shell/resolveDefaultShell.js'; -import { isPowerShellToolEnabled } from '../shell/shellToolUtils.js'; -import { processToolResultBlock } from '../toolResultStorage.js'; -import { escapeXml } from '../xml.js'; -import type { ProcessUserInputContext } from './processUserInput.js'; -export async function processBashCommand(inputString: string, precedingInputBlocks: ContentBlockParam[], attachmentMessages: AttachmentMessage[], context: ProcessUserInputContext, setToolJSX: SetToolJSXFn): Promise<{ - messages: (UserMessage | AttachmentMessage | SystemMessage)[]; - shouldQuery: boolean; +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources' +import { randomUUID } from 'crypto' +import * as React from 'react' +import { BashModeProgress } from 'src/components/BashModeProgress.js' +import type { SetToolJSXFn } from 'src/Tool.js' +import { BashTool } from 'src/tools/BashTool/BashTool.js' +import type { + AttachmentMessage, + SystemMessage, + UserMessage, +} from 'src/types/message.js' +import type { ShellProgress } from 'src/types/tools.js' +import { logEvent } from '../../services/analytics/index.js' +import { errorMessage, ShellError } from '../errors.js' +import { + createSyntheticUserCaveatMessage, + createUserInterruptionMessage, + createUserMessage, + prepareUserContent, +} from '../messages.js' +import { resolveDefaultShell } from '../shell/resolveDefaultShell.js' +import { isPowerShellToolEnabled } from '../shell/shellToolUtils.js' +import { processToolResultBlock } from '../toolResultStorage.js' +import { escapeXml } from '../xml.js' +import type { ProcessUserInputContext } from './processUserInput.js' + +export async function processBashCommand( + inputString: string, + precedingInputBlocks: ContentBlockParam[], + attachmentMessages: AttachmentMessage[], + context: ProcessUserInputContext, + setToolJSX: SetToolJSXFn, +): Promise<{ + messages: (UserMessage | AttachmentMessage | SystemMessage)[] + shouldQuery: boolean }> { // Shell routing (docs/design/ps-shell-selection.md §5.2): consult // defaultShell, fall back to bash. isPowerShellToolEnabled() applies the // same platform + env-var gate as tools.ts so input-box routing matches // tool-list visibility. Computed up front so telemetry records the // actual shell, not the raw setting. - const usePowerShell = isPowerShellToolEnabled() && resolveDefaultShell() === 'powershell'; - logEvent('tengu_input_bash', { - powershell: usePowerShell - }); + const usePowerShell = + isPowerShellToolEnabled() && resolveDefaultShell() === 'powershell' + + logEvent('tengu_input_bash', { powershell: usePowerShell }) + const userMessage = createUserMessage({ content: prepareUserContent({ inputString: `${inputString}`, - precedingInputBlocks - }) - }); + precedingInputBlocks, + }), + }) // ctrl+b to background indicator - let jsx: React.ReactNode; + let jsx: React.ReactNode // Just show initial UI setToolJSX({ - jsx: , - shouldHidePromptInput: false - }); + jsx: ( + + ), + shouldHidePromptInput: false, + }) + try { const bashModeContext: ProcessUserInputContext = { ...context, // TODO: Clean up this hack setToolJSX: _ => { - jsx = _?.jsx; - } - }; + jsx = _?.jsx + }, + } // Progress UI — shared across both shell backends (both emit ShellProgress) - const onProgress = (progress: { - data: ShellProgress; - }) => { + const onProgress = (progress: { data: ShellProgress }) => { setToolJSX({ - jsx: <> - + jsx: ( + <> + {jsx} - , + + ), shouldHidePromptInput: false, - showSpinner: false - }); - }; + showSpinner: false, + }) + } // User-initiated `!` commands run outside sandbox. Both shell tools honor // dangerouslyDisableSandbox (checked against areUnsandboxedCommandsAllowed() @@ -71,69 +99,107 @@ export async function processBashCommand(inputString: string, precedingInputBloc // native, shouldUseSandbox() returns false regardless (unsupported platform). // Lazy-require PowerShellTool so its ~300KB chunk only loads when the // user has actually selected the powershell default shell. - type PSMod = typeof import('src/tools/PowerShellTool/PowerShellTool.js'); - let PowerShellTool: PSMod['PowerShellTool'] | null = null; + type PSMod = typeof import('src/tools/PowerShellTool/PowerShellTool.js') + let PowerShellTool: PSMod['PowerShellTool'] | null = null if (usePowerShell) { /* eslint-disable @typescript-eslint/no-require-imports */ - PowerShellTool = (require('src/tools/PowerShellTool/PowerShellTool.js') as PSMod).PowerShellTool; + PowerShellTool = ( + require('src/tools/PowerShellTool/PowerShellTool.js') as PSMod + ).PowerShellTool /* eslint-enable @typescript-eslint/no-require-imports */ } - const shellTool = PowerShellTool ?? BashTool; - const response = PowerShellTool ? await PowerShellTool.call({ - command: inputString, - dangerouslyDisableSandbox: true - }, bashModeContext, undefined, undefined, onProgress) : await BashTool.call({ - command: inputString, - dangerouslyDisableSandbox: true - }, bashModeContext, undefined, undefined, onProgress); - const data = response.data; + const shellTool = PowerShellTool ?? BashTool + + const response = PowerShellTool + ? await PowerShellTool.call( + { command: inputString, dangerouslyDisableSandbox: true }, + bashModeContext, + undefined, + undefined, + onProgress, + ) + : await BashTool.call( + { + command: inputString, + dangerouslyDisableSandbox: true, + }, + bashModeContext, + undefined, + undefined, + onProgress, + ) + const data = response.data + if (!data) { - throw new Error('No result received from shell command'); + throw new Error('No result received from shell command') } - const stderr = data.stderr; + + const stderr = data.stderr // Reuse the same formatting pipeline as inline !`cmd` bash (promptShellExecution) // and model-initiated Bash. When BashTool.call() persists large output to disk, // data.persistedOutputPath is set and the formatter wraps in . // Pass stderr:'' to keep it separate for the UI tag. - const mapped = await processToolResultBlock(shellTool, { - ...data, - stderr: '' - }, randomUUID()); + const mapped = await processToolResultBlock( + shellTool, + { ...data, stderr: '' }, + randomUUID(), + ) // mapped.content may contain our own wrapper (trusted // XML from buildLargeToolResultMessage). Escaping it would turn structural // tags into <persisted-output>, breaking the model's parse and // UserBashOutputMessage's extractTag. Escape the raw fallback only. - const stdout = typeof mapped.content === 'string' ? mapped.content : escapeXml(data.stdout); + const stdout = + typeof mapped.content === 'string' + ? mapped.content + : escapeXml(data.stdout) return { - messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({ - content: `${stdout}${escapeXml(stderr)}` - })], - shouldQuery: false - }; + messages: [ + createSyntheticUserCaveatMessage(), + userMessage, + ...attachmentMessages, + createUserMessage({ + content: `${stdout}${escapeXml(stderr)}`, + }), + ], + shouldQuery: false, + } } catch (e) { if (e instanceof ShellError) { if (e.interrupted) { return { - messages: [createSyntheticUserCaveatMessage(), userMessage, createUserInterruptionMessage({ - toolUse: false - }), ...attachmentMessages], - shouldQuery: false - }; + messages: [ + createSyntheticUserCaveatMessage(), + userMessage, + createUserInterruptionMessage({ toolUse: false }), + ...attachmentMessages, + ], + shouldQuery: false, + } } return { - messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({ - content: `${escapeXml(e.stdout)}${escapeXml(e.stderr)}` - })], - shouldQuery: false - }; + messages: [ + createSyntheticUserCaveatMessage(), + userMessage, + ...attachmentMessages, + createUserMessage({ + content: `${escapeXml(e.stdout)}${escapeXml(e.stderr)}`, + }), + ], + shouldQuery: false, + } } return { - messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({ - content: `Command failed: ${escapeXml(errorMessage(e))}` - })], - shouldQuery: false - }; + messages: [ + createSyntheticUserCaveatMessage(), + userMessage, + ...attachmentMessages, + createUserMessage({ + content: `Command failed: ${escapeXml(errorMessage(e))}`, + }), + ], + shouldQuery: false, + } } finally { - setToolJSX(null); + setToolJSX(null) } } diff --git a/src/utils/processUserInput/processSlashCommand.tsx b/src/utils/processUserInput/processSlashCommand.tsx index 5d231c1cf..b461ba8cf 100644 --- a/src/utils/processUserInput/processSlashCommand.tsx +++ b/src/utils/processUserInput/processSlashCommand.tsx @@ -1,91 +1,155 @@ -import { feature } from 'bun:bundle'; -import type { ContentBlockParam, TextBlockParam } from '@anthropic-ai/sdk/resources'; -import { randomUUID } from 'crypto'; -import { setPromptId } from 'src/bootstrap/state.js'; -import { builtInCommandNames, type Command, type CommandBase, findCommand, getCommand, getCommandName, hasCommand, type PromptCommand } from 'src/commands.js'; -import { NO_CONTENT_MESSAGE } from 'src/constants/messages.js'; -import type { SetToolJSXFn, ToolUseContext } from 'src/Tool.js'; -import type { AssistantMessage, AttachmentMessage, Message, NormalizedUserMessage, ProgressMessage, UserMessage } from 'src/types/message.js'; -import { addInvokedSkill, getSessionId } from '../../bootstrap/state.js'; -import { COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG } from '../../constants/xml.js'; -import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent } from '../../services/analytics/index.js'; -import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'; -import { buildPostCompactMessages } from '../../services/compact/compact.js'; -import { resetMicrocompactState } from '../../services/compact/microCompact.js'; -import type { Progress as AgentProgress } from '../../tools/AgentTool/AgentTool.js'; -import { runAgent } from '../../tools/AgentTool/runAgent.js'; -import { renderToolUseProgressMessage } from '../../tools/AgentTool/UI.js'; -import type { CommandResultDisplay } from '../../types/command.js'; -import { createAbortController } from '../abortController.js'; -import { getAgentContext } from '../agentContext.js'; -import { createAttachmentMessage, getAttachmentMessages } from '../attachments.js'; -import { logForDebugging } from '../debug.js'; -import { isEnvTruthy } from '../envUtils.js'; -import { AbortError, MalformedCommandError } from '../errors.js'; -import { getDisplayPath } from '../file.js'; -import { extractResultText, prepareForkedCommandContext } from '../forkedAgent.js'; -import { getFsImplementation } from '../fsOperations.js'; -import { isFullscreenEnvEnabled } from '../fullscreen.js'; -import { toArray } from '../generators.js'; -import { registerSkillHooks } from '../hooks/registerSkillHooks.js'; -import { logError } from '../log.js'; -import { enqueuePendingNotification } from '../messageQueueManager.js'; -import { createCommandInputMessage, createSyntheticUserCaveatMessage, createSystemMessage, createUserInterruptionMessage, createUserMessage, formatCommandInputTags, isCompactBoundaryMessage, isSystemLocalCommandMessage, normalizeMessages, prepareUserContent } from '../messages.js'; -import type { ModelAlias } from '../model/aliases.js'; -import { parseToolListFromCLI } from '../permissions/permissionSetup.js'; -import { hasPermissionsToUseTool } from '../permissions/permissions.js'; -import { isOfficialMarketplaceName, parsePluginIdentifier } from '../plugins/pluginIdentifier.js'; -import { isRestrictedToPluginOnly, isSourceAdminTrusted } from '../settings/pluginOnlyPolicy.js'; -import { parseSlashCommand } from '../slashCommandParsing.js'; -import { sleep } from '../sleep.js'; -import { recordSkillUsage } from '../suggestions/skillUsageTracking.js'; -import { logOTelEvent, redactIfDisabled } from '../telemetry/events.js'; -import { buildPluginCommandTelemetryFields } from '../telemetry/pluginTelemetry.js'; -import { getAssistantMessageContentLength } from '../tokens.js'; -import { createAgentId } from '../uuid.js'; -import { getWorkload } from '../workloadContext.js'; -import type { ProcessUserInputBaseResult, ProcessUserInputContext } from './processUserInput.js'; +import { feature } from 'bun:bundle' +import type { + ContentBlockParam, + TextBlockParam, +} from '@anthropic-ai/sdk/resources' +import { randomUUID } from 'crypto' +import { setPromptId } from 'src/bootstrap/state.js' +import { + builtInCommandNames, + type Command, + type CommandBase, + findCommand, + getCommand, + getCommandName, + hasCommand, + type PromptCommand, +} from 'src/commands.js' +import { NO_CONTENT_MESSAGE } from 'src/constants/messages.js' +import type { SetToolJSXFn, ToolUseContext } from 'src/Tool.js' +import type { + AssistantMessage, + AttachmentMessage, + Message, + NormalizedUserMessage, + ProgressMessage, + UserMessage, +} from 'src/types/message.js' +import { addInvokedSkill, getSessionId } from '../../bootstrap/state.js' +import { COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG } from '../../constants/xml.js' +import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + logEvent, +} from '../../services/analytics/index.js' +import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js' +import { buildPostCompactMessages } from '../../services/compact/compact.js' +import { resetMicrocompactState } from '../../services/compact/microCompact.js' +import type { Progress as AgentProgress } from '../../tools/AgentTool/AgentTool.js' +import { runAgent } from '../../tools/AgentTool/runAgent.js' +import { renderToolUseProgressMessage } from '../../tools/AgentTool/UI.js' +import type { CommandResultDisplay } from '../../types/command.js' +import { createAbortController } from '../abortController.js' +import { getAgentContext } from '../agentContext.js' +import { + createAttachmentMessage, + getAttachmentMessages, +} from '../attachments.js' +import { logForDebugging } from '../debug.js' +import { isEnvTruthy } from '../envUtils.js' +import { AbortError, MalformedCommandError } from '../errors.js' +import { getDisplayPath } from '../file.js' +import { + extractResultText, + prepareForkedCommandContext, +} from '../forkedAgent.js' +import { getFsImplementation } from '../fsOperations.js' +import { isFullscreenEnvEnabled } from '../fullscreen.js' +import { toArray } from '../generators.js' +import { registerSkillHooks } from '../hooks/registerSkillHooks.js' +import { logError } from '../log.js' +import { enqueuePendingNotification } from '../messageQueueManager.js' +import { + createCommandInputMessage, + createSyntheticUserCaveatMessage, + createSystemMessage, + createUserInterruptionMessage, + createUserMessage, + formatCommandInputTags, + isCompactBoundaryMessage, + isSystemLocalCommandMessage, + normalizeMessages, + prepareUserContent, +} from '../messages.js' +import type { ModelAlias } from '../model/aliases.js' +import { parseToolListFromCLI } from '../permissions/permissionSetup.js' +import { hasPermissionsToUseTool } from '../permissions/permissions.js' +import { + isOfficialMarketplaceName, + parsePluginIdentifier, +} from '../plugins/pluginIdentifier.js' +import { + isRestrictedToPluginOnly, + isSourceAdminTrusted, +} from '../settings/pluginOnlyPolicy.js' +import { parseSlashCommand } from '../slashCommandParsing.js' +import { sleep } from '../sleep.js' +import { recordSkillUsage } from '../suggestions/skillUsageTracking.js' +import { logOTelEvent, redactIfDisabled } from '../telemetry/events.js' +import { buildPluginCommandTelemetryFields } from '../telemetry/pluginTelemetry.js' +import { getAssistantMessageContentLength } from '../tokens.js' +import { createAgentId } from '../uuid.js' +import { getWorkload } from '../workloadContext.js' +import type { + ProcessUserInputBaseResult, + ProcessUserInputContext, +} from './processUserInput.js' + type SlashCommandResult = ProcessUserInputBaseResult & { - command: Command; -}; + command: Command +} // Poll interval and deadline for MCP settle before launching a background // forked subagent. MCP servers typically connect within 1-3s of startup; // 10s headroom covers slow SSE handshakes. -const MCP_SETTLE_POLL_MS = 200; -const MCP_SETTLE_TIMEOUT_MS = 10_000; +const MCP_SETTLE_POLL_MS = 200 +const MCP_SETTLE_TIMEOUT_MS = 10_000 /** * Executes a slash command with context: fork in a sub-agent. */ -async function executeForkedSlashCommand(command: CommandBase & PromptCommand, args: string, context: ProcessUserInputContext, precedingInputBlocks: ContentBlockParam[], setToolJSX: SetToolJSXFn, canUseTool: CanUseToolFn): Promise { - const agentId = createAgentId(); - const pluginMarketplace = command.pluginInfo ? parsePluginIdentifier(command.pluginInfo.repository).marketplace : undefined; +async function executeForkedSlashCommand( + command: CommandBase & PromptCommand, + args: string, + context: ProcessUserInputContext, + precedingInputBlocks: ContentBlockParam[], + setToolJSX: SetToolJSXFn, + canUseTool: CanUseToolFn, +): Promise { + const agentId = createAgentId() + + const pluginMarketplace = command.pluginInfo + ? parsePluginIdentifier(command.pluginInfo.repository).marketplace + : undefined logEvent('tengu_slash_command_forked', { - command_name: command.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - invocation_trigger: 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + command_name: + command.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + invocation_trigger: + 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(command.pluginInfo && { - _PROTO_plugin_name: command.pluginInfo.pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + _PROTO_plugin_name: command.pluginInfo.pluginManifest + .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, ...(pluginMarketplace && { - _PROTO_marketplace_name: pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED + _PROTO_marketplace_name: + pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, }), - ...buildPluginCommandTelemetryFields(command.pluginInfo) - }) - }); - const { - skillContent, - modifiedGetAppState, - baseAgent, - promptMessages - } = await prepareForkedCommandContext(command, args, context); + ...buildPluginCommandTelemetryFields(command.pluginInfo), + }), + }) + + const { skillContent, modifiedGetAppState, baseAgent, promptMessages } = + await prepareForkedCommandContext(command, args, context) // Merge skill's effort into the agent definition so runAgent applies it - const agentDefinition = command.effort !== undefined ? { - ...baseAgent, - effort: command.effort - } : baseAgent; - logForDebugging(`Executing forked slash command /${command.name} with agent ${agentDefinition.agentType}`); + const agentDefinition = + command.effort !== undefined + ? { ...baseAgent, effort: command.effort } + : baseAgent + + logForDebugging( + `Executing forked slash command /${command.name} with agent ${agentDefinition.agentType}`, + ) // Assistant mode: fire-and-forget. Launch subagent in background, return // immediately, re-enqueue the result as an isMeta prompt when done. @@ -103,8 +167,8 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a // Standalone abortController — background subagents survive main-thread // ESC (same policy as AgentTool's async path). They're cron-driven; if // killed mid-run they just re-fire on the next schedule. - const bgAbortController = createAbortController(); - const commandName = getCommandName(command); + const bgAbortController = createAbortController() + const commandName = getCommandName(command) // Workload: handlePromptSubmit wraps the entire turn in runWithWorkload // (AsyncLocalStorage). ALS context is captured when this `void` fires @@ -115,7 +179,7 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a // handlePromptSubmit → fresh runWithWorkload boundary (which always // establishes a new context, even for `undefined`) → so it needs its // own QueuedCommand.workload tag to preserve attribution. - const spawnTimeWorkload = getWorkload(); + const spawnTimeWorkload = getWorkload() // Re-enter the queue as a hidden prompt. isMeta: hides from queue // preview + placeholder + transcript. skipSlashCommands: prevents @@ -123,14 +187,16 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a // drained, this triggers a main-agent turn that sees the result and // decides whether to SendUserMessage. Propagate workload so that // second turn is also tagged. - const enqueueResult = (value: string): void => enqueuePendingNotification({ - value, - mode: 'prompt', - priority: 'later', - isMeta: true, - skipSlashCommands: true, - workload: spawnTimeWorkload - }); + const enqueueResult = (value: string): void => + enqueuePendingNotification({ + value, + mode: 'prompt', + priority: 'later', + isMeta: true, + skipSlashCommands: true, + workload: spawnTimeWorkload, + }) + void (async () => { // Wait for MCP servers to settle. Scheduled tasks fire at startup and // all N drain within ~1ms (since we return immediately), capturing @@ -138,91 +204,95 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a // accidentally avoided this — tasks serialized, so task N's drain // happened after task N-1's 30s run, by which time MCP was up. // Poll until no 'pending' clients remain, then refresh. - const deadline = Date.now() + MCP_SETTLE_TIMEOUT_MS; + const deadline = Date.now() + MCP_SETTLE_TIMEOUT_MS while (Date.now() < deadline) { - const s = context.getAppState(); - if (!s.mcp.clients.some(c => c.type === 'pending')) break; - await sleep(MCP_SETTLE_POLL_MS); + const s = context.getAppState() + if (!s.mcp.clients.some(c => c.type === 'pending')) break + await sleep(MCP_SETTLE_POLL_MS) } - const freshTools = context.options.refreshTools?.() ?? context.options.tools; - const agentMessages: Message[] = []; + const freshTools = + context.options.refreshTools?.() ?? context.options.tools + + const agentMessages: Message[] = [] for await (const message of runAgent({ agentDefinition, promptMessages, toolUseContext: { ...context, getAppState: modifiedGetAppState, - abortController: bgAbortController + abortController: bgAbortController, }, canUseTool, isAsync: true, querySource: 'agent:custom', model: command.model as ModelAlias | undefined, availableTools: freshTools, - override: { - agentId - } + override: { agentId }, })) { - agentMessages.push(message); + agentMessages.push(message) } - const resultText = extractResultText(agentMessages, 'Command completed'); - logForDebugging(`Background forked command /${commandName} completed (agent ${agentId})`); - enqueueResult(`\n${resultText}\n`); + const resultText = extractResultText(agentMessages, 'Command completed') + logForDebugging( + `Background forked command /${commandName} completed (agent ${agentId})`, + ) + enqueueResult( + `\n${resultText}\n`, + ) })().catch(err => { - logError(err); - enqueueResult(`\n${err instanceof Error ? err.message : String(err)}\n`); - }); + logError(err) + enqueueResult( + `\n${err instanceof Error ? err.message : String(err)}\n`, + ) + }) // Nothing to render, nothing to query — the background runner re-enters // the queue on its own schedule. - return { - messages: [], - shouldQuery: false, - command - }; + return { messages: [], shouldQuery: false, command } } // Collect messages from the forked agent - const agentMessages: Message[] = []; + const agentMessages: Message[] = [] // Build progress messages for the agent progress UI - const progressMessages: ProgressMessage[] = []; - const parentToolUseID = `forked-command-${command.name}`; - let toolUseCounter = 0; + const progressMessages: ProgressMessage[] = [] + const parentToolUseID = `forked-command-${command.name}` + let toolUseCounter = 0 // Helper to create a progress message from an agent message - const createProgressMessage = (message: AssistantMessage | NormalizedUserMessage): ProgressMessage => { - toolUseCounter++; + const createProgressMessage = ( + message: AssistantMessage | NormalizedUserMessage, + ): ProgressMessage => { + toolUseCounter++ return { type: 'progress', data: { message, type: 'agent_progress', prompt: skillContent, - agentId + agentId, }, parentToolUseID, toolUseID: `${parentToolUseID}-${toolUseCounter}`, timestamp: new Date().toISOString(), - uuid: randomUUID() - }; - }; + uuid: randomUUID(), + } + } // Helper to update progress display using agent progress UI const updateProgress = (): void => { setToolJSX({ jsx: renderToolUseProgressMessage(progressMessages, { tools: context.options.tools, - verbose: false + verbose: false, }), shouldHidePromptInput: false, shouldContinueAnimation: true, - showSpinner: true - }); - }; + showSpinner: true, + }) + } // Show initial "Initializing…" state - updateProgress(); + updateProgress() // Run the sub-agent try { @@ -231,67 +301,76 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a promptMessages, toolUseContext: { ...context, - getAppState: modifiedGetAppState + getAppState: modifiedGetAppState, }, canUseTool, isAsync: false, querySource: 'agent:custom', model: command.model as ModelAlias | undefined, - availableTools: context.options.tools + availableTools: context.options.tools, })) { - agentMessages.push(message); - const normalizedNew = normalizeMessages([message]); + agentMessages.push(message) + const normalizedNew = normalizeMessages([message]) // Add progress message for assistant messages (which contain tool uses) if (message.type === 'assistant') { // Increment token count in spinner for assistant messages - const contentLength = getAssistantMessageContentLength(message as AssistantMessage); + const contentLength = getAssistantMessageContentLength(message) if (contentLength > 0) { - context.setResponseLength(len => len + contentLength); + context.setResponseLength(len => len + contentLength) } - const normalizedMsg = normalizedNew[0]; + + const normalizedMsg = normalizedNew[0] if (normalizedMsg && normalizedMsg.type === 'assistant') { - progressMessages.push(createProgressMessage(message as AssistantMessage)); - updateProgress(); + progressMessages.push(createProgressMessage(message)) + updateProgress() } } // Add progress message for user messages (which contain tool results) if (message.type === 'user') { - const normalizedMsg = normalizedNew[0]; + const normalizedMsg = normalizedNew[0] if (normalizedMsg && normalizedMsg.type === 'user') { - progressMessages.push(createProgressMessage(normalizedMsg as AssistantMessage | UserMessage)); - updateProgress(); + progressMessages.push(createProgressMessage(normalizedMsg)) + updateProgress() } } } } finally { // Clear the progress display - setToolJSX(null); + setToolJSX(null) } - let resultText = extractResultText(agentMessages, 'Command completed'); - logForDebugging(`Forked slash command /${command.name} completed with agent ${agentId}`); + + let resultText = extractResultText(agentMessages, 'Command completed') + + logForDebugging( + `Forked slash command /${command.name} completed with agent ${agentId}`, + ) // Prepend debug log for ant users so it appears inside the command output - if ((process.env.USER_TYPE) === 'ant') { - resultText = `[ANT-ONLY] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}\n${resultText}`; + if (process.env.USER_TYPE === 'ant') { + resultText = `[ANT-ONLY] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}\n${resultText}` } // Return the result as a user message (simulates the agent's output) - const messages: UserMessage[] = [createUserMessage({ - content: prepareUserContent({ - inputString: `/${getCommandName(command)} ${args}`.trim(), - precedingInputBlocks - }) - }), createUserMessage({ - content: `\n${resultText}\n` - })]; + const messages: UserMessage[] = [ + createUserMessage({ + content: prepareUserContent({ + inputString: `/${getCommandName(command)} ${args}`.trim(), + precedingInputBlocks, + }), + }), + createUserMessage({ + content: `\n${resultText}\n`, + }), + ] + return { messages, shouldQuery: false, command, - resultText - }; + resultText, + } } /** @@ -304,80 +383,111 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a export function looksLikeCommand(commandName: string): boolean { // Command names should only contain [a-zA-Z0-9:_-] // If it contains other characters, it's probably a file path or other input - return !/[^a-zA-Z0-9:\-_]/.test(commandName); + return !/[^a-zA-Z0-9:\-_]/.test(commandName) } -export async function processSlashCommand(inputString: string, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], attachmentMessages: AttachmentMessage[], context: ProcessUserInputContext, setToolJSX: SetToolJSXFn, uuid?: string, isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn): Promise { - const parsed = parseSlashCommand(inputString); + +export async function processSlashCommand( + inputString: string, + precedingInputBlocks: ContentBlockParam[], + imageContentBlocks: ContentBlockParam[], + attachmentMessages: AttachmentMessage[], + context: ProcessUserInputContext, + setToolJSX: SetToolJSXFn, + uuid?: string, + isAlreadyProcessing?: boolean, + canUseTool?: CanUseToolFn, +): Promise { + const parsed = parseSlashCommand(inputString) if (!parsed) { - logEvent('tengu_input_slash_missing', {}); - const errorMessage = 'Commands are in the form `/command [args]`'; + logEvent('tengu_input_slash_missing', {}) + const errorMessage = 'Commands are in the form `/command [args]`' return { - messages: [createSyntheticUserCaveatMessage(), ...attachmentMessages, createUserMessage({ - content: prepareUserContent({ - inputString: errorMessage, - precedingInputBlocks - }) - })], + messages: [ + createSyntheticUserCaveatMessage(), + ...attachmentMessages, + createUserMessage({ + content: prepareUserContent({ + inputString: errorMessage, + precedingInputBlocks, + }), + }), + ], shouldQuery: false, - resultText: errorMessage - }; + resultText: errorMessage, + } } - const { - commandName, - args: parsedArgs, - isMcp - } = parsed; - const sanitizedCommandName = isMcp ? 'mcp' : !builtInCommandNames().has(commandName) ? 'custom' : commandName; + + const { commandName, args: parsedArgs, isMcp } = parsed + + const sanitizedCommandName = isMcp + ? 'mcp' + : !builtInCommandNames().has(commandName) + ? 'custom' + : commandName // Check if it's a real command before processing if (!hasCommand(commandName, context.options.commands)) { // Check if this looks like a command name vs a file path or other input // Also check if it's an actual file path that exists - let isFilePath = false; + let isFilePath = false try { - await getFsImplementation().stat(`/${commandName}`); - isFilePath = true; + await getFsImplementation().stat(`/${commandName}`) + isFilePath = true } catch { // Not a file path — treat as command name } if (looksLikeCommand(commandName) && !isFilePath) { logEvent('tengu_input_slash_invalid', { - input: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - const unknownMessage = `Unknown skill: ${commandName}`; + input: + commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + const unknownMessage = `Unknown skill: ${commandName}` return { - messages: [createSyntheticUserCaveatMessage(), ...attachmentMessages, createUserMessage({ - content: prepareUserContent({ - inputString: unknownMessage, - precedingInputBlocks - }) - }), - // gh-32591: preserve args so the user can copy/resubmit without - // retyping. System warning is UI-only (filtered before API). - ...(parsedArgs ? [createSystemMessage(`Args from unknown skill: ${parsedArgs}`, 'warning')] : [])], + messages: [ + createSyntheticUserCaveatMessage(), + ...attachmentMessages, + createUserMessage({ + content: prepareUserContent({ + inputString: unknownMessage, + precedingInputBlocks, + }), + }), + // gh-32591: preserve args so the user can copy/resubmit without + // retyping. System warning is UI-only (filtered before API). + ...(parsedArgs + ? [ + createSystemMessage( + `Args from unknown skill: ${parsedArgs}`, + 'warning', + ), + ] + : []), + ], shouldQuery: false, - resultText: unknownMessage - }; + resultText: unknownMessage, + } } - const promptId = randomUUID(); - setPromptId(promptId); - logEvent('tengu_input_prompt', {}); + + const promptId = randomUUID() + setPromptId(promptId) + logEvent('tengu_input_prompt', {}) // Log user prompt event for OTLP void logOTelEvent('user_prompt', { prompt_length: String(inputString.length), prompt: redactIfDisabled(inputString), - 'prompt.id': promptId - }); + 'prompt.id': promptId, + }) return { - messages: [createUserMessage({ - content: prepareUserContent({ - inputString, - precedingInputBlocks + messages: [ + createUserMessage({ + content: prepareUserContent({ inputString, precedingInputBlocks }), + uuid: uuid, }), - uuid: uuid - }), ...attachmentMessages], - shouldQuery: true - }; + ...attachmentMessages, + ], + shouldQuery: true, + } } // Track slash command usage for feature discovery @@ -391,233 +501,329 @@ export async function processSlashCommand(inputString: string, precedingInputBlo command: returnedCommand, resultText, nextInput, - submitNextInput - } = await getMessagesForSlashCommand(commandName, parsedArgs, setToolJSX, context, precedingInputBlocks, imageContentBlocks, isAlreadyProcessing, canUseTool, uuid); + submitNextInput, + } = await getMessagesForSlashCommand( + commandName, + parsedArgs, + setToolJSX, + context, + precedingInputBlocks, + imageContentBlocks, + isAlreadyProcessing, + canUseTool, + uuid, + ) // Local slash commands that skip messages if (newMessages.length === 0) { const eventData: Record = { - input: sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }; + input: + sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } // Add plugin metadata if this is a plugin command if (returnedCommand.type === 'prompt' && returnedCommand.pluginInfo) { - const { - pluginManifest, - repository - } = returnedCommand.pluginInfo; - const { - marketplace - } = parsePluginIdentifier(repository); - const isOfficial = isOfficialMarketplaceName(marketplace); + const { pluginManifest, repository } = returnedCommand.pluginInfo + const { marketplace } = parsePluginIdentifier(repository) + const isOfficial = isOfficialMarketplaceName(marketplace) // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns // (unredacted, all users); plugin_name/plugin_repository stay in // additional_metadata as redacted variants for general-access dashboards. - eventData._PROTO_plugin_name = pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED; + eventData._PROTO_plugin_name = + pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED if (marketplace) { - eventData._PROTO_marketplace_name = marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED; + eventData._PROTO_marketplace_name = + marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED } - eventData.plugin_repository = (isOfficial ? repository : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; - eventData.plugin_name = (isOfficial ? pluginManifest.name : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; + eventData.plugin_repository = ( + isOfficial ? repository : 'third-party' + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + eventData.plugin_name = ( + isOfficial ? pluginManifest.name : 'third-party' + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS if (isOfficial && pluginManifest.version) { - eventData.plugin_version = pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; + eventData.plugin_version = + pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } - Object.assign(eventData, buildPluginCommandTelemetryFields(returnedCommand.pluginInfo)); + Object.assign( + eventData, + buildPluginCommandTelemetryFields(returnedCommand.pluginInfo), + ) } + logEvent('tengu_input_command', { ...eventData, - invocation_trigger: 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ...((process.env.USER_TYPE) === 'ant' && { - skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + invocation_trigger: + 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(process.env.USER_TYPE === 'ant' && { + skill_name: + commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(returnedCommand.type === 'prompt' && { - skill_source: returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + skill_source: + returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(returnedCommand.loadedFrom && { - skill_loaded_from: returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + skill_loaded_from: + returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(returnedCommand.kind && { - skill_kind: returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }) - }) - }); + skill_kind: + returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + }), + }) return { messages: [], shouldQuery: false, + model, nextInput, - submitNextInput - }; + submitNextInput, + } } // For invalid commands, preserve both the user message and error - if (newMessages.length === 2 && newMessages[1]!.type === 'user' && typeof newMessages[1]!.message.content === 'string' && newMessages[1]!.message.content.startsWith('Unknown command:')) { + if ( + newMessages.length === 2 && + newMessages[1]!.type === 'user' && + typeof newMessages[1]!.message.content === 'string' && + newMessages[1]!.message.content.startsWith('Unknown command:') + ) { // Don't log as invalid if it looks like a common file path - const looksLikeFilePath = inputString.startsWith('/var') || inputString.startsWith('/tmp') || inputString.startsWith('/private'); + const looksLikeFilePath = + inputString.startsWith('/var') || + inputString.startsWith('/tmp') || + inputString.startsWith('/private') + if (!looksLikeFilePath) { logEvent('tengu_input_slash_invalid', { - input: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + input: + commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } + return { messages: [createSyntheticUserCaveatMessage(), ...newMessages], shouldQuery: messageShouldQuery, allowedTools, - model - }; + + model, + } } // A valid command const eventData: Record = { - input: sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }; + input: + sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } // Add plugin metadata if this is a plugin command if (returnedCommand.type === 'prompt' && returnedCommand.pluginInfo) { - const { - pluginManifest, - repository - } = returnedCommand.pluginInfo; - const { - marketplace - } = parsePluginIdentifier(repository); - const isOfficial = isOfficialMarketplaceName(marketplace); - eventData._PROTO_plugin_name = pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED; + const { pluginManifest, repository } = returnedCommand.pluginInfo + const { marketplace } = parsePluginIdentifier(repository) + const isOfficial = isOfficialMarketplaceName(marketplace) + eventData._PROTO_plugin_name = + pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED if (marketplace) { - eventData._PROTO_marketplace_name = marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED; + eventData._PROTO_marketplace_name = + marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED } - eventData.plugin_repository = (isOfficial ? repository : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; - eventData.plugin_name = (isOfficial ? pluginManifest.name : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; + eventData.plugin_repository = ( + isOfficial ? repository : 'third-party' + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + eventData.plugin_name = ( + isOfficial ? pluginManifest.name : 'third-party' + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS if (isOfficial && pluginManifest.version) { - eventData.plugin_version = pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; + eventData.plugin_version = + pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } - Object.assign(eventData, buildPluginCommandTelemetryFields(returnedCommand.pluginInfo)); + Object.assign( + eventData, + buildPluginCommandTelemetryFields(returnedCommand.pluginInfo), + ) } + logEvent('tengu_input_command', { ...eventData, - invocation_trigger: 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ...((process.env.USER_TYPE) === 'ant' && { - skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + invocation_trigger: + 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(process.env.USER_TYPE === 'ant' && { + skill_name: + commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(returnedCommand.type === 'prompt' && { - skill_source: returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + skill_source: + returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(returnedCommand.loadedFrom && { - skill_loaded_from: returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + skill_loaded_from: + returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(returnedCommand.kind && { - skill_kind: returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }) - }) - }); + skill_kind: + returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + }), + }) // Check if this is a compact result which handle their own synthetic caveat message ordering - const isCompactResult = newMessages.length > 0 && newMessages[0] && isCompactBoundaryMessage(newMessages[0]); + const isCompactResult = + newMessages.length > 0 && + newMessages[0] && + isCompactBoundaryMessage(newMessages[0]) + return { - messages: messageShouldQuery || newMessages.every(isSystemLocalCommandMessage) || isCompactResult ? newMessages : [createSyntheticUserCaveatMessage(), ...newMessages], + messages: + messageShouldQuery || + newMessages.every(isSystemLocalCommandMessage) || + isCompactResult + ? newMessages + : [createSyntheticUserCaveatMessage(), ...newMessages], shouldQuery: messageShouldQuery, allowedTools, model, effort, resultText, nextInput, - submitNextInput - }; + submitNextInput, + } } -async function getMessagesForSlashCommand(commandName: string, args: string, setToolJSX: SetToolJSXFn, context: ProcessUserInputContext, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], _isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn, uuid?: string): Promise { - const command = getCommand(commandName, context.options.commands); + +async function getMessagesForSlashCommand( + commandName: string, + args: string, + setToolJSX: SetToolJSXFn, + context: ProcessUserInputContext, + precedingInputBlocks: ContentBlockParam[], + imageContentBlocks: ContentBlockParam[], + _isAlreadyProcessing?: boolean, + canUseTool?: CanUseToolFn, + uuid?: string, +): Promise { + const command = getCommand(commandName, context.options.commands) // Track skill usage for ranking (only for prompt commands that are user-invocable) if (command.type === 'prompt' && command.userInvocable !== false) { - recordSkillUsage(commandName); + recordSkillUsage(commandName) } // Check if the command is user-invocable // Skills with userInvocable === false can only be invoked by the model via SkillTool if (command.userInvocable === false) { return { - messages: [createUserMessage({ - content: prepareUserContent({ - inputString: `/${commandName}`, - precedingInputBlocks - }) - }), createUserMessage({ - content: `This skill can only be invoked by Claude, not directly by users. Ask Claude to use the "${commandName}" skill for you.` - })], + messages: [ + createUserMessage({ + content: prepareUserContent({ + inputString: `/${commandName}`, + precedingInputBlocks, + }), + }), + createUserMessage({ + content: `This skill can only be invoked by Claude, not directly by users. Ask Claude to use the "${commandName}" skill for you.`, + }), + ], shouldQuery: false, - command - }; + command, + } } + try { switch (command.type) { - case 'local-jsx': - { - return new Promise(resolve => { - let doneWasCalled = false; - const onDone = (result?: string, options?: { - display?: CommandResultDisplay; - shouldQuery?: boolean; - metaMessages?: string[]; - nextInput?: string; - submitNextInput?: boolean; - }) => { - doneWasCalled = true; - // If display is 'skip', don't add any messages to the conversation - if (options?.display === 'skip') { - void resolve({ - messages: [], - shouldQuery: false, - command, - nextInput: options?.nextInput, - submitNextInput: options?.submitNextInput - }); - return; - } - - // Meta messages are model-visible but hidden from the user - const metaMessages = (options?.metaMessages ?? []).map((content: string) => createUserMessage({ - content, - isMeta: true - })); - - // In fullscreen the command just showed as a centered modal - // pane — the transient notification is enough feedback. The - // "❯ /config" + "⎿ dismissed" transcript entries are - // type:system subtype:local_command (user-visible but NOT sent - // to the model), so skipping them doesn't affect model context. - // Outside fullscreen keep them so scrollback shows what ran. - // Only skip " dismissed" modal-close notifications — - // commands that early-exit before showing a modal (/ultraplan - // usage, /rename, /proactive) use display:system for actual - // output that must reach the transcript. - const skipTranscript = isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed'); + case 'local-jsx': { + return new Promise(resolve => { + let doneWasCalled = false + const onDone = ( + result?: string, + options?: { + display?: CommandResultDisplay + shouldQuery?: boolean + metaMessages?: string[] + nextInput?: string + submitNextInput?: boolean + }, + ) => { + doneWasCalled = true + // If display is 'skip', don't add any messages to the conversation + if (options?.display === 'skip') { void resolve({ - messages: options?.display === 'system' ? skipTranscript ? metaMessages : [createCommandInputMessage(formatCommandInput(command, args)), createCommandInputMessage(`${result}`), ...metaMessages] : [createUserMessage({ - content: prepareUserContent({ - inputString: formatCommandInput(command, args), - precedingInputBlocks - }) - }), result ? createUserMessage({ - content: `${result}` - }) : createUserMessage({ - content: `${NO_CONTENT_MESSAGE}` - }), ...metaMessages], - shouldQuery: options?.shouldQuery ?? false, + messages: [], + shouldQuery: false, command, nextInput: options?.nextInput, - submitNextInput: options?.submitNextInput - }); - }; - void command.load().then(mod => mod.call(onDone, { - ...context, - canUseTool - }, args)).then(jsx => { - if (jsx == null) return; + submitNextInput: options?.submitNextInput, + }) + return + } + + // Meta messages are model-visible but hidden from the user + const metaMessages = (options?.metaMessages ?? []).map( + (content: string) => createUserMessage({ content, isMeta: true }), + ) + + // In fullscreen the command just showed as a centered modal + // pane — the transient notification is enough feedback. The + // "❯ /config" + "⎿ dismissed" transcript entries are + // type:system subtype:local_command (user-visible but NOT sent + // to the model), so skipping them doesn't affect model context. + // Outside fullscreen keep them so scrollback shows what ran. + // Only skip " dismissed" modal-close notifications — + // commands that early-exit before showing a modal (/ultraplan + // usage, /rename, /proactive) use display:system for actual + // output that must reach the transcript. + const skipTranscript = + isFullscreenEnvEnabled() && + typeof result === 'string' && + result.endsWith(' dismissed') + + void resolve({ + messages: + options?.display === 'system' + ? skipTranscript + ? metaMessages + : [ + createCommandInputMessage( + formatCommandInput(command, args), + ), + createCommandInputMessage( + `${result}`, + ), + ...metaMessages, + ] + : [ + createUserMessage({ + content: prepareUserContent({ + inputString: formatCommandInput(command, args), + precedingInputBlocks, + }), + }), + result + ? createUserMessage({ + content: `${result}`, + }) + : createUserMessage({ + content: `${NO_CONTENT_MESSAGE}`, + }), + ...metaMessages, + ], + shouldQuery: options?.shouldQuery ?? false, + command, + nextInput: options?.nextInput, + submitNextInput: options?.submitNextInput, + }) + } + + void command + .load() + .then(mod => mod.call(onDone, { ...context, canUseTool }, args)) + .then(jsx => { + if (jsx == null) return if (context.options.isNonInteractiveSession) { void resolve({ messages: [], shouldQuery: false, - command - }); - return; + command, + }) + return } // Guard: if onDone fired during mod.call() (early-exit path // that calls onDone then returns JSX), skip setToolJSX. This @@ -626,173 +832,231 @@ async function getMessagesForSlashCommand(commandName: string, args: string, set // its setToolJSX({clearLocalJSX: true}) before we get here. // Setting isLocalJSXCommand after clear leaves it stuck true, // blocking useQueueProcessor and TextInput focus. - if (doneWasCalled) return; + if (doneWasCalled) return setToolJSX({ jsx, shouldHidePromptInput: true, showSpinner: false, isLocalJSXCommand: true, - isImmediate: command.immediate === true - }); - }).catch(e => { + isImmediate: command.immediate === true, + }) + }) + .catch(e => { // If load()/call() throws and onDone never fired, the outer // Promise hangs forever, leaving queryGuard stuck in // 'dispatching' and deadlocking the queue processor. - logError(e); - if (doneWasCalled) return; - doneWasCalled = true; + logError(e) + if (doneWasCalled) return + doneWasCalled = true setToolJSX({ jsx: null, shouldHidePromptInput: false, - clearLocalJSX: true - }); - void resolve({ - messages: [], - shouldQuery: false, - command - }); - }); - }); - } - case 'local': - { - const displayArgs = command.isSensitive && args.trim() ? '***' : args; - const userMessage = createUserMessage({ - content: prepareUserContent({ - inputString: formatCommandInput(command, displayArgs), - precedingInputBlocks + clearLocalJSX: true, + }) + void resolve({ messages: [], shouldQuery: false, command }) }) - }); - try { - const syntheticCaveatMessage = createSyntheticUserCaveatMessage(); - const mod = await command.load(); - const result = await mod.call(args, context); - if (result.type === 'skip') { - return { - messages: [], - shouldQuery: false, - command - }; - } + }) + } + case 'local': { + const displayArgs = command.isSensitive && args.trim() ? '***' : args + const userMessage = createUserMessage({ + content: prepareUserContent({ + inputString: formatCommandInput(command, displayArgs), + precedingInputBlocks, + }), + }) - // Use discriminated union to handle different result types - if (result.type === 'compact') { - // Append slash command messages to messagesToKeep so that - // attachments and hookResults come after user messages - const slashCommandMessages = [syntheticCaveatMessage, userMessage, ...(result.displayText ? [createUserMessage({ - content: `${result.displayText}`, - // --resume looks at latest timestamp message to determine which message to resume from - // This is a perf optimization to avoid having to recaculcate the leaf node every time - // Since we're creating a bunch of synthetic messages for compact, it's important to set - // the timestamp of the last message to be slightly after the current time - // This is mostly important for sdk / -p mode - timestamp: new Date(Date.now() + 100).toISOString() - })] : [])]; - const compactionResultWithSlashMessages = { - ...result.compactionResult, - messagesToKeep: [...(result.compactionResult.messagesToKeep ?? []), ...slashCommandMessages] - }; - // Reset microcompact state since full compact replaces all - // messages — old tool IDs are no longer relevant. Budget state - // (on toolUseContext) needs no reset: stale entries are inert - // (UUIDs never repeat, so they're never looked up). - resetMicrocompactState(); - return { - messages: buildPostCompactMessages(compactionResultWithSlashMessages) as SlashCommandResult['messages'], - shouldQuery: false, - command - }; - } + try { + const syntheticCaveatMessage = createSyntheticUserCaveatMessage() + const mod = await command.load() + const result = await mod.call(args, context) - // Text result — use system message so it doesn't render as a user bubble + if (result.type === 'skip') { return { - messages: [userMessage, createCommandInputMessage(`${result.value}`)], + messages: [], shouldQuery: false, command, - resultText: result.value - }; - } catch (e) { - logError(e); + } + } + + // Use discriminated union to handle different result types + if (result.type === 'compact') { + // Append slash command messages to messagesToKeep so that + // attachments and hookResults come after user messages + const slashCommandMessages = [ + syntheticCaveatMessage, + userMessage, + ...(result.displayText + ? [ + createUserMessage({ + content: `${result.displayText}`, + // --resume looks at latest timestamp message to determine which message to resume from + // This is a perf optimization to avoid having to recaculcate the leaf node every time + // Since we're creating a bunch of synthetic messages for compact, it's important to set + // the timestamp of the last message to be slightly after the current time + // This is mostly important for sdk / -p mode + timestamp: new Date(Date.now() + 100).toISOString(), + }), + ] + : []), + ] + const compactionResultWithSlashMessages = { + ...result.compactionResult, + messagesToKeep: [ + ...(result.compactionResult.messagesToKeep ?? []), + ...slashCommandMessages, + ], + } + // Reset microcompact state since full compact replaces all + // messages — old tool IDs are no longer relevant. Budget state + // (on toolUseContext) needs no reset: stale entries are inert + // (UUIDs never repeat, so they're never looked up). + resetMicrocompactState() return { - messages: [userMessage, createCommandInputMessage(`${String(e)}`)], + messages: buildPostCompactMessages( + compactionResultWithSlashMessages, + ), shouldQuery: false, - command - }; + command, + } + } + + // Text result — use system message so it doesn't render as a user bubble + return { + messages: [ + userMessage, + createCommandInputMessage( + `${result.value}`, + ), + ], + shouldQuery: false, + command, + resultText: result.value, + } + } catch (e) { + logError(e) + return { + messages: [ + userMessage, + createCommandInputMessage( + `${String(e)}`, + ), + ], + shouldQuery: false, + command, } } - case 'prompt': - { - try { - // Check if command should run as forked sub-agent - if (command.context === 'fork') { - return await executeForkedSlashCommand(command, args, context, precedingInputBlocks, setToolJSX, canUseTool ?? hasPermissionsToUseTool); - } - return await getMessagesForPromptSlashCommand(command, args, context, precedingInputBlocks, imageContentBlocks, uuid); - } catch (e) { - // Handle abort errors specially to show proper "Interrupted" message - if (e instanceof AbortError) { - return { - messages: [createUserMessage({ + } + case 'prompt': { + try { + // Check if command should run as forked sub-agent + if (command.context === 'fork') { + return await executeForkedSlashCommand( + command, + args, + context, + precedingInputBlocks, + setToolJSX, + canUseTool ?? hasPermissionsToUseTool, + ) + } + + return await getMessagesForPromptSlashCommand( + command, + args, + context, + precedingInputBlocks, + imageContentBlocks, + uuid, + ) + } catch (e) { + // Handle abort errors specially to show proper "Interrupted" message + if (e instanceof AbortError) { + return { + messages: [ + createUserMessage({ content: prepareUserContent({ inputString: formatCommandInput(command, args), - precedingInputBlocks - }) - }), createUserInterruptionMessage({ - toolUse: false - })], - shouldQuery: false, - command - }; + precedingInputBlocks, + }), + }), + createUserInterruptionMessage({ toolUse: false }), + ], + shouldQuery: false, + command, } - return { - messages: [createUserMessage({ + } + return { + messages: [ + createUserMessage({ content: prepareUserContent({ inputString: formatCommandInput(command, args), - precedingInputBlocks - }) - }), createUserMessage({ - content: `${String(e)}` - })], - shouldQuery: false, - command - }; + precedingInputBlocks, + }), + }), + createUserMessage({ + content: `${String(e)}`, + }), + ], + shouldQuery: false, + command, } } + } } } catch (e) { if (e instanceof MalformedCommandError) { return { - messages: [createUserMessage({ - content: prepareUserContent({ - inputString: e.message, - precedingInputBlocks - }) - })], + messages: [ + createUserMessage({ + content: prepareUserContent({ + inputString: e.message, + precedingInputBlocks, + }), + }), + ], shouldQuery: false, - command - }; + command, + } } - throw e; + throw e } } + function formatCommandInput(command: CommandBase, args: string): string { - return formatCommandInputTags(getCommandName(command), args); + return formatCommandInputTags(getCommandName(command), args) } /** * Formats the metadata for a skill loading message. * Used by the Skill tool and for subagent skill preloading. */ -export function formatSkillLoadingMetadata(skillName: string, _progressMessage: string = 'loading'): string { +export function formatSkillLoadingMetadata( + skillName: string, + _progressMessage: string = 'loading', +): string { // Use skill name only - UserCommandMessage renders as "Skill(name)" - return [`<${COMMAND_MESSAGE_TAG}>${skillName}`, `<${COMMAND_NAME_TAG}>${skillName}`, `true`].join('\n'); + return [ + `<${COMMAND_MESSAGE_TAG}>${skillName}`, + `<${COMMAND_NAME_TAG}>${skillName}`, + `true`, + ].join('\n') } /** * Formats the metadata for a slash command loading message. */ -function formatSlashCommandLoadingMetadata(commandName: string, args?: string): string { - return [`<${COMMAND_MESSAGE_TAG}>${commandName}`, `<${COMMAND_NAME_TAG}>/${commandName}`, args ? `${args}` : null].filter(Boolean).join('\n'); +function formatSlashCommandLoadingMetadata( + commandName: string, + args?: string, +): string { + return [ + `<${COMMAND_MESSAGE_TAG}>${commandName}`, + `<${COMMAND_NAME_TAG}>/${commandName}`, + args ? `${args}` : null, + ] + .filter(Boolean) + .join('\n') } /** @@ -800,31 +1064,61 @@ function formatSlashCommandLoadingMetadata(commandName: string, args?: string): * User-invocable skills use slash command format (/name), while model-only * skills use the skill format ("The X skill is running"). */ -function formatCommandLoadingMetadata(command: CommandBase & PromptCommand, args?: string): string { +function formatCommandLoadingMetadata( + command: CommandBase & PromptCommand, + args?: string, +): string { // Use command.name (the qualified name including plugin prefix, e.g. // "product-management:feature-spec") instead of userFacingName() which may // strip the plugin prefix via displayName fallback. // User-invocable skills should show as /command-name like regular slash commands if (command.userInvocable !== false) { - return formatSlashCommandLoadingMetadata(command.name, args); + return formatSlashCommandLoadingMetadata(command.name, args) } // Model-only skills (userInvocable: false) show as "The X skill is running" - if (command.loadedFrom === 'skills' || command.loadedFrom === 'plugin' || command.loadedFrom === 'mcp') { - return formatSkillLoadingMetadata(command.name, command.progressMessage); + if ( + command.loadedFrom === 'skills' || + command.loadedFrom === 'plugin' || + command.loadedFrom === 'mcp' + ) { + return formatSkillLoadingMetadata(command.name, command.progressMessage) } - return formatSlashCommandLoadingMetadata(command.name, args); + return formatSlashCommandLoadingMetadata(command.name, args) } -export async function processPromptSlashCommand(commandName: string, args: string, commands: Command[], context: ToolUseContext, imageContentBlocks: ContentBlockParam[] = []): Promise { - const command = findCommand(commandName, commands); + +export async function processPromptSlashCommand( + commandName: string, + args: string, + commands: Command[], + context: ToolUseContext, + imageContentBlocks: ContentBlockParam[] = [], +): Promise { + const command = findCommand(commandName, commands) if (!command) { - throw new MalformedCommandError(`Unknown command: ${commandName}`); + throw new MalformedCommandError(`Unknown command: ${commandName}`) } if (command.type !== 'prompt') { - throw new Error(`Unexpected ${command.type} command. Expected 'prompt' command. Use /${commandName} directly in the main conversation.`); + throw new Error( + `Unexpected ${command.type} command. Expected 'prompt' command. Use /${commandName} directly in the main conversation.`, + ) } - return getMessagesForPromptSlashCommand(command, args, context, [], imageContentBlocks); + return getMessagesForPromptSlashCommand( + command, + args, + context, + [], + imageContentBlocks, + ) } -async function getMessagesForPromptSlashCommand(command: CommandBase & PromptCommand, args: string, context: ToolUseContext, precedingInputBlocks: ContentBlockParam[] = [], imageContentBlocks: ContentBlockParam[] = [], uuid?: string): Promise { + +async function getMessagesForPromptSlashCommand( + command: CommandBase & PromptCommand, + args: string, + context: ToolUseContext, + precedingInputBlocks: ContentBlockParam[] = [], + imageContentBlocks: ContentBlockParam[] = [], + uuid?: string, +): Promise { // In coordinator mode (main thread only), skip loading the full skill content // and permissions. The coordinator only has Agent + TaskStop tools, so the // skill content and allowedTools are useless. Instead, send a brief summary @@ -834,88 +1128,135 @@ async function getMessagesForPromptSlashCommand(command: CommandBase & PromptCom // parent env, so we also check !context.agentId: agentId is only set for // subagents, letting workers fall through to getPromptForCommand and receive // the real skill content when they invoke the Skill tool. - if (feature('COORDINATOR_MODE') && isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) && !context.agentId) { - const metadata = formatCommandLoadingMetadata(command, args); - const parts: string[] = [`Skill "/${command.name}" is available for workers.`]; + if ( + feature('COORDINATOR_MODE') && + isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) && + !context.agentId + ) { + const metadata = formatCommandLoadingMetadata(command, args) + const parts: string[] = [ + `Skill "/${command.name}" is available for workers.`, + ] if (command.description) { - parts.push(`Description: ${command.description}`); + parts.push(`Description: ${command.description}`) } if (command.whenToUse) { - parts.push(`When to use: ${command.whenToUse}`); + parts.push(`When to use: ${command.whenToUse}`) } - const skillAllowedTools = command.allowedTools ?? []; + const skillAllowedTools = command.allowedTools ?? [] if (skillAllowedTools.length > 0) { - parts.push(`This skill grants workers additional tool permissions: ${skillAllowedTools.join(', ')}`); + parts.push( + `This skill grants workers additional tool permissions: ${skillAllowedTools.join(', ')}`, + ) } - parts.push(`\nInstruct a worker to use this skill by including "Use the /${command.name} skill" in your Agent prompt. The worker has access to the Skill tool and will receive the skill's content and permissions when it invokes it.`); - const summaryContent: ContentBlockParam[] = [{ - type: 'text', - text: parts.join('\n') - }]; + parts.push( + `\nInstruct a worker to use this skill by including "Use the /${command.name} skill" in your Agent prompt. The worker has access to the Skill tool and will receive the skill's content and permissions when it invokes it.`, + ) + const summaryContent: ContentBlockParam[] = [ + { type: 'text', text: parts.join('\n') }, + ] return { - messages: [createUserMessage({ - content: metadata, - uuid - }), createUserMessage({ - content: summaryContent, - isMeta: true - })], + messages: [ + createUserMessage({ content: metadata, uuid }), + createUserMessage({ content: summaryContent, isMeta: true }), + ], shouldQuery: true, model: command.model, effort: command.effort, - command - }; + command, + } } - const result = await command.getPromptForCommand(args, context); + + const result = await command.getPromptForCommand(args, context) // Register skill hooks if defined. Under ["hooks"]-only (skills not locked), // user skills still load and reach this point — block hook REGISTRATION here // where source is known. Mirrors the agent frontmatter gate in runAgent.ts. - const hooksAllowedForThisSkill = !isRestrictedToPluginOnly('hooks') || isSourceAdminTrusted(command.source); + const hooksAllowedForThisSkill = + !isRestrictedToPluginOnly('hooks') || isSourceAdminTrusted(command.source) if (command.hooks && hooksAllowedForThisSkill) { - const sessionId = getSessionId(); - registerSkillHooks(context.setAppState, sessionId, command.hooks, command.name, command.type === 'prompt' ? command.skillRoot : undefined); + const sessionId = getSessionId() + registerSkillHooks( + context.setAppState, + sessionId, + command.hooks, + command.name, + command.type === 'prompt' ? command.skillRoot : undefined, + ) } // Record skill invocation for compaction preservation, scoped by agent context. // Skills are tagged with their agentId so only skills belonging to the current // agent are restored during compaction (preventing cross-agent leaks). - const skillPath = command.source ? `${command.source}:${command.name}` : command.name; - const skillContent = result.filter((b): b is TextBlockParam => b.type === 'text').map(b => b.text).join('\n\n'); - addInvokedSkill(command.name, skillPath, skillContent, getAgentContext()?.agentId ?? null); - const metadata = formatCommandLoadingMetadata(command, args); - const additionalAllowedTools = parseToolListFromCLI(command.allowedTools ?? []); + const skillPath = command.source + ? `${command.source}:${command.name}` + : command.name + const skillContent = result + .filter((b): b is TextBlockParam => b.type === 'text') + .map(b => b.text) + .join('\n\n') + addInvokedSkill( + command.name, + skillPath, + skillContent, + getAgentContext()?.agentId ?? null, + ) + + const metadata = formatCommandLoadingMetadata(command, args) + + const additionalAllowedTools = parseToolListFromCLI( + command.allowedTools ?? [], + ) // Create content for the main message, including any pasted images - const mainMessageContent: ContentBlockParam[] = imageContentBlocks.length > 0 || precedingInputBlocks.length > 0 ? [...imageContentBlocks, ...precedingInputBlocks, ...result] : result; + const mainMessageContent: ContentBlockParam[] = + imageContentBlocks.length > 0 || precedingInputBlocks.length > 0 + ? [...imageContentBlocks, ...precedingInputBlocks, ...result] + : result // Extract attachments from command arguments (@-mentions, MCP resources, // agent mentions in SKILL.md). skipSkillDiscovery prevents the SKILL.md // content itself from triggering discovery — it's meta-content, not user // intent, and a large SKILL.md (e.g. 110KB) would fire chunked AKI queries // adding seconds of latency to every skill invocation. - const attachmentMessages = await toArray(getAttachmentMessages(result.filter((block): block is TextBlockParam => block.type === 'text').map(block => block.text).join(' '), context, null, [], - // queuedCommands - handled by query.ts for mid-turn attachments - context.messages, 'repl_main_thread', { - skipSkillDiscovery: true - })); - const messages = [createUserMessage({ - content: metadata, - uuid - }), createUserMessage({ - content: mainMessageContent, - isMeta: true - }), ...attachmentMessages, createAttachmentMessage({ - type: 'command_permissions', - allowedTools: additionalAllowedTools, - model: command.model - })]; + const attachmentMessages = await toArray( + getAttachmentMessages( + result + .filter((block): block is TextBlockParam => block.type === 'text') + .map(block => block.text) + .join(' '), + context, + null, + [], // queuedCommands - handled by query.ts for mid-turn attachments + context.messages, + 'repl_main_thread', + { skipSkillDiscovery: true }, + ), + ) + + const messages = [ + createUserMessage({ + content: metadata, + uuid, + }), + createUserMessage({ + content: mainMessageContent, + isMeta: true, + }), + ...attachmentMessages, + createAttachmentMessage({ + type: 'command_permissions', + allowedTools: additionalAllowedTools, + model: command.model, + }), + ] + return { messages, shouldQuery: true, allowedTools: additionalAllowedTools, model: command.model, effort: command.effort, - command - }; + command, + } } diff --git a/src/utils/staticRender.tsx b/src/utils/staticRender.tsx index d13e52d37..66a809c19 100644 --- a/src/utils/staticRender.tsx +++ b/src/utils/staticRender.tsx @@ -1,9 +1,8 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useLayoutEffect } from 'react'; -import { PassThrough } from 'stream'; -import stripAnsi from 'strip-ansi'; -import { render, useApp } from '../ink.js'; +import * as React from 'react' +import { useLayoutEffect } from 'react' +import { PassThrough } from 'stream' +import stripAnsi from 'strip-ansi' +import { render, useApp } from '../ink.js' // This is a workaround for the fact that Ink doesn't support multiple // components in the same render tree. Instead of using a we just render @@ -15,44 +14,26 @@ import { render, useApp } from '../ink.js'; * before exiting. This is more robust than process.nextTick() for React 19's * async render cycle. */ -function RenderOnceAndExit(t0) { - const $ = _c(5); - const { - children - } = t0; - const { - exit - } = useApp(); - let t1; - let t2; - if ($[0] !== exit) { - t1 = () => { - const timer = setTimeout(exit, 0); - return () => clearTimeout(timer); - }; - t2 = [exit]; - $[0] = exit; - $[1] = t1; - $[2] = t2; - } else { - t1 = $[1]; - t2 = $[2]; - } - useLayoutEffect(t1, t2); - let t3; - if ($[3] !== children) { - t3 = <>{children}; - $[3] = children; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; +function RenderOnceAndExit({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + const { exit } = useApp() + + // useLayoutEffect runs synchronously after React commits DOM mutations. + // setTimeout(0) defers exit to allow Ink to flush output to the stream. + useLayoutEffect(() => { + const timer = setTimeout(exit, 0) + return () => clearTimeout(timer) + }, [exit]) + + return <>{children} } // DEC synchronized update markers used by terminals -const SYNC_START = '\x1B[?2026h'; -const SYNC_END = '\x1B[?2026l'; +const SYNC_START = '\x1B[?2026h' +const SYNC_END = '\x1B[?2026l' /** * Extracts content from the first complete frame in Ink's output. @@ -60,56 +41,64 @@ const SYNC_END = '\x1B[?2026l'; * update sequences ([?2026h ... [?2026l). We only want the first frame's content. */ function extractFirstFrame(output: string): string { - const startIndex = output.indexOf(SYNC_START); - if (startIndex === -1) return output; - const contentStart = startIndex + SYNC_START.length; - const endIndex = output.indexOf(SYNC_END, contentStart); - if (endIndex === -1) return output; - return output.slice(contentStart, endIndex); + const startIndex = output.indexOf(SYNC_START) + if (startIndex === -1) return output + + const contentStart = startIndex + SYNC_START.length + const endIndex = output.indexOf(SYNC_END, contentStart) + if (endIndex === -1) return output + + return output.slice(contentStart, endIndex) } /** * Renders a React node to a string with ANSI escape codes (for terminal output). */ -export function renderToAnsiString(node: React.ReactNode, columns?: number): Promise { +export function renderToAnsiString( + node: React.ReactNode, + columns?: number, +): Promise { return new Promise(async resolve => { - let output = ''; + let output = '' // Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a // chosen width instead of PassThrough's undefined → 80 fallback — // useful for rendering at terminal width for file dumps that should // match what the user sees on screen. - const stream = new PassThrough(); + const stream = new PassThrough() if (columns !== undefined) { - ; - (stream as unknown as { - columns: number; - }).columns = columns; + ;(stream as unknown as { columns: number }).columns = columns } stream.on('data', chunk => { - output += chunk.toString(); - }); + output += chunk.toString() + }) // Render the component wrapped in RenderOnceAndExit // Non-TTY stdout (PassThrough) gives full-frame output instead of diffs - const instance = await render({node}, { - stdout: stream as unknown as NodeJS.WriteStream, - patchConsole: false - }); + const instance = await render( + {node}, + { + stdout: stream as unknown as NodeJS.WriteStream, + patchConsole: false, + }, + ) // Wait for the component to exit naturally - await instance.waitUntilExit(); + await instance.waitUntilExit() // Extract only the first frame's content to avoid duplication // (Ink outputs multiple frames in non-TTY mode) - await resolve(extractFirstFrame(output)); - }); + await resolve(extractFirstFrame(output)) + }) } /** * Renders a React node to a plain text string (ANSI codes stripped). */ -export async function renderToString(node: React.ReactNode, columns?: number): Promise { - const output = await renderToAnsiString(node, columns); - return stripAnsi(output); +export async function renderToString( + node: React.ReactNode, + columns?: number, +): Promise { + const output = await renderToAnsiString(node, columns) + return stripAnsi(output) } diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 39afd3ad6..45be82dc8 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -1,361 +1,472 @@ -import chalk from 'chalk'; -import figures from 'figures'; -import * as React from 'react'; -import { color, Text } from '../ink.js'; -import type { MCPServerConnection } from '../services/mcp/types.js'; -import { getAccountInformation, isClaudeAISubscriber } from './auth.js'; -import { getLargeMemoryFiles, getMemoryFiles, MAX_MEMORY_CHARACTER_COUNT } from './claudemd.js'; -import { getDoctorDiagnostic } from './doctorDiagnostic.js'; -import { getAWSRegion, getDefaultVertexRegion, isEnvTruthy } from './envUtils.js'; -import { getDisplayPath } from './file.js'; -import { formatNumber } from './format.js'; -import { getIdeClientName, type IDEExtensionInstallationStatus, isJetBrainsIde, toIDEDisplayName } from './ide.js'; -import { getClaudeAiUserDefaultModelDescription, modelDisplayString } from './model/model.js'; -import { getAPIProvider } from './model/providers.js'; -import { getMTLSConfig } from './mtls.js'; -import { checkInstall } from './nativeInstaller/index.js'; -import { getProxyUrl } from './proxy.js'; -import { SandboxManager } from './sandbox/sandbox-adapter.js'; -import { getSettingsWithAllErrors } from './settings/allErrors.js'; -import { getEnabledSettingSources, getSettingSourceDisplayNameCapitalized } from './settings/constants.js'; -import { getManagedFileSettingsPresence, getPolicySettingsOrigin, getSettingsForSource } from './settings/settings.js'; -import type { ThemeName } from './theme.js'; +import chalk from 'chalk' +import figures from 'figures' +import * as React from 'react' +import { color, Text } from '../ink.js' +import type { MCPServerConnection } from '../services/mcp/types.js' +import { getAccountInformation, isClaudeAISubscriber } from './auth.js' +import { + getLargeMemoryFiles, + getMemoryFiles, + MAX_MEMORY_CHARACTER_COUNT, +} from './claudemd.js' +import { getDoctorDiagnostic } from './doctorDiagnostic.js' +import { + getAWSRegion, + getDefaultVertexRegion, + isEnvTruthy, +} from './envUtils.js' +import { getDisplayPath } from './file.js' +import { formatNumber } from './format.js' +import { + getIdeClientName, + type IDEExtensionInstallationStatus, + isJetBrainsIde, + toIDEDisplayName, +} from './ide.js' +import { + getClaudeAiUserDefaultModelDescription, + modelDisplayString, +} from './model/model.js' +import { getAPIProvider } from './model/providers.js' +import { getMTLSConfig } from './mtls.js' +import { checkInstall } from './nativeInstaller/index.js' +import { getProxyUrl } from './proxy.js' +import { SandboxManager } from './sandbox/sandbox-adapter.js' +import { getSettingsWithAllErrors } from './settings/allErrors.js' +import { + getEnabledSettingSources, + getSettingSourceDisplayNameCapitalized, +} from './settings/constants.js' +import { + getManagedFileSettingsPresence, + getPolicySettingsOrigin, + getSettingsForSource, +} from './settings/settings.js' +import type { ThemeName } from './theme.js' + export type Property = { - label?: string; - value: React.ReactNode | Array; -}; -export type Diagnostic = React.ReactNode; -export function buildSandboxProperties(): Property[] { - if ((process.env.USER_TYPE) !== 'ant') { - return []; - } - const isSandboxed = SandboxManager.isSandboxingEnabled(); - return [{ - label: 'Bash Sandbox', - value: isSandboxed ? 'Enabled' : 'Disabled' - }]; + label?: string + value: React.ReactNode | Array } -export function buildIDEProperties(mcpClients: MCPServerConnection[], ideInstallationStatus: IDEExtensionInstallationStatus | null = null, theme: ThemeName): Property[] { - const ideClient = mcpClients?.find(client => client.name === 'ide'); + +export type Diagnostic = React.ReactNode + +export function buildSandboxProperties(): Property[] { + if (process.env.USER_TYPE !== 'ant') { + return [] + } + + const isSandboxed = SandboxManager.isSandboxingEnabled() + + return [ + { + label: 'Bash Sandbox', + value: isSandboxed ? 'Enabled' : 'Disabled', + }, + ] +} + +export function buildIDEProperties( + mcpClients: MCPServerConnection[], + ideInstallationStatus: IDEExtensionInstallationStatus | null = null, + theme: ThemeName, +): Property[] { + const ideClient = mcpClients?.find(client => client.name === 'ide') + if (ideInstallationStatus) { - const ideName = toIDEDisplayName(ideInstallationStatus.ideType); - const pluginOrExtension = isJetBrainsIde(ideInstallationStatus.ideType) ? 'plugin' : 'extension'; + const ideName = toIDEDisplayName(ideInstallationStatus.ideType) + const pluginOrExtension = isJetBrainsIde(ideInstallationStatus.ideType) + ? 'plugin' + : 'extension' + if (ideInstallationStatus.error) { - return [{ - label: 'IDE', - value: + return [ + { + label: 'IDE', + value: ( + {color('error', theme)(figures.cross)} Error installing {ideName}{' '} {pluginOrExtension}: {ideInstallationStatus.error} {'\n'}Please restart your IDE and try again. - }]; + ), + }, + ] } + if (ideInstallationStatus.installed) { if (ideClient && ideClient.type === 'connected') { - if (ideInstallationStatus.installedVersion !== ideClient.serverInfo?.version) { - return [{ - label: 'IDE', - value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion} (server version: ${ideClient.serverInfo?.version})` - }]; + if ( + ideInstallationStatus.installedVersion !== + ideClient.serverInfo?.version + ) { + return [ + { + label: 'IDE', + value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion} (server version: ${ideClient.serverInfo?.version})`, + }, + ] } else { - return [{ - label: 'IDE', - value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion}` - }]; + return [ + { + label: 'IDE', + value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion}`, + }, + ] } } else { - return [{ - label: 'IDE', - value: `Installed ${ideName} ${pluginOrExtension}` - }]; + return [ + { + label: 'IDE', + value: `Installed ${ideName} ${pluginOrExtension}`, + }, + ] } } } else if (ideClient) { - const ideName = getIdeClientName(ideClient) ?? 'IDE'; + const ideName = getIdeClientName(ideClient) ?? 'IDE' if (ideClient.type === 'connected') { - return [{ - label: 'IDE', - value: `Connected to ${ideName} extension` - }]; + return [ + { + label: 'IDE', + value: `Connected to ${ideName} extension`, + }, + ] } else { - return [{ - label: 'IDE', - value: `${color('error', theme)(figures.cross)} Not connected to ${ideName}` - }]; + return [ + { + label: 'IDE', + value: `${color('error', theme)(figures.cross)} Not connected to ${ideName}`, + }, + ] } } - return []; + + return [] } -export function buildMcpProperties(clients: MCPServerConnection[] = [], theme: ThemeName): Property[] { - const servers = clients.filter(client => client.name !== 'ide'); + +export function buildMcpProperties( + clients: MCPServerConnection[] = [], + theme: ThemeName, +): Property[] { + const servers = clients.filter(client => client.name !== 'ide') if (!servers.length) { - return []; + return [] } // Summary instead of a full server list — 20+ servers wrapped onto many // rows, dominating the Status pane. Show counts by state + /mcp hint. - const byState = { - connected: 0, - pending: 0, - needsAuth: 0, - failed: 0 - }; + const byState = { connected: 0, pending: 0, needsAuth: 0, failed: 0 } for (const s of servers) { - if (s.type === 'connected') byState.connected++;else if (s.type === 'pending') byState.pending++;else if (s.type === 'needs-auth') byState.needsAuth++;else byState.failed++; + if (s.type === 'connected') byState.connected++ + else if (s.type === 'pending') byState.pending++ + else if (s.type === 'needs-auth') byState.needsAuth++ + else byState.failed++ } - const parts: string[] = []; - if (byState.connected) parts.push(color('success', theme)(`${byState.connected} connected`)); - if (byState.needsAuth) parts.push(color('warning', theme)(`${byState.needsAuth} need auth`)); - if (byState.pending) parts.push(color('inactive', theme)(`${byState.pending} pending`)); - if (byState.failed) parts.push(color('error', theme)(`${byState.failed} failed`)); - return [{ - label: 'MCP servers', - value: `${parts.join(', ')} ${color('inactive', theme)('· /mcp')}` - }]; + const parts: string[] = [] + if (byState.connected) + parts.push(color('success', theme)(`${byState.connected} connected`)) + if (byState.needsAuth) + parts.push(color('warning', theme)(`${byState.needsAuth} need auth`)) + if (byState.pending) + parts.push(color('inactive', theme)(`${byState.pending} pending`)) + if (byState.failed) + parts.push(color('error', theme)(`${byState.failed} failed`)) + + return [ + { + label: 'MCP servers', + value: `${parts.join(', ')} ${color('inactive', theme)('· /mcp')}`, + }, + ] } + export async function buildMemoryDiagnostics(): Promise { - const files = await getMemoryFiles(); - const largeFiles = getLargeMemoryFiles(files); - const diagnostics: Diagnostic[] = []; + const files = await getMemoryFiles() + const largeFiles = getLargeMemoryFiles(files) + + const diagnostics: Diagnostic[] = [] + largeFiles.forEach(file => { - const displayPath = getDisplayPath(file.path); - diagnostics.push(`Large ${displayPath} will impact performance (${formatNumber(file.content.length)} chars > ${formatNumber(MAX_MEMORY_CHARACTER_COUNT)})`); - }); - return diagnostics; + const displayPath = getDisplayPath(file.path) + diagnostics.push( + `Large ${displayPath} will impact performance (${formatNumber(file.content.length)} chars > ${formatNumber(MAX_MEMORY_CHARACTER_COUNT)})`, + ) + }) + + return diagnostics } + export function buildSettingSourcesProperties(): Property[] { - const enabledSources = getEnabledSettingSources(); + const enabledSources = getEnabledSettingSources() // Filter to only sources that actually have settings loaded const sourcesWithSettings = enabledSources.filter(source => { - const settings = getSettingsForSource(source); - return settings !== null && Object.keys(settings).length > 0; - }); + const settings = getSettingsForSource(source) + return settings !== null && Object.keys(settings).length > 0 + }) // Map internal names to user-friendly names // For policySettings, distinguish between remote and local (or skip if neither exists) - const sourceNames = sourcesWithSettings.map(source => { - if (source === 'policySettings') { - const origin = getPolicySettingsOrigin(); - if (origin === null) { - return null; // Skip - no policy settings exist - } - switch (origin) { - case 'remote': - return 'Enterprise managed settings (remote)'; - case 'plist': - return 'Enterprise managed settings (plist)'; - case 'hklm': - return 'Enterprise managed settings (HKLM)'; - case 'file': - { - const { - hasBase, - hasDropIns - } = getManagedFileSettingsPresence(); + const sourceNames = sourcesWithSettings + .map(source => { + if (source === 'policySettings') { + const origin = getPolicySettingsOrigin() + if (origin === null) { + return null // Skip - no policy settings exist + } + switch (origin) { + case 'remote': + return 'Enterprise managed settings (remote)' + case 'plist': + return 'Enterprise managed settings (plist)' + case 'hklm': + return 'Enterprise managed settings (HKLM)' + case 'file': { + const { hasBase, hasDropIns } = getManagedFileSettingsPresence() if (hasBase && hasDropIns) { - return 'Enterprise managed settings (file + drop-ins)'; + return 'Enterprise managed settings (file + drop-ins)' } if (hasDropIns) { - return 'Enterprise managed settings (drop-ins)'; + return 'Enterprise managed settings (drop-ins)' } - return 'Enterprise managed settings (file)'; + return 'Enterprise managed settings (file)' } - case 'hkcu': - return 'Enterprise managed settings (HKCU)'; + case 'hkcu': + return 'Enterprise managed settings (HKCU)' + } } - } - return getSettingSourceDisplayNameCapitalized(source); - }).filter((name): name is string => name !== null); - return [{ - label: 'Setting sources', - value: sourceNames - }]; + return getSettingSourceDisplayNameCapitalized(source) + }) + .filter((name): name is string => name !== null) + + return [ + { + label: 'Setting sources', + value: sourceNames, + }, + ] } + export async function buildInstallationDiagnostics(): Promise { - const installWarnings = await checkInstall(); - return installWarnings.map(warning => warning.message); + const installWarnings = await checkInstall() + return installWarnings.map(warning => warning.message) } -export async function buildInstallationHealthDiagnostics(): Promise { - const diagnostic = await getDoctorDiagnostic(); - const items: Diagnostic[] = []; - const { - errors: validationErrors - } = getSettingsWithAllErrors(); + +export async function buildInstallationHealthDiagnostics(): Promise< + Diagnostic[] +> { + const diagnostic = await getDoctorDiagnostic() + const items: Diagnostic[] = [] + + const { errors: validationErrors } = getSettingsWithAllErrors() if (validationErrors.length > 0) { - const invalidFiles = Array.from(new Set(validationErrors.map(error => error.file))); - const fileList = invalidFiles.join(', '); - items.push(`Found invalid settings files: ${fileList}. They will be ignored.`); + const invalidFiles = Array.from( + new Set(validationErrors.map(error => error.file)), + ) + const fileList = invalidFiles.join(', ') + + items.push( + `Found invalid settings files: ${fileList}. They will be ignored.`, + ) } // Add warnings from doctor diagnostic (includes leftover installations, config mismatches, etc.) diagnostic.warnings.forEach(warning => { - items.push(warning.issue); - }); + items.push(warning.issue) + }) + if (diagnostic.hasUpdatePermissions === false) { - items.push('No write permissions for auto-updates (requires sudo)'); + items.push('No write permissions for auto-updates (requires sudo)') } - return items; + + return items } + export function buildAccountProperties(): Property[] { - const accountInfo = getAccountInformation(); + const accountInfo = getAccountInformation() if (!accountInfo) { - return []; + return [] } - const properties: Property[] = []; + + const properties: Property[] = [] + if (accountInfo.subscription) { properties.push({ label: 'Login method', - value: `${accountInfo.subscription} Account` - }); + value: `${accountInfo.subscription} Account`, + }) } + if (accountInfo.tokenSource) { properties.push({ label: 'Auth token', - value: accountInfo.tokenSource - }); + value: accountInfo.tokenSource, + }) } + if (accountInfo.apiKeySource) { properties.push({ label: 'API key', - value: accountInfo.apiKeySource - }); + value: accountInfo.apiKeySource, + }) } // Hide sensitive account info in demo mode if (accountInfo.organization && !process.env.IS_DEMO) { properties.push({ label: 'Organization', - value: accountInfo.organization - }); + value: accountInfo.organization, + }) } if (accountInfo.email && !process.env.IS_DEMO) { properties.push({ label: 'Email', - value: accountInfo.email - }); + value: accountInfo.email, + }) } - return properties; + + return properties } + export function buildAPIProviderProperties(): Property[] { - const apiProvider = getAPIProvider(); - const properties: Property[] = []; + const apiProvider = getAPIProvider() + + const properties: Property[] = [] + if (apiProvider !== 'firstParty') { const providerLabel = { bedrock: 'AWS Bedrock', vertex: 'Google Vertex AI', - foundry: 'Microsoft Foundry' - }[apiProvider]; + foundry: 'Microsoft Foundry', + }[apiProvider] + properties.push({ label: 'API provider', - value: providerLabel - }); + value: providerLabel, + }) } + if (apiProvider === 'firstParty') { - const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL; + const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL if (anthropicBaseUrl) { properties.push({ label: 'Anthropic base URL', - value: anthropicBaseUrl - }); + value: anthropicBaseUrl, + }) } } else if (apiProvider === 'bedrock') { - const bedrockBaseUrl = process.env.BEDROCK_BASE_URL; + const bedrockBaseUrl = process.env.BEDROCK_BASE_URL if (bedrockBaseUrl) { properties.push({ label: 'Bedrock base URL', - value: bedrockBaseUrl - }); + value: bedrockBaseUrl, + }) } + properties.push({ label: 'AWS region', - value: getAWSRegion() - }); + value: getAWSRegion(), + }) + if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) { properties.push({ - value: 'AWS auth skipped' - }); + value: 'AWS auth skipped', + }) } } else if (apiProvider === 'vertex') { - const vertexBaseUrl = process.env.VERTEX_BASE_URL; + const vertexBaseUrl = process.env.VERTEX_BASE_URL if (vertexBaseUrl) { properties.push({ label: 'Vertex base URL', - value: vertexBaseUrl - }); + value: vertexBaseUrl, + }) } - const gcpProject = process.env.ANTHROPIC_VERTEX_PROJECT_ID; + + const gcpProject = process.env.ANTHROPIC_VERTEX_PROJECT_ID if (gcpProject) { properties.push({ label: 'GCP project', - value: gcpProject - }); + value: gcpProject, + }) } + properties.push({ label: 'Default region', - value: getDefaultVertexRegion() - }); + value: getDefaultVertexRegion(), + }) + if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { properties.push({ - value: 'GCP auth skipped' - }); + value: 'GCP auth skipped', + }) } } else if (apiProvider === 'foundry') { - const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL; + const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL if (foundryBaseUrl) { properties.push({ label: 'Microsoft Foundry base URL', - value: foundryBaseUrl - }); + value: foundryBaseUrl, + }) } - const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE; + + const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE if (foundryResource) { properties.push({ label: 'Microsoft Foundry resource', - value: foundryResource - }); + value: foundryResource, + }) } + if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_FOUNDRY_AUTH)) { properties.push({ - value: 'Microsoft Foundry auth skipped' - }); + value: 'Microsoft Foundry auth skipped', + }) } } - const proxyUrl = getProxyUrl(); + + const proxyUrl = getProxyUrl() if (proxyUrl) { properties.push({ label: 'Proxy', - value: proxyUrl - }); + value: proxyUrl, + }) } - const mtlsConfig = getMTLSConfig(); + + const mtlsConfig = getMTLSConfig() if (process.env.NODE_EXTRA_CA_CERTS) { properties.push({ label: 'Additional CA cert(s)', - value: process.env.NODE_EXTRA_CA_CERTS - }); + value: process.env.NODE_EXTRA_CA_CERTS, + }) } if (mtlsConfig) { if (mtlsConfig.cert && process.env.CLAUDE_CODE_CLIENT_CERT) { properties.push({ label: 'mTLS client cert', - value: process.env.CLAUDE_CODE_CLIENT_CERT - }); + value: process.env.CLAUDE_CODE_CLIENT_CERT, + }) } + if (mtlsConfig.key && process.env.CLAUDE_CODE_CLIENT_KEY) { properties.push({ label: 'mTLS client key', - value: process.env.CLAUDE_CODE_CLIENT_KEY - }); + value: process.env.CLAUDE_CODE_CLIENT_KEY, + }) } } - return properties; + + return properties } + export function getModelDisplayLabel(mainLoopModel: string | null): string { - let modelLabel = modelDisplayString(mainLoopModel); + let modelLabel = modelDisplayString(mainLoopModel) + if (mainLoopModel === null && isClaudeAISubscriber()) { - const description = getClaudeAiUserDefaultModelDescription(); - modelLabel = `${chalk.bold('Default')} ${description}`; + const description = getClaudeAiUserDefaultModelDescription() + + modelLabel = `${chalk.bold('Default')} ${description}` } - return modelLabel; + + return modelLabel } diff --git a/src/utils/statusNoticeDefinitions.tsx b/src/utils/statusNoticeDefinitions.tsx index 569405bb8..e04f9e3d3 100644 --- a/src/utils/statusNoticeDefinitions.tsx +++ b/src/utils/statusNoticeDefinitions.tsx @@ -1,31 +1,49 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { Box, Text } from '../ink.js'; -import * as React from 'react'; -import { getLargeMemoryFiles, MAX_MEMORY_CHARACTER_COUNT, type MemoryFileInfo } from './claudemd.js'; -import figures from 'figures'; -import { getCwd } from './cwd.js'; -import { relative } from 'path'; -import { formatNumber } from './format.js'; -import type { getGlobalConfig } from './config.js'; -import { getAnthropicApiKeyWithSource, getApiKeyFromConfigOrMacOSKeychain, getAuthTokenSource, isClaudeAISubscriber } from './auth.js'; -import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'; -import { getAgentDescriptionsTotalTokens, AGENT_DESCRIPTIONS_THRESHOLD } from './statusNoticeHelpers.js'; -import { isSupportedJetBrainsTerminal, toIDEDisplayName, getTerminalIdeType } from './ide.js'; -import { isJetBrainsPluginInstalledCachedSync } from './jetbrains.js'; +import { Box, Text } from '../ink.js' +import * as React from 'react' +import { + getLargeMemoryFiles, + MAX_MEMORY_CHARACTER_COUNT, + type MemoryFileInfo, +} from './claudemd.js' +import figures from 'figures' +import { getCwd } from './cwd.js' +import { relative } from 'path' +import { formatNumber } from './format.js' +import type { getGlobalConfig } from './config.js' +import { + getAnthropicApiKeyWithSource, + getApiKeyFromConfigOrMacOSKeychain, + getAuthTokenSource, + isClaudeAISubscriber, +} from './auth.js' +import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js' +import { + getAgentDescriptionsTotalTokens, + AGENT_DESCRIPTIONS_THRESHOLD, +} from './statusNoticeHelpers.js' +import { + isSupportedJetBrainsTerminal, + toIDEDisplayName, + getTerminalIdeType, +} from './ide.js' +import { isJetBrainsPluginInstalledCachedSync } from './jetbrains.js' // Types -export type StatusNoticeType = 'warning' | 'info'; +export type StatusNoticeType = 'warning' | 'info' + export type StatusNoticeContext = { - config: ReturnType; - agentDefinitions?: AgentDefinitionsResult; - memoryFiles: MemoryFileInfo[]; -}; + config: ReturnType + agentDefinitions?: AgentDefinitionsResult + memoryFiles: MemoryFileInfo[] +} + export type StatusNoticeDefinition = { - id: string; - type: StatusNoticeType; - isActive: (context: StatusNoticeContext) => boolean; - render: (context: StatusNoticeContext) => React.ReactNode; -}; + id: string + type: StatusNoticeType + isActive: (context: StatusNoticeContext) => boolean + render: (context: StatusNoticeContext) => React.ReactNode +} // Individual notice definitions const largeMemoryFilesNotice: StatusNoticeDefinition = { @@ -33,11 +51,16 @@ const largeMemoryFilesNotice: StatusNoticeDefinition = { type: 'warning', isActive: ctx => getLargeMemoryFiles(ctx.memoryFiles).length > 0, render: ctx => { - const largeMemoryFiles = getLargeMemoryFiles(ctx.memoryFiles); - return <> + const largeMemoryFiles = getLargeMemoryFiles(ctx.memoryFiles) + return ( + <> {largeMemoryFiles.map(file => { - const displayPath = file.path.startsWith(getCwd()) ? relative(getCwd(), file.path) : file.path; - return + const displayPath = file.path.startsWith(getCwd()) + ? relative(getCwd(), file.path) + : file.path + + return ( + {figures.warning} Large {displayPath} will impact performance ( @@ -45,76 +68,92 @@ const largeMemoryFilesNotice: StatusNoticeDefinition = { {formatNumber(MAX_MEMORY_CHARACTER_COUNT)}) · /memory to edit - ; - })} - ; - } -}; + + ) + })} + + ) + }, +} + const claudeAiSubscriberExternalTokenNotice: StatusNoticeDefinition = { id: 'claude-ai-external-token', type: 'warning', isActive: () => { - const authTokenInfo = getAuthTokenSource(); - return isClaudeAISubscriber() && (authTokenInfo.source === 'ANTHROPIC_AUTH_TOKEN' || authTokenInfo.source === 'apiKeyHelper'); + const authTokenInfo = getAuthTokenSource() + return ( + isClaudeAISubscriber() && + (authTokenInfo.source === 'ANTHROPIC_AUTH_TOKEN' || + authTokenInfo.source === 'apiKeyHelper') + ) }, render: () => { - const authTokenInfo = getAuthTokenSource(); - return + const authTokenInfo = getAuthTokenSource() + return ( + {figures.warning} Auth conflict: Using {authTokenInfo.source} instead of Claude account subscription token. Either unset {authTokenInfo.source}, or run `claude /logout`. - ; - } -}; + + ) + }, +} + const apiKeyConflictNotice: StatusNoticeDefinition = { id: 'api-key-conflict', type: 'warning', isActive: () => { - const { - source: apiKeySource - } = getAnthropicApiKeyWithSource({ - skipRetrievingKeyFromApiKeyHelper: true - }); - return !!getApiKeyFromConfigOrMacOSKeychain() && (apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper'); + const { source: apiKeySource } = getAnthropicApiKeyWithSource({ + skipRetrievingKeyFromApiKeyHelper: true, + }) + return ( + !!getApiKeyFromConfigOrMacOSKeychain() && + (apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper') + ) }, render: () => { - const { - source: apiKeySource - } = getAnthropicApiKeyWithSource({ - skipRetrievingKeyFromApiKeyHelper: true - }); - return + const { source: apiKeySource } = getAnthropicApiKeyWithSource({ + skipRetrievingKeyFromApiKeyHelper: true, + }) + return ( + {figures.warning} Auth conflict: Using {apiKeySource} instead of Anthropic Console key. Either unset {apiKeySource}, or run `claude /logout`. - ; - } -}; + + ) + }, +} + const bothAuthMethodsNotice: StatusNoticeDefinition = { id: 'both-auth-methods', type: 'warning', isActive: () => { - const { - source: apiKeySource - } = getAnthropicApiKeyWithSource({ - skipRetrievingKeyFromApiKeyHelper: true - }); - const authTokenInfo = getAuthTokenSource(); - return apiKeySource !== 'none' && authTokenInfo.source !== 'none' && !(apiKeySource === 'apiKeyHelper' && authTokenInfo.source === 'apiKeyHelper'); + const { source: apiKeySource } = getAnthropicApiKeyWithSource({ + skipRetrievingKeyFromApiKeyHelper: true, + }) + const authTokenInfo = getAuthTokenSource() + return ( + apiKeySource !== 'none' && + authTokenInfo.source !== 'none' && + !( + apiKeySource === 'apiKeyHelper' && + authTokenInfo.source === 'apiKeyHelper' + ) + ) }, render: () => { - const { - source: apiKeySource - } = getAnthropicApiKeyWithSource({ - skipRetrievingKeyFromApiKeyHelper: true - }); - const authTokenInfo = getAuthTokenSource(); - return + const { source: apiKeySource } = getAnthropicApiKeyWithSource({ + skipRetrievingKeyFromApiKeyHelper: true, + }) + const authTokenInfo = getAuthTokenSource() + return ( + {figures.warning} @@ -125,28 +164,43 @@ const bothAuthMethodsNotice: StatusNoticeDefinition = { · Trying to use{' '} - {authTokenInfo.source === 'claude.ai' ? 'claude.ai' : authTokenInfo.source} + {authTokenInfo.source === 'claude.ai' + ? 'claude.ai' + : authTokenInfo.source} ?{' '} - {apiKeySource === 'ANTHROPIC_API_KEY' ? 'Unset the ANTHROPIC_API_KEY environment variable, or claude /logout then say "No" to the API key approval before login.' : apiKeySource === 'apiKeyHelper' ? 'Unset the apiKeyHelper setting.' : 'claude /logout'} + {apiKeySource === 'ANTHROPIC_API_KEY' + ? 'Unset the ANTHROPIC_API_KEY environment variable, or claude /logout then say "No" to the API key approval before login.' + : apiKeySource === 'apiKeyHelper' + ? 'Unset the apiKeyHelper setting.' + : 'claude /logout'} · Trying to use {apiKeySource}?{' '} - {authTokenInfo.source === 'claude.ai' ? 'claude /logout to sign out of claude.ai.' : `Unset the ${authTokenInfo.source} environment variable.`} + {authTokenInfo.source === 'claude.ai' + ? 'claude /logout to sign out of claude.ai.' + : `Unset the ${authTokenInfo.source} environment variable.`} - ; - } -}; + + ) + }, +} + const largeAgentDescriptionsNotice: StatusNoticeDefinition = { id: 'large-agent-descriptions', type: 'warning', isActive: context => { - const totalTokens = getAgentDescriptionsTotalTokens(context.agentDefinitions); - return totalTokens > AGENT_DESCRIPTIONS_THRESHOLD; + const totalTokens = getAgentDescriptionsTotalTokens( + context.agentDefinitions, + ) + return totalTokens > AGENT_DESCRIPTIONS_THRESHOLD }, render: context => { - const totalTokens = getAgentDescriptionsTotalTokens(context.agentDefinitions); - return + const totalTokens = getAgentDescriptionsTotalTokens( + context.agentDefinitions, + ) + return ( + {figures.warning} Large cumulative agent descriptions will impact performance (~ @@ -154,44 +208,58 @@ const largeAgentDescriptionsNotice: StatusNoticeDefinition = { {formatNumber(AGENT_DESCRIPTIONS_THRESHOLD)}) · /agents to manage - ; - } -}; + + ) + }, +} + const jetbrainsPluginNotice: StatusNoticeDefinition = { id: 'jetbrains-plugin-install', type: 'info', isActive: context => { // Only show if running in JetBrains built-in terminal if (!isSupportedJetBrainsTerminal()) { - return false; + return false } // Don't show if auto-install is disabled - const shouldAutoInstall = context.config.autoInstallIdeExtension ?? true; + const shouldAutoInstall = context.config.autoInstallIdeExtension ?? true if (!shouldAutoInstall) { - return false; + return false } // Check if plugin is already installed (cached to avoid repeated filesystem checks) - const ideType = getTerminalIdeType(); - return ideType !== null && !isJetBrainsPluginInstalledCachedSync(ideType); + const ideType = getTerminalIdeType() + return ideType !== null && !isJetBrainsPluginInstalledCachedSync(ideType) }, render: () => { - const ideType = getTerminalIdeType(); - const ideName = toIDEDisplayName(ideType); - return + const ideType = getTerminalIdeType() + const ideName = toIDEDisplayName(ideType) + return ( + {figures.arrowUp} Install the {ideName} plugin from the JetBrains Marketplace:{' '} https://docs.claude.com/s/claude-code-jetbrains - ; - } -}; + + ) + }, +} + // All notice definitions -export const statusNoticeDefinitions: StatusNoticeDefinition[] = [largeMemoryFilesNotice, largeAgentDescriptionsNotice, claudeAiSubscriberExternalTokenNotice, apiKeyConflictNotice, bothAuthMethodsNotice, jetbrainsPluginNotice]; +export const statusNoticeDefinitions: StatusNoticeDefinition[] = [ + largeMemoryFilesNotice, + largeAgentDescriptionsNotice, + claudeAiSubscriberExternalTokenNotice, + apiKeyConflictNotice, + bothAuthMethodsNotice, + jetbrainsPluginNotice, +] // Helper functions for external use -export function getActiveNotices(context: StatusNoticeContext): StatusNoticeDefinition[] { - return statusNoticeDefinitions.filter(notice => notice.isActive(context)); +export function getActiveNotices( + context: StatusNoticeContext, +): StatusNoticeDefinition[] { + return statusNoticeDefinitions.filter(notice => notice.isActive(context)) } diff --git a/src/utils/swarm/It2SetupPrompt.tsx b/src/utils/swarm/It2SetupPrompt.tsx index 72898f3d0..688e2c8f4 100644 --- a/src/utils/swarm/It2SetupPrompt.tsx +++ b/src/utils/swarm/It2SetupPrompt.tsx @@ -1,379 +1,377 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useEffect, useState } from 'react'; -import { type OptionWithDescription, Select } from '../../components/CustomSelect/index.js'; -import { Pane } from '../../components/design-system/Pane.js'; -import { Spinner } from '../../components/Spinner.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import React, { useCallback, useEffect, useState } from 'react' +import { + type OptionWithDescription, + Select, +} from '../../components/CustomSelect/index.js' +import { Pane } from '../../components/design-system/Pane.js' +import { Spinner } from '../../components/Spinner.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to proceed through setup steps -import { Box, Text, useInput } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { detectPythonPackageManager, getPythonApiInstructions, installIt2, markIt2SetupComplete, type PythonPackageManager, setPreferTmuxOverIterm2, verifyIt2Setup } from './backends/it2Setup.js'; -type SetupStep = 'initial' | 'installing' | 'install-failed' | 'verify-api' | 'api-instructions' | 'verifying' | 'success' | 'failed'; +import { Box, Text, useInput } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { + detectPythonPackageManager, + getPythonApiInstructions, + installIt2, + markIt2SetupComplete, + type PythonPackageManager, + setPreferTmuxOverIterm2, + verifyIt2Setup, +} from './backends/it2Setup.js' + +type SetupStep = + | 'initial' + | 'installing' + | 'install-failed' + | 'verify-api' + | 'api-instructions' + | 'verifying' + | 'success' + | 'failed' + type Props = { - onDone: (result: 'installed' | 'use-tmux' | 'cancelled') => void; - tmuxAvailable: boolean; -}; -export function It2SetupPrompt(t0) { - const $ = _c(44); - const { - onDone, - tmuxAvailable - } = t0; - const [step, setStep] = useState("initial"); - const [packageManager, setPackageManager] = useState(null); - const [error, setError] = useState(null); - const exitState = useExitOnCtrlCDWithKeybindings(); - let t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - detectPythonPackageManager().then(pm => { - setPackageManager(pm); - }); - }; - t2 = []; - $[0] = t1; - $[1] = t2; - } else { - t1 = $[0]; - t2 = $[1]; - } - useEffect(t1, t2); - let t3; - if ($[2] !== onDone) { - t3 = () => { - onDone("cancelled"); - }; - $[2] = onDone; - $[3] = t3; - } else { - t3 = $[3]; - } - const handleCancel = t3; - const t4 = step !== "installing" && step !== "verifying"; - let t5; - if ($[4] !== t4) { - t5 = { - context: "Confirmation", - isActive: t4 - }; - $[4] = t4; - $[5] = t5; - } else { - t5 = $[5]; - } - useKeybinding("confirm:no", handleCancel, t5); - let t6; - if ($[6] !== onDone || $[7] !== step) { - t6 = (_input, key) => { - if (step === "api-instructions" && key.return) { - setStep("verifying"); - verifyIt2Setup().then(result => { - if (result.success) { - markIt2SetupComplete(); - setStep("success"); - setTimeout(onDone, 1500, "installed" as const); - } else { - setError(result.error || "Verification failed"); - setStep("failed"); - } - }); - } - }; - $[6] = onDone; - $[7] = step; - $[8] = t6; - } else { - t6 = $[8]; - } - useInput(t6); - let t7; - if ($[9] !== packageManager) { - t7 = async function handleInstall() { - if (!packageManager) { - setError("No Python package manager found (uvx, pipx, or pip)"); - setStep("failed"); - return; - } - setStep("installing"); - const result_0 = await installIt2(packageManager); - if (result_0.success) { - setStep("api-instructions"); - } else { - setError(result_0.error || "Installation failed"); - setStep("install-failed"); - } - }; - $[9] = packageManager; - $[10] = t7; - } else { - t7 = $[10]; - } - const handleInstall = t7; - let t8; - if ($[11] !== onDone) { - t8 = function handleUseTmux() { - setPreferTmuxOverIterm2(true); - onDone("use-tmux"); - }; - $[11] = onDone; - $[12] = t8; - } else { - t8 = $[12]; - } - const handleUseTmux = t8; - let T0; - let T1; - let t10; - let t11; - let t12; - let t13; - let t14; - let t9; - if ($[13] !== error || $[14] !== handleInstall || $[15] !== handleUseTmux || $[16] !== onDone || $[17] !== packageManager || $[18] !== step || $[19] !== tmuxAvailable) { - const renderContent = () => { - switch (step) { - case "initial": - { - return renderInitialPrompt(); - } - case "installing": - { - return renderInstalling(); - } - case "install-failed": - { - return renderInstallFailed(); - } - case "api-instructions": - { - return renderApiInstructions(); - } - case "verifying": - { - return renderVerifying(); - } - case "success": - { - return renderSuccess(); - } - case "failed": - { - return renderFailed(); - } - default: - { - return null; - } - } - }; - function renderInitialPrompt() { - const options = [{ - label: "Install it2 now", - value: "install", - description: packageManager ? `Uses ${packageManager} to install the it2 CLI tool` : "Requires Python (uvx, pipx, or pip)" - }]; - if (tmuxAvailable) { - options.push({ - label: "Use tmux instead", - value: "tmux", - description: "Opens teammates in a separate tmux session" - }); - } - options.push({ - label: "Cancel", - value: "cancel", - description: "Skip teammate spawning for now" - }); - return To use native iTerm2 split panes for teammates, you need the{" "}it2 CLI tool.This enables teammates to appear as split panes within your current window. { - bb89: switch (value_0) { - case "retry": - { - handleInstall(); - break bb89; - } - case "tmux": - { - handleUseTmux(); - break bb89; - } - case "cancel": - { - onDone("cancelled"); - } - } - }} onCancel={() => onDone("cancelled")} />; - } - function renderApiInstructions() { - const instructions = getPythonApiInstructions(); - return ✓ it2 installed successfully{instructions.map(_temp)}Press Enter when ready to verify…; - } - function renderVerifying() { - return Verifying it2 can communicate with iTerm2…; - } - function renderSuccess() { - return ✓ iTerm2 split pane support is readyTeammates will now appear as split panes.; - } - function renderFailed() { - const options_1 = [{ - label: "Try again", - value: "retry", - description: "Verify the connection again" - }]; - if (tmuxAvailable) { - options_1.push({ - label: "Use tmux instead", - value: "tmux", - description: "Falls back to tmux for teammate panes" - }); - } - options_1.push({ - label: "Cancel", - value: "cancel", - description: "Skip teammate spawning for now" - }); - return Verification failed{error && {error}}Make sure:· Python API is enabled in iTerm2 preferences· You may need to restart iTerm2 after enabling { + switch (value) { + case 'install': + void handleInstall() + break + case 'tmux': + handleUseTmux() + break + case 'cancel': + onDone('cancelled') + break + } + }} + onCancel={() => onDone('cancelled')} + /> + + + ) } - return t17; -} -function _temp(line, i) { - return {line}; + + function renderInstalling(): React.ReactNode { + return ( + + + + Installing it2 using {packageManager}… + + This may take a moment. + + ) + } + + function renderInstallFailed(): React.ReactNode { + const options: OptionWithDescription[] = [ + { + label: 'Try again', + value: 'retry', + description: 'Retry the installation', + }, + ] + + if (tmuxAvailable) { + options.push({ + label: 'Use tmux instead', + value: 'tmux', + description: 'Falls back to tmux for teammate panes', + }) + } + + options.push({ + label: 'Cancel', + value: 'cancel', + description: 'Skip teammate spawning for now', + }) + + return ( + + Installation failed + {error && {error}} + + You can try installing manually:{' '} + {packageManager === 'uvx' + ? 'uv tool install it2' + : packageManager === 'pipx' + ? 'pipx install it2' + : 'pip install --user it2'} + + + { + switch (value) { + case 'retry': + setStep('verifying') + void verifyIt2Setup().then(result => { + if (result.success) { + markIt2SetupComplete() + setStep('success') + setTimeout(onDone, 1500, 'installed' as const) + } else { + setError(result.error || 'Verification failed') + setStep('failed') + } + }) + break + case 'tmux': + handleUseTmux() + break + case 'cancel': + onDone('cancelled') + break + } + }} + onCancel={() => onDone('cancelled')} + /> + + + ) + } + + return ( + + + + iTerm2 Split Pane Setup + + {renderContent()} + {step !== 'installing' && + step !== 'verifying' && + step !== 'success' && ( + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + <>Esc to cancel + )} + + )} + + + ) } diff --git a/src/utils/teleport.tsx b/src/utils/teleport.tsx index 74dd51f52..04202dbc7 100644 --- a/src/utils/teleport.tsx +++ b/src/utils/teleport.tsx @@ -1,62 +1,105 @@ -import axios from 'axios'; -import chalk from 'chalk'; -import { randomUUID } from 'crypto'; -import React from 'react'; -import { getOriginalCwd, getSessionId } from 'src/bootstrap/state.js'; -import { checkGate_CACHED_OR_BLOCKING } 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 { isPolicyAllowed } from 'src/services/policyLimits/index.js'; -import { z } from 'zod/v4'; -import { getTeleportErrors, TeleportError, type TeleportLocalErrorType } from '../components/TeleportError.js'; -import { getOauthConfig } from '../constants/oauth.js'; -import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'; -import type { Root } from '../ink.js'; -import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; -import { queryHaiku } from '../services/api/claude.js'; -import { getSessionLogsViaOAuth, getTeleportEvents } from '../services/api/sessionIngress.js'; -import { getOrganizationUUID } from '../services/oauth/client.js'; -import { AppStateProvider } from '../state/AppState.js'; -import type { Message, SystemMessage } from '../types/message.js'; -import type { PermissionMode } from '../types/permissions.js'; -import { checkAndRefreshOAuthTokenIfNeeded, getClaudeAIOAuthTokens } from './auth.js'; -import { checkGithubAppInstalled } from './background/remote/preconditions.js'; -import { deserializeMessages, type TeleportRemoteResponse } from './conversationRecovery.js'; -import { getCwd } from './cwd.js'; -import { logForDebugging } from './debug.js'; -import { detectCurrentRepositoryWithHost, parseGitHubRepository, parseGitRemote } from './detectRepository.js'; -import { isEnvTruthy } from './envUtils.js'; -import { TeleportOperationError, toError } from './errors.js'; -import { execFileNoThrow } from './execFileNoThrow.js'; -import { truncateToWidth } from './format.js'; -import { findGitRoot, getDefaultBranch, getIsClean, gitExe } from './git.js'; -import { safeParseJSON } from './json.js'; -import { logError } from './log.js'; -import { createSystemMessage, createUserMessage } from './messages.js'; -import { getMainLoopModel } from './model/model.js'; -import { isTranscriptMessage } from './sessionStorage.js'; -import { getSettings_DEPRECATED } from './settings/settings.js'; -import { jsonStringify } from './slowOperations.js'; -import { asSystemPrompt } from './systemPromptType.js'; -import { fetchSession, type GitRepositoryOutcome, type GitSource, getBranchFromSession, getOAuthHeaders, type SessionResource } from './teleport/api.js'; -import { fetchEnvironments } from './teleport/environments.js'; -import { createAndUploadGitBundle } from './teleport/gitBundle.js'; +import axios from 'axios' +import chalk from 'chalk' +import { randomUUID } from 'crypto' +import React from 'react' +import { getOriginalCwd, getSessionId } from 'src/bootstrap/state.js' +import { checkGate_CACHED_OR_BLOCKING } 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 { isPolicyAllowed } from 'src/services/policyLimits/index.js' +import { z } from 'zod/v4' +import { + getTeleportErrors, + TeleportError, + type TeleportLocalErrorType, +} from '../components/TeleportError.js' +import { getOauthConfig } from '../constants/oauth.js' +import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' +import type { Root } from '../ink.js' +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' +import { queryHaiku } from '../services/api/claude.js' +import { + getSessionLogsViaOAuth, + getTeleportEvents, +} from '../services/api/sessionIngress.js' +import { getOrganizationUUID } from '../services/oauth/client.js' +import { AppStateProvider } from '../state/AppState.js' +import type { Message, SystemMessage } from '../types/message.js' +import type { PermissionMode } from '../types/permissions.js' +import { + checkAndRefreshOAuthTokenIfNeeded, + getClaudeAIOAuthTokens, +} from './auth.js' +import { checkGithubAppInstalled } from './background/remote/preconditions.js' +import { + deserializeMessages, + type TeleportRemoteResponse, +} from './conversationRecovery.js' +import { getCwd } from './cwd.js' +import { logForDebugging } from './debug.js' +import { + detectCurrentRepositoryWithHost, + parseGitHubRepository, + parseGitRemote, +} from './detectRepository.js' +import { isEnvTruthy } from './envUtils.js' +import { TeleportOperationError, toError } from './errors.js' +import { execFileNoThrow } from './execFileNoThrow.js' +import { truncateToWidth } from './format.js' +import { findGitRoot, getDefaultBranch, getIsClean, gitExe } from './git.js' +import { safeParseJSON } from './json.js' +import { logError } from './log.js' +import { createSystemMessage, createUserMessage } from './messages.js' +import { getMainLoopModel } from './model/model.js' +import { isTranscriptMessage } from './sessionStorage.js' +import { getSettings_DEPRECATED } from './settings/settings.js' +import { jsonStringify } from './slowOperations.js' +import { asSystemPrompt } from './systemPromptType.js' +import { + fetchSession, + type GitRepositoryOutcome, + type GitSource, + getBranchFromSession, + getOAuthHeaders, + type SessionResource, +} from './teleport/api.js' +import { fetchEnvironments } from './teleport/environments.js' +import { createAndUploadGitBundle } from './teleport/gitBundle.js' + export type TeleportResult = { - messages: Message[]; - branchName: string; -}; -export type TeleportProgressStep = 'validating' | 'fetching_logs' | 'fetching_branch' | 'checking_out' | 'done'; -export type TeleportProgressCallback = (step: TeleportProgressStep) => void; + messages: Message[] + branchName: string +} + +export type TeleportProgressStep = + | 'validating' + | 'fetching_logs' + | 'fetching_branch' + | 'checking_out' + | 'done' + +export type TeleportProgressCallback = (step: TeleportProgressStep) => void /** * Creates a system message to inform about teleport session resume * @returns SystemMessage indicating session was resumed from another machine */ -function createTeleportResumeSystemMessage(branchError: Error | null): SystemMessage { +function createTeleportResumeSystemMessage( + branchError: Error | null, +): SystemMessage { if (branchError === null) { - return createSystemMessage('Session resumed', 'suggestion'); + return createSystemMessage('Session resumed', 'suggestion') } - const formattedError = branchError instanceof TeleportOperationError ? branchError.formattedMessage : branchError.message; - return createSystemMessage(`Session resumed without branch: ${formattedError}`, 'warning'); + const formattedError = + branchError instanceof TeleportOperationError + ? branchError.formattedMessage + : branchError.message + return createSystemMessage( + `Session resumed without branch: ${formattedError}`, + 'warning', + ) } /** @@ -66,13 +109,15 @@ function createTeleportResumeSystemMessage(branchError: Error | null): SystemMes function createTeleportResumeUserMessage() { return createUserMessage({ content: `This session is being continued from another machine. Application state may have changed. The updated working directory is ${getOriginalCwd()}`, - isMeta: true - }); + isMeta: true, + }) } + type TeleportToRemoteResponse = { - id: string; - title: string; -}; + id: string + title: string +} + const SESSION_TITLE_AND_BRANCH_PROMPT = `You are coming up with a succinct title and git branch name for a coding session based on the provided description. The title should be clear, concise, and accurately reflect the content of the coding task. You should keep it short and simple, ideally no more than 6 words. Avoid using jargon or overly technical terms unless absolutely necessary. The title should be easy to understand for anyone reading it. Use sentence case for the title (capitalize only the first word and proper nouns), not Title Case. @@ -88,22 +133,31 @@ Example 3: {"title": "Improve performance of data processing script", "branch": Here is the session description: {description} -Please generate a title and branch name for this session.`; +Please generate a title and branch name for this session.` + type TitleAndBranch = { - title: string; - branchName: string; -}; + title: string + branchName: string +} /** * Generates a title and branch name for a coding session using Claude Haiku * @param description The description/prompt for the session * @returns Promise The generated title and branch name */ -async function generateTitleAndBranch(description: string, signal: AbortSignal): Promise { - const fallbackTitle = truncateToWidth(description, 75); - const fallbackBranch = 'claude/task'; +async function generateTitleAndBranch( + description: string, + signal: AbortSignal, +): Promise { + const fallbackTitle = truncateToWidth(description, 75) + const fallbackBranch = 'claude/task' + try { - const userPrompt = SESSION_TITLE_AND_BRANCH_PROMPT.replace('{description}', description); + const userPrompt = SESSION_TITLE_AND_BRANCH_PROMPT.replace( + '{description}', + description, + ) + const response = await queryHaiku({ systemPrompt: asSystemPrompt([]), userPrompt, @@ -112,16 +166,12 @@ async function generateTitleAndBranch(description: string, signal: AbortSignal): schema: { type: 'object', properties: { - title: { - type: 'string' - }, - branch: { - type: 'string' - } + title: { type: 'string' }, + branch: { type: 'string' }, }, required: ['title', 'branch'], - additionalProperties: false - } + additionalProperties: false, + }, }, signal, options: { @@ -129,46 +179,31 @@ async function generateTitleAndBranch(description: string, signal: AbortSignal): agents: [], isNonInteractiveSession: false, hasAppendSystemPrompt: false, - mcpTools: [] - } - }); + mcpTools: [], + }, + }) // Extract text from the response - const content = response.message.content; - if (!Array.isArray(content)) { - return { - title: fallbackTitle, - branchName: fallbackBranch - }; + const firstBlock = response.message.content[0] + if (firstBlock?.type !== 'text') { + return { title: fallbackTitle, branchName: fallbackBranch } } - const firstBlock = content[0]; - if (!firstBlock || typeof firstBlock === 'string' || !('type' in firstBlock) || firstBlock.type !== 'text') { - return { - title: fallbackTitle, - branchName: fallbackBranch - }; - } - const parsed = safeParseJSON(('text' in firstBlock ? firstBlock.text : '').trim()); - const parseResult = z.object({ - title: z.string(), - branch: z.string() - }).safeParse(parsed); + + const parsed = safeParseJSON(firstBlock.text.trim()) + const parseResult = z + .object({ title: z.string(), branch: z.string() }) + .safeParse(parsed) if (parseResult.success) { return { title: parseResult.data.title || fallbackTitle, - branchName: parseResult.data.branch || fallbackBranch - }; + branchName: parseResult.data.branch || fallbackBranch, + } } - return { - title: fallbackTitle, - branchName: fallbackBranch - }; + + return { title: fallbackTitle, branchName: fallbackBranch } } catch (error) { - logError(new Error(`Error generating title and branch: ${error}`)); - return { - title: fallbackTitle, - branchName: fallbackBranch - }; + logError(new Error(`Error generating title and branch: ${error}`)) + return { title: fallbackTitle, branchName: fallbackBranch } } } @@ -177,13 +212,16 @@ async function generateTitleAndBranch(description: string, signal: AbortSignal): * Untracked files are ignored because they won't be lost during branch switching */ export async function validateGitState(): Promise { - const isClean = await getIsClean({ - ignoreUntracked: true - }); + const isClean = await getIsClean({ ignoreUntracked: true }) if (!isClean) { - logEvent('tengu_teleport_error_git_not_clean', {}); - const error = new TeleportOperationError('Git working directory is not clean. Please commit or stash your changes before using --teleport.', chalk.red('Error: Git working directory is not clean. Please commit or stash your changes before using --teleport.\n')); - throw error; + logEvent('tengu_teleport_error_git_not_clean', {}) + const error = new TeleportOperationError( + 'Git working directory is not clean. Please commit or stash your changes before using --teleport.', + chalk.red( + 'Error: Git working directory is not clean. Please commit or stash your changes before using --teleport.\n', + ), + ) + throw error } } @@ -192,25 +230,30 @@ export async function validateGitState(): Promise { * @param branch The branch to fetch. If not specified, fetches all branches. */ async function fetchFromOrigin(branch?: string): Promise { - const fetchArgs = branch ? ['fetch', 'origin', `${branch}:${branch}`] : ['fetch', 'origin']; - const { - code: fetchCode, - stderr: fetchStderr - } = await execFileNoThrow(gitExe(), fetchArgs); + const fetchArgs = branch + ? ['fetch', 'origin', `${branch}:${branch}`] + : ['fetch', 'origin'] + + const { code: fetchCode, stderr: fetchStderr } = await execFileNoThrow( + gitExe(), + fetchArgs, + ) if (fetchCode !== 0) { // If fetching a specific branch fails, it might not exist locally yet // Try fetching just the ref without mapping to local branch if (branch && fetchStderr.includes('refspec')) { - logForDebugging(`Specific branch fetch failed, trying to fetch ref: ${branch}`); - const { - code: refFetchCode, - stderr: refFetchStderr - } = await execFileNoThrow(gitExe(), ['fetch', 'origin', branch]); + logForDebugging( + `Specific branch fetch failed, trying to fetch ref: ${branch}`, + ) + const { code: refFetchCode, stderr: refFetchStderr } = + await execFileNoThrow(gitExe(), ['fetch', 'origin', branch]) if (refFetchCode !== 0) { - logError(new Error(`Failed to fetch from remote origin: ${refFetchStderr}`)); + logError( + new Error(`Failed to fetch from remote origin: ${refFetchStderr}`), + ) } } else { - logError(new Error(`Failed to fetch from remote origin: ${fetchStderr}`)); + logError(new Error(`Failed to fetch from remote origin: ${fetchStderr}`)) } } } @@ -221,34 +264,50 @@ async function fetchFromOrigin(branch?: string): Promise { */ async function ensureUpstreamIsSet(branchName: string): Promise { // Check if upstream is already set - const { - code: upstreamCheckCode - } = await execFileNoThrow(gitExe(), ['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`]); + const { code: upstreamCheckCode } = await execFileNoThrow(gitExe(), [ + 'rev-parse', + '--abbrev-ref', + `${branchName}@{upstream}`, + ]) + if (upstreamCheckCode === 0) { // Upstream is already set - logForDebugging(`Branch '${branchName}' already has upstream set`); - return; + logForDebugging(`Branch '${branchName}' already has upstream set`) + return } // Check if origin/ exists - const { - code: remoteCheckCode - } = await execFileNoThrow(gitExe(), ['rev-parse', '--verify', `origin/${branchName}`]); + const { code: remoteCheckCode } = await execFileNoThrow(gitExe(), [ + 'rev-parse', + '--verify', + `origin/${branchName}`, + ]) + if (remoteCheckCode === 0) { // Remote branch exists, set upstream - logForDebugging(`Setting upstream for '${branchName}' to 'origin/${branchName}'`); - const { - code: setUpstreamCode, - stderr: setUpstreamStderr - } = await execFileNoThrow(gitExe(), ['branch', '--set-upstream-to', `origin/${branchName}`, branchName]); + logForDebugging( + `Setting upstream for '${branchName}' to 'origin/${branchName}'`, + ) + const { code: setUpstreamCode, stderr: setUpstreamStderr } = + await execFileNoThrow(gitExe(), [ + 'branch', + '--set-upstream-to', + `origin/${branchName}`, + branchName, + ]) + if (setUpstreamCode !== 0) { - logForDebugging(`Failed to set upstream for '${branchName}': ${setUpstreamStderr}`); + logForDebugging( + `Failed to set upstream for '${branchName}': ${setUpstreamStderr}`, + ) // Don't throw, just log - this is not critical } else { - logForDebugging(`Successfully set upstream for '${branchName}'`); + logForDebugging(`Successfully set upstream for '${branchName}'`) } } else { - logForDebugging(`Remote branch 'origin/${branchName}' does not exist, skipping upstream setup`); + logForDebugging( + `Remote branch 'origin/${branchName}' does not exist, skipping upstream setup`, + ) } } @@ -257,45 +316,65 @@ async function ensureUpstreamIsSet(branchName: string): Promise { */ async function checkoutBranch(branchName: string): Promise { // First try to checkout the branch as-is (might be local) - let { - code: checkoutCode, - stderr: checkoutStderr - } = await execFileNoThrow(gitExe(), ['checkout', branchName]); + let { code: checkoutCode, stderr: checkoutStderr } = await execFileNoThrow( + gitExe(), + ['checkout', branchName], + ) // If that fails, try to checkout from origin if (checkoutCode !== 0) { - logForDebugging(`Local checkout failed, trying to checkout from origin: ${checkoutStderr}`); + logForDebugging( + `Local checkout failed, trying to checkout from origin: ${checkoutStderr}`, + ) // Try to checkout the remote branch and create a local tracking branch - const result = await execFileNoThrow(gitExe(), ['checkout', '-b', branchName, '--track', `origin/${branchName}`]); - checkoutCode = result.code; - checkoutStderr = result.stderr; + const result = await execFileNoThrow(gitExe(), [ + 'checkout', + '-b', + branchName, + '--track', + `origin/${branchName}`, + ]) + + checkoutCode = result.code + checkoutStderr = result.stderr // If that also fails, try without -b in case the branch exists but isn't checked out if (checkoutCode !== 0) { - logForDebugging(`Remote checkout with -b failed, trying without -b: ${checkoutStderr}`); - const finalResult = await execFileNoThrow(gitExe(), ['checkout', '--track', `origin/${branchName}`]); - checkoutCode = finalResult.code; - checkoutStderr = finalResult.stderr; + logForDebugging( + `Remote checkout with -b failed, trying without -b: ${checkoutStderr}`, + ) + const finalResult = await execFileNoThrow(gitExe(), [ + 'checkout', + '--track', + `origin/${branchName}`, + ]) + checkoutCode = finalResult.code + checkoutStderr = finalResult.stderr } } + if (checkoutCode !== 0) { - logEvent('tengu_teleport_error_branch_checkout_failed', {}); - throw new TeleportOperationError(`Failed to checkout branch '${branchName}': ${checkoutStderr}`, chalk.red(`Failed to checkout branch '${branchName}'\n`)); + logEvent('tengu_teleport_error_branch_checkout_failed', {}) + throw new TeleportOperationError( + `Failed to checkout branch '${branchName}': ${checkoutStderr}`, + chalk.red(`Failed to checkout branch '${branchName}'\n`), + ) } // After successful checkout, ensure upstream is set - await ensureUpstreamIsSet(branchName); + await ensureUpstreamIsSet(branchName) } /** * Gets the current branch name */ async function getCurrentBranch(): Promise { - const { - stdout: currentBranch - } = await execFileNoThrow(gitExe(), ['branch', '--show-current']); - return currentBranch.trim(); + const { stdout: currentBranch } = await execFileNoThrow(gitExe(), [ + 'branch', + '--show-current', + ]) + return currentBranch.trim() } /** @@ -305,13 +384,21 @@ async function getCurrentBranch(): Promise { * @param error Optional error from branch checkout * @returns Processed messages ready for resume */ -export function processMessagesForTeleportResume(messages: Message[], error: Error | null): Message[] { +export function processMessagesForTeleportResume( + messages: Message[], + error: Error | null, +): Message[] { // Shared logic with resume for handling interruped session transcripts - const deserializedMessages = deserializeMessages(messages); + const deserializedMessages = deserializeMessages(messages) // Add user message about teleport resume (visible to model) - const messagesWithTeleportNotice = [...deserializedMessages, createTeleportResumeUserMessage(), createTeleportResumeSystemMessage(error)]; - return messagesWithTeleportNotice; + const messagesWithTeleportNotice = [ + ...deserializedMessages, + createTeleportResumeUserMessage(), + createTeleportResumeSystemMessage(error), + ] + + return messagesWithTeleportNotice } /** @@ -319,34 +406,29 @@ export function processMessagesForTeleportResume(messages: Message[], error: Err * @param branch Optional branch to checkout * @returns The current branch name and any error that occurred */ -export async function checkOutTeleportedSessionBranch(branch?: string): Promise<{ - branchName: string; - branchError: Error | null; -}> { +export async function checkOutTeleportedSessionBranch( + branch?: string, +): Promise<{ branchName: string; branchError: Error | null }> { try { - const currentBranch = await getCurrentBranch(); - logForDebugging(`Current branch before teleport: '${currentBranch}'`); + const currentBranch = await getCurrentBranch() + logForDebugging(`Current branch before teleport: '${currentBranch}'`) + if (branch) { - logForDebugging(`Switching to branch '${branch}'...`); - await fetchFromOrigin(branch); - await checkoutBranch(branch); - const newBranch = await getCurrentBranch(); - logForDebugging(`Branch after checkout: '${newBranch}'`); + logForDebugging(`Switching to branch '${branch}'...`) + await fetchFromOrigin(branch) + await checkoutBranch(branch) + const newBranch = await getCurrentBranch() + logForDebugging(`Branch after checkout: '${newBranch}'`) } else { - logForDebugging('No branch specified, staying on current branch'); + logForDebugging('No branch specified, staying on current branch') } - const branchName = await getCurrentBranch(); - return { - branchName, - branchError: null - }; + + const branchName = await getCurrentBranch() + return { branchName, branchError: null } } catch (error) { - const branchName = await getCurrentBranch(); - const branchError = toError(error); - return { - branchName, - branchError - }; + const branchName = await getCurrentBranch() + const branchError = toError(error) + return { branchName, branchError } } } @@ -354,15 +436,15 @@ export async function checkOutTeleportedSessionBranch(branch?: string): Promise< * Result of repository validation for teleport */ export type RepoValidationResult = { - status: 'match' | 'mismatch' | 'not_in_repo' | 'no_repo_required' | 'error'; - sessionRepo?: string; - currentRepo?: string | null; + status: 'match' | 'mismatch' | 'not_in_repo' | 'no_repo_required' | 'error' + sessionRepo?: string + currentRepo?: string | null /** Host of the session repo (e.g. "github.com" or "ghe.corp.com") — for display only */ - sessionHost?: string; + sessionHost?: string /** Host of the current repo (e.g. "github.com" or "ghe.corp.com") — for display only */ - currentHost?: string; - errorMessage?: string; -}; + currentHost?: string + errorMessage?: string +} /** * Validates that the current repository matches the session's repository. @@ -371,48 +453,68 @@ export type RepoValidationResult = { * @param sessionData The session resource to validate against * @returns Validation result with status and repo information */ -export async function validateSessionRepository(sessionData: SessionResource): Promise { - const currentParsed = await detectCurrentRepositoryWithHost(); - const currentRepo = currentParsed ? `${currentParsed.owner}/${currentParsed.name}` : null; - const gitSource = sessionData.session_context.sources.find((source): source is GitSource => source.type === 'git_repository'); +export async function validateSessionRepository( + sessionData: SessionResource, +): Promise { + const currentParsed = await detectCurrentRepositoryWithHost() + const currentRepo = currentParsed + ? `${currentParsed.owner}/${currentParsed.name}` + : null + + const gitSource = sessionData.session_context.sources.find( + (source): source is GitSource => source.type === 'git_repository', + ) + if (!gitSource?.url) { // Session has no repo requirement - logForDebugging(currentRepo ? 'Session has no associated repository, proceeding without validation' : 'Session has no repo requirement and not in git directory, proceeding'); - return { - status: 'no_repo_required' - }; + logForDebugging( + currentRepo + ? 'Session has no associated repository, proceeding without validation' + : 'Session has no repo requirement and not in git directory, proceeding', + ) + return { status: 'no_repo_required' } } - const sessionParsed = parseGitRemote(gitSource.url); - const sessionRepo = sessionParsed ? `${sessionParsed.owner}/${sessionParsed.name}` : parseGitHubRepository(gitSource.url); + + const sessionParsed = parseGitRemote(gitSource.url) + const sessionRepo = sessionParsed + ? `${sessionParsed.owner}/${sessionParsed.name}` + : parseGitHubRepository(gitSource.url) if (!sessionRepo) { - return { - status: 'no_repo_required' - }; + return { status: 'no_repo_required' } } - logForDebugging(`Session is for repository: ${sessionRepo}, current repo: ${currentRepo ?? 'none'}`); + + logForDebugging( + `Session is for repository: ${sessionRepo}, current repo: ${currentRepo ?? 'none'}`, + ) + if (!currentRepo) { // Not in a git repo, but session requires one return { status: 'not_in_repo', sessionRepo, sessionHost: sessionParsed?.host, - currentRepo: null - }; + currentRepo: null, + } } // Compare both owner/repo and host to avoid cross-instance mismatches. // Strip ports before comparing hosts — SSH remotes omit the port while // HTTPS remotes may include a non-standard port (e.g. ghe.corp.com:8443), // which would cause a false mismatch. - const stripPort = (host: string): string => host.replace(/:\d+$/, ''); - const repoMatch = currentRepo.toLowerCase() === sessionRepo.toLowerCase(); - const hostMatch = !currentParsed || !sessionParsed || stripPort(currentParsed.host.toLowerCase()) === stripPort(sessionParsed.host.toLowerCase()); + const stripPort = (host: string): string => host.replace(/:\d+$/, '') + const repoMatch = currentRepo.toLowerCase() === sessionRepo.toLowerCase() + const hostMatch = + !currentParsed || + !sessionParsed || + stripPort(currentParsed.host.toLowerCase()) === + stripPort(sessionParsed.host.toLowerCase()) + if (repoMatch && hostMatch) { return { status: 'match', sessionRepo, - currentRepo - }; + currentRepo, + } } // Repo mismatch — keep sessionRepo/currentRepo as plain "owner/repo" so @@ -423,8 +525,8 @@ export async function validateSessionRepository(sessionData: SessionResource): P sessionRepo, currentRepo, sessionHost: sessionParsed?.host, - currentHost: currentParsed?.host - }; + currentHost: currentParsed?.host, + } } /** @@ -434,78 +536,132 @@ export async function validateSessionRepository(sessionData: SessionResource): P * @param onProgress Optional callback for progress updates * @returns The raw session log and branch name */ -export async function teleportResumeCodeSession(sessionId: string, onProgress?: TeleportProgressCallback): Promise { +export async function teleportResumeCodeSession( + sessionId: string, + onProgress?: TeleportProgressCallback, +): Promise { if (!isPolicyAllowed('allow_remote_sessions')) { - throw new Error("Remote sessions are disabled by your organization's policy."); + throw new Error( + "Remote sessions are disabled by your organization's policy.", + ) } - logForDebugging(`Resuming code session ID: ${sessionId}`); + + logForDebugging(`Resuming code session ID: ${sessionId}`) + try { - const accessToken = getClaudeAIOAuthTokens()?.accessToken; + const accessToken = getClaudeAIOAuthTokens()?.accessToken if (!accessToken) { logEvent('tengu_teleport_resume_error', { - error_type: 'no_access_token' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - throw new Error('Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.'); + error_type: + 'no_access_token' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + throw new Error( + 'Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.', + ) } // Get organization UUID - const orgUUID = await getOrganizationUUID(); + const orgUUID = await getOrganizationUUID() if (!orgUUID) { logEvent('tengu_teleport_resume_error', { - error_type: 'no_org_uuid' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - throw new Error('Unable to get organization UUID for constructing session URL'); + error_type: + 'no_org_uuid' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + throw new Error( + 'Unable to get organization UUID for constructing session URL', + ) } // Fetch and validate repository matches before resuming - onProgress?.('validating'); - const sessionData = await fetchSession(sessionId); - const repoValidation = await validateSessionRepository(sessionData); + onProgress?.('validating') + const sessionData = await fetchSession(sessionId) + const repoValidation = await validateSessionRepository(sessionData) + switch (repoValidation.status) { case 'match': case 'no_repo_required': // Proceed with teleport - break; - case 'not_in_repo': - { - logEvent('tengu_teleport_error_repo_not_in_git_dir_sessions_api', { - sessionId: sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - // Include host for GHE users so they know which instance the repo is on - const notInRepoDisplay = repoValidation.sessionHost && repoValidation.sessionHost.toLowerCase() !== 'github.com' ? `${repoValidation.sessionHost}/${repoValidation.sessionRepo}` : repoValidation.sessionRepo; - throw new TeleportOperationError(`You must run claude --teleport ${sessionId} from a checkout of ${notInRepoDisplay}.`, chalk.red(`You must run claude --teleport ${sessionId} from a checkout of ${chalk.bold(notInRepoDisplay)}.\n`)); - } - case 'mismatch': - { - logEvent('tengu_teleport_error_repo_mismatch_sessions_api', { - sessionId: sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - // Only include host prefix when hosts actually differ to disambiguate - // cross-instance mismatches; for same-host mismatches the host is noise. - const hostsDiffer = repoValidation.sessionHost && repoValidation.currentHost && repoValidation.sessionHost.replace(/:\d+$/, '').toLowerCase() !== repoValidation.currentHost.replace(/:\d+$/, '').toLowerCase(); - const sessionDisplay = hostsDiffer ? `${repoValidation.sessionHost}/${repoValidation.sessionRepo}` : repoValidation.sessionRepo; - const currentDisplay = hostsDiffer ? `${repoValidation.currentHost}/${repoValidation.currentRepo}` : repoValidation.currentRepo; - throw new TeleportOperationError(`You must run claude --teleport ${sessionId} from a checkout of ${sessionDisplay}.\nThis repo is ${currentDisplay}.`, chalk.red(`You must run claude --teleport ${sessionId} from a checkout of ${chalk.bold(sessionDisplay)}.\nThis repo is ${chalk.bold(currentDisplay)}.\n`)); - } + break + case 'not_in_repo': { + logEvent('tengu_teleport_error_repo_not_in_git_dir_sessions_api', { + sessionId: + sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + // Include host for GHE users so they know which instance the repo is on + const notInRepoDisplay = + repoValidation.sessionHost && + repoValidation.sessionHost.toLowerCase() !== 'github.com' + ? `${repoValidation.sessionHost}/${repoValidation.sessionRepo}` + : repoValidation.sessionRepo + throw new TeleportOperationError( + `You must run claude --teleport ${sessionId} from a checkout of ${notInRepoDisplay}.`, + chalk.red( + `You must run claude --teleport ${sessionId} from a checkout of ${chalk.bold(notInRepoDisplay)}.\n`, + ), + ) + } + case 'mismatch': { + logEvent('tengu_teleport_error_repo_mismatch_sessions_api', { + sessionId: + sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + // Only include host prefix when hosts actually differ to disambiguate + // cross-instance mismatches; for same-host mismatches the host is noise. + const hostsDiffer = + repoValidation.sessionHost && + repoValidation.currentHost && + repoValidation.sessionHost.replace(/:\d+$/, '').toLowerCase() !== + repoValidation.currentHost.replace(/:\d+$/, '').toLowerCase() + const sessionDisplay = hostsDiffer + ? `${repoValidation.sessionHost}/${repoValidation.sessionRepo}` + : repoValidation.sessionRepo + const currentDisplay = hostsDiffer + ? `${repoValidation.currentHost}/${repoValidation.currentRepo}` + : repoValidation.currentRepo + throw new TeleportOperationError( + `You must run claude --teleport ${sessionId} from a checkout of ${sessionDisplay}.\nThis repo is ${currentDisplay}.`, + chalk.red( + `You must run claude --teleport ${sessionId} from a checkout of ${chalk.bold(sessionDisplay)}.\nThis repo is ${chalk.bold(currentDisplay)}.\n`, + ), + ) + } case 'error': - throw new TeleportOperationError(repoValidation.errorMessage || 'Failed to validate session repository', chalk.red(`Error: ${repoValidation.errorMessage || 'Failed to validate session repository'}\n`)); - default: - { - const _exhaustive: never = repoValidation.status; - throw new Error(`Unhandled repo validation status: ${_exhaustive}`); - } + throw new TeleportOperationError( + repoValidation.errorMessage || + 'Failed to validate session repository', + chalk.red( + `Error: ${repoValidation.errorMessage || 'Failed to validate session repository'}\n`, + ), + ) + default: { + const _exhaustive: never = repoValidation.status + throw new Error(`Unhandled repo validation status: ${_exhaustive}`) + } } - return await teleportFromSessionsAPI(sessionId, orgUUID, accessToken, onProgress, sessionData); + + return await teleportFromSessionsAPI( + sessionId, + orgUUID, + accessToken, + onProgress, + sessionData, + ) } catch (error) { if (error instanceof TeleportOperationError) { - throw error; + throw error } - const err = toError(error); - logError(err); + + const err = toError(error) + logError(err) logEvent('tengu_teleport_resume_error', { - error_type: 'resume_session_id_catch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - throw new TeleportOperationError(err.message, chalk.red(`Error: ${err.message}\n`)); + error_type: + 'resume_session_id_catch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + + throw new TeleportOperationError( + err.message, + chalk.red(`Error: ${err.message}\n`), + ) } } @@ -513,29 +669,43 @@ export async function teleportResumeCodeSession(sessionId: string, onProgress?: * Helper function to handle teleport prerequisites (authentication and git state) * Shows TeleportError dialog rendered into the existing root if needed */ -async function handleTeleportPrerequisites(root: Root, errorsToIgnore?: Set): Promise { - const errors = await getTeleportErrors(); +async function handleTeleportPrerequisites( + root: Root, + errorsToIgnore?: Set, +): Promise { + const errors = await getTeleportErrors() if (errors.size > 0) { // Log teleport errors detected logEvent('tengu_teleport_errors_detected', { - error_types: Array.from(errors).join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - errors_ignored: Array.from(errorsToIgnore || []).join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + error_types: Array.from(errors).join( + ',', + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + errors_ignored: Array.from(errorsToIgnore || []).join( + ',', + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) // Show TeleportError dialog for user interaction await new Promise(resolve => { - root.render( + root.render( + - { - // Log when errors are resolved - logEvent('tengu_teleport_errors_resolved', { - error_types: Array.from(errors).join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - void resolve(); - }} /> + { + // Log when errors are resolved + logEvent('tengu_teleport_errors_resolved', { + error_types: Array.from(errors).join( + ',', + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + void resolve() + }} + /> - ); - }); + , + ) + }) } } @@ -548,15 +718,20 @@ async function handleTeleportPrerequisites(root: Root, errorsToIgnore?: Set The created session or null if creation fails */ -export async function teleportToRemoteWithErrorHandling(root: Root, description: string | null, signal: AbortSignal, branchName?: string): Promise { - const errorsToIgnore = new Set(['needsGitStash']); - await handleTeleportPrerequisites(root, errorsToIgnore); +export async function teleportToRemoteWithErrorHandling( + root: Root, + description: string | null, + signal: AbortSignal, + branchName?: string, +): Promise { + const errorsToIgnore = new Set(['needsGitStash']) + await handleTeleportPrerequisites(root, errorsToIgnore) return teleportToRemote({ initialMessage: description, signal, branchName, - onBundleFail: msg => process.stderr.write(`\n${msg}\n`) - }); + onBundleFail: msg => process.stderr.write(`\n${msg}\n`), + }) } /** @@ -569,56 +744,83 @@ export async function teleportToRemoteWithErrorHandling(root: Root, description: * @param sessionData Optional session data (used to extract branch info) * @returns TeleportRemoteResponse with session logs as Message[] */ -export async function teleportFromSessionsAPI(sessionId: string, orgUUID: string, accessToken: string, onProgress?: TeleportProgressCallback, sessionData?: SessionResource): Promise { - const startTime = Date.now(); +export async function teleportFromSessionsAPI( + sessionId: string, + orgUUID: string, + accessToken: string, + onProgress?: TeleportProgressCallback, + sessionData?: SessionResource, +): Promise { + const startTime = Date.now() + try { // Fetch session logs via session ingress - logForDebugging(`[teleport] Starting fetch for session: ${sessionId}`); - onProgress?.('fetching_logs'); - const logsStartTime = Date.now(); + logForDebugging(`[teleport] Starting fetch for session: ${sessionId}`) + onProgress?.('fetching_logs') + + const logsStartTime = Date.now() // Try CCR v2 first (GetTeleportEvents — server dispatches Spanner/ // threadstore). Fall back to session-ingress if it returns null // (endpoint not yet deployed, or transient error). Once session-ingress // is gone, the fallback becomes a no-op — getSessionLogsViaOAuth will // return null too and we fail with "Failed to fetch session logs". - let logs = await getTeleportEvents(sessionId, accessToken, orgUUID); + let logs = await getTeleportEvents(sessionId, accessToken, orgUUID) if (logs === null) { - logForDebugging('[teleport] v2 endpoint returned null, trying session-ingress'); - logs = await getSessionLogsViaOAuth(sessionId, accessToken, orgUUID); + logForDebugging( + '[teleport] v2 endpoint returned null, trying session-ingress', + ) + logs = await getSessionLogsViaOAuth(sessionId, accessToken, orgUUID) } - logForDebugging(`[teleport] Session logs fetched in ${Date.now() - logsStartTime}ms`); + logForDebugging( + `[teleport] Session logs fetched in ${Date.now() - logsStartTime}ms`, + ) + if (logs === null) { - throw new Error('Failed to fetch session logs'); + throw new Error('Failed to fetch session logs') } // Filter to get only transcript messages, excluding sidechain messages - const filterStartTime = Date.now(); - const messages = logs.filter(entry => isTranscriptMessage(entry) && !entry.isSidechain) as Message[]; - logForDebugging(`[teleport] Filtered ${logs.length} entries to ${messages.length} messages in ${Date.now() - filterStartTime}ms`); + const filterStartTime = Date.now() + const messages = logs.filter( + entry => isTranscriptMessage(entry) && !entry.isSidechain, + ) as Message[] + logForDebugging( + `[teleport] Filtered ${logs.length} entries to ${messages.length} messages in ${Date.now() - filterStartTime}ms`, + ) // Extract branch info from session data - onProgress?.('fetching_branch'); - const branch = sessionData ? getBranchFromSession(sessionData) : undefined; + onProgress?.('fetching_branch') + const branch = sessionData ? getBranchFromSession(sessionData) : undefined if (branch) { - logForDebugging(`[teleport] Found branch: ${branch}`); + logForDebugging(`[teleport] Found branch: ${branch}`) } - logForDebugging(`[teleport] Total teleportFromSessionsAPI time: ${Date.now() - startTime}ms`); + + logForDebugging( + `[teleport] Total teleportFromSessionsAPI time: ${Date.now() - startTime}ms`, + ) + return { log: messages, - branch - }; + branch, + } } catch (error) { - const err = toError(error); + const err = toError(error) // Handle 404 specifically if (axios.isAxiosError(error) && error.response?.status === 404) { logEvent('tengu_teleport_error_session_not_found_404', { - sessionId: sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); - throw new TeleportOperationError(`${sessionId} not found.`, `${sessionId} not found.\n${chalk.dim('Run /status in Claude Code to check your account.')}`); + sessionId: + sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + throw new TeleportOperationError( + `${sessionId} not found.`, + `${sessionId} not found.\n${chalk.dim('Run /status in Claude Code to check your account.')}`, + ) } - logError(err); - throw new Error(`Failed to fetch session from Sessions API: ${err.message}`); + + logError(err) + + throw new Error(`Failed to fetch session from Sessions API: ${err.message}`) } } @@ -626,99 +828,107 @@ export async function teleportFromSessionsAPI(sessionId: string, orgUUID: string * Response type for polling remote session events (uses SDK events format) */ export type PollRemoteSessionResponse = { - newEvents: SDKMessage[]; - lastEventId: string | null; - branch?: string; - sessionStatus?: 'idle' | 'running' | 'requires_action' | 'archived'; -}; + newEvents: SDKMessage[] + lastEventId: string | null + branch?: string + sessionStatus?: 'idle' | 'running' | 'requires_action' | 'archived' +} /** * Polls remote session events. Pass the previous response's `lastEventId` * as `afterId` to fetch only the delta. Set `skipMetadata` to avoid the * per-call GET /v1/sessions/{id} when branch/status aren't needed. */ -export async function pollRemoteSessionEvents(sessionId: string, afterId: string | null = null, opts?: { - skipMetadata?: boolean; -}): Promise { - const accessToken = getClaudeAIOAuthTokens()?.accessToken; +export async function pollRemoteSessionEvents( + sessionId: string, + afterId: string | null = null, + opts?: { skipMetadata?: boolean }, +): Promise { + const accessToken = getClaudeAIOAuthTokens()?.accessToken if (!accessToken) { - throw new Error('No access token for polling'); + throw new Error('No access token for polling') } - const orgUUID = await getOrganizationUUID(); + + const orgUUID = await getOrganizationUUID() if (!orgUUID) { - throw new Error('No org UUID for polling'); + throw new Error('No org UUID for polling') } + const headers = { ...getOAuthHeaders(accessToken), 'anthropic-beta': 'ccr-byoc-2025-07-29', - 'x-organization-uuid': orgUUID - }; - const eventsUrl = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`; + 'x-organization-uuid': orgUUID, + } + const eventsUrl = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events` + type EventsResponse = { - data: unknown[]; - has_more: boolean; - first_id: string | null; - last_id: string | null; - }; + data: unknown[] + has_more: boolean + first_id: string | null + last_id: string | null + } // Cap is a safety valve against stuck cursors; steady-state is 0–1 pages. - const MAX_EVENT_PAGES = 50; - const sdkMessages: SDKMessage[] = []; - let cursor = afterId; + const MAX_EVENT_PAGES = 50 + const sdkMessages: SDKMessage[] = [] + let cursor = afterId for (let page = 0; page < MAX_EVENT_PAGES; page++) { const eventsResponse = await axios.get(eventsUrl, { headers, - params: cursor ? { - after_id: cursor - } : undefined, - timeout: 30000 - }); + params: cursor ? { after_id: cursor } : undefined, + timeout: 30000, + }) + if (eventsResponse.status !== 200) { - throw new Error(`Failed to fetch session events: ${eventsResponse.statusText}`); + throw new Error( + `Failed to fetch session events: ${eventsResponse.statusText}`, + ) } - const eventsData: EventsResponse = eventsResponse.data; + + const eventsData: EventsResponse = eventsResponse.data if (!eventsData?.data || !Array.isArray(eventsData.data)) { - throw new Error('Invalid events response'); + throw new Error('Invalid events response') } + for (const event of eventsData.data) { if (event && typeof event === 'object' && 'type' in event) { - if (event.type === 'env_manager_log' || event.type === 'control_response') { - continue; + if ( + event.type === 'env_manager_log' || + event.type === 'control_response' + ) { + continue } if ('session_id' in event) { - sdkMessages.push(event as SDKMessage); + sdkMessages.push(event as SDKMessage) } } } - if (!eventsData.last_id) break; - cursor = eventsData.last_id; - if (!eventsData.has_more) break; + + if (!eventsData.last_id) break + cursor = eventsData.last_id + if (!eventsData.has_more) break } + if (opts?.skipMetadata) { - return { - newEvents: sdkMessages, - lastEventId: cursor - }; + return { newEvents: sdkMessages, lastEventId: cursor } } // Fetch session metadata (branch, status) - let branch: string | undefined; - let sessionStatus: PollRemoteSessionResponse['sessionStatus']; + let branch: string | undefined + let sessionStatus: PollRemoteSessionResponse['sessionStatus'] try { - const sessionData = await fetchSession(sessionId); - branch = getBranchFromSession(sessionData); - sessionStatus = sessionData.session_status as PollRemoteSessionResponse['sessionStatus']; + const sessionData = await fetchSession(sessionId) + branch = getBranchFromSession(sessionData) + sessionStatus = + sessionData.session_status as PollRemoteSessionResponse['sessionStatus'] } catch (e) { - logForDebugging(`teleport: failed to fetch session ${sessionId} metadata: ${e}`, { - level: 'debug' - }); + logForDebugging( + `teleport: failed to fetch session ${sessionId} metadata: ${e}`, + { level: 'debug' }, + ) } - return { - newEvents: sdkMessages, - lastEventId: cursor, - branch, - sessionStatus - }; + + return { newEvents: sdkMessages, lastEventId: cursor, branch, sessionStatus } } /** @@ -735,26 +945,26 @@ export async function pollRemoteSessionEvents(sessionId: string, afterId: string * Backend: anthropic#303856. */ export async function teleportToRemote(options: { - initialMessage: string | null; - branchName?: string; - title?: string; + initialMessage: string | null + branchName?: string + title?: string /** * The description of the session. This is used to generate the title and * session branch name (unless they are explicitly provided). */ - description?: string; - model?: string; - permissionMode?: PermissionMode; - ultraplan?: boolean; - signal: AbortSignal; - useDefaultEnvironment?: boolean; + description?: string + model?: string + permissionMode?: PermissionMode + ultraplan?: boolean + signal: AbortSignal + useDefaultEnvironment?: boolean /** * Explicit environment_id (e.g. the code_review synthetic env). Bypasses * fetchEnvironments; the usual repo-detection → git source still runs so * the container gets the repo checked out (orchestrator reads --repo-dir * from pwd, it doesn't clone). */ - environmentId?: string; + environmentId?: string /** * Per-session env vars merged into session_context.environment_variables. * Write-only at the API layer (stripped from Get/List responses). When @@ -763,7 +973,7 @@ export async function teleportToRemote(options: { * server only passes through what the caller sends; bughunter.go mints * its own, user sessions don't get one automatically). */ - environmentVariables?: Record; + environmentVariables?: Record /** * When set with environmentId, creates and uploads a git bundle of the * local working tree (createAndUploadGitBundle handles the stash-create @@ -771,53 +981,50 @@ export async function teleportToRemote(options: { * clones from the bundle instead of GitHub — container gets the caller's * exact local state. Needs .git/ only, not a GitHub remote. */ - useBundle?: boolean; + useBundle?: boolean /** * Called with a user-facing message when the bundle path is attempted but * fails. The wrapper stderr.writes it (pre-REPL). Remote-agent callers * capture it to include in their throw (in-REPL, Ink-rendered). */ - onBundleFail?: (message: string) => void; + onBundleFail?: (message: string) => void /** * When true, disables the git-bundle fallback entirely. Use for flows like * autofix where CCR must push to GitHub — a bundle can't do that. */ - skipBundle?: boolean; + skipBundle?: boolean /** * When set, reuses this branch as the outcome branch instead of generating * a new claude/ branch. Sets allow_unrestricted_git_push on the source and * reuse_outcome_branches on the session context so the remote pushes to the * caller's branch directly. */ - reuseOutcomeBranch?: string; + reuseOutcomeBranch?: string /** * GitHub PR to attach to the session context. Backend uses this to * identify the PR associated with this session. */ - githubPr?: { - owner: string; - repo: string; - number: number; - }; + githubPr?: { owner: string; repo: string; number: number } }): Promise { - const { - initialMessage, - signal - } = options; + const { initialMessage, signal } = options try { // Check authentication - await checkAndRefreshOAuthTokenIfNeeded(); - const accessToken = getClaudeAIOAuthTokens()?.accessToken; + await checkAndRefreshOAuthTokenIfNeeded() + const accessToken = getClaudeAIOAuthTokens()?.accessToken if (!accessToken) { - logError(new Error('No access token found for remote session creation')); - return null; + logError(new Error('No access token found for remote session creation')) + return null } // Get organization UUID - const orgUUID = await getOrganizationUUID(); + const orgUUID = await getOrganizationUUID() if (!orgUUID) { - logError(new Error('Unable to get organization UUID for remote session creation')); - return null; + logError( + new Error( + 'Unable to get organization UUID for remote session creation', + ), + ) + return null } // Explicit environmentId short-circuits Haiku title-gen + env selection. @@ -826,87 +1033,96 @@ export async function teleportToRemote(options: { // (bughunter.go:520 sets a git source too; env-manager does the checkout // before the SessionStart hook fires). if (options.environmentId) { - const url = `${getOauthConfig().BASE_API_URL}/v1/sessions`; + const url = `${getOauthConfig().BASE_API_URL}/v1/sessions` const headers = { ...getOAuthHeaders(accessToken), 'anthropic-beta': 'ccr-byoc-2025-07-29', - 'x-organization-uuid': orgUUID - }; + 'x-organization-uuid': orgUUID, + } const envVars = { CLAUDE_CODE_OAUTH_TOKEN: accessToken, - ...(options.environmentVariables ?? {}) - }; + ...(options.environmentVariables ?? {}), + } // Bundle mode: upload local working tree (uncommitted changes via // refs/seed/stash), container clones from the bundle. No GitHub. // Otherwise: github.com source — caller checked eligibility. - let gitSource: GitSource | null = null; - let seedBundleFileId: string | null = null; + let gitSource: GitSource | null = null + let seedBundleFileId: string | null = null if (options.useBundle) { - const bundle = await createAndUploadGitBundle({ - oauthToken: accessToken, - sessionId: getSessionId(), - baseUrl: getOauthConfig().BASE_API_URL - }, { - signal - }); + const bundle = await createAndUploadGitBundle( + { + oauthToken: accessToken, + sessionId: getSessionId(), + baseUrl: getOauthConfig().BASE_API_URL, + }, + { signal }, + ) if (!bundle.success) { - const failedBundle = bundle as { success: false; error: string; failReason?: string }; - logError(new Error(`Bundle upload failed: ${failedBundle.error}`)); - return null; + logError(new Error(`Bundle upload failed: ${bundle.error}`)) + return null } - seedBundleFileId = bundle.fileId; + seedBundleFileId = bundle.fileId logEvent('tengu_teleport_bundle_mode', { size_bytes: bundle.bundleSizeBytes, - scope: bundle.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + scope: + bundle.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, has_wip: bundle.hasWip, - reason: 'explicit_env_bundle' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + reason: + 'explicit_env_bundle' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } else { - const repoInfo = await detectCurrentRepositoryWithHost(); + const repoInfo = await detectCurrentRepositoryWithHost() if (repoInfo) { gitSource = { type: 'git_repository', url: `https://${repoInfo.host}/${repoInfo.owner}/${repoInfo.name}`, - revision: options.branchName - }; + revision: options.branchName, + } } } + const requestBody = { title: options.title || options.description || 'Remote task', events: [], session_context: { sources: gitSource ? [gitSource] : [], - ...(seedBundleFileId && { - seed_bundle_file_id: seedBundleFileId - }), + ...(seedBundleFileId && { seed_bundle_file_id: seedBundleFileId }), outcomes: [], - environment_variables: envVars + environment_variables: envVars, }, - environment_id: options.environmentId - }; - logForDebugging(`[teleportToRemote] explicit env ${options.environmentId}, ${Object.keys(envVars).length} env vars, ${seedBundleFileId ? `bundle=${seedBundleFileId}` : `source=${gitSource?.url ?? 'none'}@${options.branchName ?? 'default'}`}`); - const response = await axios.post(url, requestBody, { - headers, - signal - }); - if (response.status !== 200 && response.status !== 201) { - logError(new Error(`CreateSession ${response.status}: ${jsonStringify(response.data)}`)); - return null; + environment_id: options.environmentId, } - const sessionData = response.data as SessionResource; + logForDebugging( + `[teleportToRemote] explicit env ${options.environmentId}, ${Object.keys(envVars).length} env vars, ${seedBundleFileId ? `bundle=${seedBundleFileId}` : `source=${gitSource?.url ?? 'none'}@${options.branchName ?? 'default'}`}`, + ) + const response = await axios.post(url, requestBody, { headers, signal }) + if (response.status !== 200 && response.status !== 201) { + logError( + new Error( + `CreateSession ${response.status}: ${jsonStringify(response.data)}`, + ), + ) + return null + } + const sessionData = response.data as SessionResource if (!sessionData || typeof sessionData.id !== 'string') { - logError(new Error(`No session id in response: ${jsonStringify(response.data)}`)); - return null; + logError( + new Error( + `No session id in response: ${jsonStringify(response.data)}`, + ), + ) + return null } return { id: sessionData.id, - title: sessionData.title || requestBody.title - }; + title: sessionData.title || requestBody.title, + } } - let gitSource: GitSource | null = null; - let gitOutcome: GitRepositoryOutcome | null = null; - let seedBundleFileId: string | null = null; + + let gitSource: GitSource | null = null + let gitOutcome: GitRepositoryOutcome | null = null + let seedBundleFileId: string | null = null // Source selection ladder: GitHub clone (if CCR can actually pull it) → // bundle fallback (if .git exists) → empty sandbox. @@ -921,19 +1137,22 @@ export async function teleportToRemote(options: { // or when you know your GitHub auth is busted. Read here (not in the // caller) so it works for remote-agent too, not just --remote. - const repoInfo = await detectCurrentRepositoryWithHost(); + const repoInfo = await detectCurrentRepositoryWithHost() // Generate title and branch name for the session. Skip the Haiku call // when both title and outcome branch are explicitly provided. - let sessionTitle: string; - let sessionBranch: string; + let sessionTitle: string + let sessionBranch: string if (options.title && options.reuseOutcomeBranch) { - sessionTitle = options.title; - sessionBranch = options.reuseOutcomeBranch; + sessionTitle = options.title + sessionBranch = options.reuseOutcomeBranch } else { - const generated = await generateTitleAndBranch(options.description || initialMessage || 'Background task', signal); - sessionTitle = options.title || generated.title; - sessionBranch = options.reuseOutcomeBranch || generated.branchName; + const generated = await generateTitleAndBranch( + options.description || initialMessage || 'Background task', + signal, + ) + sessionTitle = options.title || generated.title + sessionBranch = options.reuseOutcomeBranch || generated.branchName } // Preflight: does CCR have a token that can clone this repo? @@ -942,51 +1161,69 @@ export async function teleportToRemote(options: { // setup. For them (and for non-GitHub hosts that parseGitRemote // somehow accepted), fall through optimistically; if the backend // rejects the host, bundle next time. - let ghViable = false; - let sourceReason: 'github_preflight_ok' | 'ghes_optimistic' | 'github_preflight_failed' | 'no_github_remote' | 'forced_bundle' | 'no_git_at_all' = 'no_git_at_all'; + let ghViable = false + let sourceReason: + | 'github_preflight_ok' + | 'ghes_optimistic' + | 'github_preflight_failed' + | 'no_github_remote' + | 'forced_bundle' + | 'no_git_at_all' = 'no_git_at_all' // gitRoot gates both bundle creation and the gate check itself — no // point awaiting GrowthBook when there's nothing to bundle. - const gitRoot = findGitRoot(getCwd()); - const forceBundle = !options.skipBundle && isEnvTruthy(process.env.CCR_FORCE_BUNDLE); - const bundleSeedGateOn = !options.skipBundle && gitRoot !== null && (isEnvTruthy(process.env.CCR_ENABLE_BUNDLE) || (await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bundle_seed_enabled'))); + const gitRoot = findGitRoot(getCwd()) + const forceBundle = + !options.skipBundle && isEnvTruthy(process.env.CCR_FORCE_BUNDLE) + const bundleSeedGateOn = + !options.skipBundle && + gitRoot !== null && + (isEnvTruthy(process.env.CCR_ENABLE_BUNDLE) || + (await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bundle_seed_enabled'))) + if (repoInfo && !forceBundle) { if (repoInfo.host === 'github.com') { - ghViable = await checkGithubAppInstalled(repoInfo.owner, repoInfo.name, signal); - sourceReason = ghViable ? 'github_preflight_ok' : 'github_preflight_failed'; + ghViable = await checkGithubAppInstalled( + repoInfo.owner, + repoInfo.name, + signal, + ) + sourceReason = ghViable + ? 'github_preflight_ok' + : 'github_preflight_failed' } else { - ghViable = true; - sourceReason = 'ghes_optimistic'; + ghViable = true + sourceReason = 'ghes_optimistic' } } else if (forceBundle) { - sourceReason = 'forced_bundle'; + sourceReason = 'forced_bundle' } else if (gitRoot) { - sourceReason = 'no_github_remote'; + sourceReason = 'no_github_remote' } // Preflight failed but bundle is off — fall through optimistically like // pre-preflight behavior. Backend reports the real auth error. if (!ghViable && !bundleSeedGateOn && repoInfo) { - ghViable = true; + ghViable = true } + if (ghViable && repoInfo) { - const { - host, - owner, - name - } = repoInfo; + const { host, owner, name } = repoInfo // Resolve the base branch: prefer explicit branchName, fall back to default branch - const revision = options.branchName ?? (await getDefaultBranch()) ?? undefined; - logForDebugging(`[teleportToRemote] Git source: ${host}/${owner}/${name}, revision: ${revision ?? 'none'}`); + const revision = + options.branchName ?? (await getDefaultBranch()) ?? undefined + logForDebugging( + `[teleportToRemote] Git source: ${host}/${owner}/${name}, revision: ${revision ?? 'none'}`, + ) gitSource = { type: 'git_repository', url: `https://${host}/${owner}/${name}`, // The revision specifies which ref to checkout as the base branch revision, ...(options.reuseOutcomeBranch && { - allow_unrestricted_git_push: true - }) - }; + allow_unrestricted_git_push: true, + }), + } // type: 'github' is used for all GitHub-compatible hosts (github.com and GHE). // The CLI can't distinguish GHE from non-GitHub hosts (GitLab, Bitbucket) // client-side — the backend validates the URL against configured GHE instances @@ -996,9 +1233,9 @@ export async function teleportToRemote(options: { git_info: { type: 'github', repo: `${owner}/${name}`, - branches: [sessionBranch] - } - }; + branches: [sessionBranch], + }, + } } // Bundle fallback. Only try bundle if GitHub wasn't viable, the gate is @@ -1007,131 +1244,165 @@ export async function teleportToRemote(options: { // .git definitely exists (detectCurrentRepositoryWithHost read the // remote from it). if (!gitSource && bundleSeedGateOn) { - logForDebugging(`[teleportToRemote] Bundling (reason: ${sourceReason})`); - const bundle = await createAndUploadGitBundle({ - oauthToken: accessToken, - sessionId: getSessionId(), - baseUrl: getOauthConfig().BASE_API_URL - }, { - signal - }); + logForDebugging(`[teleportToRemote] Bundling (reason: ${sourceReason})`) + const bundle = await createAndUploadGitBundle( + { + oauthToken: accessToken, + sessionId: getSessionId(), + baseUrl: getOauthConfig().BASE_API_URL, + }, + { signal }, + ) if (!bundle.success) { - const failedBundle = bundle as { success: false; error: string; failReason?: 'git_error' | 'too_large' | 'empty_repo' }; - logError(new Error(`Bundle upload failed: ${failedBundle.error}`)); + logError(new Error(`Bundle upload failed: ${bundle.error}`)) // Only steer users to GitHub setup when there's a remote to clone from. - const setup = repoInfo ? '. Please setup GitHub on https://claude.ai/code' : ''; - let msg: string; - switch (failedBundle.failReason) { + const setup = repoInfo + ? '. Please setup GitHub on https://claude.ai/code' + : '' + let msg: string + switch (bundle.failReason) { case 'empty_repo': - msg = 'Repository has no commits — run `git add . && git commit -m "initial"` then retry'; - break; + msg = + 'Repository has no commits — run `git add . && git commit -m "initial"` then retry' + break case 'too_large': - msg = `Repo is too large to teleport${setup}`; - break; + msg = `Repo is too large to teleport${setup}` + break case 'git_error': - msg = `Failed to create git bundle (${failedBundle.error})${setup}`; - break; + msg = `Failed to create git bundle (${bundle.error})${setup}` + break case undefined: - msg = `Bundle upload failed: ${failedBundle.error}${setup}`; - break; - default: - { - const _exhaustive: never = failedBundle.failReason; - void _exhaustive; - msg = `Bundle upload failed: ${failedBundle.error}`; - } + msg = `Bundle upload failed: ${bundle.error}${setup}` + break + default: { + const _exhaustive: never = bundle.failReason + void _exhaustive + msg = `Bundle upload failed: ${bundle.error}` + } } - options.onBundleFail?.(msg); - return null; + options.onBundleFail?.(msg) + return null } - seedBundleFileId = bundle.fileId; + seedBundleFileId = bundle.fileId logEvent('tengu_teleport_bundle_mode', { size_bytes: bundle.bundleSizeBytes, - scope: bundle.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + scope: + bundle.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, has_wip: bundle.hasWip, - reason: sourceReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + reason: + sourceReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } + logEvent('tengu_teleport_source_decision', { - reason: sourceReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - path: (gitSource ? 'github' : seedBundleFileId ? 'bundle' : 'empty') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + reason: + sourceReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + path: (gitSource + ? 'github' + : seedBundleFileId + ? 'bundle' + : 'empty') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + if (!gitSource && !seedBundleFileId) { - logForDebugging('[teleportToRemote] No repository detected — session will have an empty sandbox'); + logForDebugging( + '[teleportToRemote] No repository detected — session will have an empty sandbox', + ) } // Fetch available environments - let environments = await fetchEnvironments(); + let environments = await fetchEnvironments() if (!environments || environments.length === 0) { - logError(new Error('No environments available for session creation')); - return null; + logError(new Error('No environments available for session creation')) + return null } - logForDebugging(`Available environments: ${environments.map(e => `${e.environment_id} (${e.name}, ${e.kind})`).join(', ')}`); + + logForDebugging( + `Available environments: ${environments.map(e => `${e.environment_id} (${e.name}, ${e.kind})`).join(', ')}`, + ) // Select environment based on settings, then anthropic_cloud preference, then first available. // Prefer anthropic_cloud environments over byoc: anthropic_cloud environments (e.g. "Default") // are the standard compute environments with full repo access, whereas byoc environments // (e.g. "monorepo") are user-owned compute that may not support the current repository. - const settings = getSettings_DEPRECATED(); - const defaultEnvironmentId = options.useDefaultEnvironment ? undefined : settings?.remote?.defaultEnvironmentId; - let cloudEnv = environments.find(env => env.kind === 'anthropic_cloud'); + const settings = getSettings_DEPRECATED() + const defaultEnvironmentId = options.useDefaultEnvironment + ? undefined + : settings?.remote?.defaultEnvironmentId + let cloudEnv = environments.find(env => env.kind === 'anthropic_cloud') // When the caller opts out of their configured default, do not fall // through to a BYOC env that may not support the current repo or the // requested permission mode. Retry once for eventual consistency, // then fail loudly. if (options.useDefaultEnvironment && !cloudEnv) { - logForDebugging(`No anthropic_cloud in env list (${environments.length} envs); retrying fetchEnvironments`); - const retried = await fetchEnvironments(); - cloudEnv = retried?.find(env => env.kind === 'anthropic_cloud'); + logForDebugging( + `No anthropic_cloud in env list (${environments.length} envs); retrying fetchEnvironments`, + ) + const retried = await fetchEnvironments() + cloudEnv = retried?.find(env => env.kind === 'anthropic_cloud') if (!cloudEnv) { - logError(new Error(`No anthropic_cloud environment available after retry (got: ${(retried ?? environments).map(e => `${e.name} (${e.kind})`).join(', ')}). Silent byoc fallthrough would launch into a dead env — fail fast instead.`)); - return null; + logError( + new Error( + `No anthropic_cloud environment available after retry (got: ${(retried ?? environments).map(e => `${e.name} (${e.kind})`).join(', ')}). Silent byoc fallthrough would launch into a dead env — fail fast instead.`, + ), + ) + return null } - if (retried) environments = retried; + if (retried) environments = retried } - const selectedEnvironment = defaultEnvironmentId && environments.find(env => env.environment_id === defaultEnvironmentId) || cloudEnv || environments.find(env => env.kind !== 'bridge') || environments[0]; + const selectedEnvironment = + (defaultEnvironmentId && + environments.find( + env => env.environment_id === defaultEnvironmentId, + )) || + cloudEnv || + environments.find(env => env.kind !== 'bridge') || + environments[0] + if (!selectedEnvironment) { - logError(new Error('No environments available for session creation')); - return null; + logError(new Error('No environments available for session creation')) + return null } + if (defaultEnvironmentId) { - const matchedDefault = selectedEnvironment.environment_id === defaultEnvironmentId; - logForDebugging(matchedDefault ? `Using configured default environment: ${defaultEnvironmentId}` : `Configured default environment ${defaultEnvironmentId} not found, using first available`); + const matchedDefault = + selectedEnvironment.environment_id === defaultEnvironmentId + logForDebugging( + matchedDefault + ? `Using configured default environment: ${defaultEnvironmentId}` + : `Configured default environment ${defaultEnvironmentId} not found, using first available`, + ) } - const environmentId = selectedEnvironment.environment_id; - logForDebugging(`Selected environment: ${environmentId} (${selectedEnvironment.name}, ${selectedEnvironment.kind})`); + + const environmentId = selectedEnvironment.environment_id + logForDebugging( + `Selected environment: ${environmentId} (${selectedEnvironment.name}, ${selectedEnvironment.kind})`, + ) // Prepare API request for Sessions API - const url = `${getOauthConfig().BASE_API_URL}/v1/sessions`; + const url = `${getOauthConfig().BASE_API_URL}/v1/sessions` + const headers = { ...getOAuthHeaders(accessToken), 'anthropic-beta': 'ccr-byoc-2025-07-29', - 'x-organization-uuid': orgUUID - }; + 'x-organization-uuid': orgUUID, + } + const sessionContext = { sources: gitSource ? [gitSource] : [], - ...(seedBundleFileId && { - seed_bundle_file_id: seedBundleFileId - }), + ...(seedBundleFileId && { seed_bundle_file_id: seedBundleFileId }), outcomes: gitOutcome ? [gitOutcome] : [], model: options.model ?? getMainLoopModel(), - ...(options.reuseOutcomeBranch && { - reuse_outcome_branches: true - }), - ...(options.githubPr && { - github_pr: options.githubPr - }) - }; + ...(options.reuseOutcomeBranch && { reuse_outcome_branches: true }), + ...(options.githubPr && { github_pr: options.githubPr }), + } // CreateCCRSessionPayload has no permission_mode field — a top-level // body entry is silently dropped by the proto parser server-side. // Instead prepend a set_permission_mode control_request event. Initial // events are written to threadstore before the container connects, so // the CLI applies the mode before the first user turn — no readiness race. - const events: Array<{ - type: 'event'; - data: Record; - }> = []; + const events: Array<{ type: 'event'; data: Record }> = [] if (options.permissionMode) { events.push({ type: 'event', @@ -1141,10 +1412,10 @@ export async function teleportToRemote(options: { request: { subtype: 'set_permission_mode', mode: options.permissionMode, - ultraplan: options.ultraplan - } - } - }); + ultraplan: options.ultraplan, + }, + }, + }) } if (initialMessage) { events.push({ @@ -1156,45 +1427,56 @@ export async function teleportToRemote(options: { parent_tool_use_id: null, message: { role: 'user', - content: initialMessage - } - } - }); + content: initialMessage, + }, + }, + }) } + const requestBody = { title: options.ultraplan ? `ultraplan: ${sessionTitle}` : sessionTitle, events, session_context: sessionContext, - environment_id: environmentId - }; - logForDebugging(`Creating session with payload: ${jsonStringify(requestBody, null, 2)}`); + environment_id: environmentId, + } + + logForDebugging( + `Creating session with payload: ${jsonStringify(requestBody, null, 2)}`, + ) // Make API call - const response = await axios.post(url, requestBody, { - headers, - signal - }); - const isSuccess = response.status === 200 || response.status === 201; + const response = await axios.post(url, requestBody, { headers, signal }) + const isSuccess = response.status === 200 || response.status === 201 + if (!isSuccess) { - logError(new Error(`API request failed with status ${response.status}: ${response.statusText}\n\nResponse data: ${jsonStringify(response.data, null, 2)}`)); - return null; + logError( + new Error( + `API request failed with status ${response.status}: ${response.statusText}\n\nResponse data: ${jsonStringify(response.data, null, 2)}`, + ), + ) + return null } // Parse response as SessionResource - const sessionData = response.data as SessionResource; + const sessionData = response.data as SessionResource if (!sessionData || typeof sessionData.id !== 'string') { - logError(new Error(`Cannot determine session ID from API response: ${jsonStringify(response.data)}`)); - return null; + logError( + new Error( + `Cannot determine session ID from API response: ${jsonStringify(response.data)}`, + ), + ) + return null } - logForDebugging(`Successfully created remote session: ${sessionData.id}`); + + logForDebugging(`Successfully created remote session: ${sessionData.id}`) return { id: sessionData.id, - title: sessionData.title || requestBody.title - }; + title: sessionData.title || requestBody.title, + } } catch (error) { - const err = toError(error); - logError(err); - return null; + const err = toError(error) + logError(err) + return null } } @@ -1207,28 +1489,30 @@ export async function teleportToRemote(options: { * reaper collects it. */ export async function archiveRemoteSession(sessionId: string): Promise { - const accessToken = getClaudeAIOAuthTokens()?.accessToken; - if (!accessToken) return; - const orgUUID = await getOrganizationUUID(); - if (!orgUUID) return; + const accessToken = getClaudeAIOAuthTokens()?.accessToken + if (!accessToken) return + const orgUUID = await getOrganizationUUID() + if (!orgUUID) return const headers = { ...getOAuthHeaders(accessToken), 'anthropic-beta': 'ccr-byoc-2025-07-29', - 'x-organization-uuid': orgUUID - }; - const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`; + 'x-organization-uuid': orgUUID, + } + const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive` try { - const resp = await axios.post(url, {}, { - headers, - timeout: 10000, - validateStatus: s => s < 500 - }); + const resp = await axios.post( + url, + {}, + { headers, timeout: 10000, validateStatus: s => s < 500 }, + ) if (resp.status === 200 || resp.status === 409) { - logForDebugging(`[archiveRemoteSession] archived ${sessionId}`); + logForDebugging(`[archiveRemoteSession] archived ${sessionId}`) } else { - logForDebugging(`[archiveRemoteSession] ${sessionId} failed ${resp.status}: ${jsonStringify(resp.data)}`); + logForDebugging( + `[archiveRemoteSession] ${sessionId} failed ${resp.status}: ${jsonStringify(resp.data)}`, + ) } } catch (err) { - logError(err); + logError(err) } }