source dump of claude code
at main 1758 lines 56 kB view raw
1import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 2import type { Attributes, Meter, MetricOptions } from '@opentelemetry/api' 3import type { logs } from '@opentelemetry/api-logs' 4import type { LoggerProvider } from '@opentelemetry/sdk-logs' 5import type { MeterProvider } from '@opentelemetry/sdk-metrics' 6import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' 7import { realpathSync } from 'fs' 8import sumBy from 'lodash-es/sumBy.js' 9import { cwd } from 'process' 10import type { HookEvent, ModelUsage } from 'src/entrypoints/agentSdkTypes.js' 11import type { AgentColorName } from 'src/tools/AgentTool/agentColorManager.js' 12import 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 18import { randomUUID } from 'src/utils/crypto.js' 19import type { ModelSetting } from 'src/utils/model/model.js' 20import type { ModelStrings } from 'src/utils/model/modelStrings.js' 21import type { SettingSource } from 'src/utils/settings/constants.js' 22import { resetSettingsCache } from 'src/utils/settings/settingsCache.js' 23import type { PluginHookMatcher } from 'src/utils/settings/types.js' 24import { createSignal } from 'src/utils/signal.js' 25 26// Union type for registered hooks - can be SDK callbacks or native plugin hooks 27type RegisteredHookMatcher = HookCallbackMatcher | PluginHookMatcher 28 29import 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. 37export type ChannelEntry = 38 | { kind: 'plugin'; name: string; marketplace: string; dev?: boolean } 39 | { kind: 'server'; name: string; dev?: boolean } 40 41export type AttributedCounter = { 42 add(value: number, additionalAttributes?: Attributes): void 43} 44 45type 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 260function 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 429const STATE: State = getInitialState() 430 431export function getSessionId(): SessionId { 432 return STATE.sessionId 433} 434 435export 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 452export 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 */ 468export 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 481const 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 */ 489export 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 */ 496export function getSessionProjectDir(): string | null { 497 return STATE.sessionProjectDir 498} 499 500export 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 */ 511export function getProjectRoot(): string { 512 return STATE.projectRoot 513} 514 515export 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 */ 523export function setProjectRoot(cwd: string): void { 524 STATE.projectRoot = cwd.normalize('NFC') 525} 526 527export function getCwdState(): string { 528 return STATE.cwd 529} 530 531export function setCwdState(cwd: string): void { 532 STATE.cwd = cwd.normalize('NFC') 533} 534 535export function getDirectConnectServerUrl(): string | undefined { 536 return STATE.directConnectServerUrl 537} 538 539export function setDirectConnectServerUrl(url: string): void { 540 STATE.directConnectServerUrl = url 541} 542 543export function addToTotalDurationState( 544 duration: number, 545 durationWithoutRetries: number, 546): void { 547 STATE.totalAPIDuration += duration 548 STATE.totalAPIDurationWithoutRetries += durationWithoutRetries 549} 550 551export function resetTotalDurationStateAndCost_FOR_TESTS_ONLY(): void { 552 STATE.totalAPIDuration = 0 553 STATE.totalAPIDurationWithoutRetries = 0 554 STATE.totalCostUSD = 0 555} 556 557export function addToTotalCostState( 558 cost: number, 559 modelUsage: ModelUsage, 560 model: string, 561): void { 562 STATE.modelUsage[model] = modelUsage 563 STATE.totalCostUSD += cost 564} 565 566export function getTotalCostUSD(): number { 567 return STATE.totalCostUSD 568} 569 570export function getTotalAPIDuration(): number { 571 return STATE.totalAPIDuration 572} 573 574export function getTotalDuration(): number { 575 return Date.now() - STATE.startTime 576} 577 578export function getTotalAPIDurationWithoutRetries(): number { 579 return STATE.totalAPIDurationWithoutRetries 580} 581 582export function getTotalToolDuration(): number { 583 return STATE.totalToolDuration 584} 585 586export function addToToolDuration(duration: number): void { 587 STATE.totalToolDuration += duration 588 STATE.turnToolDurationMs += duration 589 STATE.turnToolCount++ 590} 591 592export function getTurnHookDurationMs(): number { 593 return STATE.turnHookDurationMs 594} 595 596export function addToTurnHookDuration(duration: number): void { 597 STATE.turnHookDurationMs += duration 598 STATE.turnHookCount++ 599} 600 601export function resetTurnHookDuration(): void { 602 STATE.turnHookDurationMs = 0 603 STATE.turnHookCount = 0 604} 605 606export function getTurnHookCount(): number { 607 return STATE.turnHookCount 608} 609 610export function getTurnToolDurationMs(): number { 611 return STATE.turnToolDurationMs 612} 613 614export function resetTurnToolDuration(): void { 615 STATE.turnToolDurationMs = 0 616 STATE.turnToolCount = 0 617} 618 619export function getTurnToolCount(): number { 620 return STATE.turnToolCount 621} 622 623export function getTurnClassifierDurationMs(): number { 624 return STATE.turnClassifierDurationMs 625} 626 627export function addToTurnClassifierDuration(duration: number): void { 628 STATE.turnClassifierDurationMs += duration 629 STATE.turnClassifierCount++ 630} 631 632export function resetTurnClassifierDuration(): void { 633 STATE.turnClassifierDurationMs = 0 634 STATE.turnClassifierCount = 0 635} 636 637export function getTurnClassifierCount(): number { 638 return STATE.turnClassifierCount 639} 640 641export function getStatsStore(): { 642 observe(name: string, value: number): void 643} | null { 644 return STATE.statsStore 645} 646 647export 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 */ 665let interactionTimeDirty = false 666 667export 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 */ 680export function flushInteractionTime(): void { 681 if (interactionTimeDirty) { 682 flushInteractionTime_inner() 683 } 684} 685 686function flushInteractionTime_inner(): void { 687 STATE.lastInteractionTime = Date.now() 688 interactionTimeDirty = false 689} 690 691export function addToTotalLinesChanged(added: number, removed: number): void { 692 STATE.totalLinesAdded += added 693 STATE.totalLinesRemoved += removed 694} 695 696export function getTotalLinesAdded(): number { 697 return STATE.totalLinesAdded 698} 699 700export function getTotalLinesRemoved(): number { 701 return STATE.totalLinesRemoved 702} 703 704export function getTotalInputTokens(): number { 705 return sumBy(Object.values(STATE.modelUsage), 'inputTokens') 706} 707 708export function getTotalOutputTokens(): number { 709 return sumBy(Object.values(STATE.modelUsage), 'outputTokens') 710} 711 712export function getTotalCacheReadInputTokens(): number { 713 return sumBy(Object.values(STATE.modelUsage), 'cacheReadInputTokens') 714} 715 716export function getTotalCacheCreationInputTokens(): number { 717 return sumBy(Object.values(STATE.modelUsage), 'cacheCreationInputTokens') 718} 719 720export function getTotalWebSearchRequests(): number { 721 return sumBy(Object.values(STATE.modelUsage), 'webSearchRequests') 722} 723 724let outputTokensAtTurnStart = 0 725let currentTurnTokenBudget: number | null = null 726export function getTurnOutputTokens(): number { 727 return getTotalOutputTokens() - outputTokensAtTurnStart 728} 729export function getCurrentTurnTokenBudget(): number | null { 730 return currentTurnTokenBudget 731} 732let budgetContinuationCount = 0 733export function snapshotOutputTokensForTurn(budget: number | null): void { 734 outputTokensAtTurnStart = getTotalOutputTokens() 735 currentTurnTokenBudget = budget 736 budgetContinuationCount = 0 737} 738export function getBudgetContinuationCount(): number { 739 return budgetContinuationCount 740} 741export function incrementBudgetContinuationCount(): void { 742 budgetContinuationCount++ 743} 744 745export function setHasUnknownModelCost(): void { 746 STATE.hasUnknownModelCost = true 747} 748 749export function hasUnknownModelCost(): boolean { 750 return STATE.hasUnknownModelCost 751} 752 753export function getLastMainRequestId(): string | undefined { 754 return STATE.lastMainRequestId 755} 756 757export function setLastMainRequestId(requestId: string): void { 758 STATE.lastMainRequestId = requestId 759} 760 761export function getLastApiCompletionTimestamp(): number | null { 762 return STATE.lastApiCompletionTimestamp 763} 764 765export 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. */ 771export 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. */ 777export function consumePostCompaction(): boolean { 778 const was = STATE.pendingPostCompaction 779 STATE.pendingPostCompaction = false 780 return was 781} 782 783export 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. 792let scrollDraining = false 793let scrollDrainTimer: ReturnType<typeof setTimeout> | undefined 794const 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. */ 798export 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. */ 811export 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. */ 818export 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 826export function getModelUsage(): { [modelName: string]: ModelUsage } { 827 return STATE.modelUsage 828} 829 830export 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 */ 838export function getMainLoopModelOverride(): ModelSetting | undefined { 839 return STATE.mainLoopModelOverride 840} 841 842export function getInitialMainLoopModel(): ModelSetting { 843 return STATE.initialMainLoopModel 844} 845 846export function setMainLoopModelOverride( 847 model: ModelSetting | undefined, 848): void { 849 STATE.mainLoopModelOverride = model 850} 851 852export function setInitialMainLoopModel(model: ModelSetting): void { 853 STATE.initialMainLoopModel = model 854} 855 856export function getSdkBetas(): string[] | undefined { 857 return STATE.sdkBetas 858} 859 860export function setSdkBetas(betas: string[] | undefined): void { 861 STATE.sdkBetas = betas 862} 863 864export 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 */ 881export 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 919export 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() 933export function getModelStrings(): ModelStrings | null { 934 return STATE.modelStrings 935} 936 937// You shouldn't use this directly. See src/utils/model/modelStrings.ts 938export 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. 944export function resetModelStringsForTestingOnly() { 945 STATE.modelStrings = null 946} 947 948export 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 989export function getMeter(): Meter | null { 990 return STATE.meter 991} 992 993export function getSessionCounter(): AttributedCounter | null { 994 return STATE.sessionCounter 995} 996 997export function getLocCounter(): AttributedCounter | null { 998 return STATE.locCounter 999} 1000 1001export function getPrCounter(): AttributedCounter | null { 1002 return STATE.prCounter 1003} 1004 1005export function getCommitCounter(): AttributedCounter | null { 1006 return STATE.commitCounter 1007} 1008 1009export function getCostCounter(): AttributedCounter | null { 1010 return STATE.costCounter 1011} 1012 1013export function getTokenCounter(): AttributedCounter | null { 1014 return STATE.tokenCounter 1015} 1016 1017export function getCodeEditToolDecisionCounter(): AttributedCounter | null { 1018 return STATE.codeEditToolDecisionCounter 1019} 1020 1021export function getActiveTimeCounter(): AttributedCounter | null { 1022 return STATE.activeTimeCounter 1023} 1024 1025export function getLoggerProvider(): LoggerProvider | null { 1026 return STATE.loggerProvider 1027} 1028 1029export function setLoggerProvider(provider: LoggerProvider | null): void { 1030 STATE.loggerProvider = provider 1031} 1032 1033export function getEventLogger(): ReturnType<typeof logs.getLogger> | null { 1034 return STATE.eventLogger 1035} 1036 1037export function setEventLogger( 1038 logger: ReturnType<typeof logs.getLogger> | null, 1039): void { 1040 STATE.eventLogger = logger 1041} 1042 1043export function getMeterProvider(): MeterProvider | null { 1044 return STATE.meterProvider 1045} 1046 1047export function setMeterProvider(provider: MeterProvider | null): void { 1048 STATE.meterProvider = provider 1049} 1050export function getTracerProvider(): BasicTracerProvider | null { 1051 return STATE.tracerProvider 1052} 1053export function setTracerProvider(provider: BasicTracerProvider | null): void { 1054 STATE.tracerProvider = provider 1055} 1056 1057export function getIsNonInteractiveSession(): boolean { 1058 return !STATE.isInteractive 1059} 1060 1061export function getIsInteractive(): boolean { 1062 return STATE.isInteractive 1063} 1064 1065export function setIsInteractive(value: boolean): void { 1066 STATE.isInteractive = value 1067} 1068 1069export function getClientType(): string { 1070 return STATE.clientType 1071} 1072 1073export function setClientType(type: string): void { 1074 STATE.clientType = type 1075} 1076 1077export function getSdkAgentProgressSummariesEnabled(): boolean { 1078 return STATE.sdkAgentProgressSummariesEnabled 1079} 1080 1081export function setSdkAgentProgressSummariesEnabled(value: boolean): void { 1082 STATE.sdkAgentProgressSummariesEnabled = value 1083} 1084 1085export function getKairosActive(): boolean { 1086 return STATE.kairosActive 1087} 1088 1089export function setKairosActive(value: boolean): void { 1090 STATE.kairosActive = value 1091} 1092 1093export function getStrictToolResultPairing(): boolean { 1094 return STATE.strictToolResultPairing 1095} 1096 1097export 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). 1104export function getUserMsgOptIn(): boolean { 1105 return STATE.userMsgOptIn 1106} 1107 1108export function setUserMsgOptIn(value: boolean): void { 1109 STATE.userMsgOptIn = value 1110} 1111 1112export function getSessionSource(): string | undefined { 1113 return STATE.sessionSource 1114} 1115 1116export function setSessionSource(source: string): void { 1117 STATE.sessionSource = source 1118} 1119 1120export function getQuestionPreviewFormat(): 'markdown' | 'html' | undefined { 1121 return STATE.questionPreviewFormat 1122} 1123 1124export function setQuestionPreviewFormat(format: 'markdown' | 'html'): void { 1125 STATE.questionPreviewFormat = format 1126} 1127 1128export function getAgentColorMap(): Map<string, AgentColorName> { 1129 return STATE.agentColorMap 1130} 1131 1132export function getFlagSettingsPath(): string | undefined { 1133 return STATE.flagSettingsPath 1134} 1135 1136export function setFlagSettingsPath(path: string | undefined): void { 1137 STATE.flagSettingsPath = path 1138} 1139 1140export function getFlagSettingsInline(): Record<string, unknown> | null { 1141 return STATE.flagSettingsInline 1142} 1143 1144export function setFlagSettingsInline( 1145 settings: Record<string, unknown> | null, 1146): void { 1147 STATE.flagSettingsInline = settings 1148} 1149 1150export function getSessionIngressToken(): string | null | undefined { 1151 return STATE.sessionIngressToken 1152} 1153 1154export function setSessionIngressToken(token: string | null): void { 1155 STATE.sessionIngressToken = token 1156} 1157 1158export function getOauthTokenFromFd(): string | null | undefined { 1159 return STATE.oauthTokenFromFd 1160} 1161 1162export function setOauthTokenFromFd(token: string | null): void { 1163 STATE.oauthTokenFromFd = token 1164} 1165 1166export function getApiKeyFromFd(): string | null | undefined { 1167 return STATE.apiKeyFromFd 1168} 1169 1170export function setApiKeyFromFd(key: string | null): void { 1171 STATE.apiKeyFromFd = key 1172} 1173 1174export function setLastAPIRequest( 1175 params: Omit<BetaMessageStreamParams, 'messages'> | null, 1176): void { 1177 STATE.lastAPIRequest = params 1178} 1179 1180export function getLastAPIRequest(): Omit< 1181 BetaMessageStreamParams, 1182 'messages' 1183> | null { 1184 return STATE.lastAPIRequest 1185} 1186 1187export function setLastAPIRequestMessages( 1188 messages: BetaMessageStreamParams['messages'] | null, 1189): void { 1190 STATE.lastAPIRequestMessages = messages 1191} 1192 1193export function getLastAPIRequestMessages(): 1194 | BetaMessageStreamParams['messages'] 1195 | null { 1196 return STATE.lastAPIRequestMessages 1197} 1198 1199export function setLastClassifierRequests(requests: unknown[] | null): void { 1200 STATE.lastClassifierRequests = requests 1201} 1202 1203export function getLastClassifierRequests(): unknown[] | null { 1204 return STATE.lastClassifierRequests 1205} 1206 1207export function setCachedClaudeMdContent(content: string | null): void { 1208 STATE.cachedClaudeMdContent = content 1209} 1210 1211export function getCachedClaudeMdContent(): string | null { 1212 return STATE.cachedClaudeMdContent 1213} 1214 1215export 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 1226export function getAllowedSettingSources(): SettingSource[] { 1227 return STATE.allowedSettingSources 1228} 1229 1230export function setAllowedSettingSources(sources: SettingSource[]): void { 1231 STATE.allowedSettingSources = sources 1232} 1233 1234export function preferThirdPartyAuthentication(): boolean { 1235 // IDE extension should behave as 1P for authentication reasons. 1236 return getIsNonInteractiveSession() && STATE.clientType !== 'claude-vscode' 1237} 1238 1239export function setInlinePlugins(plugins: Array<string>): void { 1240 STATE.inlinePlugins = plugins 1241} 1242 1243export function getInlinePlugins(): Array<string> { 1244 return STATE.inlinePlugins 1245} 1246 1247export function setChromeFlagOverride(value: boolean | undefined): void { 1248 STATE.chromeFlagOverride = value 1249} 1250 1251export function getChromeFlagOverride(): boolean | undefined { 1252 return STATE.chromeFlagOverride 1253} 1254 1255export function setUseCoworkPlugins(value: boolean): void { 1256 STATE.useCoworkPlugins = value 1257 resetSettingsCache() 1258} 1259 1260export function getUseCoworkPlugins(): boolean { 1261 return STATE.useCoworkPlugins 1262} 1263 1264export function setSessionBypassPermissionsMode(enabled: boolean): void { 1265 STATE.sessionBypassPermissionsMode = enabled 1266} 1267 1268export function getSessionBypassPermissionsMode(): boolean { 1269 return STATE.sessionBypassPermissionsMode 1270} 1271 1272export function setScheduledTasksEnabled(enabled: boolean): void { 1273 STATE.scheduledTasksEnabled = enabled 1274} 1275 1276export function getScheduledTasksEnabled(): boolean { 1277 return STATE.scheduledTasksEnabled 1278} 1279 1280export 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 1294export function getSessionCronTasks(): SessionCronTask[] { 1295 return STATE.sessionCronTasks 1296} 1297 1298export 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 */ 1307export 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 1317export function setSessionTrustAccepted(accepted: boolean): void { 1318 STATE.sessionTrustAccepted = accepted 1319} 1320 1321export function getSessionTrustAccepted(): boolean { 1322 return STATE.sessionTrustAccepted 1323} 1324 1325export function setSessionPersistenceDisabled(disabled: boolean): void { 1326 STATE.sessionPersistenceDisabled = disabled 1327} 1328 1329export function isSessionPersistenceDisabled(): boolean { 1330 return STATE.sessionPersistenceDisabled 1331} 1332 1333export function hasExitedPlanModeInSession(): boolean { 1334 return STATE.hasExitedPlanMode 1335} 1336 1337export function setHasExitedPlanMode(value: boolean): void { 1338 STATE.hasExitedPlanMode = value 1339} 1340 1341export function needsPlanModeExitAttachment(): boolean { 1342 return STATE.needsPlanModeExitAttachment 1343} 1344 1345export function setNeedsPlanModeExitAttachment(value: boolean): void { 1346 STATE.needsPlanModeExitAttachment = value 1347} 1348 1349export 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 1365export function needsAutoModeExitAttachment(): boolean { 1366 return STATE.needsAutoModeExitAttachment 1367} 1368 1369export function setNeedsAutoModeExitAttachment(value: boolean): void { 1370 STATE.needsAutoModeExitAttachment = value 1371} 1372 1373export 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 1402export function hasShownLspRecommendationThisSession(): boolean { 1403 return STATE.lspRecommendationShownThisSession 1404} 1405 1406export function setLspRecommendationShownThisSession(value: boolean): void { 1407 STATE.lspRecommendationShownThisSession = value 1408} 1409 1410// SDK init event state 1411export function setInitJsonSchema(schema: Record<string, unknown>): void { 1412 STATE.initJsonSchema = schema 1413} 1414 1415export function getInitJsonSchema(): Record<string, unknown> | null { 1416 return STATE.initJsonSchema 1417} 1418 1419export 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 1436export function getRegisteredHooks(): Partial< 1437 Record<HookEvent, RegisteredHookMatcher[]> 1438> | null { 1439 return STATE.registeredHooks 1440} 1441 1442export function clearRegisteredHooks(): void { 1443 STATE.registeredHooks = null 1444} 1445 1446export 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 1463export function resetSdkInitState(): void { 1464 STATE.initJsonSchema = null 1465 STATE.registeredHooks = null 1466} 1467 1468export function getPlanSlugCache(): Map<string, string> { 1469 return STATE.planSlugCache 1470} 1471 1472export function getSessionCreatedTeams(): Set<string> { 1473 return STATE.sessionCreatedTeams 1474} 1475 1476// Teleported session tracking for reliability logging 1477export 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 1487export function getTeleportedSessionInfo(): { 1488 isTeleported: boolean 1489 hasLoggedFirstMessage: boolean 1490 sessionId: string | null 1491} | null { 1492 return STATE.teleportedSessionInfo 1493} 1494 1495export function markFirstTeleportMessageLogged(): void { 1496 if (STATE.teleportedSessionInfo) { 1497 STATE.teleportedSessionInfo.hasLoggedFirstMessage = true 1498 } 1499} 1500 1501// Invoked skills tracking for preservation across compaction 1502export type InvokedSkillInfo = { 1503 skillName: string 1504 skillPath: string 1505 content: string 1506 invokedAt: number 1507 agentId: string | null 1508} 1509 1510export 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 1526export function getInvokedSkills(): Map<string, InvokedSkillInfo> { 1527 return STATE.invokedSkills 1528} 1529 1530export 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 1543export 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 1557export 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 1566const MAX_SLOW_OPERATIONS = 10 1567const SLOW_OPERATION_TTL_MS = 10000 1568 1569export 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 1589const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{ 1590 operation: string 1591 durationMs: number 1592 timestamp: number 1593}> = [] 1594 1595export 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 1623export function getMainThreadAgentType(): string | undefined { 1624 return STATE.mainThreadAgentType 1625} 1626 1627export function setMainThreadAgentType(agentType: string | undefined): void { 1628 STATE.mainThreadAgentType = agentType 1629} 1630 1631export function getIsRemoteMode(): boolean { 1632 return STATE.isRemoteMode 1633} 1634 1635export function setIsRemoteMode(value: boolean): void { 1636 STATE.isRemoteMode = value 1637} 1638 1639// System prompt section accessors 1640 1641export function getSystemPromptSectionCache(): Map<string, string | null> { 1642 return STATE.systemPromptSectionCache 1643} 1644 1645export function setSystemPromptSectionCacheEntry( 1646 name: string, 1647 value: string | null, 1648): void { 1649 STATE.systemPromptSectionCache.set(name, value) 1650} 1651 1652export function clearSystemPromptSectionState(): void { 1653 STATE.systemPromptSectionCache.clear() 1654} 1655 1656// Last emitted date accessors (for detecting midnight date changes) 1657 1658export function getLastEmittedDate(): string | null { 1659 return STATE.lastEmittedDate 1660} 1661 1662export function setLastEmittedDate(date: string | null): void { 1663 STATE.lastEmittedDate = date 1664} 1665 1666export function getAdditionalDirectoriesForClaudeMd(): string[] { 1667 return STATE.additionalDirectoriesForClaudeMd 1668} 1669 1670export function setAdditionalDirectoriesForClaudeMd( 1671 directories: string[], 1672): void { 1673 STATE.additionalDirectoriesForClaudeMd = directories 1674} 1675 1676export function getAllowedChannels(): ChannelEntry[] { 1677 return STATE.allowedChannels 1678} 1679 1680export function setAllowedChannels(entries: ChannelEntry[]): void { 1681 STATE.allowedChannels = entries 1682} 1683 1684export function getHasDevChannels(): boolean { 1685 return STATE.hasDevChannels 1686} 1687 1688export function setHasDevChannels(value: boolean): void { 1689 STATE.hasDevChannels = value 1690} 1691 1692export function getPromptCache1hAllowlist(): string[] | null { 1693 return STATE.promptCache1hAllowlist 1694} 1695 1696export function setPromptCache1hAllowlist(allowlist: string[] | null): void { 1697 STATE.promptCache1hAllowlist = allowlist 1698} 1699 1700export function getPromptCache1hEligible(): boolean | null { 1701 return STATE.promptCache1hEligible 1702} 1703 1704export function setPromptCache1hEligible(eligible: boolean | null): void { 1705 STATE.promptCache1hEligible = eligible 1706} 1707 1708export function getAfkModeHeaderLatched(): boolean | null { 1709 return STATE.afkModeHeaderLatched 1710} 1711 1712export function setAfkModeHeaderLatched(v: boolean): void { 1713 STATE.afkModeHeaderLatched = v 1714} 1715 1716export function getFastModeHeaderLatched(): boolean | null { 1717 return STATE.fastModeHeaderLatched 1718} 1719 1720export function setFastModeHeaderLatched(v: boolean): void { 1721 STATE.fastModeHeaderLatched = v 1722} 1723 1724export function getCacheEditingHeaderLatched(): boolean | null { 1725 return STATE.cacheEditingHeaderLatched 1726} 1727 1728export function setCacheEditingHeaderLatched(v: boolean): void { 1729 STATE.cacheEditingHeaderLatched = v 1730} 1731 1732export function getThinkingClearLatched(): boolean | null { 1733 return STATE.thinkingClearLatched 1734} 1735 1736export 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 */ 1744export function clearBetaHeaderLatches(): void { 1745 STATE.afkModeHeaderLatched = null 1746 STATE.fastModeHeaderLatched = null 1747 STATE.cacheEditingHeaderLatched = null 1748 STATE.thinkingClearLatched = null 1749} 1750 1751export function getPromptId(): string | null { 1752 return STATE.promptId 1753} 1754 1755export function setPromptId(id: string | null): void { 1756 STATE.promptId = id 1757} 1758