import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import type { Attributes, Meter, MetricOptions } from '@opentelemetry/api' import type { logs } from '@opentelemetry/api-logs' import type { LoggerProvider } from '@opentelemetry/sdk-logs' import type { MeterProvider } from '@opentelemetry/sdk-metrics' import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' import { realpathSync } from 'fs' import sumBy from 'lodash-es/sumBy.js' import { cwd } from 'process' import type { HookEvent, ModelUsage } from 'src/entrypoints/agentSdkTypes.js' import type { AgentColorName } from 'src/tools/AgentTool/agentColorManager.js' import type { HookCallbackMatcher } from 'src/types/hooks.js' // 用于浏览器端构建的间接引用(package.json 的 "browser" 字段会将 // crypto.ts 替换为 crypto.browser.ts)。纯叶子节点重新导出 node:crypto — // 零循环依赖风险。路径别名导入绕过了启动隔离( // 规则仅检查 ./ 和 / 前缀);显式禁用说明了意图。 // eslint-disable-next-line custom-rules/bootstrap-isolation import { randomUUID } from 'src/utils/crypto.js' import type { ModelSetting } from 'src/utils/model/model.js' import type { ModelStrings } from 'src/utils/model/modelStrings.js' import type { SettingSource } from 'src/utils/settings/constants.js' import { resetSettingsCache } from 'src/utils/settings/settingsCache.js' import type { PluginHookMatcher } from 'src/utils/settings/types.js' import { createSignal } from 'src/utils/signal.js' // 注册钩子的联合类型——可以是 SDK 回调或原生插件钩子 type RegisteredHookMatcher = HookCallbackMatcher | PluginHookMatcher import type { SessionId } from 'src/types/ids.js' // 不要再加状态了——请谨慎对待全局状态 // dev: 对于通过 --dangerously-load-development-channels 传入的条目为 true。 // 白名单门控逐条目检查此项(而非会话范围的 hasDevChannels 标志), // 因此同时传入两个标志不会让开发对话框的接受行为泄漏白名单绕过权限到 --channels 条目。 export type ChannelEntry = | { kind: 'plugin'; name: string; marketplace: string; dev?: boolean } | { kind: 'server'; name: string; dev?: boolean } export type AttributedCounter = { add(value: number, additionalAttributes?: Attributes): void } type State = { originalCwd: string // 稳定的项目根目录 - 启动时设置一次(包括 --worktree 标志), // 会话中途的 EnterWorktreeTool 永远不会更新它。 // 用于项目标识(历史记录、技能、会话),不用于文件操作。 projectRoot: string totalCostUSD: number totalAPIDuration: number totalAPIDurationWithoutRetries: number totalToolDuration: number turnHookDurationMs: number turnToolDurationMs: number turnClassifierDurationMs: number turnToolCount: number turnHookCount: number turnClassifierCount: number startTime: number lastInteractionTime: number totalLinesAdded: number totalLinesRemoved: number hasUnknownModelCost: boolean cwd: string modelUsage: { [modelName: string]: ModelUsage } mainLoopModelOverride: ModelSetting | undefined initialMainLoopModel: ModelSetting modelStrings: ModelStrings | null isInteractive: boolean kairosActive: boolean // 当为 true 时,ensureToolResultPairing 在不匹配时抛出异常, // 而不是用合成占位符修复。HFI 在启动时选择加入,因此 // 轨迹会快速失败,而不是用假的 tool_result 训练模型。 strictToolResultPairing: boolean sdkAgentProgressSummariesEnabled: boolean userMsgOptIn: boolean clientType: string sessionSource: string | undefined questionPreviewFormat: 'markdown' | 'html' | undefined flagSettingsPath: string | undefined flagSettingsInline: Record | null allowedSettingSources: SettingSource[] sessionIngressToken: string | null | undefined oauthTokenFromFd: string | null | undefined apiKeyFromFd: string | null | undefined // Telemetry state meter: Meter | null sessionCounter: AttributedCounter | null locCounter: AttributedCounter | null prCounter: AttributedCounter | null commitCounter: AttributedCounter | null costCounter: AttributedCounter | null tokenCounter: AttributedCounter | null codeEditToolDecisionCounter: AttributedCounter | null activeTimeCounter: AttributedCounter | null statsStore: { observe(name: string, value: number): void } | null sessionId: SessionId // 父会话 ID,用于跟踪会话谱系(例如,规划模式 -> 实现) parentSessionId: SessionId | undefined // Logger state loggerProvider: LoggerProvider | null eventLogger: ReturnType | null // Meter provider state meterProvider: MeterProvider | null // Tracer provider state tracerProvider: BasicTracerProvider | null // Agent color state agentColorMap: Map agentColorIndex: number // Last API request for bug reports lastAPIRequest: Omit | null // 上次 API 请求的消息(ant 专用;引用,非克隆)。 // 捕获发送给 API 的压缩后、CLAUDE.md 注入的确切消息集, // 以便 /share 的 serialized_conversation.json 反映实际情况。 lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null // Last auto-mode classifier request(s) for /share transcript lastClassifierRequests: unknown[] | null // CLAUDE.md 内容缓存,供自动模式分类器使用。 // 打破 yoloClassifier → claudemd → 文件系统 → 权限的循环。 cachedClaudeMdContent: string | null // In-memory error log for recent errors inMemoryErrorLog: Array<{ error: string; timestamp: string }> // 来自 --plugin-dir 标志的仅会话插件 inlinePlugins: Array // 显式 --chrome / --no-chrome 标志值(undefined = 未在 CLI 设置) chromeFlagOverride: boolean | undefined // 使用 cowork_plugins 目录代替 plugins(--cowork 标志或环境变量) useCoworkPlugins: boolean // 仅会话的绕过权限模式标志(不持久化) sessionBypassPermissionsMode: boolean // 仅会话的标志,用于控制 .claude/scheduled_tasks.json 监视器 // (useScheduledTasks)。当 JSON 有条目时由 cronScheduler.start() 设置, // 或由 CronCreateTool 设置。不持久化。 scheduledTasksEnabled: boolean // 通过 CronCreate 以 durable: false 创建的仅会话定时任务。 // 按调度触发,类似文件支持的任务,但永远不会写入 // .claude/scheduled_tasks.json —— 它们随进程终止而消失。类型通过 // 下面的 SessionCronTask 定义(不从 cronTasks.ts 导入以保持 // bootstrap 为导入 DAG 的叶子节点)。 sessionCronTasks: SessionCronTask[] // 本会话通过 TeamCreate 创建的团队。cleanupSessionTeams() // 在优雅关闭时移除它们,以避免子代理创建的团队永远残留在磁盘上 // (gh-32730)。TeamDelete 移除条目以避免重复清理。 // 放在这里(而非 teamHelpers.ts)以便 resetStateForTests() 在测试间清除。 sessionCreatedTeams: Set // 仅会话的家庭目录信任标志(不持久化到磁盘) // 从家庭目录运行时,显示信任对话框但不保存到磁盘。 // 此标志允许需要信任的功能在会话期间正常工作。 sessionTrustAccepted: boolean // 仅会话的标志,禁用会话持久化到磁盘 sessionPersistenceDisabled: boolean // 跟踪用户是否在此会话中退出了规划模式(用于重新进入引导) hasExitedPlanMode: boolean // 跟踪是否需要显示规划模式退出附件(一次性通知) needsPlanModeExitAttachment: boolean // 跟踪是否需要显示自动模式退出附件(一次性通知) needsAutoModeExitAttachment: boolean // 跟踪 LSP 插件推荐是否已在本会话中显示(仅显示一次) lspRecommendationShownThisSession: boolean // SDK 初始化事件状态 - 结构化输出的 jsonSchema initJsonSchema: Record | null // 已注册的钩子 - SDK 回调和插件原生钩子 registeredHooks: Partial> | null // 规划 slug 缓存:sessionId -> wordSlug planSlugCache: Map // 跟踪远程连接会话以进行可靠性日志记录 teleportedSessionInfo: { isTeleported: boolean hasLoggedFirstMessage: boolean sessionId: string | null } | null // 跟踪调用的技能,以便在压缩时保留 // 键是组合的:`${agentId ?? ''}:${skillName}` 以防止跨代理覆盖 invokedSkills: Map< string, { skillName: string skillPath: string content: string invokedAt: number agentId: string | null } > // 跟踪慢速操作以在开发栏显示(仅 ant) slowOperations: Array<{ operation: string durationMs: number timestamp: number }> // SDK 提供的 beta 功能(例如,context-1m-2025-08-07) sdkBetas: string[] | undefined // 主线程代理类型(来自 --agent 标志或设置) mainThreadAgentType: string | undefined // 远程模式(--remote 标志) isRemoteMode: boolean // 直连服务器 URL(用于在头部显示) directConnectServerUrl: string | undefined // 系统提示片段缓存状态 systemPromptSectionCache: Map // 上次向模型发送的日期(用于检测午夜日期变化) lastEmittedDate: string | null // 来自 --add-dir 标志的额外目录(用于 CLAUDE.md 加载) additionalDirectoriesForClaudeMd: string[] // 来自 --channels 标志的通道服务器白名单(其通道通知应注册到本会话的服务器) // 在 main.tsx 中解析一次——标签决定信任模型:'plugin' → 市场验证 + // 白名单,'server' → 白名单始终失败(schema 仅限插件)。 // 两种类型都需要 entry.dev 来绕过白名单。 allowedChannels: ChannelEntry[] // 如果 allowedChannels 中任何条目来自 // --dangerously-load-development-channels(以便 ChannelsNotice 可在被策略拦截的消息中 // 指出正确的标志名称) hasDevChannels: boolean // 包含会话 `.jsonl` 的目录;null = 从 originalCwd 推导。 sessionProjectDir: string | null // GrowthBook 缓存的 prompt cache 1h TTL 白名单(会话稳定) promptCache1hAllowlist: string[] | null // 缓存的 1h TTL 用户资格(会话稳定)。首次评估时锁定,使会话中的超额翻转 // 不会改变 cache_control TTL,否则会破坏服务器端 prompt 缓存。 promptCache1hEligible: boolean | null // AFK_MODE_BETA_HEADER 的粘性开启锁存。首次激活自动模式后, // 会话剩余时间持续发送此标头,使 Shift+Tab 切换不会破坏 ~50-70K 令牌的 prompt 缓存。 afkModeHeaderLatched: boolean | null // FAST_MODE_BETA_HEADER 的粘性开启锁存。首次启用快速模式后, // 保持发送标头,避免冷启动进出双重破坏 prompt 缓存。`speed` body 参数保持动态。 fastModeHeaderLatched: boolean | null // 缓存编辑 beta 标头的粘性开启锁存。首次启用缓存微压缩后, // 保持发送标头,避免会话中的 GrowthBook/设置切换破坏 prompt 缓存。 cacheEditingHeaderLatched: boolean | null // 清除之前工具循环思考的粘性开启锁存。当距上次 API 调用超过 1h 时触发 // (确认缓存未命中——保留思考无缓存命中好处)。锁定后保持开启, // 使新预热的清除思考缓存不会因切回 keep:'all' 而被破坏。 // 当前提示 ID (UUID),将用户提示与后续 OTel 事件关联 promptId: string | null // 主对话链的最后 API requestId(不含子代理)。 // 在每次主会话查询的 API 响应成功后更新。 // 在关闭时读取,以向推理端发送缓存驱逐提示。 lastMainRequestId: string | undefined // 上次成功 API 调用完成的时间戳(Date.now())。 // 用于在 tengu_api_success 中计算 timeSinceLastApiCallMs, // 以将缓存未命中与空闲时间关联(缓存 TTL 约 5 分钟)。 lastApiCompletionTimestamp: number | null // 压缩(自动或手动 /compact)后设置为 true。由 // logAPISuccess 消费,以标记压缩后的第一次 API 调用,使我们能 // 区分压缩导致的缓存未命中与 TTL 过期。 pendingPostCompaction: boolean } // 这里是——修改前三思 function getInitialState(): State { // 解析 cwd 中的符号链接,以匹配 shell.ts 的 setCwd 行为 // 这确保路径清理的一致性,以便会话存储 let resolvedCwd = '' if ( typeof process !== 'undefined' && typeof process.cwd === 'function' && typeof realpathSync === 'function' ) { const rawCwd = cwd() try { resolvedCwd = realpathSync(rawCwd).normalize('NFC') } catch { // CloudStorage 挂载时文件提供程序 EPERM(逐路径组件 lstat)。 resolvedCwd = rawCwd.normalize('NFC') } } const state: State = { originalCwd: resolvedCwd, projectRoot: resolvedCwd, totalCostUSD: 0, totalAPIDuration: 0, totalAPIDurationWithoutRetries: 0, totalToolDuration: 0, turnHookDurationMs: 0, turnToolDurationMs: 0, turnClassifierDurationMs: 0, turnToolCount: 0, turnHookCount: 0, turnClassifierCount: 0, startTime: Date.now(), lastInteractionTime: Date.now(), totalLinesAdded: 0, totalLinesRemoved: 0, hasUnknownModelCost: false, cwd: resolvedCwd, modelUsage: {}, mainLoopModelOverride: undefined, initialMainLoopModel: null, modelStrings: null, isInteractive: false, kairosActive: false, strictToolResultPairing: false, sdkAgentProgressSummariesEnabled: false, userMsgOptIn: false, clientType: 'cli', sessionSource: undefined, questionPreviewFormat: undefined, sessionIngressToken: undefined, oauthTokenFromFd: undefined, apiKeyFromFd: undefined, flagSettingsPath: undefined, flagSettingsInline: null, allowedSettingSources: [ 'userSettings', 'projectSettings', 'localSettings', 'flagSettings', 'policySettings', ], // 遥测状态 meter: null, sessionCounter: null, locCounter: null, prCounter: null, commitCounter: null, costCounter: null, tokenCounter: null, codeEditToolDecisionCounter: null, activeTimeCounter: null, statsStore: null, sessionId: randomUUID() as SessionId, parentSessionId: undefined, // 日志记录器状态 loggerProvider: null, eventLogger: null, // Meter 提供者状态 meterProvider: null, tracerProvider: null, // 代理颜色状态 agentColorMap: new Map(), agentColorIndex: 0, // 用于 bug 报告的上次 API 请求 lastAPIRequest: null, lastAPIRequestMessages: null, // 用于 /share 转录的上次自动模式分类器请求 lastClassifierRequests: null, cachedClaudeMdContent: null, // 最近错误的内存日志 inMemoryErrorLog: [], // 来自 --plugin-dir 标志的仅会话插件 inlinePlugins: [], // 显式 --chrome / --no-chrome 标志值(undefined = 未在 CLI 设置) chromeFlagOverride: undefined, // 使用 cowork_plugins 目录代替 plugins useCoworkPlugins: false, // 仅会话的绕过权限模式标志(不持久化) sessionBypassPermissionsMode: false, // 定时任务默认禁用,直到标志或对话框启用 scheduledTasksEnabled: false, sessionCronTasks: [], sessionCreatedTeams: new Set(), // 仅会话的信任标志(不持久化到磁盘) sessionTrustAccepted: false, // 仅会话的标志,禁用会话持久化到磁盘 sessionPersistenceDisabled: false, // 跟踪用户是否在此会话中退出了规划模式 hasExitedPlanMode: false, // 跟踪是否需要显示规划模式退出附件 needsPlanModeExitAttachment: false, // 跟踪是否需要显示自动模式退出附件 needsAutoModeExitAttachment: false, // 跟踪 LSP 插件推荐是否已在本会话中显示 lspRecommendationShownThisSession: false, // SDK 初始化事件状态 initJsonSchema: null, registeredHooks: null, // 规划 slug 缓存 planSlugCache: new Map(), // 跟踪远程连接会话以进行可靠性日志记录 teleportedSessionInfo: null, // 跟踪调用的技能以在压缩时保留 invokedSkills: new Map(), // 跟踪开发栏显示的慢速操作 slowOperations: [], // SDK 提供的 beta 功能 sdkBetas: undefined, // 主线程代理类型 mainThreadAgentType: undefined, // 远程模式 isRemoteMode: false, ...(process.env.USER_TYPE === 'ant' ? { replBridgeActive: false, } : {}), // 直连服务器 URL directConnectServerUrl: undefined, // 系统提示片段缓存状态 systemPromptSectionCache: new Map(), // 上次向模型发送的日期 lastEmittedDate: null, // 来自 --add-dir 标志的额外目录(用于 CLAUDE.md 加载) additionalDirectoriesForClaudeMd: [], // 来自 --channels 标志的通道服务器白名单 allowedChannels: [], hasDevChannels: false, // 会话项目目录(null = 从 originalCwd 推导) sessionProjectDir: null, // Prompt cache 1h 白名单(null = 尚未从 GrowthBook 获取) promptCache1hAllowlist: null, // Prompt cache 1h 资格(null = 尚未评估) promptCache1hEligible: null, // Beta 标头锁存(null = 尚未触发) afkModeHeaderLatched: null, fastModeHeaderLatched: null, cacheEditingHeaderLatched: null, thinkingClearLatched: null, // 当前提示 ID promptId: null, lastMainRequestId: undefined, lastApiCompletionTimestamp: null, pendingPostCompaction: false, } return state } // 特别是这里 const STATE: State = getInitialState() export function getSessionId(): SessionId { return STATE.sessionId } export function regenerateSessionId( options: { setCurrentAsParent?: boolean } = {}, ): SessionId { if (options.setCurrentAsParent) { STATE.parentSessionId = STATE.sessionId } // Drop the outgoing session's plan-slug entry so the Map doesn't // accumulate stale keys. Callers that need to carry the slug across // (REPL.tsx clearContext) read it before calling clearConversation. STATE.planSlugCache.delete(STATE.sessionId) // Regenerated sessions live in the current project: reset projectDir to // null so getTranscriptPath() derives from originalCwd. STATE.sessionId = randomUUID() as SessionId STATE.sessionProjectDir = null return STATE.sessionId } export function getParentSessionId(): SessionId | undefined { return STATE.parentSessionId } /** * Atomically switch the active session. `sessionId` and `sessionProjectDir` * always change together — there is no separate setter for either, so they * cannot drift out of sync (CC-34). * * @param projectDir — directory containing `.jsonl`. Omit (or * pass `null`) for sessions in the current project — the path will derive * from originalCwd at read time. Pass `dirname(transcriptPath)` when the * session lives in a different project directory (git worktrees, * cross-project resume). Every call resets the project dir; it never * carries over from the previous session. */ export function switchSession( sessionId: SessionId, projectDir: string | null = null, ): void { // Drop the outgoing session's plan-slug entry so the Map stays bounded // across repeated /resume. Only the current session's slug is ever read // (plans.ts getPlanSlug defaults to getSessionId()). STATE.planSlugCache.delete(STATE.sessionId) STATE.sessionId = sessionId STATE.sessionProjectDir = projectDir sessionSwitched.emit(sessionId) } const sessionSwitched = createSignal<[id: SessionId]>() /** * Register a callback that fires when switchSession changes the active * sessionId. bootstrap can't import listeners directly (DAG leaf), so * callers register themselves. concurrentSessions.ts uses this to keep the * PID file's sessionId in sync with --resume. */ export const onSessionSwitch = sessionSwitched.subscribe /** * Project directory the current session's transcript lives in, or `null` if * the session was created in the current project (common case — derive from * originalCwd). See `switchSession()`. */ export function getSessionProjectDir(): string | null { return STATE.sessionProjectDir } export function getOriginalCwd(): string { return STATE.originalCwd } /** * Get the stable project root directory. * Unlike getOriginalCwd(), this is never updated by mid-session EnterWorktreeTool * (so skills/history stay stable when entering a throwaway worktree). * It IS set at startup by --worktree, since that worktree is the session's project. * Use for project identity (history, skills, sessions) not file operations. */ export function getProjectRoot(): string { return STATE.projectRoot } export function setOriginalCwd(cwd: string): void { STATE.originalCwd = cwd.normalize('NFC') } /** * Only for --worktree startup flag. Mid-session EnterWorktreeTool must NOT * call this — skills/history should stay anchored to where the session started. */ export function setProjectRoot(cwd: string): void { STATE.projectRoot = cwd.normalize('NFC') } export function getCwdState(): string { return STATE.cwd } export function setCwdState(cwd: string): void { STATE.cwd = cwd.normalize('NFC') } export function getDirectConnectServerUrl(): string | undefined { return STATE.directConnectServerUrl } export function setDirectConnectServerUrl(url: string): void { STATE.directConnectServerUrl = url } export function addToTotalDurationState( duration: number, durationWithoutRetries: number, ): void { STATE.totalAPIDuration += duration STATE.totalAPIDurationWithoutRetries += durationWithoutRetries } export function resetTotalDurationStateAndCost_FOR_TESTS_ONLY(): void { STATE.totalAPIDuration = 0 STATE.totalAPIDurationWithoutRetries = 0 STATE.totalCostUSD = 0 } export function addToTotalCostState( cost: number, modelUsage: ModelUsage, model: string, ): void { STATE.modelUsage[model] = modelUsage STATE.totalCostUSD += cost } export function getTotalCostUSD(): number { return STATE.totalCostUSD } export function getTotalAPIDuration(): number { return STATE.totalAPIDuration } export function getTotalDuration(): number { return Date.now() - STATE.startTime } export function getTotalAPIDurationWithoutRetries(): number { return STATE.totalAPIDurationWithoutRetries } export function getTotalToolDuration(): number { return STATE.totalToolDuration } export function addToToolDuration(duration: number): void { STATE.totalToolDuration += duration STATE.turnToolDurationMs += duration STATE.turnToolCount++ } export function getTurnHookDurationMs(): number { return STATE.turnHookDurationMs } export function addToTurnHookDuration(duration: number): void { STATE.turnHookDurationMs += duration STATE.turnHookCount++ } export function resetTurnHookDuration(): void { STATE.turnHookDurationMs = 0 STATE.turnHookCount = 0 } export function getTurnHookCount(): number { return STATE.turnHookCount } export function getTurnToolDurationMs(): number { return STATE.turnToolDurationMs } export function resetTurnToolDuration(): void { STATE.turnToolDurationMs = 0 STATE.turnToolCount = 0 } export function getTurnToolCount(): number { return STATE.turnToolCount } export function getTurnClassifierDurationMs(): number { return STATE.turnClassifierDurationMs } export function addToTurnClassifierDuration(duration: number): void { STATE.turnClassifierDurationMs += duration STATE.turnClassifierCount++ } export function resetTurnClassifierDuration(): void { STATE.turnClassifierDurationMs = 0 STATE.turnClassifierCount = 0 } export function getTurnClassifierCount(): number { return STATE.turnClassifierCount } export function getStatsStore(): { observe(name: string, value: number): void } | null { return STATE.statsStore } export function setStatsStore( store: { observe(name: string, value: number): void } | null, ): void { STATE.statsStore = store } /** * Marks that an interaction occurred. * * By default the actual Date.now() call is deferred until the next Ink render * frame (via flushInteractionTime()) so we avoid calling Date.now() on every * single keypress. * * Pass `immediate = true` when calling from React useEffect callbacks or * other code that runs *after* the Ink render cycle has already flushed. * Without it the timestamp stays stale until the next render, which may never * come if the user is idle (e.g. permission dialog waiting for input). */ let interactionTimeDirty = false export function updateLastInteractionTime(immediate?: boolean): void { if (immediate) { flushInteractionTime_inner() } else { interactionTimeDirty = true } } /** * If an interaction was recorded since the last flush, update the timestamp * now. Called by Ink before each render cycle so we batch many keypresses into * a single Date.now() call. */ export function flushInteractionTime(): void { if (interactionTimeDirty) { flushInteractionTime_inner() } } function flushInteractionTime_inner(): void { STATE.lastInteractionTime = Date.now() interactionTimeDirty = false } export function addToTotalLinesChanged(added: number, removed: number): void { STATE.totalLinesAdded += added STATE.totalLinesRemoved += removed } export function getTotalLinesAdded(): number { return STATE.totalLinesAdded } export function getTotalLinesRemoved(): number { return STATE.totalLinesRemoved } export function getTotalInputTokens(): number { return sumBy(Object.values(STATE.modelUsage), 'inputTokens') } export function getTotalOutputTokens(): number { return sumBy(Object.values(STATE.modelUsage), 'outputTokens') } export function getTotalCacheReadInputTokens(): number { return sumBy(Object.values(STATE.modelUsage), 'cacheReadInputTokens') } export function getTotalCacheCreationInputTokens(): number { return sumBy(Object.values(STATE.modelUsage), 'cacheCreationInputTokens') } export function getTotalWebSearchRequests(): number { return sumBy(Object.values(STATE.modelUsage), 'webSearchRequests') } let outputTokensAtTurnStart = 0 let currentTurnTokenBudget: number | null = null export function getTurnOutputTokens(): number { return getTotalOutputTokens() - outputTokensAtTurnStart } export function getCurrentTurnTokenBudget(): number | null { return currentTurnTokenBudget } let budgetContinuationCount = 0 export function snapshotOutputTokensForTurn(budget: number | null): void { outputTokensAtTurnStart = getTotalOutputTokens() currentTurnTokenBudget = budget budgetContinuationCount = 0 } export function getBudgetContinuationCount(): number { return budgetContinuationCount } export function incrementBudgetContinuationCount(): void { budgetContinuationCount++ } export function setHasUnknownModelCost(): void { STATE.hasUnknownModelCost = true } export function hasUnknownModelCost(): boolean { return STATE.hasUnknownModelCost } export function getLastMainRequestId(): string | undefined { return STATE.lastMainRequestId } export function setLastMainRequestId(requestId: string): void { STATE.lastMainRequestId = requestId } export function getLastApiCompletionTimestamp(): number | null { return STATE.lastApiCompletionTimestamp } export function setLastApiCompletionTimestamp(timestamp: number): void { STATE.lastApiCompletionTimestamp = timestamp } /** Mark that a compaction just occurred. The next API success event will * include isPostCompaction=true, then the flag auto-resets. */ export function markPostCompaction(): void { STATE.pendingPostCompaction = true } /** Consume the post-compaction flag. Returns true once after compaction, * then returns false until the next compaction. */ export function consumePostCompaction(): boolean { const was = STATE.pendingPostCompaction STATE.pendingPostCompaction = false return was } export function getLastInteractionTime(): number { return STATE.lastInteractionTime } // Scroll drain suspension — background intervals check this before doing work // so they don't compete with scroll frames for the event loop. Set by // ScrollBox scrollBy/scrollTo, cleared SCROLL_DRAIN_IDLE_MS after the last // scroll event. Module-scope (not in STATE) — ephemeral hot-path flag, no // test-reset needed since the debounce timer self-clears. let scrollDraining = false let scrollDrainTimer: ReturnType | undefined const SCROLL_DRAIN_IDLE_MS = 150 /** Mark that a scroll event just happened. Background intervals gate on * getIsScrollDraining() and skip their work until the debounce clears. */ export function markScrollActivity(): void { scrollDraining = true if (scrollDrainTimer) clearTimeout(scrollDrainTimer) scrollDrainTimer = setTimeout(() => { scrollDraining = false scrollDrainTimer = undefined }, SCROLL_DRAIN_IDLE_MS) scrollDrainTimer.unref?.() } /** True while scroll is actively draining (within 150ms of last event). * Intervals should early-return when this is set — the work picks up next * tick after scroll settles. */ export function getIsScrollDraining(): boolean { return scrollDraining } /** Await this before expensive one-shot work (network, subprocess) that could * coincide with scroll. Resolves immediately if not scrolling; otherwise * polls at the idle interval until the flag clears. */ export async function waitForScrollIdle(): Promise { while (scrollDraining) { // bootstrap-isolation forbids importing sleep() from src/utils/ // eslint-disable-next-line no-restricted-syntax await new Promise(r => setTimeout(r, SCROLL_DRAIN_IDLE_MS).unref?.()) } } export function getModelUsage(): { [modelName: string]: ModelUsage } { return STATE.modelUsage } export function getUsageForModel(model: string): ModelUsage | undefined { return STATE.modelUsage[model] } /** * Gets the model override set from the --model CLI flag or after the user * updates their configured model. */ export function getMainLoopModelOverride(): ModelSetting | undefined { return STATE.mainLoopModelOverride } export function getInitialMainLoopModel(): ModelSetting { return STATE.initialMainLoopModel } export function setMainLoopModelOverride( model: ModelSetting | undefined, ): void { STATE.mainLoopModelOverride = model } export function setInitialMainLoopModel(model: ModelSetting): void { STATE.initialMainLoopModel = model } export function getSdkBetas(): string[] | undefined { return STATE.sdkBetas } export function setSdkBetas(betas: string[] | undefined): void { STATE.sdkBetas = betas } export function resetCostState(): void { STATE.totalCostUSD = 0 STATE.totalAPIDuration = 0 STATE.totalAPIDurationWithoutRetries = 0 STATE.totalToolDuration = 0 STATE.startTime = Date.now() STATE.totalLinesAdded = 0 STATE.totalLinesRemoved = 0 STATE.hasUnknownModelCost = false STATE.modelUsage = {} STATE.promptId = null } /** * Sets cost state values for session restore. * Called by restoreCostStateForSession in cost-tracker.ts. */ export function setCostStateForRestore({ totalCostUSD, totalAPIDuration, totalAPIDurationWithoutRetries, totalToolDuration, totalLinesAdded, totalLinesRemoved, lastDuration, modelUsage, }: { totalCostUSD: number totalAPIDuration: number totalAPIDurationWithoutRetries: number totalToolDuration: number totalLinesAdded: number totalLinesRemoved: number lastDuration: number | undefined modelUsage: { [modelName: string]: ModelUsage } | undefined }): void { STATE.totalCostUSD = totalCostUSD STATE.totalAPIDuration = totalAPIDuration STATE.totalAPIDurationWithoutRetries = totalAPIDurationWithoutRetries STATE.totalToolDuration = totalToolDuration STATE.totalLinesAdded = totalLinesAdded STATE.totalLinesRemoved = totalLinesRemoved // Restore per-model usage breakdown if (modelUsage) { STATE.modelUsage = modelUsage } // Adjust startTime to make wall duration accumulate if (lastDuration) { STATE.startTime = Date.now() - lastDuration } } // Only used in tests export function resetStateForTests(): void { if (process.env.NODE_ENV !== 'test') { throw new Error('resetStateForTests can only be called in tests') } Object.entries(getInitialState()).forEach(([key, value]) => { STATE[key as keyof State] = value as never }) outputTokensAtTurnStart = 0 currentTurnTokenBudget = null budgetContinuationCount = 0 sessionSwitched.clear() } // You shouldn't use this directly. See src/utils/model/modelStrings.ts::getModelStrings() export function getModelStrings(): ModelStrings | null { return STATE.modelStrings } // You shouldn't use this directly. See src/utils/model/modelStrings.ts export function setModelStrings(modelStrings: ModelStrings): void { STATE.modelStrings = modelStrings } // Test utility function to reset model strings for re-initialization. // Separate from setModelStrings because we only want to accept 'null' in tests. export function resetModelStringsForTestingOnly() { STATE.modelStrings = null } export function setMeter( meter: Meter, createCounter: (name: string, options: MetricOptions) => AttributedCounter, ): void { STATE.meter = meter // Initialize all counters using the provided factory STATE.sessionCounter = createCounter('claude_code.session.count', { description: 'CLI 会话启动次数', }) STATE.locCounter = createCounter('claude_code.lines_of_code.count', { description: "修改的代码行数,'type' 属性指示是添加还是删除的行", }) STATE.prCounter = createCounter('claude_code.pull_request.count', { description: '创建的拉取请求数量', }) STATE.commitCounter = createCounter('claude_code.commit.count', { description: '创建的 Git 提交数量', }) STATE.costCounter = createCounter('claude_code.cost.usage', { description: 'Claude Code 会话成本', unit: 'USD', }) STATE.tokenCounter = createCounter('claude_code.token.usage', { description: '使用的令牌数量', unit: 'tokens', }) STATE.codeEditToolDecisionCounter = createCounter( 'claude_code.code_edit_tool.decision', { description: '代码编辑工具权限决策次数(接受/拒绝),适用于 Edit、Write 和 NotebookEdit 工具', }, ) STATE.activeTimeCounter = createCounter('claude_code.active_time.total', { description: '总活跃时间(秒)', unit: 's', }) } export function getMeter(): Meter | null { return STATE.meter } export function getSessionCounter(): AttributedCounter | null { return STATE.sessionCounter } export function getLocCounter(): AttributedCounter | null { return STATE.locCounter } export function getPrCounter(): AttributedCounter | null { return STATE.prCounter } export function getCommitCounter(): AttributedCounter | null { return STATE.commitCounter } export function getCostCounter(): AttributedCounter | null { return STATE.costCounter } export function getTokenCounter(): AttributedCounter | null { return STATE.tokenCounter } export function getCodeEditToolDecisionCounter(): AttributedCounter | null { return STATE.codeEditToolDecisionCounter } export function getActiveTimeCounter(): AttributedCounter | null { return STATE.activeTimeCounter } export function getLoggerProvider(): LoggerProvider | null { return STATE.loggerProvider } export function setLoggerProvider(provider: LoggerProvider | null): void { STATE.loggerProvider = provider } export function getEventLogger(): ReturnType | null { return STATE.eventLogger } export function setEventLogger( logger: ReturnType | null, ): void { STATE.eventLogger = logger } export function getMeterProvider(): MeterProvider | null { return STATE.meterProvider } export function setMeterProvider(provider: MeterProvider | null): void { STATE.meterProvider = provider } export function getTracerProvider(): BasicTracerProvider | null { return STATE.tracerProvider } export function setTracerProvider(provider: BasicTracerProvider | null): void { STATE.tracerProvider = provider } export function getIsNonInteractiveSession(): boolean { return !STATE.isInteractive } export function getIsInteractive(): boolean { return STATE.isInteractive } export function setIsInteractive(value: boolean): void { STATE.isInteractive = value } export function getClientType(): string { return STATE.clientType } export function setClientType(type: string): void { STATE.clientType = type } export function getSdkAgentProgressSummariesEnabled(): boolean { return STATE.sdkAgentProgressSummariesEnabled } export function setSdkAgentProgressSummariesEnabled(value: boolean): void { STATE.sdkAgentProgressSummariesEnabled = value } export function getKairosActive(): boolean { return STATE.kairosActive } export function setKairosActive(value: boolean): void { STATE.kairosActive = value } export function getStrictToolResultPairing(): boolean { return STATE.strictToolResultPairing } export function setStrictToolResultPairing(value: boolean): void { STATE.strictToolResultPairing = value } // Field name 'userMsgOptIn' avoids excluded-string substrings ('BriefTool', // 'SendUserMessage' — case-insensitive). All callers are inside feature() // guards so these accessors don't need their own (matches getKairosActive). export function getUserMsgOptIn(): boolean { return STATE.userMsgOptIn } export function setUserMsgOptIn(value: boolean): void { STATE.userMsgOptIn = value } export function getSessionSource(): string | undefined { return STATE.sessionSource } export function setSessionSource(source: string): void { STATE.sessionSource = source } export function getQuestionPreviewFormat(): 'markdown' | 'html' | undefined { return STATE.questionPreviewFormat } export function setQuestionPreviewFormat(format: 'markdown' | 'html'): void { STATE.questionPreviewFormat = format } export function getAgentColorMap(): Map { return STATE.agentColorMap } export function getFlagSettingsPath(): string | undefined { return STATE.flagSettingsPath } export function setFlagSettingsPath(path: string | undefined): void { STATE.flagSettingsPath = path } export function getFlagSettingsInline(): Record | null { return STATE.flagSettingsInline } export function setFlagSettingsInline( settings: Record | null, ): void { STATE.flagSettingsInline = settings } export function getSessionIngressToken(): string | null | undefined { return STATE.sessionIngressToken } export function setSessionIngressToken(token: string | null): void { STATE.sessionIngressToken = token } export function getOauthTokenFromFd(): string | null | undefined { return STATE.oauthTokenFromFd } export function setOauthTokenFromFd(token: string | null): void { STATE.oauthTokenFromFd = token } export function getApiKeyFromFd(): string | null | undefined { return STATE.apiKeyFromFd } export function setApiKeyFromFd(key: string | null): void { STATE.apiKeyFromFd = key } export function setLastAPIRequest( params: Omit | null, ): void { STATE.lastAPIRequest = params } export function getLastAPIRequest(): Omit< BetaMessageStreamParams, 'messages' > | null { return STATE.lastAPIRequest } export function setLastAPIRequestMessages( messages: BetaMessageStreamParams['messages'] | null, ): void { STATE.lastAPIRequestMessages = messages } export function getLastAPIRequestMessages(): | BetaMessageStreamParams['messages'] | null { return STATE.lastAPIRequestMessages } export function setLastClassifierRequests(requests: unknown[] | null): void { STATE.lastClassifierRequests = requests } export function getLastClassifierRequests(): unknown[] | null { return STATE.lastClassifierRequests } export function setCachedClaudeMdContent(content: string | null): void { STATE.cachedClaudeMdContent = content } export function getCachedClaudeMdContent(): string | null { return STATE.cachedClaudeMdContent } export function addToInMemoryErrorLog(errorInfo: { error: string timestamp: string }): void { const MAX_IN_MEMORY_ERRORS = 100 if (STATE.inMemoryErrorLog.length >= MAX_IN_MEMORY_ERRORS) { STATE.inMemoryErrorLog.shift() // Remove oldest error } STATE.inMemoryErrorLog.push(errorInfo) } export function getAllowedSettingSources(): SettingSource[] { return STATE.allowedSettingSources } export function setAllowedSettingSources(sources: SettingSource[]): void { STATE.allowedSettingSources = sources } export function preferThirdPartyAuthentication(): boolean { // IDE extension should behave as 1P for authentication reasons. return getIsNonInteractiveSession() && STATE.clientType !== 'claude-vscode' } export function setInlinePlugins(plugins: Array): void { STATE.inlinePlugins = plugins } export function getInlinePlugins(): Array { return STATE.inlinePlugins } export function setChromeFlagOverride(value: boolean | undefined): void { STATE.chromeFlagOverride = value } export function getChromeFlagOverride(): boolean | undefined { return STATE.chromeFlagOverride } export function setUseCoworkPlugins(value: boolean): void { STATE.useCoworkPlugins = value resetSettingsCache() } export function getUseCoworkPlugins(): boolean { return STATE.useCoworkPlugins } export function setSessionBypassPermissionsMode(enabled: boolean): void { STATE.sessionBypassPermissionsMode = enabled } export function getSessionBypassPermissionsMode(): boolean { return STATE.sessionBypassPermissionsMode } export function setScheduledTasksEnabled(enabled: boolean): void { STATE.scheduledTasksEnabled = enabled } export function getScheduledTasksEnabled(): boolean { return STATE.scheduledTasksEnabled } export type SessionCronTask = { id: string cron: string prompt: string createdAt: number recurring?: boolean /** * When set, the task was created by an in-process teammate (not the team lead). * The scheduler routes fires to that teammate's pendingUserMessages queue * instead of the main REPL command queue. Session-only — never written to disk. */ agentId?: string } export function getSessionCronTasks(): SessionCronTask[] { return STATE.sessionCronTasks } export function addSessionCronTask(task: SessionCronTask): void { STATE.sessionCronTasks.push(task) } /** * Returns the number of tasks actually removed. Callers use this to skip * downstream work (e.g. the disk read in removeCronTasks) when all ids * were accounted for here. */ export function removeSessionCronTasks(ids: readonly string[]): number { if (ids.length === 0) return 0 const idSet = new Set(ids) const remaining = STATE.sessionCronTasks.filter(t => !idSet.has(t.id)) const removed = STATE.sessionCronTasks.length - remaining.length if (removed === 0) return 0 STATE.sessionCronTasks = remaining return removed } export function setSessionTrustAccepted(accepted: boolean): void { STATE.sessionTrustAccepted = accepted } export function getSessionTrustAccepted(): boolean { return STATE.sessionTrustAccepted } export function setSessionPersistenceDisabled(disabled: boolean): void { STATE.sessionPersistenceDisabled = disabled } export function isSessionPersistenceDisabled(): boolean { return STATE.sessionPersistenceDisabled } export function hasExitedPlanModeInSession(): boolean { return STATE.hasExitedPlanMode } export function setHasExitedPlanMode(value: boolean): void { STATE.hasExitedPlanMode = value } export function needsPlanModeExitAttachment(): boolean { return STATE.needsPlanModeExitAttachment } export function setNeedsPlanModeExitAttachment(value: boolean): void { STATE.needsPlanModeExitAttachment = value } export function handlePlanModeTransition( fromMode: string, toMode: string, ): void { // If switching TO plan mode, clear any pending exit attachment // This prevents sending both plan_mode and plan_mode_exit when user toggles quickly if (toMode === 'plan' && fromMode !== 'plan') { STATE.needsPlanModeExitAttachment = false } // If switching out of plan mode, trigger the plan_mode_exit attachment if (fromMode === 'plan' && toMode !== 'plan') { STATE.needsPlanModeExitAttachment = true } } export function needsAutoModeExitAttachment(): boolean { return STATE.needsAutoModeExitAttachment } export function setNeedsAutoModeExitAttachment(value: boolean): void { STATE.needsAutoModeExitAttachment = value } export function handleAutoModeTransition( fromMode: string, toMode: string, ): void { // Auto↔plan transitions are handled by prepareContextForPlanMode (auto may // stay active through plan if opted in) and ExitPlanMode (restores mode). // Skip both directions so this function only handles direct auto transitions. if ( (fromMode === 'auto' && toMode === 'plan') || (fromMode === 'plan' && toMode === 'auto') ) { return } const fromIsAuto = fromMode === 'auto' const toIsAuto = toMode === 'auto' // If switching TO auto mode, clear any pending exit attachment // This prevents sending both auto_mode and auto_mode_exit when user toggles quickly if (toIsAuto && !fromIsAuto) { STATE.needsAutoModeExitAttachment = false } // If switching out of auto mode, trigger the auto_mode_exit attachment if (fromIsAuto && !toIsAuto) { STATE.needsAutoModeExitAttachment = true } } // LSP plugin recommendation session tracking export function hasShownLspRecommendationThisSession(): boolean { return STATE.lspRecommendationShownThisSession } export function setLspRecommendationShownThisSession(value: boolean): void { STATE.lspRecommendationShownThisSession = value } // SDK init event state export function setInitJsonSchema(schema: Record): void { STATE.initJsonSchema = schema } export function getInitJsonSchema(): Record | null { return STATE.initJsonSchema } export function registerHookCallbacks( hooks: Partial>, ): void { if (!STATE.registeredHooks) { STATE.registeredHooks = {} } // `registerHookCallbacks` may be called multiple times, so we need to merge (not overwrite) for (const [event, matchers] of Object.entries(hooks)) { const eventKey = event as HookEvent if (!STATE.registeredHooks[eventKey]) { STATE.registeredHooks[eventKey] = [] } STATE.registeredHooks[eventKey]!.push(...matchers) } } export function getRegisteredHooks(): Partial< Record > | null { return STATE.registeredHooks } export function clearRegisteredHooks(): void { STATE.registeredHooks = null } export function clearRegisteredPluginHooks(): void { if (!STATE.registeredHooks) { return } const filtered: Partial> = {} for (const [event, matchers] of Object.entries(STATE.registeredHooks)) { // Keep only callback hooks (those without pluginRoot) const callbackHooks = matchers.filter(m => !('pluginRoot' in m)) if (callbackHooks.length > 0) { filtered[event as HookEvent] = callbackHooks } } STATE.registeredHooks = Object.keys(filtered).length > 0 ? filtered : null } export function resetSdkInitState(): void { STATE.initJsonSchema = null STATE.registeredHooks = null } export function getPlanSlugCache(): Map { return STATE.planSlugCache } export function getSessionCreatedTeams(): Set { return STATE.sessionCreatedTeams } // Teleported session tracking for reliability logging export function setTeleportedSessionInfo(info: { sessionId: string | null }): void { STATE.teleportedSessionInfo = { isTeleported: true, hasLoggedFirstMessage: false, sessionId: info.sessionId, } } export function getTeleportedSessionInfo(): { isTeleported: boolean hasLoggedFirstMessage: boolean sessionId: string | null } | null { return STATE.teleportedSessionInfo } export function markFirstTeleportMessageLogged(): void { if (STATE.teleportedSessionInfo) { STATE.teleportedSessionInfo.hasLoggedFirstMessage = true } } // Invoked skills tracking for preservation across compaction export type InvokedSkillInfo = { skillName: string skillPath: string content: string invokedAt: number agentId: string | null } export function addInvokedSkill( skillName: string, skillPath: string, content: string, agentId: string | null = null, ): void { const key = `${agentId ?? ''}:${skillName}` STATE.invokedSkills.set(key, { skillName, skillPath, content, invokedAt: Date.now(), agentId, }) } export function getInvokedSkills(): Map { return STATE.invokedSkills } export function getInvokedSkillsForAgent( agentId: string | undefined | null, ): Map { const normalizedId = agentId ?? null const filtered = new Map() for (const [key, skill] of STATE.invokedSkills) { if (skill.agentId === normalizedId) { filtered.set(key, skill) } } return filtered } export function clearInvokedSkills( preservedAgentIds?: ReadonlySet, ): void { if (!preservedAgentIds || preservedAgentIds.size === 0) { STATE.invokedSkills.clear() return } for (const [key, skill] of STATE.invokedSkills) { if (skill.agentId === null || !preservedAgentIds.has(skill.agentId)) { STATE.invokedSkills.delete(key) } } } export function clearInvokedSkillsForAgent(agentId: string): void { for (const [key, skill] of STATE.invokedSkills) { if (skill.agentId === agentId) { STATE.invokedSkills.delete(key) } } } // Slow operations tracking for dev bar const MAX_SLOW_OPERATIONS = 10 const SLOW_OPERATION_TTL_MS = 10000 export function addSlowOperation(operation: string, durationMs: number): void { if (process.env.USER_TYPE !== 'ant') return // Skip tracking for editor sessions (user editing a prompt file in $EDITOR) // These are intentionally slow since the user is drafting text if (operation.includes('exec') && operation.includes('claude-prompt-')) { return } const now = Date.now() // Remove stale operations STATE.slowOperations = STATE.slowOperations.filter( op => now - op.timestamp < SLOW_OPERATION_TTL_MS, ) // Add new operation STATE.slowOperations.push({ operation, durationMs, timestamp: now }) // Keep only the most recent operations if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) { STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS) } } const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{ operation: string durationMs: number timestamp: number }> = [] export function getSlowOperations(): ReadonlyArray<{ operation: string durationMs: number timestamp: number }> { // Most common case: nothing tracked. Return a stable reference so the // caller's setState() can bail via Object.is instead of re-rendering at 2fps. if (STATE.slowOperations.length === 0) { return EMPTY_SLOW_OPERATIONS } const now = Date.now() // Only allocate a new array when something actually expired; otherwise keep // the reference stable across polls while ops are still fresh. if ( STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS) ) { STATE.slowOperations = STATE.slowOperations.filter( op => now - op.timestamp < SLOW_OPERATION_TTL_MS, ) if (STATE.slowOperations.length === 0) { return EMPTY_SLOW_OPERATIONS } } // Safe to return directly: addSlowOperation() reassigns STATE.slowOperations // before pushing, so the array held in React state is never mutated. return STATE.slowOperations } export function getMainThreadAgentType(): string | undefined { return STATE.mainThreadAgentType } export function setMainThreadAgentType(agentType: string | undefined): void { STATE.mainThreadAgentType = agentType } export function getIsRemoteMode(): boolean { return STATE.isRemoteMode } export function setIsRemoteMode(value: boolean): void { STATE.isRemoteMode = value } // System prompt section accessors export function getSystemPromptSectionCache(): Map { return STATE.systemPromptSectionCache } export function setSystemPromptSectionCacheEntry( name: string, value: string | null, ): void { STATE.systemPromptSectionCache.set(name, value) } export function clearSystemPromptSectionState(): void { STATE.systemPromptSectionCache.clear() } // Last emitted date accessors (for detecting midnight date changes) export function getLastEmittedDate(): string | null { return STATE.lastEmittedDate } export function setLastEmittedDate(date: string | null): void { STATE.lastEmittedDate = date } export function getAdditionalDirectoriesForClaudeMd(): string[] { return STATE.additionalDirectoriesForClaudeMd } export function setAdditionalDirectoriesForClaudeMd( directories: string[], ): void { STATE.additionalDirectoriesForClaudeMd = directories } export function getAllowedChannels(): ChannelEntry[] { return STATE.allowedChannels } export function setAllowedChannels(entries: ChannelEntry[]): void { STATE.allowedChannels = entries } export function getHasDevChannels(): boolean { return STATE.hasDevChannels } export function setHasDevChannels(value: boolean): void { STATE.hasDevChannels = value } export function getPromptCache1hAllowlist(): string[] | null { return STATE.promptCache1hAllowlist } export function setPromptCache1hAllowlist(allowlist: string[] | null): void { STATE.promptCache1hAllowlist = allowlist } export function getPromptCache1hEligible(): boolean | null { return STATE.promptCache1hEligible } export function setPromptCache1hEligible(eligible: boolean | null): void { STATE.promptCache1hEligible = eligible } export function getAfkModeHeaderLatched(): boolean | null { return STATE.afkModeHeaderLatched } export function setAfkModeHeaderLatched(v: boolean): void { STATE.afkModeHeaderLatched = v } export function getFastModeHeaderLatched(): boolean | null { return STATE.fastModeHeaderLatched } export function setFastModeHeaderLatched(v: boolean): void { STATE.fastModeHeaderLatched = v } export function getCacheEditingHeaderLatched(): boolean | null { return STATE.cacheEditingHeaderLatched } export function setCacheEditingHeaderLatched(v: boolean): void { STATE.cacheEditingHeaderLatched = v } export function getThinkingClearLatched(): boolean | null { return STATE.thinkingClearLatched } export function setThinkingClearLatched(v: boolean): void { STATE.thinkingClearLatched = v } /** * Reset beta header latches to null. Called on /clear and /compact so a * fresh conversation gets fresh header evaluation. */ export function clearBetaHeaderLatches(): void { STATE.afkModeHeaderLatched = null STATE.fastModeHeaderLatched = null STATE.cacheEditingHeaderLatched = null STATE.thinkingClearLatched = null } export function getPromptId(): string | null { return STATE.promptId } export function setPromptId(id: string | null): void { STATE.promptId = id }