1759 lines
55 KiB
TypeScript
1759 lines
55 KiB
TypeScript
import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
|
import type { Attributes, Meter, MetricOptions } from '@opentelemetry/api'
|
|
import type { logs } from '@opentelemetry/api-logs'
|
|
import type { LoggerProvider } from '@opentelemetry/sdk-logs'
|
|
import type { MeterProvider } from '@opentelemetry/sdk-metrics'
|
|
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'
|
|
import { realpathSync } from 'fs'
|
|
import sumBy from 'lodash-es/sumBy.js'
|
|
import { cwd } from 'process'
|
|
import type { HookEvent, ModelUsage } from 'src/entrypoints/agentSdkTypes.js'
|
|
import type { AgentColorName } from 'src/tools/AgentTool/agentColorManager.js'
|
|
import type { HookCallbackMatcher } from 'src/types/hooks.js'
|
|
// Indirection for browser-sdk build (package.json "browser" field swaps
|
|
// crypto.ts for crypto.browser.ts). Pure leaf re-export of node:crypto —
|
|
// zero circular-dep risk. Path-alias import bypasses bootstrap-isolation
|
|
// (rule only checks ./ and / prefixes); explicit disable documents intent.
|
|
// eslint-disable-next-line custom-rules/bootstrap-isolation
|
|
import { randomUUID } from 'src/utils/crypto.js'
|
|
import type { ModelSetting } from 'src/utils/model/model.js'
|
|
import type { ModelStrings } from 'src/utils/model/modelStrings.js'
|
|
import type { SettingSource } from 'src/utils/settings/constants.js'
|
|
import { resetSettingsCache } from 'src/utils/settings/settingsCache.js'
|
|
import type { PluginHookMatcher } from 'src/utils/settings/types.js'
|
|
import { createSignal } from 'src/utils/signal.js'
|
|
|
|
// Union type for registered hooks - can be SDK callbacks or native plugin hooks
|
|
type RegisteredHookMatcher = HookCallbackMatcher | PluginHookMatcher
|
|
|
|
import type { SessionId } from 'src/types/ids.js'
|
|
|
|
// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE
|
|
|
|
// dev: true on entries that came via --dangerously-load-development-channels.
|
|
// The allowlist gate checks this per-entry (not the session-wide
|
|
// hasDevChannels bit) so passing both flags doesn't let the dev dialog's
|
|
// acceptance leak allowlist-bypass to the --channels entries.
|
|
export type ChannelEntry =
|
|
| { kind: 'plugin'; name: string; marketplace: string; dev?: boolean }
|
|
| { kind: 'server'; name: string; dev?: boolean }
|
|
|
|
export type AttributedCounter = {
|
|
add(value: number, additionalAttributes?: Attributes): void
|
|
}
|
|
|
|
type State = {
|
|
originalCwd: string
|
|
// Stable project root - set once at startup (including by --worktree flag),
|
|
// never updated by mid-session EnterWorktreeTool.
|
|
// Use for project identity (history, skills, sessions) not file operations.
|
|
projectRoot: string
|
|
totalCostUSD: number
|
|
totalAPIDuration: number
|
|
totalAPIDurationWithoutRetries: number
|
|
totalToolDuration: number
|
|
turnHookDurationMs: number
|
|
turnToolDurationMs: number
|
|
turnClassifierDurationMs: number
|
|
turnToolCount: number
|
|
turnHookCount: number
|
|
turnClassifierCount: number
|
|
startTime: number
|
|
lastInteractionTime: number
|
|
totalLinesAdded: number
|
|
totalLinesRemoved: number
|
|
hasUnknownModelCost: boolean
|
|
cwd: string
|
|
modelUsage: { [modelName: string]: ModelUsage }
|
|
mainLoopModelOverride: ModelSetting | undefined
|
|
initialMainLoopModel: ModelSetting
|
|
modelStrings: ModelStrings | null
|
|
isInteractive: boolean
|
|
kairosActive: boolean
|
|
// When true, ensureToolResultPairing throws on mismatch instead of
|
|
// repairing with synthetic placeholders. HFI opts in at startup so
|
|
// trajectories fail fast rather than conditioning the model on fake
|
|
// tool_results.
|
|
strictToolResultPairing: boolean
|
|
sdkAgentProgressSummariesEnabled: boolean
|
|
userMsgOptIn: boolean
|
|
clientType: string
|
|
sessionSource: string | undefined
|
|
questionPreviewFormat: 'markdown' | 'html' | undefined
|
|
flagSettingsPath: string | undefined
|
|
flagSettingsInline: Record<string, unknown> | null
|
|
allowedSettingSources: SettingSource[]
|
|
sessionIngressToken: string | null | undefined
|
|
oauthTokenFromFd: string | null | undefined
|
|
apiKeyFromFd: string | null | undefined
|
|
// Telemetry state
|
|
meter: Meter | null
|
|
sessionCounter: AttributedCounter | null
|
|
locCounter: AttributedCounter | null
|
|
prCounter: AttributedCounter | null
|
|
commitCounter: AttributedCounter | null
|
|
costCounter: AttributedCounter | null
|
|
tokenCounter: AttributedCounter | null
|
|
codeEditToolDecisionCounter: AttributedCounter | null
|
|
activeTimeCounter: AttributedCounter | null
|
|
statsStore: { observe(name: string, value: number): void } | null
|
|
sessionId: SessionId
|
|
// Parent session ID for tracking session lineage (e.g., plan mode -> implementation)
|
|
parentSessionId: SessionId | undefined
|
|
// Logger state
|
|
loggerProvider: LoggerProvider | null
|
|
eventLogger: ReturnType<typeof logs.getLogger> | null
|
|
// Meter provider state
|
|
meterProvider: MeterProvider | null
|
|
// Tracer provider state
|
|
tracerProvider: BasicTracerProvider | null
|
|
// Agent color state
|
|
agentColorMap: Map<string, AgentColorName>
|
|
agentColorIndex: number
|
|
// Last API request for bug reports
|
|
lastAPIRequest: Omit<BetaMessageStreamParams, 'messages'> | null
|
|
// Messages from the last API request (ant-only; reference, not clone).
|
|
// Captures the exact post-compaction, CLAUDE.md-injected message set sent
|
|
// to the API so /share's serialized_conversation.json reflects reality.
|
|
lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null
|
|
// Last auto-mode classifier request(s) for /share transcript
|
|
lastClassifierRequests: unknown[] | null
|
|
// CLAUDE.md content cached by context.ts for the auto-mode classifier.
|
|
// Breaks the yoloClassifier → claudemd → filesystem → permissions cycle.
|
|
cachedClaudeMdContent: string | null
|
|
// In-memory error log for recent errors
|
|
inMemoryErrorLog: Array<{ error: string; timestamp: string }>
|
|
// Session-only plugins from --plugin-dir flag
|
|
inlinePlugins: Array<string>
|
|
// Explicit --chrome / --no-chrome flag value (undefined = not set on CLI)
|
|
chromeFlagOverride: boolean | undefined
|
|
// Use cowork_plugins directory instead of plugins (--cowork flag or env var)
|
|
useCoworkPlugins: boolean
|
|
// Session-only bypass permissions mode flag (not persisted)
|
|
sessionBypassPermissionsMode: boolean
|
|
// Session-only flag gating the .claude/scheduled_tasks.json watcher
|
|
// (useScheduledTasks). Set by cronScheduler.start() when the JSON has
|
|
// entries, or by CronCreateTool. Not persisted.
|
|
scheduledTasksEnabled: boolean
|
|
// Session-only cron tasks created via CronCreate with durable: false.
|
|
// Fire on schedule like file-backed tasks but are never written to
|
|
// .claude/scheduled_tasks.json — they die with the process. Typed via
|
|
// SessionCronTask below (not importing from cronTasks.ts keeps
|
|
// bootstrap a leaf of the import DAG).
|
|
sessionCronTasks: SessionCronTask[]
|
|
// Teams created this session via TeamCreate. cleanupSessionTeams()
|
|
// removes these on gracefulShutdown so subagent-created teams don't
|
|
// persist on disk forever (gh-32730). TeamDelete removes entries to
|
|
// avoid double-cleanup. Lives here (not teamHelpers.ts) so
|
|
// resetStateForTests() clears it between tests.
|
|
sessionCreatedTeams: Set<string>
|
|
// Session-only trust flag for home directory (not persisted to disk)
|
|
// When running from home dir, trust dialog is shown but not saved to disk.
|
|
// This flag allows features requiring trust to work during the session.
|
|
sessionTrustAccepted: boolean
|
|
// Session-only flag to disable session persistence to disk
|
|
sessionPersistenceDisabled: boolean
|
|
// Track if user has exited plan mode in this session (for re-entry guidance)
|
|
hasExitedPlanMode: boolean
|
|
// Track if we need to show the plan mode exit attachment (one-time notification)
|
|
needsPlanModeExitAttachment: boolean
|
|
// Track if we need to show the auto mode exit attachment (one-time notification)
|
|
needsAutoModeExitAttachment: boolean
|
|
// Track if LSP plugin recommendation has been shown this session (only show once)
|
|
lspRecommendationShownThisSession: boolean
|
|
// SDK init event state - jsonSchema for structured output
|
|
initJsonSchema: Record<string, unknown> | null
|
|
// Registered hooks - SDK callbacks and plugin native hooks
|
|
registeredHooks: Partial<Record<HookEvent, RegisteredHookMatcher[]>> | null
|
|
// Cache for plan slugs: sessionId -> wordSlug
|
|
planSlugCache: Map<string, string>
|
|
// Track teleported session for reliability logging
|
|
teleportedSessionInfo: {
|
|
isTeleported: boolean
|
|
hasLoggedFirstMessage: boolean
|
|
sessionId: string | null
|
|
} | null
|
|
// Track invoked skills for preservation across compaction
|
|
// Keys are composite: `${agentId ?? ''}:${skillName}` to prevent cross-agent overwrites
|
|
invokedSkills: Map<
|
|
string,
|
|
{
|
|
skillName: string
|
|
skillPath: string
|
|
content: string
|
|
invokedAt: number
|
|
agentId: string | null
|
|
}
|
|
>
|
|
// Track slow operations for dev bar display (ant-only)
|
|
slowOperations: Array<{
|
|
operation: string
|
|
durationMs: number
|
|
timestamp: number
|
|
}>
|
|
// SDK-provided betas (e.g., context-1m-2025-08-07)
|
|
sdkBetas: string[] | undefined
|
|
// Main thread agent type (from --agent flag or settings)
|
|
mainThreadAgentType: string | undefined
|
|
// Remote mode (--remote flag)
|
|
isRemoteMode: boolean
|
|
// Direct connect server URL (for display in header)
|
|
directConnectServerUrl: string | undefined
|
|
// System prompt section cache state
|
|
systemPromptSectionCache: Map<string, string | null>
|
|
// Last date emitted to the model (for detecting midnight date changes)
|
|
lastEmittedDate: string | null
|
|
// Additional directories from --add-dir flag (for CLAUDE.md loading)
|
|
additionalDirectoriesForClaudeMd: string[]
|
|
// Channel server allowlist from --channels flag (servers whose channel
|
|
// notifications should register this session). Parsed once in main.tsx —
|
|
// the tag decides trust model: 'plugin' → marketplace verification +
|
|
// allowlist, 'server' → allowlist always fails (schema is plugin-only).
|
|
// Either kind needs entry.dev to bypass allowlist.
|
|
allowedChannels: ChannelEntry[]
|
|
// True if any entry in allowedChannels came from
|
|
// --dangerously-load-development-channels (so ChannelsNotice can name the
|
|
// right flag in policy-blocked messages)
|
|
hasDevChannels: boolean
|
|
// Dir containing the session's `.jsonl`; null = derive from originalCwd.
|
|
sessionProjectDir: string | null
|
|
// Cached prompt cache 1h TTL allowlist from GrowthBook (session-stable)
|
|
promptCache1hAllowlist: string[] | null
|
|
// Cached 1h TTL user eligibility (session-stable). Latched on first
|
|
// evaluation so mid-session overage flips don't change the cache_control
|
|
// TTL, which would bust the server-side prompt cache.
|
|
promptCache1hEligible: boolean | null
|
|
// Sticky-on latch for AFK_MODE_BETA_HEADER. Once auto mode is first
|
|
// activated, keep sending the header for the rest of the session so
|
|
// Shift+Tab toggles don't bust the ~50-70K token prompt cache.
|
|
afkModeHeaderLatched: boolean | null
|
|
// Sticky-on latch for FAST_MODE_BETA_HEADER. Once fast mode is first
|
|
// enabled, keep sending the header so cooldown enter/exit doesn't
|
|
// double-bust the prompt cache. The `speed` body param stays dynamic.
|
|
fastModeHeaderLatched: boolean | null
|
|
// Sticky-on latch for the cache-editing beta header. Once cached
|
|
// microcompact is first enabled, keep sending the header so mid-session
|
|
// GrowthBook/settings toggles don't bust the prompt cache.
|
|
cacheEditingHeaderLatched: boolean | null
|
|
// Sticky-on latch for clearing thinking from prior tool loops. Triggered
|
|
// when >1h since last API call (confirmed cache miss — no cache-hit
|
|
// benefit to keeping thinking). Once latched, stays on so the newly-warmed
|
|
// thinking-cleared cache isn't busted by flipping back to keep:'all'.
|
|
thinkingClearLatched: boolean | null
|
|
// Current prompt ID (UUID) correlating a user prompt with subsequent OTel events
|
|
promptId: string | null
|
|
// Last API requestId for the main conversation chain (not subagents).
|
|
// Updated after each successful API response for main-session queries.
|
|
// Read at shutdown to send cache eviction hints to inference.
|
|
lastMainRequestId: string | undefined
|
|
// Timestamp (Date.now()) of the last successful API call completion.
|
|
// Used to compute timeSinceLastApiCallMs in tengu_api_success for
|
|
// correlating cache misses with idle time (cache TTL is ~5min).
|
|
lastApiCompletionTimestamp: number | null
|
|
// Set to true after compaction (auto or manual /compact). Consumed by
|
|
// logAPISuccess to tag the first post-compaction API call so we can
|
|
// distinguish compaction-induced cache misses from TTL expiry.
|
|
pendingPostCompaction: boolean
|
|
}
|
|
|
|
// ALSO HERE - THINK THRICE BEFORE MODIFYING
|
|
function getInitialState(): State {
|
|
// Resolve symlinks in cwd to match behavior of shell.ts setCwd
|
|
// This ensures consistency with how paths are sanitized for session storage
|
|
let resolvedCwd = ''
|
|
if (
|
|
typeof process !== 'undefined' &&
|
|
typeof process.cwd === 'function' &&
|
|
typeof realpathSync === 'function'
|
|
) {
|
|
const rawCwd = cwd()
|
|
try {
|
|
resolvedCwd = realpathSync(rawCwd).normalize('NFC')
|
|
} catch {
|
|
// File Provider EPERM on CloudStorage mounts (lstat per path component).
|
|
resolvedCwd = rawCwd.normalize('NFC')
|
|
}
|
|
}
|
|
const state: State = {
|
|
originalCwd: resolvedCwd,
|
|
projectRoot: resolvedCwd,
|
|
totalCostUSD: 0,
|
|
totalAPIDuration: 0,
|
|
totalAPIDurationWithoutRetries: 0,
|
|
totalToolDuration: 0,
|
|
turnHookDurationMs: 0,
|
|
turnToolDurationMs: 0,
|
|
turnClassifierDurationMs: 0,
|
|
turnToolCount: 0,
|
|
turnHookCount: 0,
|
|
turnClassifierCount: 0,
|
|
startTime: Date.now(),
|
|
lastInteractionTime: Date.now(),
|
|
totalLinesAdded: 0,
|
|
totalLinesRemoved: 0,
|
|
hasUnknownModelCost: false,
|
|
cwd: resolvedCwd,
|
|
modelUsage: {},
|
|
mainLoopModelOverride: undefined,
|
|
initialMainLoopModel: null,
|
|
modelStrings: null,
|
|
isInteractive: false,
|
|
kairosActive: false,
|
|
strictToolResultPairing: false,
|
|
sdkAgentProgressSummariesEnabled: false,
|
|
userMsgOptIn: false,
|
|
clientType: 'cli',
|
|
sessionSource: undefined,
|
|
questionPreviewFormat: undefined,
|
|
sessionIngressToken: undefined,
|
|
oauthTokenFromFd: undefined,
|
|
apiKeyFromFd: undefined,
|
|
flagSettingsPath: undefined,
|
|
flagSettingsInline: null,
|
|
allowedSettingSources: [
|
|
'userSettings',
|
|
'projectSettings',
|
|
'localSettings',
|
|
'flagSettings',
|
|
'policySettings',
|
|
],
|
|
// Telemetry state
|
|
meter: null,
|
|
sessionCounter: null,
|
|
locCounter: null,
|
|
prCounter: null,
|
|
commitCounter: null,
|
|
costCounter: null,
|
|
tokenCounter: null,
|
|
codeEditToolDecisionCounter: null,
|
|
activeTimeCounter: null,
|
|
statsStore: null,
|
|
sessionId: randomUUID() as SessionId,
|
|
parentSessionId: undefined,
|
|
// Logger state
|
|
loggerProvider: null,
|
|
eventLogger: null,
|
|
// Meter provider state
|
|
meterProvider: null,
|
|
tracerProvider: null,
|
|
// Agent color state
|
|
agentColorMap: new Map(),
|
|
agentColorIndex: 0,
|
|
// Last API request for bug reports
|
|
lastAPIRequest: null,
|
|
lastAPIRequestMessages: null,
|
|
// Last auto-mode classifier request(s) for /share transcript
|
|
lastClassifierRequests: null,
|
|
cachedClaudeMdContent: null,
|
|
// In-memory error log for recent errors
|
|
inMemoryErrorLog: [],
|
|
// Session-only plugins from --plugin-dir flag
|
|
inlinePlugins: [],
|
|
// Explicit --chrome / --no-chrome flag value (undefined = not set on CLI)
|
|
chromeFlagOverride: undefined,
|
|
// Use cowork_plugins directory instead of plugins
|
|
useCoworkPlugins: false,
|
|
// Session-only bypass permissions mode flag (not persisted)
|
|
sessionBypassPermissionsMode: false,
|
|
// Scheduled tasks disabled until flag or dialog enables them
|
|
scheduledTasksEnabled: false,
|
|
sessionCronTasks: [],
|
|
sessionCreatedTeams: new Set(),
|
|
// Session-only trust flag (not persisted to disk)
|
|
sessionTrustAccepted: false,
|
|
// Session-only flag to disable session persistence to disk
|
|
sessionPersistenceDisabled: false,
|
|
// Track if user has exited plan mode in this session
|
|
hasExitedPlanMode: false,
|
|
// Track if we need to show the plan mode exit attachment
|
|
needsPlanModeExitAttachment: false,
|
|
// Track if we need to show the auto mode exit attachment
|
|
needsAutoModeExitAttachment: false,
|
|
// Track if LSP plugin recommendation has been shown this session
|
|
lspRecommendationShownThisSession: false,
|
|
// SDK init event state
|
|
initJsonSchema: null,
|
|
registeredHooks: null,
|
|
// Cache for plan slugs
|
|
planSlugCache: new Map(),
|
|
// Track teleported session for reliability logging
|
|
teleportedSessionInfo: null,
|
|
// Track invoked skills for preservation across compaction
|
|
invokedSkills: new Map(),
|
|
// Track slow operations for dev bar display
|
|
slowOperations: [],
|
|
// SDK-provided betas
|
|
sdkBetas: undefined,
|
|
// Main thread agent type
|
|
mainThreadAgentType: undefined,
|
|
// Remote mode
|
|
isRemoteMode: false,
|
|
...(process.env.USER_TYPE === 'ant'
|
|
? {
|
|
replBridgeActive: false,
|
|
}
|
|
: {}),
|
|
// Direct connect server URL
|
|
directConnectServerUrl: undefined,
|
|
// System prompt section cache state
|
|
systemPromptSectionCache: new Map(),
|
|
// Last date emitted to the model
|
|
lastEmittedDate: null,
|
|
// Additional directories from --add-dir flag (for CLAUDE.md loading)
|
|
additionalDirectoriesForClaudeMd: [],
|
|
// Channel server allowlist from --channels flag
|
|
allowedChannels: [],
|
|
hasDevChannels: false,
|
|
// Session project dir (null = derive from originalCwd)
|
|
sessionProjectDir: null,
|
|
// Prompt cache 1h allowlist (null = not yet fetched from GrowthBook)
|
|
promptCache1hAllowlist: null,
|
|
// Prompt cache 1h eligibility (null = not yet evaluated)
|
|
promptCache1hEligible: null,
|
|
// Beta header latches (null = not yet triggered)
|
|
afkModeHeaderLatched: null,
|
|
fastModeHeaderLatched: null,
|
|
cacheEditingHeaderLatched: null,
|
|
thinkingClearLatched: null,
|
|
// Current prompt ID
|
|
promptId: null,
|
|
lastMainRequestId: undefined,
|
|
lastApiCompletionTimestamp: null,
|
|
pendingPostCompaction: false,
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
// AND ESPECIALLY HERE
|
|
const STATE: State = getInitialState()
|
|
|
|
export function getSessionId(): SessionId {
|
|
return STATE.sessionId
|
|
}
|
|
|
|
export function regenerateSessionId(
|
|
options: { setCurrentAsParent?: boolean } = {},
|
|
): SessionId {
|
|
if (options.setCurrentAsParent) {
|
|
STATE.parentSessionId = STATE.sessionId
|
|
}
|
|
// Drop the outgoing session's plan-slug entry so the Map doesn't
|
|
// accumulate stale keys. Callers that need to carry the slug across
|
|
// (REPL.tsx clearContext) read it before calling clearConversation.
|
|
STATE.planSlugCache.delete(STATE.sessionId)
|
|
// Regenerated sessions live in the current project: reset projectDir to
|
|
// null so getTranscriptPath() derives from originalCwd.
|
|
STATE.sessionId = randomUUID() as SessionId
|
|
STATE.sessionProjectDir = null
|
|
return STATE.sessionId
|
|
}
|
|
|
|
export function getParentSessionId(): SessionId | undefined {
|
|
return STATE.parentSessionId
|
|
}
|
|
|
|
/**
|
|
* Atomically switch the active session. `sessionId` and `sessionProjectDir`
|
|
* always change together — there is no separate setter for either, so they
|
|
* cannot drift out of sync (CC-34).
|
|
*
|
|
* @param projectDir — directory containing `<sessionId>.jsonl`. Omit (or
|
|
* pass `null`) for sessions in the current project — the path will derive
|
|
* from originalCwd at read time. Pass `dirname(transcriptPath)` when the
|
|
* session lives in a different project directory (git worktrees,
|
|
* cross-project resume). Every call resets the project dir; it never
|
|
* carries over from the previous session.
|
|
*/
|
|
export function switchSession(
|
|
sessionId: SessionId,
|
|
projectDir: string | null = null,
|
|
): void {
|
|
// Drop the outgoing session's plan-slug entry so the Map stays bounded
|
|
// across repeated /resume. Only the current session's slug is ever read
|
|
// (plans.ts getPlanSlug defaults to getSessionId()).
|
|
STATE.planSlugCache.delete(STATE.sessionId)
|
|
STATE.sessionId = sessionId
|
|
STATE.sessionProjectDir = projectDir
|
|
sessionSwitched.emit(sessionId)
|
|
}
|
|
|
|
const sessionSwitched = createSignal<[id: SessionId]>()
|
|
|
|
/**
|
|
* Register a callback that fires when switchSession changes the active
|
|
* sessionId. bootstrap can't import listeners directly (DAG leaf), so
|
|
* callers register themselves. concurrentSessions.ts uses this to keep the
|
|
* PID file's sessionId in sync with --resume.
|
|
*/
|
|
export const onSessionSwitch = sessionSwitched.subscribe
|
|
|
|
/**
|
|
* Project directory the current session's transcript lives in, or `null` if
|
|
* the session was created in the current project (common case — derive from
|
|
* originalCwd). See `switchSession()`.
|
|
*/
|
|
export function getSessionProjectDir(): string | null {
|
|
return STATE.sessionProjectDir
|
|
}
|
|
|
|
export function getOriginalCwd(): string {
|
|
return STATE.originalCwd
|
|
}
|
|
|
|
/**
|
|
* Get the stable project root directory.
|
|
* Unlike getOriginalCwd(), this is never updated by mid-session EnterWorktreeTool
|
|
* (so skills/history stay stable when entering a throwaway worktree).
|
|
* It IS set at startup by --worktree, since that worktree is the session's project.
|
|
* Use for project identity (history, skills, sessions) not file operations.
|
|
*/
|
|
export function getProjectRoot(): string {
|
|
return STATE.projectRoot
|
|
}
|
|
|
|
export function setOriginalCwd(cwd: string): void {
|
|
STATE.originalCwd = cwd.normalize('NFC')
|
|
}
|
|
|
|
/**
|
|
* Only for --worktree startup flag. Mid-session EnterWorktreeTool must NOT
|
|
* call this — skills/history should stay anchored to where the session started.
|
|
*/
|
|
export function setProjectRoot(cwd: string): void {
|
|
STATE.projectRoot = cwd.normalize('NFC')
|
|
}
|
|
|
|
export function getCwdState(): string {
|
|
return STATE.cwd
|
|
}
|
|
|
|
export function setCwdState(cwd: string): void {
|
|
STATE.cwd = cwd.normalize('NFC')
|
|
}
|
|
|
|
export function getDirectConnectServerUrl(): string | undefined {
|
|
return STATE.directConnectServerUrl
|
|
}
|
|
|
|
export function setDirectConnectServerUrl(url: string): void {
|
|
STATE.directConnectServerUrl = url
|
|
}
|
|
|
|
export function addToTotalDurationState(
|
|
duration: number,
|
|
durationWithoutRetries: number,
|
|
): void {
|
|
STATE.totalAPIDuration += duration
|
|
STATE.totalAPIDurationWithoutRetries += durationWithoutRetries
|
|
}
|
|
|
|
export function resetTotalDurationStateAndCost_FOR_TESTS_ONLY(): void {
|
|
STATE.totalAPIDuration = 0
|
|
STATE.totalAPIDurationWithoutRetries = 0
|
|
STATE.totalCostUSD = 0
|
|
}
|
|
|
|
export function addToTotalCostState(
|
|
cost: number,
|
|
modelUsage: ModelUsage,
|
|
model: string,
|
|
): void {
|
|
STATE.modelUsage[model] = modelUsage
|
|
STATE.totalCostUSD += cost
|
|
}
|
|
|
|
export function getTotalCostUSD(): number {
|
|
return STATE.totalCostUSD
|
|
}
|
|
|
|
export function getTotalAPIDuration(): number {
|
|
return STATE.totalAPIDuration
|
|
}
|
|
|
|
export function getTotalDuration(): number {
|
|
return Date.now() - STATE.startTime
|
|
}
|
|
|
|
export function getTotalAPIDurationWithoutRetries(): number {
|
|
return STATE.totalAPIDurationWithoutRetries
|
|
}
|
|
|
|
export function getTotalToolDuration(): number {
|
|
return STATE.totalToolDuration
|
|
}
|
|
|
|
export function addToToolDuration(duration: number): void {
|
|
STATE.totalToolDuration += duration
|
|
STATE.turnToolDurationMs += duration
|
|
STATE.turnToolCount++
|
|
}
|
|
|
|
export function getTurnHookDurationMs(): number {
|
|
return STATE.turnHookDurationMs
|
|
}
|
|
|
|
export function addToTurnHookDuration(duration: number): void {
|
|
STATE.turnHookDurationMs += duration
|
|
STATE.turnHookCount++
|
|
}
|
|
|
|
export function resetTurnHookDuration(): void {
|
|
STATE.turnHookDurationMs = 0
|
|
STATE.turnHookCount = 0
|
|
}
|
|
|
|
export function getTurnHookCount(): number {
|
|
return STATE.turnHookCount
|
|
}
|
|
|
|
export function getTurnToolDurationMs(): number {
|
|
return STATE.turnToolDurationMs
|
|
}
|
|
|
|
export function resetTurnToolDuration(): void {
|
|
STATE.turnToolDurationMs = 0
|
|
STATE.turnToolCount = 0
|
|
}
|
|
|
|
export function getTurnToolCount(): number {
|
|
return STATE.turnToolCount
|
|
}
|
|
|
|
export function getTurnClassifierDurationMs(): number {
|
|
return STATE.turnClassifierDurationMs
|
|
}
|
|
|
|
export function addToTurnClassifierDuration(duration: number): void {
|
|
STATE.turnClassifierDurationMs += duration
|
|
STATE.turnClassifierCount++
|
|
}
|
|
|
|
export function resetTurnClassifierDuration(): void {
|
|
STATE.turnClassifierDurationMs = 0
|
|
STATE.turnClassifierCount = 0
|
|
}
|
|
|
|
export function getTurnClassifierCount(): number {
|
|
return STATE.turnClassifierCount
|
|
}
|
|
|
|
export function getStatsStore(): {
|
|
observe(name: string, value: number): void
|
|
} | null {
|
|
return STATE.statsStore
|
|
}
|
|
|
|
export function setStatsStore(
|
|
store: { observe(name: string, value: number): void } | null,
|
|
): void {
|
|
STATE.statsStore = store
|
|
}
|
|
|
|
/**
|
|
* Marks that an interaction occurred.
|
|
*
|
|
* By default the actual Date.now() call is deferred until the next Ink render
|
|
* frame (via flushInteractionTime()) so we avoid calling Date.now() on every
|
|
* single keypress.
|
|
*
|
|
* Pass `immediate = true` when calling from React useEffect callbacks or
|
|
* other code that runs *after* the Ink render cycle has already flushed.
|
|
* Without it the timestamp stays stale until the next render, which may never
|
|
* come if the user is idle (e.g. permission dialog waiting for input).
|
|
*/
|
|
let interactionTimeDirty = false
|
|
|
|
export function updateLastInteractionTime(immediate?: boolean): void {
|
|
if (immediate) {
|
|
flushInteractionTime_inner()
|
|
} else {
|
|
interactionTimeDirty = true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If an interaction was recorded since the last flush, update the timestamp
|
|
* now. Called by Ink before each render cycle so we batch many keypresses into
|
|
* a single Date.now() call.
|
|
*/
|
|
export function flushInteractionTime(): void {
|
|
if (interactionTimeDirty) {
|
|
flushInteractionTime_inner()
|
|
}
|
|
}
|
|
|
|
function flushInteractionTime_inner(): void {
|
|
STATE.lastInteractionTime = Date.now()
|
|
interactionTimeDirty = false
|
|
}
|
|
|
|
export function addToTotalLinesChanged(added: number, removed: number): void {
|
|
STATE.totalLinesAdded += added
|
|
STATE.totalLinesRemoved += removed
|
|
}
|
|
|
|
export function getTotalLinesAdded(): number {
|
|
return STATE.totalLinesAdded
|
|
}
|
|
|
|
export function getTotalLinesRemoved(): number {
|
|
return STATE.totalLinesRemoved
|
|
}
|
|
|
|
export function getTotalInputTokens(): number {
|
|
return sumBy(Object.values(STATE.modelUsage), 'inputTokens')
|
|
}
|
|
|
|
export function getTotalOutputTokens(): number {
|
|
return sumBy(Object.values(STATE.modelUsage), 'outputTokens')
|
|
}
|
|
|
|
export function getTotalCacheReadInputTokens(): number {
|
|
return sumBy(Object.values(STATE.modelUsage), 'cacheReadInputTokens')
|
|
}
|
|
|
|
export function getTotalCacheCreationInputTokens(): number {
|
|
return sumBy(Object.values(STATE.modelUsage), 'cacheCreationInputTokens')
|
|
}
|
|
|
|
export function getTotalWebSearchRequests(): number {
|
|
return sumBy(Object.values(STATE.modelUsage), 'webSearchRequests')
|
|
}
|
|
|
|
let outputTokensAtTurnStart = 0
|
|
let currentTurnTokenBudget: number | null = null
|
|
export function getTurnOutputTokens(): number {
|
|
return getTotalOutputTokens() - outputTokensAtTurnStart
|
|
}
|
|
export function getCurrentTurnTokenBudget(): number | null {
|
|
return currentTurnTokenBudget
|
|
}
|
|
let budgetContinuationCount = 0
|
|
export function snapshotOutputTokensForTurn(budget: number | null): void {
|
|
outputTokensAtTurnStart = getTotalOutputTokens()
|
|
currentTurnTokenBudget = budget
|
|
budgetContinuationCount = 0
|
|
}
|
|
export function getBudgetContinuationCount(): number {
|
|
return budgetContinuationCount
|
|
}
|
|
export function incrementBudgetContinuationCount(): void {
|
|
budgetContinuationCount++
|
|
}
|
|
|
|
export function setHasUnknownModelCost(): void {
|
|
STATE.hasUnknownModelCost = true
|
|
}
|
|
|
|
export function hasUnknownModelCost(): boolean {
|
|
return STATE.hasUnknownModelCost
|
|
}
|
|
|
|
export function getLastMainRequestId(): string | undefined {
|
|
return STATE.lastMainRequestId
|
|
}
|
|
|
|
export function setLastMainRequestId(requestId: string): void {
|
|
STATE.lastMainRequestId = requestId
|
|
}
|
|
|
|
export function getLastApiCompletionTimestamp(): number | null {
|
|
return STATE.lastApiCompletionTimestamp
|
|
}
|
|
|
|
export function setLastApiCompletionTimestamp(timestamp: number): void {
|
|
STATE.lastApiCompletionTimestamp = timestamp
|
|
}
|
|
|
|
/** Mark that a compaction just occurred. The next API success event will
|
|
* include isPostCompaction=true, then the flag auto-resets. */
|
|
export function markPostCompaction(): void {
|
|
STATE.pendingPostCompaction = true
|
|
}
|
|
|
|
/** Consume the post-compaction flag. Returns true once after compaction,
|
|
* then returns false until the next compaction. */
|
|
export function consumePostCompaction(): boolean {
|
|
const was = STATE.pendingPostCompaction
|
|
STATE.pendingPostCompaction = false
|
|
return was
|
|
}
|
|
|
|
export function getLastInteractionTime(): number {
|
|
return STATE.lastInteractionTime
|
|
}
|
|
|
|
// Scroll drain suspension — background intervals check this before doing work
|
|
// so they don't compete with scroll frames for the event loop. Set by
|
|
// ScrollBox scrollBy/scrollTo, cleared SCROLL_DRAIN_IDLE_MS after the last
|
|
// scroll event. Module-scope (not in STATE) — ephemeral hot-path flag, no
|
|
// test-reset needed since the debounce timer self-clears.
|
|
let scrollDraining = false
|
|
let scrollDrainTimer: ReturnType<typeof setTimeout> | undefined
|
|
const SCROLL_DRAIN_IDLE_MS = 150
|
|
|
|
/** Mark that a scroll event just happened. Background intervals gate on
|
|
* getIsScrollDraining() and skip their work until the debounce clears. */
|
|
export function markScrollActivity(): void {
|
|
scrollDraining = true
|
|
if (scrollDrainTimer) clearTimeout(scrollDrainTimer)
|
|
scrollDrainTimer = setTimeout(() => {
|
|
scrollDraining = false
|
|
scrollDrainTimer = undefined
|
|
}, SCROLL_DRAIN_IDLE_MS)
|
|
scrollDrainTimer.unref?.()
|
|
}
|
|
|
|
/** True while scroll is actively draining (within 150ms of last event).
|
|
* Intervals should early-return when this is set — the work picks up next
|
|
* tick after scroll settles. */
|
|
export function getIsScrollDraining(): boolean {
|
|
return scrollDraining
|
|
}
|
|
|
|
/** Await this before expensive one-shot work (network, subprocess) that could
|
|
* coincide with scroll. Resolves immediately if not scrolling; otherwise
|
|
* polls at the idle interval until the flag clears. */
|
|
export async function waitForScrollIdle(): Promise<void> {
|
|
while (scrollDraining) {
|
|
// bootstrap-isolation forbids importing sleep() from src/utils/
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
await new Promise(r => setTimeout(r, SCROLL_DRAIN_IDLE_MS).unref?.())
|
|
}
|
|
}
|
|
|
|
export function getModelUsage(): { [modelName: string]: ModelUsage } {
|
|
return STATE.modelUsage
|
|
}
|
|
|
|
export function getUsageForModel(model: string): ModelUsage | undefined {
|
|
return STATE.modelUsage[model]
|
|
}
|
|
|
|
/**
|
|
* Gets the model override set from the --model CLI flag or after the user
|
|
* updates their configured model.
|
|
*/
|
|
export function getMainLoopModelOverride(): ModelSetting | undefined {
|
|
return STATE.mainLoopModelOverride
|
|
}
|
|
|
|
export function getInitialMainLoopModel(): ModelSetting {
|
|
return STATE.initialMainLoopModel
|
|
}
|
|
|
|
export function setMainLoopModelOverride(
|
|
model: ModelSetting | undefined,
|
|
): void {
|
|
STATE.mainLoopModelOverride = model
|
|
}
|
|
|
|
export function setInitialMainLoopModel(model: ModelSetting): void {
|
|
STATE.initialMainLoopModel = model
|
|
}
|
|
|
|
export function getSdkBetas(): string[] | undefined {
|
|
return STATE.sdkBetas
|
|
}
|
|
|
|
export function setSdkBetas(betas: string[] | undefined): void {
|
|
STATE.sdkBetas = betas
|
|
}
|
|
|
|
export function resetCostState(): void {
|
|
STATE.totalCostUSD = 0
|
|
STATE.totalAPIDuration = 0
|
|
STATE.totalAPIDurationWithoutRetries = 0
|
|
STATE.totalToolDuration = 0
|
|
STATE.startTime = Date.now()
|
|
STATE.totalLinesAdded = 0
|
|
STATE.totalLinesRemoved = 0
|
|
STATE.hasUnknownModelCost = false
|
|
STATE.modelUsage = {}
|
|
STATE.promptId = null
|
|
}
|
|
|
|
/**
|
|
* Sets cost state values for session restore.
|
|
* Called by restoreCostStateForSession in cost-tracker.ts.
|
|
*/
|
|
export function setCostStateForRestore({
|
|
totalCostUSD,
|
|
totalAPIDuration,
|
|
totalAPIDurationWithoutRetries,
|
|
totalToolDuration,
|
|
totalLinesAdded,
|
|
totalLinesRemoved,
|
|
lastDuration,
|
|
modelUsage,
|
|
}: {
|
|
totalCostUSD: number
|
|
totalAPIDuration: number
|
|
totalAPIDurationWithoutRetries: number
|
|
totalToolDuration: number
|
|
totalLinesAdded: number
|
|
totalLinesRemoved: number
|
|
lastDuration: number | undefined
|
|
modelUsage: { [modelName: string]: ModelUsage } | undefined
|
|
}): void {
|
|
STATE.totalCostUSD = totalCostUSD
|
|
STATE.totalAPIDuration = totalAPIDuration
|
|
STATE.totalAPIDurationWithoutRetries = totalAPIDurationWithoutRetries
|
|
STATE.totalToolDuration = totalToolDuration
|
|
STATE.totalLinesAdded = totalLinesAdded
|
|
STATE.totalLinesRemoved = totalLinesRemoved
|
|
|
|
// Restore per-model usage breakdown
|
|
if (modelUsage) {
|
|
STATE.modelUsage = modelUsage
|
|
}
|
|
|
|
// Adjust startTime to make wall duration accumulate
|
|
if (lastDuration) {
|
|
STATE.startTime = Date.now() - lastDuration
|
|
}
|
|
}
|
|
|
|
// Only used in tests
|
|
export function resetStateForTests(): void {
|
|
if (process.env.NODE_ENV !== 'test') {
|
|
throw new Error('resetStateForTests can only be called in tests')
|
|
}
|
|
Object.entries(getInitialState()).forEach(([key, value]) => {
|
|
STATE[key as keyof State] = value as never
|
|
})
|
|
outputTokensAtTurnStart = 0
|
|
currentTurnTokenBudget = null
|
|
budgetContinuationCount = 0
|
|
sessionSwitched.clear()
|
|
}
|
|
|
|
// You shouldn't use this directly. See src/utils/model/modelStrings.ts::getModelStrings()
|
|
export function getModelStrings(): ModelStrings | null {
|
|
return STATE.modelStrings
|
|
}
|
|
|
|
// You shouldn't use this directly. See src/utils/model/modelStrings.ts
|
|
export function setModelStrings(modelStrings: ModelStrings): void {
|
|
STATE.modelStrings = modelStrings
|
|
}
|
|
|
|
// Test utility function to reset model strings for re-initialization.
|
|
// Separate from setModelStrings because we only want to accept 'null' in tests.
|
|
export function resetModelStringsForTestingOnly() {
|
|
STATE.modelStrings = null
|
|
}
|
|
|
|
export function setMeter(
|
|
meter: Meter,
|
|
createCounter: (name: string, options: MetricOptions) => AttributedCounter,
|
|
): void {
|
|
STATE.meter = meter
|
|
|
|
// Initialize all counters using the provided factory
|
|
STATE.sessionCounter = createCounter('claude_code.session.count', {
|
|
description: 'Count of CLI sessions started',
|
|
})
|
|
STATE.locCounter = createCounter('claude_code.lines_of_code.count', {
|
|
description:
|
|
"Count of lines of code modified, with the 'type' attribute indicating whether lines were added or removed",
|
|
})
|
|
STATE.prCounter = createCounter('claude_code.pull_request.count', {
|
|
description: 'Number of pull requests created',
|
|
})
|
|
STATE.commitCounter = createCounter('claude_code.commit.count', {
|
|
description: 'Number of git commits created',
|
|
})
|
|
STATE.costCounter = createCounter('claude_code.cost.usage', {
|
|
description: 'Cost of the Claude Code session',
|
|
unit: 'USD',
|
|
})
|
|
STATE.tokenCounter = createCounter('claude_code.token.usage', {
|
|
description: 'Number of tokens used',
|
|
unit: 'tokens',
|
|
})
|
|
STATE.codeEditToolDecisionCounter = createCounter(
|
|
'claude_code.code_edit_tool.decision',
|
|
{
|
|
description:
|
|
'Count of code editing tool permission decisions (accept/reject) for Edit, Write, and NotebookEdit tools',
|
|
},
|
|
)
|
|
STATE.activeTimeCounter = createCounter('claude_code.active_time.total', {
|
|
description: 'Total active time in seconds',
|
|
unit: 's',
|
|
})
|
|
}
|
|
|
|
export function getMeter(): Meter | null {
|
|
return STATE.meter
|
|
}
|
|
|
|
export function getSessionCounter(): AttributedCounter | null {
|
|
return STATE.sessionCounter
|
|
}
|
|
|
|
export function getLocCounter(): AttributedCounter | null {
|
|
return STATE.locCounter
|
|
}
|
|
|
|
export function getPrCounter(): AttributedCounter | null {
|
|
return STATE.prCounter
|
|
}
|
|
|
|
export function getCommitCounter(): AttributedCounter | null {
|
|
return STATE.commitCounter
|
|
}
|
|
|
|
export function getCostCounter(): AttributedCounter | null {
|
|
return STATE.costCounter
|
|
}
|
|
|
|
export function getTokenCounter(): AttributedCounter | null {
|
|
return STATE.tokenCounter
|
|
}
|
|
|
|
export function getCodeEditToolDecisionCounter(): AttributedCounter | null {
|
|
return STATE.codeEditToolDecisionCounter
|
|
}
|
|
|
|
export function getActiveTimeCounter(): AttributedCounter | null {
|
|
return STATE.activeTimeCounter
|
|
}
|
|
|
|
export function getLoggerProvider(): LoggerProvider | null {
|
|
return STATE.loggerProvider
|
|
}
|
|
|
|
export function setLoggerProvider(provider: LoggerProvider | null): void {
|
|
STATE.loggerProvider = provider
|
|
}
|
|
|
|
export function getEventLogger(): ReturnType<typeof logs.getLogger> | null {
|
|
return STATE.eventLogger
|
|
}
|
|
|
|
export function setEventLogger(
|
|
logger: ReturnType<typeof logs.getLogger> | null,
|
|
): void {
|
|
STATE.eventLogger = logger
|
|
}
|
|
|
|
export function getMeterProvider(): MeterProvider | null {
|
|
return STATE.meterProvider
|
|
}
|
|
|
|
export function setMeterProvider(provider: MeterProvider | null): void {
|
|
STATE.meterProvider = provider
|
|
}
|
|
export function getTracerProvider(): BasicTracerProvider | null {
|
|
return STATE.tracerProvider
|
|
}
|
|
export function setTracerProvider(provider: BasicTracerProvider | null): void {
|
|
STATE.tracerProvider = provider
|
|
}
|
|
|
|
export function getIsNonInteractiveSession(): boolean {
|
|
return !STATE.isInteractive
|
|
}
|
|
|
|
export function getIsInteractive(): boolean {
|
|
return STATE.isInteractive
|
|
}
|
|
|
|
export function setIsInteractive(value: boolean): void {
|
|
STATE.isInteractive = value
|
|
}
|
|
|
|
export function getClientType(): string {
|
|
return STATE.clientType
|
|
}
|
|
|
|
export function setClientType(type: string): void {
|
|
STATE.clientType = type
|
|
}
|
|
|
|
export function getSdkAgentProgressSummariesEnabled(): boolean {
|
|
return STATE.sdkAgentProgressSummariesEnabled
|
|
}
|
|
|
|
export function setSdkAgentProgressSummariesEnabled(value: boolean): void {
|
|
STATE.sdkAgentProgressSummariesEnabled = value
|
|
}
|
|
|
|
export function getKairosActive(): boolean {
|
|
return STATE.kairosActive
|
|
}
|
|
|
|
export function setKairosActive(value: boolean): void {
|
|
STATE.kairosActive = value
|
|
}
|
|
|
|
export function getStrictToolResultPairing(): boolean {
|
|
return STATE.strictToolResultPairing
|
|
}
|
|
|
|
export function setStrictToolResultPairing(value: boolean): void {
|
|
STATE.strictToolResultPairing = value
|
|
}
|
|
|
|
// Field name 'userMsgOptIn' avoids excluded-string substrings ('BriefTool',
|
|
// 'SendUserMessage' — case-insensitive). All callers are inside feature()
|
|
// guards so these accessors don't need their own (matches getKairosActive).
|
|
export function getUserMsgOptIn(): boolean {
|
|
return STATE.userMsgOptIn
|
|
}
|
|
|
|
export function setUserMsgOptIn(value: boolean): void {
|
|
STATE.userMsgOptIn = value
|
|
}
|
|
|
|
export function getSessionSource(): string | undefined {
|
|
return STATE.sessionSource
|
|
}
|
|
|
|
export function setSessionSource(source: string): void {
|
|
STATE.sessionSource = source
|
|
}
|
|
|
|
export function getQuestionPreviewFormat(): 'markdown' | 'html' | undefined {
|
|
return STATE.questionPreviewFormat
|
|
}
|
|
|
|
export function setQuestionPreviewFormat(format: 'markdown' | 'html'): void {
|
|
STATE.questionPreviewFormat = format
|
|
}
|
|
|
|
export function getAgentColorMap(): Map<string, AgentColorName> {
|
|
return STATE.agentColorMap
|
|
}
|
|
|
|
export function getFlagSettingsPath(): string | undefined {
|
|
return STATE.flagSettingsPath
|
|
}
|
|
|
|
export function setFlagSettingsPath(path: string | undefined): void {
|
|
STATE.flagSettingsPath = path
|
|
}
|
|
|
|
export function getFlagSettingsInline(): Record<string, unknown> | null {
|
|
return STATE.flagSettingsInline
|
|
}
|
|
|
|
export function setFlagSettingsInline(
|
|
settings: Record<string, unknown> | null,
|
|
): void {
|
|
STATE.flagSettingsInline = settings
|
|
}
|
|
|
|
export function getSessionIngressToken(): string | null | undefined {
|
|
return STATE.sessionIngressToken
|
|
}
|
|
|
|
export function setSessionIngressToken(token: string | null): void {
|
|
STATE.sessionIngressToken = token
|
|
}
|
|
|
|
export function getOauthTokenFromFd(): string | null | undefined {
|
|
return STATE.oauthTokenFromFd
|
|
}
|
|
|
|
export function setOauthTokenFromFd(token: string | null): void {
|
|
STATE.oauthTokenFromFd = token
|
|
}
|
|
|
|
export function getApiKeyFromFd(): string | null | undefined {
|
|
return STATE.apiKeyFromFd
|
|
}
|
|
|
|
export function setApiKeyFromFd(key: string | null): void {
|
|
STATE.apiKeyFromFd = key
|
|
}
|
|
|
|
export function setLastAPIRequest(
|
|
params: Omit<BetaMessageStreamParams, 'messages'> | null,
|
|
): void {
|
|
STATE.lastAPIRequest = params
|
|
}
|
|
|
|
export function getLastAPIRequest(): Omit<
|
|
BetaMessageStreamParams,
|
|
'messages'
|
|
> | null {
|
|
return STATE.lastAPIRequest
|
|
}
|
|
|
|
export function setLastAPIRequestMessages(
|
|
messages: BetaMessageStreamParams['messages'] | null,
|
|
): void {
|
|
STATE.lastAPIRequestMessages = messages
|
|
}
|
|
|
|
export function getLastAPIRequestMessages():
|
|
| BetaMessageStreamParams['messages']
|
|
| null {
|
|
return STATE.lastAPIRequestMessages
|
|
}
|
|
|
|
export function setLastClassifierRequests(requests: unknown[] | null): void {
|
|
STATE.lastClassifierRequests = requests
|
|
}
|
|
|
|
export function getLastClassifierRequests(): unknown[] | null {
|
|
return STATE.lastClassifierRequests
|
|
}
|
|
|
|
export function setCachedClaudeMdContent(content: string | null): void {
|
|
STATE.cachedClaudeMdContent = content
|
|
}
|
|
|
|
export function getCachedClaudeMdContent(): string | null {
|
|
return STATE.cachedClaudeMdContent
|
|
}
|
|
|
|
export function addToInMemoryErrorLog(errorInfo: {
|
|
error: string
|
|
timestamp: string
|
|
}): void {
|
|
const MAX_IN_MEMORY_ERRORS = 100
|
|
if (STATE.inMemoryErrorLog.length >= MAX_IN_MEMORY_ERRORS) {
|
|
STATE.inMemoryErrorLog.shift() // Remove oldest error
|
|
}
|
|
STATE.inMemoryErrorLog.push(errorInfo)
|
|
}
|
|
|
|
export function getAllowedSettingSources(): SettingSource[] {
|
|
return STATE.allowedSettingSources
|
|
}
|
|
|
|
export function setAllowedSettingSources(sources: SettingSource[]): void {
|
|
STATE.allowedSettingSources = sources
|
|
}
|
|
|
|
export function preferThirdPartyAuthentication(): boolean {
|
|
// IDE extension should behave as 1P for authentication reasons.
|
|
return getIsNonInteractiveSession() && STATE.clientType !== 'claude-vscode'
|
|
}
|
|
|
|
export function setInlinePlugins(plugins: Array<string>): void {
|
|
STATE.inlinePlugins = plugins
|
|
}
|
|
|
|
export function getInlinePlugins(): Array<string> {
|
|
return STATE.inlinePlugins
|
|
}
|
|
|
|
export function setChromeFlagOverride(value: boolean | undefined): void {
|
|
STATE.chromeFlagOverride = value
|
|
}
|
|
|
|
export function getChromeFlagOverride(): boolean | undefined {
|
|
return STATE.chromeFlagOverride
|
|
}
|
|
|
|
export function setUseCoworkPlugins(value: boolean): void {
|
|
STATE.useCoworkPlugins = value
|
|
resetSettingsCache()
|
|
}
|
|
|
|
export function getUseCoworkPlugins(): boolean {
|
|
return STATE.useCoworkPlugins
|
|
}
|
|
|
|
export function setSessionBypassPermissionsMode(enabled: boolean): void {
|
|
STATE.sessionBypassPermissionsMode = enabled
|
|
}
|
|
|
|
export function getSessionBypassPermissionsMode(): boolean {
|
|
return STATE.sessionBypassPermissionsMode
|
|
}
|
|
|
|
export function setScheduledTasksEnabled(enabled: boolean): void {
|
|
STATE.scheduledTasksEnabled = enabled
|
|
}
|
|
|
|
export function getScheduledTasksEnabled(): boolean {
|
|
return STATE.scheduledTasksEnabled
|
|
}
|
|
|
|
export type SessionCronTask = {
|
|
id: string
|
|
cron: string
|
|
prompt: string
|
|
createdAt: number
|
|
recurring?: boolean
|
|
/**
|
|
* When set, the task was created by an in-process teammate (not the team lead).
|
|
* The scheduler routes fires to that teammate's pendingUserMessages queue
|
|
* instead of the main REPL command queue. Session-only — never written to disk.
|
|
*/
|
|
agentId?: string
|
|
}
|
|
|
|
export function getSessionCronTasks(): SessionCronTask[] {
|
|
return STATE.sessionCronTasks
|
|
}
|
|
|
|
export function addSessionCronTask(task: SessionCronTask): void {
|
|
STATE.sessionCronTasks.push(task)
|
|
}
|
|
|
|
/**
|
|
* Returns the number of tasks actually removed. Callers use this to skip
|
|
* downstream work (e.g. the disk read in removeCronTasks) when all ids
|
|
* were accounted for here.
|
|
*/
|
|
export function removeSessionCronTasks(ids: readonly string[]): number {
|
|
if (ids.length === 0) return 0
|
|
const idSet = new Set(ids)
|
|
const remaining = STATE.sessionCronTasks.filter(t => !idSet.has(t.id))
|
|
const removed = STATE.sessionCronTasks.length - remaining.length
|
|
if (removed === 0) return 0
|
|
STATE.sessionCronTasks = remaining
|
|
return removed
|
|
}
|
|
|
|
export function setSessionTrustAccepted(accepted: boolean): void {
|
|
STATE.sessionTrustAccepted = accepted
|
|
}
|
|
|
|
export function getSessionTrustAccepted(): boolean {
|
|
return STATE.sessionTrustAccepted
|
|
}
|
|
|
|
export function setSessionPersistenceDisabled(disabled: boolean): void {
|
|
STATE.sessionPersistenceDisabled = disabled
|
|
}
|
|
|
|
export function isSessionPersistenceDisabled(): boolean {
|
|
return STATE.sessionPersistenceDisabled
|
|
}
|
|
|
|
export function hasExitedPlanModeInSession(): boolean {
|
|
return STATE.hasExitedPlanMode
|
|
}
|
|
|
|
export function setHasExitedPlanMode(value: boolean): void {
|
|
STATE.hasExitedPlanMode = value
|
|
}
|
|
|
|
export function needsPlanModeExitAttachment(): boolean {
|
|
return STATE.needsPlanModeExitAttachment
|
|
}
|
|
|
|
export function setNeedsPlanModeExitAttachment(value: boolean): void {
|
|
STATE.needsPlanModeExitAttachment = value
|
|
}
|
|
|
|
export function handlePlanModeTransition(
|
|
fromMode: string,
|
|
toMode: string,
|
|
): void {
|
|
// If switching TO plan mode, clear any pending exit attachment
|
|
// This prevents sending both plan_mode and plan_mode_exit when user toggles quickly
|
|
if (toMode === 'plan' && fromMode !== 'plan') {
|
|
STATE.needsPlanModeExitAttachment = false
|
|
}
|
|
|
|
// If switching out of plan mode, trigger the plan_mode_exit attachment
|
|
if (fromMode === 'plan' && toMode !== 'plan') {
|
|
STATE.needsPlanModeExitAttachment = true
|
|
}
|
|
}
|
|
|
|
export function needsAutoModeExitAttachment(): boolean {
|
|
return STATE.needsAutoModeExitAttachment
|
|
}
|
|
|
|
export function setNeedsAutoModeExitAttachment(value: boolean): void {
|
|
STATE.needsAutoModeExitAttachment = value
|
|
}
|
|
|
|
export function handleAutoModeTransition(
|
|
fromMode: string,
|
|
toMode: string,
|
|
): void {
|
|
// Auto↔plan transitions are handled by prepareContextForPlanMode (auto may
|
|
// stay active through plan if opted in) and ExitPlanMode (restores mode).
|
|
// Skip both directions so this function only handles direct auto transitions.
|
|
if (
|
|
(fromMode === 'auto' && toMode === 'plan') ||
|
|
(fromMode === 'plan' && toMode === 'auto')
|
|
) {
|
|
return
|
|
}
|
|
const fromIsAuto = fromMode === 'auto'
|
|
const toIsAuto = toMode === 'auto'
|
|
|
|
// If switching TO auto mode, clear any pending exit attachment
|
|
// This prevents sending both auto_mode and auto_mode_exit when user toggles quickly
|
|
if (toIsAuto && !fromIsAuto) {
|
|
STATE.needsAutoModeExitAttachment = false
|
|
}
|
|
|
|
// If switching out of auto mode, trigger the auto_mode_exit attachment
|
|
if (fromIsAuto && !toIsAuto) {
|
|
STATE.needsAutoModeExitAttachment = true
|
|
}
|
|
}
|
|
|
|
// LSP plugin recommendation session tracking
|
|
export function hasShownLspRecommendationThisSession(): boolean {
|
|
return STATE.lspRecommendationShownThisSession
|
|
}
|
|
|
|
export function setLspRecommendationShownThisSession(value: boolean): void {
|
|
STATE.lspRecommendationShownThisSession = value
|
|
}
|
|
|
|
// SDK init event state
|
|
export function setInitJsonSchema(schema: Record<string, unknown>): void {
|
|
STATE.initJsonSchema = schema
|
|
}
|
|
|
|
export function getInitJsonSchema(): Record<string, unknown> | null {
|
|
return STATE.initJsonSchema
|
|
}
|
|
|
|
export function registerHookCallbacks(
|
|
hooks: Partial<Record<HookEvent, RegisteredHookMatcher[]>>,
|
|
): void {
|
|
if (!STATE.registeredHooks) {
|
|
STATE.registeredHooks = {}
|
|
}
|
|
|
|
// `registerHookCallbacks` may be called multiple times, so we need to merge (not overwrite)
|
|
for (const [event, matchers] of Object.entries(hooks)) {
|
|
const eventKey = event as HookEvent
|
|
if (!STATE.registeredHooks[eventKey]) {
|
|
STATE.registeredHooks[eventKey] = []
|
|
}
|
|
STATE.registeredHooks[eventKey]!.push(...matchers)
|
|
}
|
|
}
|
|
|
|
export function getRegisteredHooks(): Partial<
|
|
Record<HookEvent, RegisteredHookMatcher[]>
|
|
> | null {
|
|
return STATE.registeredHooks
|
|
}
|
|
|
|
export function clearRegisteredHooks(): void {
|
|
STATE.registeredHooks = null
|
|
}
|
|
|
|
export function clearRegisteredPluginHooks(): void {
|
|
if (!STATE.registeredHooks) {
|
|
return
|
|
}
|
|
|
|
const filtered: Partial<Record<HookEvent, RegisteredHookMatcher[]>> = {}
|
|
for (const [event, matchers] of Object.entries(STATE.registeredHooks)) {
|
|
// Keep only callback hooks (those without pluginRoot)
|
|
const callbackHooks = matchers.filter(m => !('pluginRoot' in m))
|
|
if (callbackHooks.length > 0) {
|
|
filtered[event as HookEvent] = callbackHooks
|
|
}
|
|
}
|
|
|
|
STATE.registeredHooks = Object.keys(filtered).length > 0 ? filtered : null
|
|
}
|
|
|
|
export function resetSdkInitState(): void {
|
|
STATE.initJsonSchema = null
|
|
STATE.registeredHooks = null
|
|
}
|
|
|
|
export function getPlanSlugCache(): Map<string, string> {
|
|
return STATE.planSlugCache
|
|
}
|
|
|
|
export function getSessionCreatedTeams(): Set<string> {
|
|
return STATE.sessionCreatedTeams
|
|
}
|
|
|
|
// Teleported session tracking for reliability logging
|
|
export function setTeleportedSessionInfo(info: {
|
|
sessionId: string | null
|
|
}): void {
|
|
STATE.teleportedSessionInfo = {
|
|
isTeleported: true,
|
|
hasLoggedFirstMessage: false,
|
|
sessionId: info.sessionId,
|
|
}
|
|
}
|
|
|
|
export function getTeleportedSessionInfo(): {
|
|
isTeleported: boolean
|
|
hasLoggedFirstMessage: boolean
|
|
sessionId: string | null
|
|
} | null {
|
|
return STATE.teleportedSessionInfo
|
|
}
|
|
|
|
export function markFirstTeleportMessageLogged(): void {
|
|
if (STATE.teleportedSessionInfo) {
|
|
STATE.teleportedSessionInfo.hasLoggedFirstMessage = true
|
|
}
|
|
}
|
|
|
|
// Invoked skills tracking for preservation across compaction
|
|
export type InvokedSkillInfo = {
|
|
skillName: string
|
|
skillPath: string
|
|
content: string
|
|
invokedAt: number
|
|
agentId: string | null
|
|
}
|
|
|
|
export function addInvokedSkill(
|
|
skillName: string,
|
|
skillPath: string,
|
|
content: string,
|
|
agentId: string | null = null,
|
|
): void {
|
|
const key = `${agentId ?? ''}:${skillName}`
|
|
STATE.invokedSkills.set(key, {
|
|
skillName,
|
|
skillPath,
|
|
content,
|
|
invokedAt: Date.now(),
|
|
agentId,
|
|
})
|
|
}
|
|
|
|
export function getInvokedSkills(): Map<string, InvokedSkillInfo> {
|
|
return STATE.invokedSkills
|
|
}
|
|
|
|
export function getInvokedSkillsForAgent(
|
|
agentId: string | undefined | null,
|
|
): Map<string, InvokedSkillInfo> {
|
|
const normalizedId = agentId ?? null
|
|
const filtered = new Map<string, InvokedSkillInfo>()
|
|
for (const [key, skill] of STATE.invokedSkills) {
|
|
if (skill.agentId === normalizedId) {
|
|
filtered.set(key, skill)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
export function clearInvokedSkills(
|
|
preservedAgentIds?: ReadonlySet<string>,
|
|
): void {
|
|
if (!preservedAgentIds || preservedAgentIds.size === 0) {
|
|
STATE.invokedSkills.clear()
|
|
return
|
|
}
|
|
for (const [key, skill] of STATE.invokedSkills) {
|
|
if (skill.agentId === null || !preservedAgentIds.has(skill.agentId)) {
|
|
STATE.invokedSkills.delete(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function clearInvokedSkillsForAgent(agentId: string): void {
|
|
for (const [key, skill] of STATE.invokedSkills) {
|
|
if (skill.agentId === agentId) {
|
|
STATE.invokedSkills.delete(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Slow operations tracking for dev bar
|
|
const MAX_SLOW_OPERATIONS = 10
|
|
const SLOW_OPERATION_TTL_MS = 10000
|
|
|
|
export function addSlowOperation(operation: string, durationMs: number): void {
|
|
if (process.env.USER_TYPE !== 'ant') return
|
|
// Skip tracking for editor sessions (user editing a prompt file in $EDITOR)
|
|
// These are intentionally slow since the user is drafting text
|
|
if (operation.includes('exec') && operation.includes('claude-prompt-')) {
|
|
return
|
|
}
|
|
const now = Date.now()
|
|
// Remove stale operations
|
|
STATE.slowOperations = STATE.slowOperations.filter(
|
|
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
|
|
)
|
|
// Add new operation
|
|
STATE.slowOperations.push({ operation, durationMs, timestamp: now })
|
|
// Keep only the most recent operations
|
|
if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) {
|
|
STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS)
|
|
}
|
|
}
|
|
|
|
const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
|
|
operation: string
|
|
durationMs: number
|
|
timestamp: number
|
|
}> = []
|
|
|
|
export function getSlowOperations(): ReadonlyArray<{
|
|
operation: string
|
|
durationMs: number
|
|
timestamp: number
|
|
}> {
|
|
// Most common case: nothing tracked. Return a stable reference so the
|
|
// caller's setState() can bail via Object.is instead of re-rendering at 2fps.
|
|
if (STATE.slowOperations.length === 0) {
|
|
return EMPTY_SLOW_OPERATIONS
|
|
}
|
|
const now = Date.now()
|
|
// Only allocate a new array when something actually expired; otherwise keep
|
|
// the reference stable across polls while ops are still fresh.
|
|
if (
|
|
STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS)
|
|
) {
|
|
STATE.slowOperations = STATE.slowOperations.filter(
|
|
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
|
|
)
|
|
if (STATE.slowOperations.length === 0) {
|
|
return EMPTY_SLOW_OPERATIONS
|
|
}
|
|
}
|
|
// Safe to return directly: addSlowOperation() reassigns STATE.slowOperations
|
|
// before pushing, so the array held in React state is never mutated.
|
|
return STATE.slowOperations
|
|
}
|
|
|
|
export function getMainThreadAgentType(): string | undefined {
|
|
return STATE.mainThreadAgentType
|
|
}
|
|
|
|
export function setMainThreadAgentType(agentType: string | undefined): void {
|
|
STATE.mainThreadAgentType = agentType
|
|
}
|
|
|
|
export function getIsRemoteMode(): boolean {
|
|
return STATE.isRemoteMode
|
|
}
|
|
|
|
export function setIsRemoteMode(value: boolean): void {
|
|
STATE.isRemoteMode = value
|
|
}
|
|
|
|
// System prompt section accessors
|
|
|
|
export function getSystemPromptSectionCache(): Map<string, string | null> {
|
|
return STATE.systemPromptSectionCache
|
|
}
|
|
|
|
export function setSystemPromptSectionCacheEntry(
|
|
name: string,
|
|
value: string | null,
|
|
): void {
|
|
STATE.systemPromptSectionCache.set(name, value)
|
|
}
|
|
|
|
export function clearSystemPromptSectionState(): void {
|
|
STATE.systemPromptSectionCache.clear()
|
|
}
|
|
|
|
// Last emitted date accessors (for detecting midnight date changes)
|
|
|
|
export function getLastEmittedDate(): string | null {
|
|
return STATE.lastEmittedDate
|
|
}
|
|
|
|
export function setLastEmittedDate(date: string | null): void {
|
|
STATE.lastEmittedDate = date
|
|
}
|
|
|
|
export function getAdditionalDirectoriesForClaudeMd(): string[] {
|
|
return STATE.additionalDirectoriesForClaudeMd
|
|
}
|
|
|
|
export function setAdditionalDirectoriesForClaudeMd(
|
|
directories: string[],
|
|
): void {
|
|
STATE.additionalDirectoriesForClaudeMd = directories
|
|
}
|
|
|
|
export function getAllowedChannels(): ChannelEntry[] {
|
|
return STATE.allowedChannels
|
|
}
|
|
|
|
export function setAllowedChannels(entries: ChannelEntry[]): void {
|
|
STATE.allowedChannels = entries
|
|
}
|
|
|
|
export function getHasDevChannels(): boolean {
|
|
return STATE.hasDevChannels
|
|
}
|
|
|
|
export function setHasDevChannels(value: boolean): void {
|
|
STATE.hasDevChannels = value
|
|
}
|
|
|
|
export function getPromptCache1hAllowlist(): string[] | null {
|
|
return STATE.promptCache1hAllowlist
|
|
}
|
|
|
|
export function setPromptCache1hAllowlist(allowlist: string[] | null): void {
|
|
STATE.promptCache1hAllowlist = allowlist
|
|
}
|
|
|
|
export function getPromptCache1hEligible(): boolean | null {
|
|
return STATE.promptCache1hEligible
|
|
}
|
|
|
|
export function setPromptCache1hEligible(eligible: boolean | null): void {
|
|
STATE.promptCache1hEligible = eligible
|
|
}
|
|
|
|
export function getAfkModeHeaderLatched(): boolean | null {
|
|
return STATE.afkModeHeaderLatched
|
|
}
|
|
|
|
export function setAfkModeHeaderLatched(v: boolean): void {
|
|
STATE.afkModeHeaderLatched = v
|
|
}
|
|
|
|
export function getFastModeHeaderLatched(): boolean | null {
|
|
return STATE.fastModeHeaderLatched
|
|
}
|
|
|
|
export function setFastModeHeaderLatched(v: boolean): void {
|
|
STATE.fastModeHeaderLatched = v
|
|
}
|
|
|
|
export function getCacheEditingHeaderLatched(): boolean | null {
|
|
return STATE.cacheEditingHeaderLatched
|
|
}
|
|
|
|
export function setCacheEditingHeaderLatched(v: boolean): void {
|
|
STATE.cacheEditingHeaderLatched = v
|
|
}
|
|
|
|
export function getThinkingClearLatched(): boolean | null {
|
|
return STATE.thinkingClearLatched
|
|
}
|
|
|
|
export function setThinkingClearLatched(v: boolean): void {
|
|
STATE.thinkingClearLatched = v
|
|
}
|
|
|
|
/**
|
|
* Reset beta header latches to null. Called on /clear and /compact so a
|
|
* fresh conversation gets fresh header evaluation.
|
|
*/
|
|
export function clearBetaHeaderLatches(): void {
|
|
STATE.afkModeHeaderLatched = null
|
|
STATE.fastModeHeaderLatched = null
|
|
STATE.cacheEditingHeaderLatched = null
|
|
STATE.thinkingClearLatched = null
|
|
}
|
|
|
|
export function getPromptId(): string | null {
|
|
return STATE.promptId
|
|
}
|
|
|
|
export function setPromptId(id: string | null): void {
|
|
STATE.promptId = id
|
|
}
|
|
|