/ bootstrap / state.ts
state.ts
   1  import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
   2  import type { Attributes, Meter, MetricOptions } from '@opentelemetry/api'
   3  import type { logs } from '@opentelemetry/api-logs'
   4  import type { LoggerProvider } from '@opentelemetry/sdk-logs'
   5  import type { MeterProvider } from '@opentelemetry/sdk-metrics'
   6  import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'
   7  import { realpathSync } from 'fs'
   8  import sumBy from 'lodash-es/sumBy.js'
   9  import { cwd } from 'process'
  10  import type { HookEvent, ModelUsage } from 'src/entrypoints/agentSdkTypes.js'
  11  import type { AgentColorName } from 'src/tools/AgentTool/agentColorManager.js'
  12  import type { HookCallbackMatcher } from 'src/types/hooks.js'
  13  // Indirection for browser-sdk build (package.json "browser" field swaps
  14  // crypto.ts for crypto.browser.ts). Pure leaf re-export of node:crypto —
  15  // zero circular-dep risk. Path-alias import bypasses bootstrap-isolation
  16  // (rule only checks ./ and / prefixes); explicit disable documents intent.
  17  // eslint-disable-next-line custom-rules/bootstrap-isolation
  18  import { randomUUID } from 'src/utils/crypto.js'
  19  import type { ModelSetting } from 'src/utils/model/model.js'
  20  import type { ModelStrings } from 'src/utils/model/modelStrings.js'
  21  import type { SettingSource } from 'src/utils/settings/constants.js'
  22  import { resetSettingsCache } from 'src/utils/settings/settingsCache.js'
  23  import type { PluginHookMatcher } from 'src/utils/settings/types.js'
  24  import { createSignal } from 'src/utils/signal.js'
  25  
  26  // Union type for registered hooks - can be SDK callbacks or native plugin hooks
  27  type RegisteredHookMatcher = HookCallbackMatcher | PluginHookMatcher
  28  
  29  import type { SessionId } from 'src/types/ids.js'
  30  
  31  // DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE
  32  
  33  // dev: true on entries that came via --dangerously-load-development-channels.
  34  // The allowlist gate checks this per-entry (not the session-wide
  35  // hasDevChannels bit) so passing both flags doesn't let the dev dialog's
  36  // acceptance leak allowlist-bypass to the --channels entries.
  37  export type ChannelEntry =
  38    | { kind: 'plugin'; name: string; marketplace: string; dev?: boolean }
  39    | { kind: 'server'; name: string; dev?: boolean }
  40  
  41  export type AttributedCounter = {
  42    add(value: number, additionalAttributes?: Attributes): void
  43  }
  44  
  45  type State = {
  46    originalCwd: string
  47    // Stable project root - set once at startup (including by --worktree flag),
  48    // never updated by mid-session EnterWorktreeTool.
  49    // Use for project identity (history, skills, sessions) not file operations.
  50    projectRoot: string
  51    totalCostUSD: number
  52    totalAPIDuration: number
  53    totalAPIDurationWithoutRetries: number
  54    totalToolDuration: number
  55    turnHookDurationMs: number
  56    turnToolDurationMs: number
  57    turnClassifierDurationMs: number
  58    turnToolCount: number
  59    turnHookCount: number
  60    turnClassifierCount: number
  61    startTime: number
  62    lastInteractionTime: number
  63    totalLinesAdded: number
  64    totalLinesRemoved: number
  65    hasUnknownModelCost: boolean
  66    cwd: string
  67    modelUsage: { [modelName: string]: ModelUsage }
  68    mainLoopModelOverride: ModelSetting | undefined
  69    initialMainLoopModel: ModelSetting
  70    modelStrings: ModelStrings | null
  71    isInteractive: boolean
  72    kairosActive: boolean
  73    // When true, ensureToolResultPairing throws on mismatch instead of
  74    // repairing with synthetic placeholders. HFI opts in at startup so
  75    // trajectories fail fast rather than conditioning the model on fake
  76    // tool_results.
  77    strictToolResultPairing: boolean
  78    sdkAgentProgressSummariesEnabled: boolean
  79    userMsgOptIn: boolean
  80    clientType: string
  81    sessionSource: string | undefined
  82    questionPreviewFormat: 'markdown' | 'html' | undefined
  83    flagSettingsPath: string | undefined
  84    flagSettingsInline: Record<string, unknown> | null
  85    allowedSettingSources: SettingSource[]
  86    sessionIngressToken: string | null | undefined
  87    oauthTokenFromFd: string | null | undefined
  88    apiKeyFromFd: string | null | undefined
  89    // Telemetry state
  90    meter: Meter | null
  91    sessionCounter: AttributedCounter | null
  92    locCounter: AttributedCounter | null
  93    prCounter: AttributedCounter | null
  94    commitCounter: AttributedCounter | null
  95    costCounter: AttributedCounter | null
  96    tokenCounter: AttributedCounter | null
  97    codeEditToolDecisionCounter: AttributedCounter | null
  98    activeTimeCounter: AttributedCounter | null
  99    statsStore: { observe(name: string, value: number): void } | null
 100    sessionId: SessionId
 101    // Parent session ID for tracking session lineage (e.g., plan mode -> implementation)
 102    parentSessionId: SessionId | undefined
 103    // Logger state
 104    loggerProvider: LoggerProvider | null
 105    eventLogger: ReturnType<typeof logs.getLogger> | null
 106    // Meter provider state
 107    meterProvider: MeterProvider | null
 108    // Tracer provider state
 109    tracerProvider: BasicTracerProvider | null
 110    // Agent color state
 111    agentColorMap: Map<string, AgentColorName>
 112    agentColorIndex: number
 113    // Last API request for bug reports
 114    lastAPIRequest: Omit<BetaMessageStreamParams, 'messages'> | null
 115    // Messages from the last API request (ant-only; reference, not clone).
 116    // Captures the exact post-compaction, CLAUDE.md-injected message set sent
 117    // to the API so /share's serialized_conversation.json reflects reality.
 118    lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null
 119    // Last auto-mode classifier request(s) for /share transcript
 120    lastClassifierRequests: unknown[] | null
 121    // CLAUDE.md content cached by context.ts for the auto-mode classifier.
 122    // Breaks the yoloClassifier → claudemd → filesystem → permissions cycle.
 123    cachedClaudeMdContent: string | null
 124    // In-memory error log for recent errors
 125    inMemoryErrorLog: Array<{ error: string; timestamp: string }>
 126    // Session-only plugins from --plugin-dir flag
 127    inlinePlugins: Array<string>
 128    // Explicit --chrome / --no-chrome flag value (undefined = not set on CLI)
 129    chromeFlagOverride: boolean | undefined
 130    // Use cowork_plugins directory instead of plugins (--cowork flag or env var)
 131    useCoworkPlugins: boolean
 132    // Session-only bypass permissions mode flag (not persisted)
 133    sessionBypassPermissionsMode: boolean
 134    // Session-only flag gating the .claude/scheduled_tasks.json watcher
 135    // (useScheduledTasks). Set by cronScheduler.start() when the JSON has
 136    // entries, or by CronCreateTool. Not persisted.
 137    scheduledTasksEnabled: boolean
 138    // Session-only cron tasks created via CronCreate with durable: false.
 139    // Fire on schedule like file-backed tasks but are never written to
 140    // .claude/scheduled_tasks.json — they die with the process. Typed via
 141    // SessionCronTask below (not importing from cronTasks.ts keeps
 142    // bootstrap a leaf of the import DAG).
 143    sessionCronTasks: SessionCronTask[]
 144    // Teams created this session via TeamCreate. cleanupSessionTeams()
 145    // removes these on gracefulShutdown so subagent-created teams don't
 146    // persist on disk forever (gh-32730). TeamDelete removes entries to
 147    // avoid double-cleanup. Lives here (not teamHelpers.ts) so
 148    // resetStateForTests() clears it between tests.
 149    sessionCreatedTeams: Set<string>
 150    // Session-only trust flag for home directory (not persisted to disk)
 151    // When running from home dir, trust dialog is shown but not saved to disk.
 152    // This flag allows features requiring trust to work during the session.
 153    sessionTrustAccepted: boolean
 154    // Session-only flag to disable session persistence to disk
 155    sessionPersistenceDisabled: boolean
 156    // Track if user has exited plan mode in this session (for re-entry guidance)
 157    hasExitedPlanMode: boolean
 158    // Track if we need to show the plan mode exit attachment (one-time notification)
 159    needsPlanModeExitAttachment: boolean
 160    // Track if we need to show the auto mode exit attachment (one-time notification)
 161    needsAutoModeExitAttachment: boolean
 162    // Track if LSP plugin recommendation has been shown this session (only show once)
 163    lspRecommendationShownThisSession: boolean
 164    // SDK init event state - jsonSchema for structured output
 165    initJsonSchema: Record<string, unknown> | null
 166    // Registered hooks - SDK callbacks and plugin native hooks
 167    registeredHooks: Partial<Record<HookEvent, RegisteredHookMatcher[]>> | null
 168    // Cache for plan slugs: sessionId -> wordSlug
 169    planSlugCache: Map<string, string>
 170    // Track teleported session for reliability logging
 171    teleportedSessionInfo: {
 172      isTeleported: boolean
 173      hasLoggedFirstMessage: boolean
 174      sessionId: string | null
 175    } | null
 176    // Track invoked skills for preservation across compaction
 177    // Keys are composite: `${agentId ?? ''}:${skillName}` to prevent cross-agent overwrites
 178    invokedSkills: Map<
 179      string,
 180      {
 181        skillName: string
 182        skillPath: string
 183        content: string
 184        invokedAt: number
 185        agentId: string | null
 186      }
 187    >
 188    // Track slow operations for dev bar display (ant-only)
 189    slowOperations: Array<{
 190      operation: string
 191      durationMs: number
 192      timestamp: number
 193    }>
 194    // SDK-provided betas (e.g., context-1m-2025-08-07)
 195    sdkBetas: string[] | undefined
 196    // Main thread agent type (from --agent flag or settings)
 197    mainThreadAgentType: string | undefined
 198    // Remote mode (--remote flag)
 199    isRemoteMode: boolean
 200    // Direct connect server URL (for display in header)
 201    directConnectServerUrl: string | undefined
 202    // System prompt section cache state
 203    systemPromptSectionCache: Map<string, string | null>
 204    // Last date emitted to the model (for detecting midnight date changes)
 205    lastEmittedDate: string | null
 206    // Additional directories from --add-dir flag (for CLAUDE.md loading)
 207    additionalDirectoriesForClaudeMd: string[]
 208    // Channel server allowlist from --channels flag (servers whose channel
 209    // notifications should register this session). Parsed once in main.tsx —
 210    // the tag decides trust model: 'plugin' → marketplace verification +
 211    // allowlist, 'server' → allowlist always fails (schema is plugin-only).
 212    // Either kind needs entry.dev to bypass allowlist.
 213    allowedChannels: ChannelEntry[]
 214    // True if any entry in allowedChannels came from
 215    // --dangerously-load-development-channels (so ChannelsNotice can name the
 216    // right flag in policy-blocked messages)
 217    hasDevChannels: boolean
 218    // Dir containing the session's `.jsonl`; null = derive from originalCwd.
 219    sessionProjectDir: string | null
 220    // Cached prompt cache 1h TTL allowlist from GrowthBook (session-stable)
 221    promptCache1hAllowlist: string[] | null
 222    // Cached 1h TTL user eligibility (session-stable). Latched on first
 223    // evaluation so mid-session overage flips don't change the cache_control
 224    // TTL, which would bust the server-side prompt cache.
 225    promptCache1hEligible: boolean | null
 226    // Sticky-on latch for AFK_MODE_BETA_HEADER. Once auto mode is first
 227    // activated, keep sending the header for the rest of the session so
 228    // Shift+Tab toggles don't bust the ~50-70K token prompt cache.
 229    afkModeHeaderLatched: boolean | null
 230    // Sticky-on latch for FAST_MODE_BETA_HEADER. Once fast mode is first
 231    // enabled, keep sending the header so cooldown enter/exit doesn't
 232    // double-bust the prompt cache. The `speed` body param stays dynamic.
 233    fastModeHeaderLatched: boolean | null
 234    // Sticky-on latch for the cache-editing beta header. Once cached
 235    // microcompact is first enabled, keep sending the header so mid-session
 236    // GrowthBook/settings toggles don't bust the prompt cache.
 237    cacheEditingHeaderLatched: boolean | null
 238    // Sticky-on latch for clearing thinking from prior tool loops. Triggered
 239    // when >1h since last API call (confirmed cache miss — no cache-hit
 240    // benefit to keeping thinking). Once latched, stays on so the newly-warmed
 241    // thinking-cleared cache isn't busted by flipping back to keep:'all'.
 242    thinkingClearLatched: boolean | null
 243    // Current prompt ID (UUID) correlating a user prompt with subsequent OTel events
 244    promptId: string | null
 245    // Last API requestId for the main conversation chain (not subagents).
 246    // Updated after each successful API response for main-session queries.
 247    // Read at shutdown to send cache eviction hints to inference.
 248    lastMainRequestId: string | undefined
 249    // Timestamp (Date.now()) of the last successful API call completion.
 250    // Used to compute timeSinceLastApiCallMs in tengu_api_success for
 251    // correlating cache misses with idle time (cache TTL is ~5min).
 252    lastApiCompletionTimestamp: number | null
 253    // Set to true after compaction (auto or manual /compact). Consumed by
 254    // logAPISuccess to tag the first post-compaction API call so we can
 255    // distinguish compaction-induced cache misses from TTL expiry.
 256    pendingPostCompaction: boolean
 257  }
 258  
 259  // ALSO HERE - THINK THRICE BEFORE MODIFYING
 260  function getInitialState(): State {
 261    // Resolve symlinks in cwd to match behavior of shell.ts setCwd
 262    // This ensures consistency with how paths are sanitized for session storage
 263    let resolvedCwd = ''
 264    if (
 265      typeof process !== 'undefined' &&
 266      typeof process.cwd === 'function' &&
 267      typeof realpathSync === 'function'
 268    ) {
 269      const rawCwd = cwd()
 270      try {
 271        resolvedCwd = realpathSync(rawCwd).normalize('NFC')
 272      } catch {
 273        // File Provider EPERM on CloudStorage mounts (lstat per path component).
 274        resolvedCwd = rawCwd.normalize('NFC')
 275      }
 276    }
 277    const state: State = {
 278      originalCwd: resolvedCwd,
 279      projectRoot: resolvedCwd,
 280      totalCostUSD: 0,
 281      totalAPIDuration: 0,
 282      totalAPIDurationWithoutRetries: 0,
 283      totalToolDuration: 0,
 284      turnHookDurationMs: 0,
 285      turnToolDurationMs: 0,
 286      turnClassifierDurationMs: 0,
 287      turnToolCount: 0,
 288      turnHookCount: 0,
 289      turnClassifierCount: 0,
 290      startTime: Date.now(),
 291      lastInteractionTime: Date.now(),
 292      totalLinesAdded: 0,
 293      totalLinesRemoved: 0,
 294      hasUnknownModelCost: false,
 295      cwd: resolvedCwd,
 296      modelUsage: {},
 297      mainLoopModelOverride: undefined,
 298      initialMainLoopModel: null,
 299      modelStrings: null,
 300      isInteractive: false,
 301      kairosActive: false,
 302      strictToolResultPairing: false,
 303      sdkAgentProgressSummariesEnabled: false,
 304      userMsgOptIn: false,
 305      clientType: 'cli',
 306      sessionSource: undefined,
 307      questionPreviewFormat: undefined,
 308      sessionIngressToken: undefined,
 309      oauthTokenFromFd: undefined,
 310      apiKeyFromFd: undefined,
 311      flagSettingsPath: undefined,
 312      flagSettingsInline: null,
 313      allowedSettingSources: [
 314        'userSettings',
 315        'projectSettings',
 316        'localSettings',
 317        'flagSettings',
 318        'policySettings',
 319      ],
 320      // Telemetry state
 321      meter: null,
 322      sessionCounter: null,
 323      locCounter: null,
 324      prCounter: null,
 325      commitCounter: null,
 326      costCounter: null,
 327      tokenCounter: null,
 328      codeEditToolDecisionCounter: null,
 329      activeTimeCounter: null,
 330      statsStore: null,
 331      sessionId: randomUUID() as SessionId,
 332      parentSessionId: undefined,
 333      // Logger state
 334      loggerProvider: null,
 335      eventLogger: null,
 336      // Meter provider state
 337      meterProvider: null,
 338      tracerProvider: null,
 339      // Agent color state
 340      agentColorMap: new Map(),
 341      agentColorIndex: 0,
 342      // Last API request for bug reports
 343      lastAPIRequest: null,
 344      lastAPIRequestMessages: null,
 345      // Last auto-mode classifier request(s) for /share transcript
 346      lastClassifierRequests: null,
 347      cachedClaudeMdContent: null,
 348      // In-memory error log for recent errors
 349      inMemoryErrorLog: [],
 350      // Session-only plugins from --plugin-dir flag
 351      inlinePlugins: [],
 352      // Explicit --chrome / --no-chrome flag value (undefined = not set on CLI)
 353      chromeFlagOverride: undefined,
 354      // Use cowork_plugins directory instead of plugins
 355      useCoworkPlugins: false,
 356      // Session-only bypass permissions mode flag (not persisted)
 357      sessionBypassPermissionsMode: false,
 358      // Scheduled tasks disabled until flag or dialog enables them
 359      scheduledTasksEnabled: false,
 360      sessionCronTasks: [],
 361      sessionCreatedTeams: new Set(),
 362      // Session-only trust flag (not persisted to disk)
 363      sessionTrustAccepted: false,
 364      // Session-only flag to disable session persistence to disk
 365      sessionPersistenceDisabled: false,
 366      // Track if user has exited plan mode in this session
 367      hasExitedPlanMode: false,
 368      // Track if we need to show the plan mode exit attachment
 369      needsPlanModeExitAttachment: false,
 370      // Track if we need to show the auto mode exit attachment
 371      needsAutoModeExitAttachment: false,
 372      // Track if LSP plugin recommendation has been shown this session
 373      lspRecommendationShownThisSession: false,
 374      // SDK init event state
 375      initJsonSchema: null,
 376      registeredHooks: null,
 377      // Cache for plan slugs
 378      planSlugCache: new Map(),
 379      // Track teleported session for reliability logging
 380      teleportedSessionInfo: null,
 381      // Track invoked skills for preservation across compaction
 382      invokedSkills: new Map(),
 383      // Track slow operations for dev bar display
 384      slowOperations: [],
 385      // SDK-provided betas
 386      sdkBetas: undefined,
 387      // Main thread agent type
 388      mainThreadAgentType: undefined,
 389      // Remote mode
 390      isRemoteMode: false,
 391      ...(process.env.USER_TYPE === 'ant'
 392        ? {
 393            replBridgeActive: false,
 394          }
 395        : {}),
 396      // Direct connect server URL
 397      directConnectServerUrl: undefined,
 398      // System prompt section cache state
 399      systemPromptSectionCache: new Map(),
 400      // Last date emitted to the model
 401      lastEmittedDate: null,
 402      // Additional directories from --add-dir flag (for CLAUDE.md loading)
 403      additionalDirectoriesForClaudeMd: [],
 404      // Channel server allowlist from --channels flag
 405      allowedChannels: [],
 406      hasDevChannels: false,
 407      // Session project dir (null = derive from originalCwd)
 408      sessionProjectDir: null,
 409      // Prompt cache 1h allowlist (null = not yet fetched from GrowthBook)
 410      promptCache1hAllowlist: null,
 411      // Prompt cache 1h eligibility (null = not yet evaluated)
 412      promptCache1hEligible: null,
 413      // Beta header latches (null = not yet triggered)
 414      afkModeHeaderLatched: null,
 415      fastModeHeaderLatched: null,
 416      cacheEditingHeaderLatched: null,
 417      thinkingClearLatched: null,
 418      // Current prompt ID
 419      promptId: null,
 420      lastMainRequestId: undefined,
 421      lastApiCompletionTimestamp: null,
 422      pendingPostCompaction: false,
 423    }
 424  
 425    return state
 426  }
 427  
 428  // AND ESPECIALLY HERE
 429  const STATE: State = getInitialState()
 430  
 431  export function getSessionId(): SessionId {
 432    return STATE.sessionId
 433  }
 434  
 435  export function regenerateSessionId(
 436    options: { setCurrentAsParent?: boolean } = {},
 437  ): SessionId {
 438    if (options.setCurrentAsParent) {
 439      STATE.parentSessionId = STATE.sessionId
 440    }
 441    // Drop the outgoing session's plan-slug entry so the Map doesn't
 442    // accumulate stale keys. Callers that need to carry the slug across
 443    // (REPL.tsx clearContext) read it before calling clearConversation.
 444    STATE.planSlugCache.delete(STATE.sessionId)
 445    // Regenerated sessions live in the current project: reset projectDir to
 446    // null so getTranscriptPath() derives from originalCwd.
 447    STATE.sessionId = randomUUID() as SessionId
 448    STATE.sessionProjectDir = null
 449    return STATE.sessionId
 450  }
 451  
 452  export function getParentSessionId(): SessionId | undefined {
 453    return STATE.parentSessionId
 454  }
 455  
 456  /**
 457   * Atomically switch the active session. `sessionId` and `sessionProjectDir`
 458   * always change together — there is no separate setter for either, so they
 459   * cannot drift out of sync (CC-34).
 460   *
 461   * @param projectDir — directory containing `<sessionId>.jsonl`. Omit (or
 462   *   pass `null`) for sessions in the current project — the path will derive
 463   *   from originalCwd at read time. Pass `dirname(transcriptPath)` when the
 464   *   session lives in a different project directory (git worktrees,
 465   *   cross-project resume). Every call resets the project dir; it never
 466   *   carries over from the previous session.
 467   */
 468  export function switchSession(
 469    sessionId: SessionId,
 470    projectDir: string | null = null,
 471  ): void {
 472    // Drop the outgoing session's plan-slug entry so the Map stays bounded
 473    // across repeated /resume. Only the current session's slug is ever read
 474    // (plans.ts getPlanSlug defaults to getSessionId()).
 475    STATE.planSlugCache.delete(STATE.sessionId)
 476    STATE.sessionId = sessionId
 477    STATE.sessionProjectDir = projectDir
 478    sessionSwitched.emit(sessionId)
 479  }
 480  
 481  const sessionSwitched = createSignal<[id: SessionId]>()
 482  
 483  /**
 484   * Register a callback that fires when switchSession changes the active
 485   * sessionId. bootstrap can't import listeners directly (DAG leaf), so
 486   * callers register themselves. concurrentSessions.ts uses this to keep the
 487   * PID file's sessionId in sync with --resume.
 488   */
 489  export const onSessionSwitch = sessionSwitched.subscribe
 490  
 491  /**
 492   * Project directory the current session's transcript lives in, or `null` if
 493   * the session was created in the current project (common case — derive from
 494   * originalCwd). See `switchSession()`.
 495   */
 496  export function getSessionProjectDir(): string | null {
 497    return STATE.sessionProjectDir
 498  }
 499  
 500  export function getOriginalCwd(): string {
 501    return STATE.originalCwd
 502  }
 503  
 504  /**
 505   * Get the stable project root directory.
 506   * Unlike getOriginalCwd(), this is never updated by mid-session EnterWorktreeTool
 507   * (so skills/history stay stable when entering a throwaway worktree).
 508   * It IS set at startup by --worktree, since that worktree is the session's project.
 509   * Use for project identity (history, skills, sessions) not file operations.
 510   */
 511  export function getProjectRoot(): string {
 512    return STATE.projectRoot
 513  }
 514  
 515  export function setOriginalCwd(cwd: string): void {
 516    STATE.originalCwd = cwd.normalize('NFC')
 517  }
 518  
 519  /**
 520   * Only for --worktree startup flag. Mid-session EnterWorktreeTool must NOT
 521   * call this — skills/history should stay anchored to where the session started.
 522   */
 523  export function setProjectRoot(cwd: string): void {
 524    STATE.projectRoot = cwd.normalize('NFC')
 525  }
 526  
 527  export function getCwdState(): string {
 528    return STATE.cwd
 529  }
 530  
 531  export function setCwdState(cwd: string): void {
 532    STATE.cwd = cwd.normalize('NFC')
 533  }
 534  
 535  export function getDirectConnectServerUrl(): string | undefined {
 536    return STATE.directConnectServerUrl
 537  }
 538  
 539  export function setDirectConnectServerUrl(url: string): void {
 540    STATE.directConnectServerUrl = url
 541  }
 542  
 543  export function addToTotalDurationState(
 544    duration: number,
 545    durationWithoutRetries: number,
 546  ): void {
 547    STATE.totalAPIDuration += duration
 548    STATE.totalAPIDurationWithoutRetries += durationWithoutRetries
 549  }
 550  
 551  export function resetTotalDurationStateAndCost_FOR_TESTS_ONLY(): void {
 552    STATE.totalAPIDuration = 0
 553    STATE.totalAPIDurationWithoutRetries = 0
 554    STATE.totalCostUSD = 0
 555  }
 556  
 557  export function addToTotalCostState(
 558    cost: number,
 559    modelUsage: ModelUsage,
 560    model: string,
 561  ): void {
 562    STATE.modelUsage[model] = modelUsage
 563    STATE.totalCostUSD += cost
 564  }
 565  
 566  export function getTotalCostUSD(): number {
 567    return STATE.totalCostUSD
 568  }
 569  
 570  export function getTotalAPIDuration(): number {
 571    return STATE.totalAPIDuration
 572  }
 573  
 574  export function getTotalDuration(): number {
 575    return Date.now() - STATE.startTime
 576  }
 577  
 578  export function getTotalAPIDurationWithoutRetries(): number {
 579    return STATE.totalAPIDurationWithoutRetries
 580  }
 581  
 582  export function getTotalToolDuration(): number {
 583    return STATE.totalToolDuration
 584  }
 585  
 586  export function addToToolDuration(duration: number): void {
 587    STATE.totalToolDuration += duration
 588    STATE.turnToolDurationMs += duration
 589    STATE.turnToolCount++
 590  }
 591  
 592  export function getTurnHookDurationMs(): number {
 593    return STATE.turnHookDurationMs
 594  }
 595  
 596  export function addToTurnHookDuration(duration: number): void {
 597    STATE.turnHookDurationMs += duration
 598    STATE.turnHookCount++
 599  }
 600  
 601  export function resetTurnHookDuration(): void {
 602    STATE.turnHookDurationMs = 0
 603    STATE.turnHookCount = 0
 604  }
 605  
 606  export function getTurnHookCount(): number {
 607    return STATE.turnHookCount
 608  }
 609  
 610  export function getTurnToolDurationMs(): number {
 611    return STATE.turnToolDurationMs
 612  }
 613  
 614  export function resetTurnToolDuration(): void {
 615    STATE.turnToolDurationMs = 0
 616    STATE.turnToolCount = 0
 617  }
 618  
 619  export function getTurnToolCount(): number {
 620    return STATE.turnToolCount
 621  }
 622  
 623  export function getTurnClassifierDurationMs(): number {
 624    return STATE.turnClassifierDurationMs
 625  }
 626  
 627  export function addToTurnClassifierDuration(duration: number): void {
 628    STATE.turnClassifierDurationMs += duration
 629    STATE.turnClassifierCount++
 630  }
 631  
 632  export function resetTurnClassifierDuration(): void {
 633    STATE.turnClassifierDurationMs = 0
 634    STATE.turnClassifierCount = 0
 635  }
 636  
 637  export function getTurnClassifierCount(): number {
 638    return STATE.turnClassifierCount
 639  }
 640  
 641  export function getStatsStore(): {
 642    observe(name: string, value: number): void
 643  } | null {
 644    return STATE.statsStore
 645  }
 646  
 647  export function setStatsStore(
 648    store: { observe(name: string, value: number): void } | null,
 649  ): void {
 650    STATE.statsStore = store
 651  }
 652  
 653  /**
 654   * Marks that an interaction occurred.
 655   *
 656   * By default the actual Date.now() call is deferred until the next Ink render
 657   * frame (via flushInteractionTime()) so we avoid calling Date.now() on every
 658   * single keypress.
 659   *
 660   * Pass `immediate = true` when calling from React useEffect callbacks or
 661   * other code that runs *after* the Ink render cycle has already flushed.
 662   * Without it the timestamp stays stale until the next render, which may never
 663   * come if the user is idle (e.g. permission dialog waiting for input).
 664   */
 665  let interactionTimeDirty = false
 666  
 667  export function updateLastInteractionTime(immediate?: boolean): void {
 668    if (immediate) {
 669      flushInteractionTime_inner()
 670    } else {
 671      interactionTimeDirty = true
 672    }
 673  }
 674  
 675  /**
 676   * If an interaction was recorded since the last flush, update the timestamp
 677   * now. Called by Ink before each render cycle so we batch many keypresses into
 678   * a single Date.now() call.
 679   */
 680  export function flushInteractionTime(): void {
 681    if (interactionTimeDirty) {
 682      flushInteractionTime_inner()
 683    }
 684  }
 685  
 686  function flushInteractionTime_inner(): void {
 687    STATE.lastInteractionTime = Date.now()
 688    interactionTimeDirty = false
 689  }
 690  
 691  export function addToTotalLinesChanged(added: number, removed: number): void {
 692    STATE.totalLinesAdded += added
 693    STATE.totalLinesRemoved += removed
 694  }
 695  
 696  export function getTotalLinesAdded(): number {
 697    return STATE.totalLinesAdded
 698  }
 699  
 700  export function getTotalLinesRemoved(): number {
 701    return STATE.totalLinesRemoved
 702  }
 703  
 704  export function getTotalInputTokens(): number {
 705    return sumBy(Object.values(STATE.modelUsage), 'inputTokens')
 706  }
 707  
 708  export function getTotalOutputTokens(): number {
 709    return sumBy(Object.values(STATE.modelUsage), 'outputTokens')
 710  }
 711  
 712  export function getTotalCacheReadInputTokens(): number {
 713    return sumBy(Object.values(STATE.modelUsage), 'cacheReadInputTokens')
 714  }
 715  
 716  export function getTotalCacheCreationInputTokens(): number {
 717    return sumBy(Object.values(STATE.modelUsage), 'cacheCreationInputTokens')
 718  }
 719  
 720  export function getTotalWebSearchRequests(): number {
 721    return sumBy(Object.values(STATE.modelUsage), 'webSearchRequests')
 722  }
 723  
 724  let outputTokensAtTurnStart = 0
 725  let currentTurnTokenBudget: number | null = null
 726  export function getTurnOutputTokens(): number {
 727    return getTotalOutputTokens() - outputTokensAtTurnStart
 728  }
 729  export function getCurrentTurnTokenBudget(): number | null {
 730    return currentTurnTokenBudget
 731  }
 732  let budgetContinuationCount = 0
 733  export function snapshotOutputTokensForTurn(budget: number | null): void {
 734    outputTokensAtTurnStart = getTotalOutputTokens()
 735    currentTurnTokenBudget = budget
 736    budgetContinuationCount = 0
 737  }
 738  export function getBudgetContinuationCount(): number {
 739    return budgetContinuationCount
 740  }
 741  export function incrementBudgetContinuationCount(): void {
 742    budgetContinuationCount++
 743  }
 744  
 745  export function setHasUnknownModelCost(): void {
 746    STATE.hasUnknownModelCost = true
 747  }
 748  
 749  export function hasUnknownModelCost(): boolean {
 750    return STATE.hasUnknownModelCost
 751  }
 752  
 753  export function getLastMainRequestId(): string | undefined {
 754    return STATE.lastMainRequestId
 755  }
 756  
 757  export function setLastMainRequestId(requestId: string): void {
 758    STATE.lastMainRequestId = requestId
 759  }
 760  
 761  export function getLastApiCompletionTimestamp(): number | null {
 762    return STATE.lastApiCompletionTimestamp
 763  }
 764  
 765  export function setLastApiCompletionTimestamp(timestamp: number): void {
 766    STATE.lastApiCompletionTimestamp = timestamp
 767  }
 768  
 769  /** Mark that a compaction just occurred. The next API success event will
 770   *  include isPostCompaction=true, then the flag auto-resets. */
 771  export function markPostCompaction(): void {
 772    STATE.pendingPostCompaction = true
 773  }
 774  
 775  /** Consume the post-compaction flag. Returns true once after compaction,
 776   *  then returns false until the next compaction. */
 777  export function consumePostCompaction(): boolean {
 778    const was = STATE.pendingPostCompaction
 779    STATE.pendingPostCompaction = false
 780    return was
 781  }
 782  
 783  export function getLastInteractionTime(): number {
 784    return STATE.lastInteractionTime
 785  }
 786  
 787  // Scroll drain suspension — background intervals check this before doing work
 788  // so they don't compete with scroll frames for the event loop. Set by
 789  // ScrollBox scrollBy/scrollTo, cleared SCROLL_DRAIN_IDLE_MS after the last
 790  // scroll event. Module-scope (not in STATE) — ephemeral hot-path flag, no
 791  // test-reset needed since the debounce timer self-clears.
 792  let scrollDraining = false
 793  let scrollDrainTimer: ReturnType<typeof setTimeout> | undefined
 794  const SCROLL_DRAIN_IDLE_MS = 150
 795  
 796  /** Mark that a scroll event just happened. Background intervals gate on
 797   *  getIsScrollDraining() and skip their work until the debounce clears. */
 798  export function markScrollActivity(): void {
 799    scrollDraining = true
 800    if (scrollDrainTimer) clearTimeout(scrollDrainTimer)
 801    scrollDrainTimer = setTimeout(() => {
 802      scrollDraining = false
 803      scrollDrainTimer = undefined
 804    }, SCROLL_DRAIN_IDLE_MS)
 805    scrollDrainTimer.unref?.()
 806  }
 807  
 808  /** True while scroll is actively draining (within 150ms of last event).
 809   *  Intervals should early-return when this is set — the work picks up next
 810   *  tick after scroll settles. */
 811  export function getIsScrollDraining(): boolean {
 812    return scrollDraining
 813  }
 814  
 815  /** Await this before expensive one-shot work (network, subprocess) that could
 816   *  coincide with scroll. Resolves immediately if not scrolling; otherwise
 817   *  polls at the idle interval until the flag clears. */
 818  export async function waitForScrollIdle(): Promise<void> {
 819    while (scrollDraining) {
 820      // bootstrap-isolation forbids importing sleep() from src/utils/
 821      // eslint-disable-next-line no-restricted-syntax
 822      await new Promise(r => setTimeout(r, SCROLL_DRAIN_IDLE_MS).unref?.())
 823    }
 824  }
 825  
 826  export function getModelUsage(): { [modelName: string]: ModelUsage } {
 827    return STATE.modelUsage
 828  }
 829  
 830  export function getUsageForModel(model: string): ModelUsage | undefined {
 831    return STATE.modelUsage[model]
 832  }
 833  
 834  /**
 835   * Gets the model override set from the --model CLI flag or after the user
 836   * updates their configured model.
 837   */
 838  export function getMainLoopModelOverride(): ModelSetting | undefined {
 839    return STATE.mainLoopModelOverride
 840  }
 841  
 842  export function getInitialMainLoopModel(): ModelSetting {
 843    return STATE.initialMainLoopModel
 844  }
 845  
 846  export function setMainLoopModelOverride(
 847    model: ModelSetting | undefined,
 848  ): void {
 849    STATE.mainLoopModelOverride = model
 850  }
 851  
 852  export function setInitialMainLoopModel(model: ModelSetting): void {
 853    STATE.initialMainLoopModel = model
 854  }
 855  
 856  export function getSdkBetas(): string[] | undefined {
 857    return STATE.sdkBetas
 858  }
 859  
 860  export function setSdkBetas(betas: string[] | undefined): void {
 861    STATE.sdkBetas = betas
 862  }
 863  
 864  export function resetCostState(): void {
 865    STATE.totalCostUSD = 0
 866    STATE.totalAPIDuration = 0
 867    STATE.totalAPIDurationWithoutRetries = 0
 868    STATE.totalToolDuration = 0
 869    STATE.startTime = Date.now()
 870    STATE.totalLinesAdded = 0
 871    STATE.totalLinesRemoved = 0
 872    STATE.hasUnknownModelCost = false
 873    STATE.modelUsage = {}
 874    STATE.promptId = null
 875  }
 876  
 877  /**
 878   * Sets cost state values for session restore.
 879   * Called by restoreCostStateForSession in cost-tracker.ts.
 880   */
 881  export function setCostStateForRestore({
 882    totalCostUSD,
 883    totalAPIDuration,
 884    totalAPIDurationWithoutRetries,
 885    totalToolDuration,
 886    totalLinesAdded,
 887    totalLinesRemoved,
 888    lastDuration,
 889    modelUsage,
 890  }: {
 891    totalCostUSD: number
 892    totalAPIDuration: number
 893    totalAPIDurationWithoutRetries: number
 894    totalToolDuration: number
 895    totalLinesAdded: number
 896    totalLinesRemoved: number
 897    lastDuration: number | undefined
 898    modelUsage: { [modelName: string]: ModelUsage } | undefined
 899  }): void {
 900    STATE.totalCostUSD = totalCostUSD
 901    STATE.totalAPIDuration = totalAPIDuration
 902    STATE.totalAPIDurationWithoutRetries = totalAPIDurationWithoutRetries
 903    STATE.totalToolDuration = totalToolDuration
 904    STATE.totalLinesAdded = totalLinesAdded
 905    STATE.totalLinesRemoved = totalLinesRemoved
 906  
 907    // Restore per-model usage breakdown
 908    if (modelUsage) {
 909      STATE.modelUsage = modelUsage
 910    }
 911  
 912    // Adjust startTime to make wall duration accumulate
 913    if (lastDuration) {
 914      STATE.startTime = Date.now() - lastDuration
 915    }
 916  }
 917  
 918  // Only used in tests
 919  export function resetStateForTests(): void {
 920    if (process.env.NODE_ENV !== 'test') {
 921      throw new Error('resetStateForTests can only be called in tests')
 922    }
 923    Object.entries(getInitialState()).forEach(([key, value]) => {
 924      STATE[key as keyof State] = value as never
 925    })
 926    outputTokensAtTurnStart = 0
 927    currentTurnTokenBudget = null
 928    budgetContinuationCount = 0
 929    sessionSwitched.clear()
 930  }
 931  
 932  // You shouldn't use this directly. See src/utils/model/modelStrings.ts::getModelStrings()
 933  export function getModelStrings(): ModelStrings | null {
 934    return STATE.modelStrings
 935  }
 936  
 937  // You shouldn't use this directly. See src/utils/model/modelStrings.ts
 938  export function setModelStrings(modelStrings: ModelStrings): void {
 939    STATE.modelStrings = modelStrings
 940  }
 941  
 942  // Test utility function to reset model strings for re-initialization.
 943  // Separate from setModelStrings because we only want to accept 'null' in tests.
 944  export function resetModelStringsForTestingOnly() {
 945    STATE.modelStrings = null
 946  }
 947  
 948  export function setMeter(
 949    meter: Meter,
 950    createCounter: (name: string, options: MetricOptions) => AttributedCounter,
 951  ): void {
 952    STATE.meter = meter
 953  
 954    // Initialize all counters using the provided factory
 955    STATE.sessionCounter = createCounter('claude_code.session.count', {
 956      description: 'Count of CLI sessions started',
 957    })
 958    STATE.locCounter = createCounter('claude_code.lines_of_code.count', {
 959      description:
 960        "Count of lines of code modified, with the 'type' attribute indicating whether lines were added or removed",
 961    })
 962    STATE.prCounter = createCounter('claude_code.pull_request.count', {
 963      description: 'Number of pull requests created',
 964    })
 965    STATE.commitCounter = createCounter('claude_code.commit.count', {
 966      description: 'Number of git commits created',
 967    })
 968    STATE.costCounter = createCounter('claude_code.cost.usage', {
 969      description: 'Cost of the Claude Code session',
 970      unit: 'USD',
 971    })
 972    STATE.tokenCounter = createCounter('claude_code.token.usage', {
 973      description: 'Number of tokens used',
 974      unit: 'tokens',
 975    })
 976    STATE.codeEditToolDecisionCounter = createCounter(
 977      'claude_code.code_edit_tool.decision',
 978      {
 979        description:
 980          'Count of code editing tool permission decisions (accept/reject) for Edit, Write, and NotebookEdit tools',
 981      },
 982    )
 983    STATE.activeTimeCounter = createCounter('claude_code.active_time.total', {
 984      description: 'Total active time in seconds',
 985      unit: 's',
 986    })
 987  }
 988  
 989  export function getMeter(): Meter | null {
 990    return STATE.meter
 991  }
 992  
 993  export function getSessionCounter(): AttributedCounter | null {
 994    return STATE.sessionCounter
 995  }
 996  
 997  export function getLocCounter(): AttributedCounter | null {
 998    return STATE.locCounter
 999  }
