source dump of claude code
23
fork

Configure Feed

Select the types of activity you want to include in your feed.

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