This pass rewrites comment-only ANT-ONLY markers to neutral internal-only language across the source tree without changing runtime strings, flags, commands, or protocol identifiers. The goal is to lower obvious internal prose leakage while keeping the diff mechanically safe and easy to review. Constraint: Phase B is limited to comments/prose only; runtime strings and user-facing labels remain deferred Rejected: Broad search-and-replace across strings and command descriptions | too risky for a prose-only pass Confidence: high Scope-risk: narrow Reversibility: clean Directive: Remaining ANT-ONLY hits are mostly runtime/user-facing strings and should be handled separately from comment cleanup Tested: bun run build Tested: bun run smoke Tested: bun run verify:privacy Tested: bun run test:provider Tested: bun run test:provider-recommendation Not-tested: Full repo typecheck (upstream baseline remains noisy) Co-authored-by: anandh8x <test@example.com>
439 lines
18 KiB
TypeScript
439 lines
18 KiB
TypeScript
/* eslint-disable custom-rules/no-process-exit */
|
|
|
|
import { feature } from 'bun:bundle'
|
|
import chalk from 'chalk'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
logEvent,
|
|
} from 'src/services/analytics/index.js'
|
|
import { getCwd } from 'src/utils/cwd.js'
|
|
import { checkForReleaseNotes } from 'src/utils/releaseNotes.js'
|
|
import { setCwd } from 'src/utils/Shell.js'
|
|
import { initSinks } from 'src/utils/sinks.js'
|
|
import {
|
|
getIsNonInteractiveSession,
|
|
getProjectRoot,
|
|
getSessionId,
|
|
setOriginalCwd,
|
|
setProjectRoot,
|
|
switchSession,
|
|
} from './bootstrap/state.js'
|
|
import { getCommands } from './commands.js'
|
|
import { initSessionMemory } from './services/SessionMemory/sessionMemory.js'
|
|
import { asSessionId } from './types/ids.js'
|
|
import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js'
|
|
import { checkAndRestoreTerminalBackup } from './utils/appleTerminalBackup.js'
|
|
import { prefetchApiKeyFromApiKeyHelperIfSafe } from './utils/auth.js'
|
|
import { clearMemoryFileCaches } from './utils/claudemd.js'
|
|
import { getCurrentProjectConfig, getGlobalConfig } from './utils/config.js'
|
|
import { logForDiagnosticsNoPII } from './utils/diagLogs.js'
|
|
import { env } from './utils/env.js'
|
|
import { envDynamic } from './utils/envDynamic.js'
|
|
import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
|
|
import { errorMessage } from './utils/errors.js'
|
|
import { findCanonicalGitRoot, findGitRoot, getIsGit } from './utils/git.js'
|
|
import { initializeFileChangedWatcher } from './utils/hooks/fileChangedWatcher.js'
|
|
import {
|
|
captureHooksConfigSnapshot,
|
|
updateHooksConfigSnapshot,
|
|
} from './utils/hooks/hooksConfigSnapshot.js'
|
|
import { hasWorktreeCreateHook } from './utils/hooks.js'
|
|
import { checkAndRestoreITerm2Backup } from './utils/iTermBackup.js'
|
|
import { logError } from './utils/log.js'
|
|
import { getRecentActivity } from './utils/logoV2Utils.js'
|
|
import { lockCurrentVersion } from './utils/nativeInstaller/index.js'
|
|
import type { PermissionMode } from './utils/permissions/PermissionMode.js'
|
|
import { getPlanSlug } from './utils/plans.js'
|
|
import { saveWorktreeState } from './utils/sessionStorage.js'
|
|
import { profileCheckpoint } from './utils/startupProfiler.js'
|
|
import {
|
|
createTmuxSessionForWorktree,
|
|
createWorktreeForSession,
|
|
generateTmuxSessionName,
|
|
worktreeBranchName,
|
|
} from './utils/worktree.js'
|
|
|
|
export async function setup(
|
|
cwd: string,
|
|
permissionMode: PermissionMode,
|
|
allowDangerouslySkipPermissions: boolean,
|
|
worktreeEnabled: boolean,
|
|
worktreeName: string | undefined,
|
|
tmuxEnabled: boolean,
|
|
customSessionId?: string | null,
|
|
worktreePRNumber?: number,
|
|
messagingSocketPath?: string,
|
|
): Promise<void> {
|
|
logForDiagnosticsNoPII('info', 'setup_started')
|
|
|
|
// Check for Node.js version < 18
|
|
const nodeVersion = process.version.match(/^v(\d+)\./)?.[1]
|
|
if (!nodeVersion || parseInt(nodeVersion) < 18) {
|
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
console.error(
|
|
chalk.bold.red(
|
|
'Error: Claude Code requires Node.js version 18 or higher.',
|
|
),
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
// Set custom session ID if provided
|
|
if (customSessionId) {
|
|
switchSession(asSessionId(customSessionId))
|
|
}
|
|
|
|
// --bare / SIMPLE: skip UDS messaging server and teammate snapshot.
|
|
// Scripted calls don't receive injected messages and don't use swarm teammates.
|
|
// Explicit --messaging-socket-path is the escape hatch (per #23222 gate pattern).
|
|
if (!isBareMode() || messagingSocketPath !== undefined) {
|
|
// Start UDS messaging server (Mac/Linux only).
|
|
// Enabled by default for ants — creates a socket in tmpdir if no
|
|
// --messaging-socket-path is passed. Awaited so the server is bound
|
|
// and $CLAUDE_CODE_MESSAGING_SOCKET is exported before any hook
|
|
// (SessionStart in particular) can spawn and snapshot process.env.
|
|
if (feature('UDS_INBOX')) {
|
|
const m = await import('./utils/udsMessaging.js')
|
|
await m.startUdsMessaging(
|
|
messagingSocketPath ?? m.getDefaultUdsSocketPath(),
|
|
{ isExplicit: messagingSocketPath !== undefined },
|
|
)
|
|
}
|
|
}
|
|
|
|
// Teammate snapshot — SIMPLE-only gate (no escape hatch, swarm not used in bare)
|
|
if (!isBareMode() && isAgentSwarmsEnabled()) {
|
|
const { captureTeammateModeSnapshot } = await import(
|
|
'./utils/swarm/backends/teammateModeSnapshot.js'
|
|
)
|
|
captureTeammateModeSnapshot()
|
|
}
|
|
|
|
// Terminal backup restoration — interactive only. Print mode doesn't
|
|
// interact with terminal settings; the next interactive session will
|
|
// detect and restore any interrupted setup.
|
|
if (!getIsNonInteractiveSession()) {
|
|
// iTerm2 backup check only when swarms enabled
|
|
if (isAgentSwarmsEnabled()) {
|
|
const restoredIterm2Backup = await checkAndRestoreITerm2Backup()
|
|
if (restoredIterm2Backup.status === 'restored') {
|
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
console.log(
|
|
chalk.yellow(
|
|
'Detected an interrupted iTerm2 setup. Your original settings have been restored. You may need to restart iTerm2 for the changes to take effect.',
|
|
),
|
|
)
|
|
} else if (restoredIterm2Backup.status === 'failed') {
|
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
console.error(
|
|
chalk.red(
|
|
`Failed to restore iTerm2 settings. Please manually restore your original settings with: defaults import com.googlecode.iterm2 ${restoredIterm2Backup.backupPath}.`,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Check and restore Terminal.app backup if setup was interrupted
|
|
try {
|
|
const restoredTerminalBackup = await checkAndRestoreTerminalBackup()
|
|
if (restoredTerminalBackup.status === 'restored') {
|
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
console.log(
|
|
chalk.yellow(
|
|
'Detected an interrupted Terminal.app setup. Your original settings have been restored. You may need to restart Terminal.app for the changes to take effect.',
|
|
),
|
|
)
|
|
} else if (restoredTerminalBackup.status === 'failed') {
|
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
console.error(
|
|
chalk.red(
|
|
`Failed to restore Terminal.app settings. Please manually restore your original settings with: defaults import com.apple.Terminal ${restoredTerminalBackup.backupPath}.`,
|
|
),
|
|
)
|
|
}
|
|
} catch (error) {
|
|
// Log but don't crash if Terminal.app backup restoration fails
|
|
logError(error)
|
|
}
|
|
}
|
|
|
|
// IMPORTANT: setCwd() must be called before any other code that depends on the cwd
|
|
setCwd(cwd)
|
|
|
|
// Capture hooks configuration snapshot to avoid hidden hook modifications.
|
|
// IMPORTANT: Must be called AFTER setCwd() so hooks are loaded from the correct directory
|
|
const hooksStart = Date.now()
|
|
captureHooksConfigSnapshot()
|
|
logForDiagnosticsNoPII('info', 'setup_hooks_captured', {
|
|
duration_ms: Date.now() - hooksStart,
|
|
})
|
|
|
|
// Initialize FileChanged hook watcher — sync, reads hook config snapshot
|
|
initializeFileChangedWatcher(cwd)
|
|
|
|
// Handle worktree creation if requested
|
|
// IMPORTANT: this must be called befiore getCommands(), otherwise /eject won't be available.
|
|
if (worktreeEnabled) {
|
|
// Mirrors bridgeMain.ts: hook-configured sessions can proceed without git
|
|
// so createWorktreeForSession() can delegate to the hook (non-git VCS).
|
|
const hasHook = hasWorktreeCreateHook()
|
|
const inGit = await getIsGit()
|
|
if (!hasHook && !inGit) {
|
|
process.stderr.write(
|
|
chalk.red(
|
|
`Error: Can only use --worktree in a git repository, but ${chalk.bold(cwd)} is not a git repository. ` +
|
|
`Configure a WorktreeCreate hook in settings.json to use --worktree with other VCS systems.\n`,
|
|
),
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
const slug = worktreePRNumber
|
|
? `pr-${worktreePRNumber}`
|
|
: (worktreeName ?? getPlanSlug())
|
|
|
|
// Git preamble runs whenever we're in a git repo — even if a hook is
|
|
// configured — so --tmux keeps working for git users who also have a
|
|
// WorktreeCreate hook. Only hook-only (non-git) mode skips it.
|
|
let tmuxSessionName: string | undefined
|
|
if (inGit) {
|
|
// Resolve to main repo root (handles being invoked from within a worktree).
|
|
// findCanonicalGitRoot is sync/filesystem-only/memoized; the underlying
|
|
// findGitRoot cache was already warmed by getIsGit() above, so this is ~free.
|
|
const mainRepoRoot = findCanonicalGitRoot(getCwd())
|
|
if (!mainRepoRoot) {
|
|
process.stderr.write(
|
|
chalk.red(
|
|
`Error: Could not determine the main git repository root.\n`,
|
|
),
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
// If we're inside a worktree, switch to the main repo for worktree creation
|
|
if (mainRepoRoot !== (findGitRoot(getCwd()) ?? getCwd())) {
|
|
logForDiagnosticsNoPII('info', 'worktree_resolved_to_main_repo')
|
|
process.chdir(mainRepoRoot)
|
|
setCwd(mainRepoRoot)
|
|
}
|
|
|
|
tmuxSessionName = tmuxEnabled
|
|
? generateTmuxSessionName(mainRepoRoot, worktreeBranchName(slug))
|
|
: undefined
|
|
} else {
|
|
// Non-git hook mode: no canonical root to resolve, so name the tmux
|
|
// session from cwd — generateTmuxSessionName only basenames the path.
|
|
tmuxSessionName = tmuxEnabled
|
|
? generateTmuxSessionName(getCwd(), worktreeBranchName(slug))
|
|
: undefined
|
|
}
|
|
|
|
let worktreeSession: Awaited<ReturnType<typeof createWorktreeForSession>>
|
|
try {
|
|
worktreeSession = await createWorktreeForSession(
|
|
getSessionId(),
|
|
slug,
|
|
tmuxSessionName,
|
|
worktreePRNumber ? { prNumber: worktreePRNumber } : undefined,
|
|
)
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
chalk.red(`Error creating worktree: ${errorMessage(error)}\n`),
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
logEvent('tengu_worktree_created', { tmux_enabled: tmuxEnabled })
|
|
|
|
// Create tmux session for the worktree if enabled
|
|
if (tmuxEnabled && tmuxSessionName) {
|
|
const tmuxResult = await createTmuxSessionForWorktree(
|
|
tmuxSessionName,
|
|
worktreeSession.worktreePath,
|
|
)
|
|
if (tmuxResult.created) {
|
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
console.log(
|
|
chalk.green(
|
|
`Created tmux session: ${chalk.bold(tmuxSessionName)}\nTo attach: ${chalk.bold(`tmux attach -t ${tmuxSessionName}`)}`,
|
|
),
|
|
)
|
|
} else {
|
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
console.error(
|
|
chalk.yellow(
|
|
`Warning: Failed to create tmux session: ${tmuxResult.error}`,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
process.chdir(worktreeSession.worktreePath)
|
|
setCwd(worktreeSession.worktreePath)
|
|
setOriginalCwd(getCwd())
|
|
// --worktree means the worktree IS the session's project, so skills/hooks/
|
|
// cron/etc. should resolve here. (EnterWorktreeTool mid-session does NOT
|
|
// touch projectRoot — that's a throwaway worktree, project stays stable.)
|
|
setProjectRoot(getCwd())
|
|
saveWorktreeState(worktreeSession)
|
|
// Clear memory files cache since originalCwd has changed
|
|
clearMemoryFileCaches()
|
|
// Settings cache was populated in init() (via applySafeConfigEnvironmentVariables)
|
|
// and again at captureHooksConfigSnapshot() above, both from the original dir's
|
|
// .claude/settings.json. Re-read from the worktree and re-capture hooks.
|
|
updateHooksConfigSnapshot()
|
|
}
|
|
|
|
// Background jobs - only critical registrations that must happen before first query
|
|
logForDiagnosticsNoPII('info', 'setup_background_jobs_starting')
|
|
// Bundled skills/plugins are registered in main.tsx before the parallel
|
|
// getCommands() kick — see comment there. Moved out of setup() because
|
|
// the await points above (startUdsMessaging, ~20ms) meant getCommands()
|
|
// raced ahead and memoized an empty bundledSkills list.
|
|
if (!isBareMode()) {
|
|
initSessionMemory() // Synchronous - registers hook, gate check happens lazily
|
|
if (feature('CONTEXT_COLLAPSE')) {
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
;(
|
|
require('./services/contextCollapse/index.js') as typeof import('./services/contextCollapse/index.js')
|
|
).initContextCollapse()
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
}
|
|
}
|
|
void lockCurrentVersion() // Lock current version to prevent deletion by other processes
|
|
logForDiagnosticsNoPII('info', 'setup_background_jobs_launched')
|
|
|
|
profileCheckpoint('setup_before_prefetch')
|
|
// Pre-fetch promises - only items needed before render
|
|
logForDiagnosticsNoPII('info', 'setup_prefetch_starting')
|
|
// When CLAUDE_CODE_SYNC_PLUGIN_INSTALL is set, skip all plugin prefetch.
|
|
// The sync install path in print.ts calls refreshPluginState() after
|
|
// installing, which reloads commands, hooks, and agents. Prefetching here
|
|
// races with the install (concurrent copyPluginToVersionedCache / cachePlugin
|
|
// on the same directories), and the hot-reload handler fires clearPluginCache()
|
|
// mid-install when policySettings arrives.
|
|
const skipPluginPrefetch =
|
|
(getIsNonInteractiveSession() &&
|
|
isEnvTruthy(process.env.CLAUDE_CODE_SYNC_PLUGIN_INSTALL)) ||
|
|
// --bare: loadPluginHooks → loadAllPlugins is filesystem work that's
|
|
// wasted when executeHooks early-returns under --bare anyway.
|
|
isBareMode()
|
|
if (!skipPluginPrefetch) {
|
|
void getCommands(getProjectRoot())
|
|
}
|
|
void import('./utils/plugins/loadPluginHooks.js').then(m => {
|
|
if (!skipPluginPrefetch) {
|
|
void m.loadPluginHooks() // Pre-load plugin hooks (consumed by processSessionStartHooks before render)
|
|
m.setupPluginHookHotReload() // Set up hot reload for plugin hooks when settings change
|
|
}
|
|
})
|
|
// --bare: skip attribution hook install + repo classification +
|
|
// session-file-access analytics + team memory watcher. These are background
|
|
// bookkeeping for commit attribution + usage metrics — scripted calls don't
|
|
// commit code, and the 49ms attribution hook stat check (measured) is pure
|
|
// overhead. NOT an early-return: the --dangerously-skip-permissions safety
|
|
// gate, tengu_started beacon, and apiKeyHelper prefetch below must still run.
|
|
if (!isBareMode()) {
|
|
if (feature('COMMIT_ATTRIBUTION')) {
|
|
// Dynamic import to enable dead code elimination (module contains excluded strings).
|
|
// Defer to next tick so the git subprocess spawn runs after first render
|
|
// rather than during the setup() microtask window.
|
|
setImmediate(() => {
|
|
void import('./utils/attributionHooks.js').then(
|
|
({ registerAttributionHooks }) => {
|
|
registerAttributionHooks() // Register attribution tracking hooks (internal-only feature)
|
|
},
|
|
)
|
|
})
|
|
}
|
|
void import('./utils/sessionFileAccessHooks.js').then(m =>
|
|
m.registerSessionFileAccessHooks(),
|
|
) // Register session file access analytics hooks
|
|
if (feature('TEAMMEM')) {
|
|
void import('./services/teamMemorySync/watcher.js').then(m =>
|
|
m.startTeamMemoryWatcher(),
|
|
) // Start team memory sync watcher
|
|
}
|
|
}
|
|
initSinks() // Attach error log + analytics sinks and drain queued events
|
|
|
|
// Session-success-rate denominator. Emit immediately after the analytics
|
|
// sink is attached — before any parsing, fetching, or I/O that could throw.
|
|
// inc-3694 (P0 CHANGELOG crash) threw at checkForReleaseNotes below; every
|
|
// event after this point was dead. This beacon is the earliest reliable
|
|
// "process started" signal for release health monitoring.
|
|
logEvent('tengu_started', {})
|
|
|
|
void prefetchApiKeyFromApiKeyHelperIfSafe(getIsNonInteractiveSession()) // Prefetch safely - only executes if trust already confirmed
|
|
profileCheckpoint('setup_after_prefetch')
|
|
|
|
// Pre-fetch data for Logo v2 - await to ensure it's ready before logo renders.
|
|
// --bare / SIMPLE: skip — release notes are interactive-UI display data,
|
|
// and getRecentActivity() reads up to 10 session JSONL files.
|
|
if (!isBareMode()) {
|
|
const { hasReleaseNotes } = await checkForReleaseNotes(
|
|
getGlobalConfig().lastReleaseNotesSeen,
|
|
)
|
|
if (hasReleaseNotes) {
|
|
await getRecentActivity()
|
|
}
|
|
}
|
|
|
|
// If permission mode is set to bypass, verify we're in a safe environment
|
|
if (
|
|
permissionMode === 'bypassPermissions' ||
|
|
allowDangerouslySkipPermissions
|
|
) {
|
|
// Check if running as root/sudo on Unix-like systems
|
|
// Allow root if in a sandbox (e.g., TPU devspaces that require root)
|
|
if (
|
|
process.platform !== 'win32' &&
|
|
typeof process.getuid === 'function' &&
|
|
process.getuid() === 0 &&
|
|
process.env.IS_SANDBOX !== '1' &&
|
|
!isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP)
|
|
) {
|
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
console.error(
|
|
`--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'test') {
|
|
return
|
|
}
|
|
|
|
// Log tengu_exit event from the last session?
|
|
const projectConfig = getCurrentProjectConfig()
|
|
if (
|
|
projectConfig.lastCost !== undefined &&
|
|
projectConfig.lastDuration !== undefined
|
|
) {
|
|
logEvent('tengu_exit', {
|
|
last_session_cost: projectConfig.lastCost,
|
|
last_session_api_duration: projectConfig.lastAPIDuration,
|
|
last_session_tool_duration: projectConfig.lastToolDuration,
|
|
last_session_duration: projectConfig.lastDuration,
|
|
last_session_lines_added: projectConfig.lastLinesAdded,
|
|
last_session_lines_removed: projectConfig.lastLinesRemoved,
|
|
last_session_total_input_tokens: projectConfig.lastTotalInputTokens,
|
|
last_session_total_output_tokens: projectConfig.lastTotalOutputTokens,
|
|
last_session_total_cache_creation_input_tokens:
|
|
projectConfig.lastTotalCacheCreationInputTokens,
|
|
last_session_total_cache_read_input_tokens:
|
|
projectConfig.lastTotalCacheReadInputTokens,
|
|
last_session_fps_average: projectConfig.lastFpsAverage,
|
|
last_session_fps_low_1_pct: projectConfig.lastFpsLow1Pct,
|
|
last_session_id:
|
|
projectConfig.lastSessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
...projectConfig.lastSessionMetrics,
|
|
})
|
|
// Note: We intentionally don't clear these values after logging.
|
|
// They're needed for cost restoration when resuming sessions.
|
|
// The values will be overwritten when the next session exits.
|
|
}
|
|
}
|