1000  
1001  export function getPrCounter(): AttributedCounter | null {
1002    return STATE.prCounter
1003  }
1004  
1005  export function getCommitCounter(): AttributedCounter | null {
1006    return STATE.commitCounter
1007  }
1008  
1009  export function getCostCounter(): AttributedCounter | null {
1010    return STATE.costCounter
1011  }
1012  
1013  export function getTokenCounter(): AttributedCounter | null {
1014    return STATE.tokenCounter
1015  }
1016  
1017  export function getCodeEditToolDecisionCounter(): AttributedCounter | null {
1018    return STATE.codeEditToolDecisionCounter
1019  }
1020  
1021  export function getActiveTimeCounter(): AttributedCounter | null {
1022    return STATE.activeTimeCounter
1023  }
1024  
1025  export function getLoggerProvider(): LoggerProvider | null {
1026    return STATE.loggerProvider
1027  }
1028  
1029  export function setLoggerProvider(provider: LoggerProvider | null): void {
1030    STATE.loggerProvider = provider
1031  }
1032  
1033  export function getEventLogger(): ReturnType<typeof logs.getLogger> | null {
1034    return STATE.eventLogger
1035  }
1036  
1037  export function setEventLogger(
1038    logger: ReturnType<typeof logs.getLogger> | null,
1039  ): void {
1040    STATE.eventLogger = logger
1041  }
1042  
1043  export function getMeterProvider(): MeterProvider | null {
1044    return STATE.meterProvider
1045  }
1046  
1047  export function setMeterProvider(provider: MeterProvider | null): void {
1048    STATE.meterProvider = provider
1049  }
1050  export function getTracerProvider(): BasicTracerProvider | null {
1051    return STATE.tracerProvider
1052  }
1053  export function setTracerProvider(provider: BasicTracerProvider | null): void {
1054    STATE.tracerProvider = provider
1055  }
1056  
1057  export function getIsNonInteractiveSession(): boolean {
1058    return !STATE.isInteractive
1059  }
1060  
1061  export function getIsInteractive(): boolean {
1062    return STATE.isInteractive
1063  }
1064  
1065  export function setIsInteractive(value: boolean): void {
1066    STATE.isInteractive = value
1067  }
1068  
1069  export function getClientType(): string {
1070    return STATE.clientType
1071  }
1072  
1073  export function setClientType(type: string): void {
1074    STATE.clientType = type
1075  }
1076  
1077  export function getSdkAgentProgressSummariesEnabled(): boolean {
1078    return STATE.sdkAgentProgressSummariesEnabled
1079  }
1080  
1081  export function setSdkAgentProgressSummariesEnabled(value: boolean): void {
1082    STATE.sdkAgentProgressSummariesEnabled = value
1083  }
1084  
1085  export function getKairosActive(): boolean {
1086    return STATE.kairosActive
1087  }
1088  
1089  export function setKairosActive(value: boolean): void {
1090    STATE.kairosActive = value
1091  }
1092  
1093  export function getStrictToolResultPairing(): boolean {
1094    return STATE.strictToolResultPairing
1095  }
1096  
1097  export function setStrictToolResultPairing(value: boolean): void {
1098    STATE.strictToolResultPairing = value
1099  }
1100  
1101  // Field name 'userMsgOptIn' avoids excluded-string substrings ('BriefTool',
1102  // 'SendUserMessage' — case-insensitive). All callers are inside feature()
1103  // guards so these accessors don't need their own (matches getKairosActive).
1104  export function getUserMsgOptIn(): boolean {
1105    return STATE.userMsgOptIn
1106  }
1107  
1108  export function setUserMsgOptIn(value: boolean): void {
1109    STATE.userMsgOptIn = value
1110  }
1111  
1112  export function getSessionSource(): string | undefined {
1113    return STATE.sessionSource
1114  }
1115  
1116  export function setSessionSource(source: string): void {
1117    STATE.sessionSource = source
1118  }
1119  
1120  export function getQuestionPreviewFormat(): 'markdown' | 'html' | undefined {
1121    return STATE.questionPreviewFormat
1122  }
1123  
1124  export function setQuestionPreviewFormat(format: 'markdown' | 'html'): void {
1125    STATE.questionPreviewFormat = format
1126  }
1127  
1128  export function getAgentColorMap(): Map<string, AgentColorName> {
1129    return STATE.agentColorMap
1130  }
1131  
1132  export function getFlagSettingsPath(): string | undefined {
1133    return STATE.flagSettingsPath
1134  }
1135  
1136  export function setFlagSettingsPath(path: string | undefined): void {
1137    STATE.flagSettingsPath = path
1138  }
1139  
1140  export function getFlagSettingsInline(): Record<string, unknown> | null {
1141    return STATE.flagSettingsInline
1142  }
1143  
1144  export function setFlagSettingsInline(
1145    settings: Record<string, unknown> | null,
1146  ): void {
1147    STATE.flagSettingsInline = settings
1148  }
1149  
1150  export function getSessionIngressToken(): string | null | undefined {
1151    return STATE.sessionIngressToken
1152  }
1153  
1154  export function setSessionIngressToken(token: string | null): void {
1155    STATE.sessionIngressToken = token
1156  }
1157  
1158  export function getOauthTokenFromFd(): string | null | undefined {
1159    return STATE.oauthTokenFromFd
1160  }
1161  
1162  export function setOauthTokenFromFd(token: string | null): void {
1163    STATE.oauthTokenFromFd = token
1164  }
1165  
1166  export function getApiKeyFromFd(): string | null | undefined {
1167    return STATE.apiKeyFromFd
1168  }
1169  
1170  export function setApiKeyFromFd(key: string | null): void {
1171    STATE.apiKeyFromFd = key
1172  }
1173  
1174  export function setLastAPIRequest(
1175    params: Omit<BetaMessageStreamParams, 'messages'> | null,
1176  ): void {
1177    STATE.lastAPIRequest = params
1178  }
1179  
1180  export function getLastAPIRequest(): Omit<
1181    BetaMessageStreamParams,
1182    'messages'
1183  > | null {
1184    return STATE.lastAPIRequest
1185  }
1186  
1187  export function setLastAPIRequestMessages(
1188    messages: BetaMessageStreamParams['messages'] | null,
1189  ): void {
1190    STATE.lastAPIRequestMessages = messages
1191  }
1192  
1193  export function getLastAPIRequestMessages():
1194    | BetaMessageStreamParams['messages']
1195    | null {
1196    return STATE.lastAPIRequestMessages
1197  }
1198  
1199  export function setLastClassifierRequests(requests: unknown[] | null): void {
1200    STATE.lastClassifierRequests = requests
1201  }
1202  
1203  export function getLastClassifierRequests(): unknown[] | null {
1204    return STATE.lastClassifierRequests
1205  }
1206  
1207  export function setCachedClaudeMdContent(content: string | null): void {
1208    STATE.cachedClaudeMdContent = content
1209  }
1210  
1211  export function getCachedClaudeMdContent(): string | null {
1212    return STATE.cachedClaudeMdContent
1213  }
1214  
1215  export function addToInMemoryErrorLog(errorInfo: {
1216    error: string
1217    timestamp: string
1218  }): void {
1219    const MAX_IN_MEMORY_ERRORS = 100
1220    if (STATE.inMemoryErrorLog.length >= MAX_IN_MEMORY_ERRORS) {
1221      STATE.inMemoryErrorLog.shift() // Remove oldest error
1222    }
1223    STATE.inMemoryErrorLog.push(errorInfo)
1224  }
1225  
1226  export function getAllowedSettingSources(): SettingSource[] {
1227    return STATE.allowedSettingSources
1228  }
1229  
1230  export function setAllowedSettingSources(sources: SettingSource[]): void {
1231    STATE.allowedSettingSources = sources
1232  }
1233  
1234  export function preferThirdPartyAuthentication(): boolean {
1235    // IDE extension should behave as 1P for authentication reasons.
1236    return getIsNonInteractiveSession() && STATE.clientType !== 'claude-vscode'
1237  }
1238  
1239  export function setInlinePlugins(plugins: Array<string>): void {
1240    STATE.inlinePlugins = plugins
1241  }
1242  
1243  export function getInlinePlugins(): Array<string> {
1244    return STATE.inlinePlugins
1245  }
1246  
1247  export function setChromeFlagOverride(value: boolean | undefined): void {
1248    STATE.chromeFlagOverride = value
1249  }
1250  
1251  export function getChromeFlagOverride(): boolean | undefined {
1252    return STATE.chromeFlagOverride
1253  }
1254  
1255  export function setUseCoworkPlugins(value: boolean): void {
1256    STATE.useCoworkPlugins = value
1257    resetSettingsCache()
1258  }
1259  
1260  export function getUseCoworkPlugins(): boolean {
1261    return STATE.useCoworkPlugins
1262  }
1263  
1264  export function setSessionBypassPermissionsMode(enabled: boolean): void {
1265    STATE.sessionBypassPermissionsMode = enabled
1266  }
1267  
1268  export function getSessionBypassPermissionsMode(): boolean {
1269    return STATE.sessionBypassPermissionsMode
1270  }
1271  
1272  export function setScheduledTasksEnabled(enabled: boolean): void {
1273    STATE.scheduledTasksEnabled = enabled
1274  }
1275  
1276  export function getScheduledTasksEnabled(): boolean {
1277    return STATE.scheduledTasksEnabled
1278  }
1279  
1280  export type SessionCronTask = {
1281    id: string
1282    cron: string
1283    prompt: string
1284    createdAt: number
1285    recurring?: boolean
1286    /**
1287     * When set, the task was created by an in-process teammate (not the team lead).
1288     * The scheduler routes fires to that teammate's pendingUserMessages queue
1289     * instead of the main REPL command queue. Session-only — never written to disk.
1290     */
1291    agentId?: string
1292  }
1293  
1294  export function getSessionCronTasks(): SessionCronTask[] {
1295    return STATE.sessionCronTasks
1296  }
1297  
1298  export function addSessionCronTask(task: SessionCronTask): void {
1299    STATE.sessionCronTasks.push(task)
1300  }
1301  
1302  /**
1303   * Returns the number of tasks actually removed. Callers use this to skip
1304   * downstream work (e.g. the disk read in removeCronTasks) when all ids
1305   * were accounted for here.
1306   */
1307  export function removeSessionCronTasks(ids: readonly string[]): number {
1308    if (ids.length === 0) return 0
1309    const idSet = new Set(ids)
1310    const remaining = STATE.sessionCronTasks.filter(t => !idSet.has(t.id))
1311    const removed = STATE.sessionCronTasks.length - remaining.length
1312    if (removed === 0) return 0
1313    STATE.sessionCronTasks = remaining
1314    return removed
1315  }
1316  
1317  export function setSessionTrustAccepted(accepted: boolean): void {
1318    STATE.sessionTrustAccepted = accepted
1319  }
1320  
1321  export function getSessionTrustAccepted(): boolean {
1322    return STATE.sessionTrustAccepted
1323  }
1324  
1325  export function setSessionPersistenceDisabled(disabled: boolean): void {
1326    STATE.sessionPersistenceDisabled = disabled
1327  }
1328  
1329  export function isSessionPersistenceDisabled(): boolean {
1330    return STATE.sessionPersistenceDisabled
1331  }
1332  
1333  export function hasExitedPlanModeInSession(): boolean {
1334    return STATE.hasExitedPlanMode
1335  }
1336  
1337  export function setHasExitedPlanMode(value: boolean): void {
1338    STATE.hasExitedPlanMode = value
1339  }
1340  
1341  export function needsPlanModeExitAttachment(): boolean {
1342    return STATE.needsPlanModeExitAttachment
1343  }
1344  
1345  export function setNeedsPlanModeExitAttachment(value: boolean): void {
1346    STATE.needsPlanModeExitAttachment = value
1347  }
1348  
1349  export function handlePlanModeTransition(
1350    fromMode: string,
1351    toMode: string,
1352  ): void {
1353    // If switching TO plan mode, clear any pending exit attachment
1354    // This prevents sending both plan_mode and plan_mode_exit when user toggles quickly
1355    if (toMode === 'plan' && fromMode !== 'plan') {
1356      STATE.needsPlanModeExitAttachment = false
1357    }
1358  
1359    // If switching out of plan mode, trigger the plan_mode_exit attachment
1360    if (fromMode === 'plan' && toMode !== 'plan') {
1361      STATE.needsPlanModeExitAttachment = true
1362    }
1363  }
1364  
1365  export function needsAutoModeExitAttachment(): boolean {
1366    return STATE.needsAutoModeExitAttachment
1367  }
1368  
1369  export function setNeedsAutoModeExitAttachment(value: boolean): void {
1370    STATE.needsAutoModeExitAttachment = value
1371  }
1372  
1373  export function handleAutoModeTransition(
1374    fromMode: string,
1375    toMode: string,
1376  ): void {
1377    // Auto↔plan transitions are handled by prepareContextForPlanMode (auto may
1378    // stay active through plan if opted in) and ExitPlanMode (restores mode).
1379    // Skip both directions so this function only handles direct auto transitions.
1380    if (
1381      (fromMode === 'auto' && toMode === 'plan') ||
1382      (fromMode === 'plan' && toMode === 'auto')
1383    ) {
1384      return
1385    }
1386    const fromIsAuto = fromMode === 'auto'
1387    const toIsAuto = toMode === 'auto'
1388  
1389    // If switching TO auto mode, clear any pending exit attachment
1390    // This prevents sending both auto_mode and auto_mode_exit when user toggles quickly
1391    if (toIsAuto && !fromIsAuto) {
1392      STATE.needsAutoModeExitAttachment = false
1393    }
1394  
1395    // If switching out of auto mode, trigger the auto_mode_exit attachment
1396    if (fromIsAuto && !toIsAuto) {
1397      STATE.needsAutoModeExitAttachment = true
1398    }
1399  }
1400  
1401  // LSP plugin recommendation session tracking
1402  export function hasShownLspRecommendationThisSession(): boolean {
1403    return STATE.lspRecommendationShownThisSession
1404  }
1405  
1406  export function setLspRecommendationShownThisSession(value: boolean): void {
1407    STATE.lspRecommendationShownThisSession = value
1408  }
1409  
1410  // SDK init event state
1411  export function setInitJsonSchema(schema: Record<string, unknown>): void {
1412    STATE.initJsonSchema = schema
1413  }
1414  
1415  export function getInitJsonSchema(): Record<string, unknown> | null {
1416    return STATE.initJsonSchema
1417  }
1418  
1419  export function registerHookCallbacks(
1420    hooks: Partial<Record<HookEvent, RegisteredHookMatcher[]>>,
1421  ): void {
1422    if (!STATE.registeredHooks) {
1423      STATE.registeredHooks = {}
1424    }
1425  
1426    // `registerHookCallbacks` may be called multiple times, so we need to merge (not overwrite)
1427    for (const [event, matchers] of Object.entries(hooks)) {
1428      const eventKey = event as HookEvent
1429      if (!STATE.registeredHooks[eventKey]) {
1430        STATE.registeredHooks[eventKey] = []
1431      }
1432      STATE.registeredHooks[eventKey]!.push(...matchers)
1433    }
1434  }
1435  
1436  export function getRegisteredHooks(): Partial<
1437    Record<HookEvent, RegisteredHookMatcher[]>
1438  > | null {
1439    return STATE.registeredHooks
1440  }
1441  
1442  export function clearRegisteredHooks(): void {
1443    STATE.registeredHooks = null
1444  }
1445  
1446  export function clearRegisteredPluginHooks(): void {
1447    if (!STATE.registeredHooks) {
1448      return
1449    }
1450  
1451    const filtered: Partial<Record<HookEvent, RegisteredHookMatcher[]>> = {}
1452    for (const [event, matchers] of Object.entries(STATE.registeredHooks)) {
1453      // Keep only callback hooks (those without pluginRoot)
1454      const callbackHooks = matchers.filter(m => !('pluginRoot' in m))
1455      if (callbackHooks.length > 0) {
1456        filtered[event as HookEvent] = callbackHooks
1457      }
1458    }
1459  
1460    STATE.registeredHooks = Object.keys(filtered).length > 0 ? filtered : null
1461  }
1462  
1463  export function resetSdkInitState(): void {
1464    STATE.initJsonSchema = null
1465    STATE.registeredHooks = null
1466  }
1467  
1468  export function getPlanSlugCache(): Map<string, string> {
1469    return STATE.planSlugCache
1470  }
1471  
1472  export function getSessionCreatedTeams(): Set<string> {
1473    return STATE.sessionCreatedTeams
1474  }
1475  
1476  // Teleported session tracking for reliability logging
1477  export function setTeleportedSessionInfo(info: {
1478    sessionId: string | null
1479  }): void {
1480    STATE.teleportedSessionInfo = {
1481      isTeleported: true,
1482      hasLoggedFirstMessage: false,
1483      sessionId: info.sessionId,
1484    }
1485  }
1486  
1487  export function getTeleportedSessionInfo(): {
1488    isTeleported: boolean
1489    hasLoggedFirstMessage: boolean
1490    sessionId: string | null
1491  } | null {
1492    return STATE.teleportedSessionInfo
1493  }
1494  
1495  export function markFirstTeleportMessageLogged(): void {
1496    if (STATE.teleportedSessionInfo) {
1497      STATE.teleportedSessionInfo.hasLoggedFirstMessage = true
1498    }
1499  }
1500  
1501  // Invoked skills tracking for preservation across compaction
1502  export type InvokedSkillInfo = {
1503    skillName: string
1504    skillPath: string
1505    content: string
1506    invokedAt: number
1507    agentId: string | null
1508  }
1509  
1510  export function addInvokedSkill(
1511    skillName: string,
1512    skillPath: string,
1513    content: string,
1514    agentId: string | null = null,
1515  ): void {
1516    const key = `${agentId ?? ''}:${skillName}`
1517    STATE.invokedSkills.set(key, {
1518      skillName,
1519      skillPath,
1520      content,
1521      invokedAt: Date.now(),
1522      agentId,
1523    })
1524  }
1525  
1526  export function getInvokedSkills(): Map<string, InvokedSkillInfo> {
1527    return STATE.invokedSkills
1528  }
1529  
1530  export function getInvokedSkillsForAgent(
1531    agentId: string | undefined | null,
1532  ): Map<string, InvokedSkillInfo> {
1533    const normalizedId = agentId ?? null
1534    const filtered = new Map<string, InvokedSkillInfo>()
1535    for (const [key, skill] of STATE.invokedSkills) {
1536      if (skill.agentId === normalizedId) {
1537        filtered.set(key, skill)
1538      }
1539    }
1540    return filtered
1541  }
1542  
1543  export function clearInvokedSkills(
1544    preservedAgentIds?: ReadonlySet<string>,
1545  ): void {
1546    if (!preservedAgentIds || preservedAgentIds.size === 0) {
1547      STATE.invokedSkills.clear()
1548      return
1549    }
1550    for (const [key, skill] of STATE.invokedSkills) {
1551      if (skill.agentId === null || !preservedAgentIds.has(skill.agentId)) {
1552        STATE.invokedSkills.delete(key)
1553      }
1554    }
1555  }
1556  
1557  export function clearInvokedSkillsForAgent(agentId: string): void {
1558    for (const [key, skill] of STATE.invokedSkills) {
1559      if (skill.agentId === agentId) {
1560        STATE.invokedSkills.delete(key)
1561      }
1562    }
1563  }
1564  
1565  // Slow operations tracking for dev bar
1566  const MAX_SLOW_OPERATIONS = 10
1567  const SLOW_OPERATION_TTL_MS = 10000
1568  
1569  export function addSlowOperation(operation: string, durationMs: number): void {
1570    if (process.env.USER_TYPE !== 'ant') return
1571    // Skip tracking for editor sessions (user editing a prompt file in $EDITOR)
1572    // These are intentionally slow since the user is drafting text
1573    if (operation.includes('exec') && operation.includes('claude-prompt-')) {
1574      return
1575    }
1576    const now = Date.now()
1577    // Remove stale operations
1578    STATE.slowOperations = STATE.slowOperations.filter(
1579      op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
1580    )
1581    // Add new operation
1582    STATE.slowOperations.push({ operation, durationMs, timestamp: now })
1583    // Keep only the most recent operations
1584    if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) {
1585      STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS)
1586    }
1587  }
1588  
1589  const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
1590    operation: string
1591    durationMs: number
1592    timestamp: number
1593  }> = []
1594  
1595  export function getSlowOperations(): ReadonlyArray<{
1596    operation: string
1597    durationMs: number
1598    timestamp: number
1599  }> {
1600    // Most common case: nothing tracked. Return a stable reference so the
1601    // caller's setState() can bail via Object.is instead of re-rendering at 2fps.
1602    if (STATE.slowOperations.length === 0) {
1603      return EMPTY_SLOW_OPERATIONS
1604    }
1605    const now = Date.now()
1606    // Only allocate a new array when something actually expired; otherwise keep
1607    // the reference stable across polls while ops are still fresh.
1608    if (
1609      STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS)
1610    ) {
1611      STATE.slowOperations = STATE.slowOperations.filter(
1612        op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
1613      )
1614      if (STATE.slowOperations.length === 0) {
1615        return EMPTY_SLOW_OPERATIONS
1616      }
1617    }
1618    // Safe to return directly: addSlowOperation() reassigns STATE.slowOperations
1619    // before pushing, so the array held in React state is never mutated.
1620    return STATE.slowOperations
1621  }
1622  
1623  export function getMainThreadAgentType(): string | undefined {
1624    return STATE.mainThreadAgentType
1625  }
1626  
1627  export function setMainThreadAgentType(agentType: string | undefined): void {
1628    STATE.mainThreadAgentType = agentType
1629  }
1630  
1631  export function getIsRemoteMode(): boolean {
1632    return STATE.isRemoteMode
1633  }
1634  
1635  export function setIsRemoteMode(value: boolean): void {
1636    STATE.isRemoteMode = value
1637  }
1638  
1639  // System prompt section accessors
1640  
1641  export function getSystemPromptSectionCache(): Map<string, string | null> {
1642    return STATE.systemPromptSectionCache
1643  }
1644  
1645  export function setSystemPromptSectionCacheEntry(
1646    name: string,
1647    value: string | null,
1648  ): void {
1649    STATE.systemPromptSectionCache.set(name, value)
1650  }
1651  
1652  export function clearSystemPromptSectionState(): void {
1653    STATE.systemPromptSectionCache.clear()
1654  }
1655  
1656  // Last emitted date accessors (for detecting midnight date changes)
1657  
1658  export function getLastEmittedDate(): string | null {
1659    return STATE.lastEmittedDate
1660  }
1661  
1662  export function setLastEmittedDate(date: string | null): void {
1663    STATE.lastEmittedDate = date
1664  }
1665  
1666  export function getAdditionalDirectoriesForClaudeMd(): string[] {
1667    return STATE.additionalDirectoriesForClaudeMd
1668  }
1669  
1670  export function setAdditionalDirectoriesForClaudeMd(
1671    directories: string[],
1672  ): void {
1673    STATE.additionalDirectoriesForClaudeMd = directories
1674  }
1675  
1676  export function getAllowedChannels(): ChannelEntry[] {
1677    return STATE.allowedChannels
1678  }
1679  
1680  export function setAllowedChannels(entries: ChannelEntry[]): void {
1681    STATE.allowedChannels = entries
1682  }
1683  
1684  export function getHasDevChannels(): boolean {
1685    return STATE.hasDevChannels
1686  }
1687  
1688  export function setHasDevChannels(value: boolean): void {
1689    STATE.hasDevChannels = value
1690  }
1691  
1692  export function getPromptCache1hAllowlist(): string[] | null {
1693    return STATE.promptCache1hAllowlist
1694  }
1695  
1696  export function setPromptCache1hAllowlist(allowlist: string[] | null): void {
1697    STATE.promptCache1hAllowlist = allowlist
1698  }
1699  
1700  export function getPromptCache1hEligible(): boolean | null {
1701    return STATE.promptCache1hEligible
1702  }
1703  
1704  export function setPromptCache1hEligible(eligible: boolean | null): void {
1705    STATE.promptCache1hEligible = eligible
1706  }
1707  
1708  export function getAfkModeHeaderLatched(): boolean | null {
1709    return STATE.afkModeHeaderLatched
1710  }
1711  
1712  export function setAfkModeHeaderLatched(v: boolean): void {
1713    STATE.afkModeHeaderLatched = v
1714  }
1715  
1716  export function getFastModeHeaderLatched(): boolean | null {
1717    return STATE.fastModeHeaderLatched
1718  }
1719  
1720  export function setFastModeHeaderLatched(v: boolean): void {
1721    STATE.fastModeHeaderLatched = v
1722  }
1723  
1724  export function getCacheEditingHeaderLatched(): boolean | null {
1725    return STATE.cacheEditingHeaderLatched
1726  }
1727  
1728  export function setCacheEditingHeaderLatched(v: boolean): void {
1729    STATE.cacheEditingHeaderLatched = v
1730  }
1731  
1732  export function getThinkingClearLatched(): boolean | null {
1733    return STATE.thinkingClearLatched
1734  }
1735  
1736  export function setThinkingClearLatched(v: boolean): void {
1737    STATE.thinkingClearLatched = v
1738  }
1739  
1740  /**
1741   * Reset beta header latches to null. Called on /clear and /compact so a
1742   * fresh conversation gets fresh header evaluation.
1743   */
1744  export function clearBetaHeaderLatches(): void {
1745    STATE.afkModeHeaderLatched = null
1746    STATE.fastModeHeaderLatched = null
1747    STATE.cacheEditingHeaderLatched = null
1748    STATE.thinkingClearLatched = null
1749  }
1750  
1751  export function getPromptId(): string | null {
1752    return STATE.promptId
1753  }
1754  
1755  export function setPromptId(id: string | null): void {
1756    STATE.promptId = id
1757  }
1758