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' // Indirection for browser-sdk build (package.json "browser" field swaps // crypto.ts for crypto.browser.ts). Pure leaf re-export of node:crypto — // zero circular-dep risk. Path-alias import bypasses bootstrap-isolation // (rule only checks ./ and / prefixes); explicit disable documents intent. // 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' // Union type for registered hooks - can be SDK callbacks or native plugin hooks type RegisteredHookMatcher = HookCallbackMatcher | PluginHookMatcher import type { SessionId } from 'src/types/ids.js' // DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE // dev: true on entries that came via --dangerously-load-development-channels. // The allowlist gate checks this per-entry (not the session-wide // hasDevChannels bit) so passing both flags doesn't let the dev dialog's // acceptance leak allowlist-bypass to the --channels entries. 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 // Stable project root - set once at startup (including by --worktree flag), // never updated by mid-session EnterWorktreeTool. // Use for project identity (history, skills, sessions) not file operations. 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 // When true, ensureToolResultPairing throws on mismatch instead of // repairing with synthetic placeholders. HFI opts in at startup so // trajectories fail fast rather than conditioning the model on fake // tool_results. 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 // Parent session ID for tracking session lineage (e.g., plan mode -> implementation) 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 // Messages from the last API request (ant-only; reference, not clone). // Captures the exact post-compaction, CLAUDE.md-injected message set sent // to the API so /share's serialized_conversation.json reflects reality. lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null // Last auto-mode classifier request(s) for /share transcript lastClassifierRequests: unknown[] | null // CLAUDE.md content cached by context.ts for the auto-mode classifier. // Breaks the yoloClassifier → claudemd → filesystem → permissions cycle. cachedClaudeMdContent: string | null // In-memory error log for recent errors inMemoryErrorLog: Array<{ error: string; timestamp: string }> // Session-only plugins from --plugin-dir flag inlinePlugins: Array // Explicit --chrome / --no-chrome flag value (undefined = not set on CLI) chromeFlagOverride: boolean | undefined // Use cowork_plugins directory instead of plugins (--cowork flag or env var) useCoworkPlugins: boolean // Session-only bypass permissions mode flag (not persisted) sessionBypassPermissionsMode: boolean // Session-only flag gating the .claude/scheduled_tasks.json watcher // (useScheduledTasks). Set by cronScheduler.start() when the JSON has // entries, or by CronCreateTool. Not persisted. scheduledTasksEnabled: boolean // Session-only cron tasks created via CronCreate with durable: false. // Fire on schedule like file-backed tasks but are never written to // .claude/scheduled_tasks.json — they die with the process. Typed via // SessionCronTask below (not importing from cronTasks.ts keeps // bootstrap a leaf of the import DAG). sessionCronTasks: SessionCronTask[] // Teams created this session via TeamCreate. cleanupSessionTeams() // removes these on gracefulShutdown so subagent-created teams don't // persist on disk forever (gh-32730). TeamDelete removes entries to // avoid double-cleanup. Lives here (not teamHelpers.ts) so // resetStateForTests() clears it between tests. sessionCreatedTeams: Set // Session-only trust flag for home directory (not persisted to disk) // When running from home dir, trust dialog is shown but not saved to disk. // This flag allows features requiring trust to work during the session. sessionTrustAccepted: boolean // Session-only flag to disable session persistence to disk sessionPersistenceDisabled: boolean // Track if user has exited plan mode in this session (for re-entry guidance) hasExitedPlanMode: boolean // Track if we need to show the plan mode exit attachment (one-time notification) needsPlanModeExitAttachment: boolean // Track if we need to show the auto mode exit attachment (one-time notification) needsAutoModeExitAttachment: boolean // Track if LSP plugin recommendation has been shown this session (only show once) lspRecommendationShownThisSession: boolean // SDK init event state - jsonSchema for structured output initJsonSchema: Record | null // Registered hooks - SDK callbacks and plugin native hooks registeredHooks: Partial> | null // Cache for plan slugs: sessionId -> wordSlug planSlugCache: Map // Track teleported session for reliability logging teleportedSessionInfo: { isTeleported: boolean hasLoggedFirstMessage: boolean sessionId: string | null } | null // Track invoked skills for preservation across compaction // Keys are composite: `${agentId ?? ''}:${skillName}` to prevent cross-agent overwrites invokedSkills: Map< string, { skillName: string skillPath: string content: string invokedAt: number agentId: string | null } > // Track slow operations for dev bar display (ant-only) slowOperations: Array<{ operation: string durationMs: number timestamp: number }> // SDK-provided betas (e.g., context-1m-2025-08-07) sdkBetas: string[] | undefined // Main thread agent type (from --agent flag or settings) mainThreadAgentType: string | undefined // Remote mode (--remote flag) isRemoteMode: boolean // Direct connect server URL (for display in header) directConnectServerUrl: string | undefined // System prompt section cache state systemPromptSectionCache: Map // Last date emitted to the model (for detecting midnight date changes) lastEmittedDate: string | null // Additional directories from --add-dir flag (for CLAUDE.md loading) additionalDirectoriesForClaudeMd: string[] // Channel server allowlist from --channels flag (servers whose channel // notifications should register this session). Parsed once in main.tsx — // the tag decides trust model: 'plugin' → marketplace verification + // allowlist, 'server' → allowlist always fails (schema is plugin-only). // Either kind needs entry.dev to bypass allowlist. allowedChannels: ChannelEntry[] // True if any entry in allowedChannels came from // --dangerously-load-development-channels (so ChannelsNotice can name the // right flag in policy-blocked messages) hasDevChannels: boolean // Dir containing the session's `.jsonl`; null = derive from originalCwd. sessionProjectDir: string | null // Cached prompt cache 1h TTL allowlist from GrowthBook (session-stable) promptCache1hAllowlist: string[] | null // Cached 1h TTL user eligibility (session-stable). Latched on first // evaluation so mid-session overage flips don't change the cache_control // TTL, which would bust the server-side prompt cache. promptCache1hEligible: boolean | null // Sticky-on latch for AFK_MODE_BETA_HEADER. Once auto mode is first // activated, keep sending the header for the rest of the session so // Shift+Tab toggles don't bust the ~50-70K token prompt cache. afkModeHeaderLatched: boolean | null // Sticky-on latch for FAST_MODE_BETA_HEADER. Once fast mode is first // enabled, keep sending the header so cooldown enter/exit doesn't // double-bust the prompt cache. The `speed` body param stays dynamic. fastModeHeaderLatched: boolean | null // Sticky-on latch for the cache-editing beta header. Once cached // microcompact is first enabled, keep sending the header so mid-session // GrowthBook/settings toggles don't bust the prompt cache. cacheEditingHeaderLatched: boolean | null // Sticky-on latch for clearing thinking from prior tool loops. Triggered // when >1h since last API call (confirmed cache miss — no cache-hit // benefit to keeping thinking). Once latched, stays on so the newly-warmed // thinking-cleared cache isn't busted by flipping back to keep:'all'. thinkingClearLatched: boolean | null // Current prompt ID (UUID) correlating a user prompt with subsequent OTel events promptId: string | null // Last API requestId for the main conversation chain (not subagents). // Updated after each successful API response for main-session queries. // Read at shutdown to send cache eviction hints to inference. lastMainRequestId: string | undefined // Timestamp (Date.now()) of the last successful API call completion. // Used to compute timeSinceLastApiCallMs in tengu_api_success for // correlating cache misses with idle time (cache TTL is ~5min). lastApiCompletionTimestamp: number | null // Set to true after compaction (auto or manual /compact). Consumed by // logAPISuccess to tag the first post-compaction API call so we can // distinguish compaction-induced cache misses from TTL expiry. pendingPostCompaction: boolean } // ALSO HERE - THINK THRICE BEFORE MODIFYING function getInitialState(): State { // Resolve symlinks in cwd to match behavior of shell.ts setCwd // This ensures consistency with how paths are sanitized for session storage let resolvedCwd = '' if ( typeof process !== 'undefined' && typeof process.cwd === 'function' && typeof realpathSync === 'function' ) { const rawCwd = cwd() try { resolvedCwd = realpathSync(rawCwd).normalize('NFC') } catch { // File Provider EPERM on CloudStorage mounts (lstat per path component). 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', ], // Telemetry state 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, // Logger state loggerProvider: null, eventLogger: null, // Meter provider state meterProvider: null, tracerProvider: null, // Agent color state agentColorMap: new Map(), agentColorIndex: 0, // Last API request for bug reports lastAPIRequest: null, lastAPIRequestMessages: null, // Last auto-mode classifier request(s) for /share transcript lastClassifierRequests: null, cachedClaudeMdContent: null, // In-memory error log for recent errors inMemoryErrorLog: [], // Session-only plugins from --plugin-dir flag inlinePlugins: [], // Explicit --chrome / --no-chrome flag value (undefined = not set on CLI) chromeFlagOverride: undefined, // Use cowork_plugins directory instead of plugins useCoworkPlugins: false, // Session-only bypass permissions mode flag (not persisted) sessionBypassPermissionsMode: false, // Scheduled tasks disabled until flag or dialog enables them scheduledTasksEnabled: false, sessionCronTasks: [], sessionCreatedTeams: new Set(), // Session-only trust flag (not persisted to disk) sessionTrustAccepted: false, // Session-only flag to disable session persistence to disk sessionPersistenceDisabled: false, // Track if user has exited plan mode in this session hasExitedPlanMode: false, // Track if we need to show the plan mode exit attachment needsPlanModeExitAttachment: false, // Track if we need to show the auto mode exit attachment needsAutoModeExitAttachment: false, // Track if LSP plugin recommendation has been shown this session lspRecommendationShownThisSession: false, // SDK init event state initJsonSchema: null, registeredHooks: null, // Cache for plan slugs planSlugCache: new Map(), // Track teleported session for reliability logging teleportedSessionInfo: null, // Track invoked skills for preservation across compaction invokedSkills: new Map(), // Track slow operations for dev bar display slowOperations: [], // SDK-provided betas sdkBetas: undefined, // Main thread agent type mainThreadAgentType: undefined, // Remote mode isRemoteMode: false, ...(process.env.USER_TYPE === 'ant' ? { replBridgeActive: false, } : {}), // Direct connect server URL directConnectServerUrl: undefined, // System prompt section cache state systemPromptSectionCache: new Map(), // Last date emitted to the model lastEmittedDate: null, // Additional directories from --add-dir flag (for CLAUDE.md loading) additionalDirectoriesForClaudeMd: [], // Channel server allowlist from --channels flag allowedChannels: [], hasDevChannels: false, // Session project dir (null = derive from originalCwd) sessionProjectDir: null, // Prompt cache 1h allowlist (null = not yet fetched from GrowthBook) promptCache1hAllowlist: null, // Prompt cache 1h eligibility (null = not yet evaluated) promptCache1hEligible: null, // Beta header latches (null = not yet triggered) afkModeHeaderLatched: null, fastModeHeaderLatched: null, cacheEditingHeaderLatched: null, thinkingClearLatched: null, // Current prompt ID promptId: null, lastMainRequestId: undefined, lastApiCompletionTimestamp: null, pendingPostCompaction: false, } return state } // AND ESPECIALLY HERE 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: 'Count of CLI sessions started', }) STATE.locCounter = createCounter('claude_code.lines_of_code.count', { description: "Count of lines of code modified, with the 'type' attribute indicating whether lines were added or removed", }) STATE.prCounter = createCounter('claude_code.pull_request.count', { description: 'Number of pull requests created', }) STATE.commitCounter = createCounter('claude_code.commit.count', { description: 'Number of git commits created', }) STATE.costCounter = createCounter('claude_code.cost.usage', { description: 'Cost of the Claude Code session', unit: 'USD', }) STATE.tokenCounter = createCounter('claude_code.token.usage', { description: 'Number of tokens used', unit: 'tokens', }) STATE.codeEditToolDecisionCounter = createCounter( 'claude_code.code_edit_tool.decision', { description: 'Count of code editing tool permission decisions (accept/reject) for Edit, Write, and NotebookEdit tools', }, ) STATE.activeTimeCounter = createCounter('claude_code.active_time.total', { description: 'Total active time in seconds', 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 }