/ state / AppStateStore.ts
AppStateStore.ts
  1  import type { Notification } from 'src/context/notifications.js'
  2  import type { TodoList } from 'src/utils/todo/types.js'
  3  import type { BridgePermissionCallbacks } from '../bridge/bridgePermissionCallbacks.js'
  4  import type { Command } from '../commands.js'
  5  import type { ChannelPermissionCallbacks } from '../services/mcp/channelPermissions.js'
  6  import type { ElicitationRequestEvent } from '../services/mcp/elicitationHandler.js'
  7  import type {
  8    MCPServerConnection,
  9    ServerResource,
 10  } from '../services/mcp/types.js'
 11  import { shouldEnablePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js'
 12  import {
 13    getEmptyToolPermissionContext,
 14    type Tool,
 15    type ToolPermissionContext,
 16  } from '../Tool.js'
 17  import type { TaskState } from '../tasks/types.js'
 18  import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'
 19  import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'
 20  import type { AllowedPrompt } from '../tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
 21  import type { AgentId } from '../types/ids.js'
 22  import type { Message, UserMessage } from '../types/message.js'
 23  import type { LoadedPlugin, PluginError } from '../types/plugin.js'
 24  import type { DeepImmutable } from '../types/utils.js'
 25  import {
 26    type AttributionState,
 27    createEmptyAttributionState,
 28  } from '../utils/commitAttribution.js'
 29  import type { EffortValue } from '../utils/effort.js'
 30  import type { FileHistoryState } from '../utils/fileHistory.js'
 31  import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js'
 32  import type { SessionHooksState } from '../utils/hooks/sessionHooks.js'
 33  import type { ModelSetting } from '../utils/model/model.js'
 34  import type { DenialTrackingState } from '../utils/permissions/denialTracking.js'
 35  import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
 36  import { getInitialSettings } from '../utils/settings/settings.js'
 37  import type { SettingsJson } from '../utils/settings/types.js'
 38  import { shouldEnableThinkingByDefault } from '../utils/thinking.js'
 39  import type { Store } from './store.js'
 40  
 41  export type CompletionBoundary =
 42    | { type: 'complete'; completedAt: number; outputTokens: number }
 43    | { type: 'bash'; command: string; completedAt: number }
 44    | { type: 'edit'; toolName: string; filePath: string; completedAt: number }
 45    | {
 46        type: 'denied_tool'
 47        toolName: string
 48        detail: string
 49        completedAt: number
 50      }
 51  
 52  export type SpeculationResult = {
 53    messages: Message[]
 54    boundary: CompletionBoundary | null
 55    timeSavedMs: number
 56  }
 57  
 58  export type SpeculationState =
 59    | { status: 'idle' }
 60    | {
 61        status: 'active'
 62        id: string
 63        abort: () => void
 64        startTime: number
 65        messagesRef: { current: Message[] } // Mutable ref - avoids array spreading per message
 66        writtenPathsRef: { current: Set<string> } // Mutable ref - relative paths written to overlay
 67        boundary: CompletionBoundary | null
 68        suggestionLength: number
 69        toolUseCount: number
 70        isPipelined: boolean
 71        contextRef: { current: REPLHookContext }
 72        pipelinedSuggestion?: {
 73          text: string
 74          promptId: 'user_intent' | 'stated_intent'
 75          generationRequestId: string | null
 76        } | null
 77      }
 78  
 79  export const IDLE_SPECULATION_STATE: SpeculationState = { status: 'idle' }
 80  
 81  export type FooterItem =
 82    | 'tasks'
 83    | 'tmux'
 84    | 'bagel'
 85    | 'teams'
 86    | 'bridge'
 87    | 'companion'
 88  
 89  export type AppState = DeepImmutable<{
 90    settings: SettingsJson
 91    verbose: boolean
 92    mainLoopModel: ModelSetting
 93    mainLoopModelForSession: ModelSetting
 94    statusLineText: string | undefined
 95    expandedView: 'none' | 'tasks' | 'teammates'
 96    isBriefOnly: boolean
 97    // Optional - only present when ENABLE_AGENT_SWARMS is true (for dead code elimination)
 98    showTeammateMessagePreview?: boolean
 99    selectedIPAgentIndex: number
100    // CoordinatorTaskPanel selection: -1 = pill, 0 = main, 1..N = agent rows.
101    // AppState (not local) so the panel can read it directly without prop-drilling
102    // through PromptInput → PromptInputFooter.
103    coordinatorTaskIndex: number
104    viewSelectionMode: 'none' | 'selecting-agent' | 'viewing-agent'
105    // Which footer pill is focused (arrow-key navigation below the prompt).
106    // Lives in AppState so pill components rendered outside PromptInput
107    // (CompanionSprite in REPL.tsx) can read their own focused state.
108    footerSelection: FooterItem | null
109    toolPermissionContext: ToolPermissionContext
110    spinnerTip?: string
111    // Agent name from --agent CLI flag or settings (for logo display)
112    agent: string | undefined
113    // Assistant mode fully enabled (settings + GrowthBook gate + trust).
114    // Single source of truth - computed once in main.tsx before option
115    // mutation, consumers read this instead of re-calling isAssistantMode().
116    kairosEnabled: boolean
117    // Remote session URL for --remote mode (shown in footer indicator)
118    remoteSessionUrl: string | undefined
119    // Remote session WS state (`claude assistant` viewer). 'connected' means the
120    // live event stream is open; 'reconnecting' = transient WS drop, backoff
121    // in progress; 'disconnected' = permanent close or reconnects exhausted.
122    remoteConnectionStatus:
123      | 'connecting'
124      | 'connected'
125      | 'reconnecting'
126      | 'disconnected'
127    // `claude assistant`: count of background tasks (Agent calls, teammates,
128    // workflows) running inside the REMOTE daemon child. Event-sourced from
129    // system/task_started and system/task_notification on the WS. The local
130    // AppState.tasks is always empty in viewer mode — the tasks live in a
131    // different process.
132    remoteBackgroundTaskCount: number
133    // Always-on bridge: desired state (controlled by /config or footer toggle)
134    replBridgeEnabled: boolean
135    // Always-on bridge: true when activated via /remote-control command, false when config-driven
136    replBridgeExplicit: boolean
137    // Outbound-only mode: forward events to CCR but reject inbound prompts/control
138    replBridgeOutboundOnly: boolean
139    // Always-on bridge: env registered + session created (= "Ready")
140    replBridgeConnected: boolean
141    // Always-on bridge: ingress WebSocket is open (= "Connected" - user on claude.ai)
142    replBridgeSessionActive: boolean
143    // Always-on bridge: poll loop is in error backoff (= "Reconnecting")
144    replBridgeReconnecting: boolean
145    // Always-on bridge: connect URL for Ready state (?bridge=envId)
146    replBridgeConnectUrl: string | undefined
147    // Always-on bridge: session URL on claude.ai (set when connected)
148    replBridgeSessionUrl: string | undefined
149    // Always-on bridge: IDs for debugging (shown in dialog when --verbose)
150    replBridgeEnvironmentId: string | undefined
151    replBridgeSessionId: string | undefined
152    // Always-on bridge: error message when connection fails (shown in BridgeDialog)
153    replBridgeError: string | undefined
154    // Always-on bridge: session name set via `/remote-control <name>` (used as session title)
155    replBridgeInitialName: string | undefined
156    // Always-on bridge: first-time remote dialog pending (set by /remote-control command)
157    showRemoteCallout: boolean
158  }> & {
159    // Unified task state - excluded from DeepImmutable because TaskState contains function types
160    tasks: { [taskId: string]: TaskState }
161    // Name → AgentId registry populated by Agent tool when `name` is provided.
162    // Latest-wins on collision. Used by SendMessage to route by name.
163    agentNameRegistry: Map<string, AgentId>
164    // Task ID that has been foregrounded - its messages are shown in main view
165    foregroundedTaskId?: string
166    // Task ID of in-process teammate whose transcript is being viewed (undefined = leader's view)
167    viewingAgentTaskId?: string
168    // Latest companion reaction from the friend observer (src/buddy/observer.ts)
169    companionReaction?: string
170    // Timestamp of last /buddy pet — CompanionSprite renders hearts while recent
171    companionPetAt?: number
172    // TODO (ashwin): see if we can use utility-types DeepReadonly for this
173    mcp: {
174      clients: MCPServerConnection[]
175      tools: Tool[]
176      commands: Command[]
177      resources: Record<string, ServerResource[]>
178      /**
179       * Incremented by /reload-plugins to trigger MCP effects to re-run
180       * and pick up newly-enabled plugin MCP servers. Effects read this
181       * as a dependency; the value itself is not consumed.
182       */
183      pluginReconnectKey: number
184    }
185    plugins: {
186      enabled: LoadedPlugin[]
187      disabled: LoadedPlugin[]
188      commands: Command[]
189      /**
190       * Plugin system errors collected during loading and initialization.
191       * See {@link PluginError} type documentation for complete details on error
192       * structure, context fields, and display format.
193       */
194      errors: PluginError[]
195      // Installation status for background plugin/marketplace installation
196      installationStatus: {
197        marketplaces: Array<{
198          name: string
199          status: 'pending' | 'installing' | 'installed' | 'failed'
200          error?: string
201        }>
202        plugins: Array<{
203          id: string
204          name: string
205          status: 'pending' | 'installing' | 'installed' | 'failed'
206          error?: string
207        }>
208      }
209      /**
210       * Set to true when plugin state on disk has changed (background reconcile,
211       * /plugin menu install, external settings edit) and active components are
212       * stale. In interactive mode, user runs /reload-plugins to consume. In
213       * headless mode, refreshPluginState() auto-consumes via refreshActivePlugins().
214       */
215      needsRefresh: boolean
216    }
217    agentDefinitions: AgentDefinitionsResult
218    fileHistory: FileHistoryState
219    attribution: AttributionState
220    todos: { [agentId: string]: TodoList }
221    remoteAgentTaskSuggestions: { summary: string; task: string }[]
222    notifications: {
223      current: Notification | null
224      queue: Notification[]
225    }
226    elicitation: {
227      queue: ElicitationRequestEvent[]
228    }
229    thinkingEnabled: boolean | undefined
230    promptSuggestionEnabled: boolean
231    sessionHooks: SessionHooksState
232    tungstenActiveSession?: {
233      sessionName: string
234      socketName: string
235      target: string // The tmux target (e.g., "session:window.pane")
236    }
237    tungstenLastCapturedTime?: number // Timestamp when frame was captured for model
238    tungstenLastCommand?: {
239      command: string // The command string to display (e.g., "Enter", "echo hello")
240      timestamp: number // When the command was sent
241    }
242    // Sticky tmux panel visibility — mirrors globalConfig.tungstenPanelVisible for reactivity.
243    tungstenPanelVisible?: boolean
244    // Transient auto-hide at turn end — separate from tungstenPanelVisible so the
245    // pill stays in the footer (user can reopen) but the panel content doesn't take
246    // screen space when idle. Cleared on next Tmux tool use or user toggle. NOT persisted.
247    tungstenPanelAutoHidden?: boolean
248    // WebBrowser tool (codename bagel): pill visible in footer
249    bagelActive?: boolean
250    // WebBrowser tool: current page URL shown in pill label
251    bagelUrl?: string
252    // WebBrowser tool: sticky panel visibility toggle
253    bagelPanelVisible?: boolean
254    // chicago MCP session state. Types inlined (not imported from
255    // @ant/computer-use-mcp/types) so external typecheck passes without the
256    // ant-scoped dep resolved. Shapes match `AppGrant`/`CuGrantFlags`
257    // structurally — wrapper.tsx assigns via structural compatibility. Only
258    // populated when feature('CHICAGO_MCP') is active.
259    computerUseMcpState?: {
260      // Session-scoped app allowlist. NOT persisted across resume.
261      allowedApps?: readonly {
262        bundleId: string
263        displayName: string
264        grantedAt: number
265      }[]
266      // Clipboard/system-key grant flags (orthogonal to allowlist).
267      grantFlags?: {
268        clipboardRead: boolean
269        clipboardWrite: boolean
270        systemKeyCombos: boolean
271      }
272      // Dims-only (NOT the blob) for scaleCoord after compaction. The full
273      // `ScreenshotResult` including base64 is process-local in wrapper.tsx.
274      lastScreenshotDims?: {
275        width: number
276        height: number
277        displayWidth: number
278        displayHeight: number
279        displayId?: number
280        originX?: number
281        originY?: number
282      }
283      // Accumulated by onAppsHidden, cleared + unhidden at turn end.
284      hiddenDuringTurn?: ReadonlySet<string>
285      // Which display CU targets. Written back by the package's
286      // `autoTargetDisplay` resolver via `onResolvedDisplayUpdated`. Persisted
287      // across resume so clicks stay on the display the model last saw.
288      selectedDisplayId?: number
289      // True when the model explicitly picked a display via `switch_display`.
290      // Makes `handleScreenshot` skip the resolver chase chain and honor
291      // `selectedDisplayId` directly. Cleared on resolver writeback (pinned
292      // display unplugged → Swift fell back to main) and on
293      // `switch_display("auto")`.
294      displayPinnedByModel?: boolean
295      // Sorted comma-joined bundle-ID set the display was last auto-resolved
296      // for. `handleScreenshot` only re-resolves when the allowed set has
297      // changed since — keeps the resolver from yanking on every screenshot.
298      displayResolvedForApps?: string
299    }
300    // REPL tool VM context - persists across REPL calls for state sharing
301    replContext?: {
302      vmContext: import('vm').Context
303      registeredTools: Map<
304        string,
305        {
306          name: string
307          description: string
308          schema: Record<string, unknown>
309          handler: (args: Record<string, unknown>) => Promise<unknown>
310        }
311      >
312      console: {
313        log: (...args: unknown[]) => void
314        error: (...args: unknown[]) => void
315        warn: (...args: unknown[]) => void
316        info: (...args: unknown[]) => void
317        debug: (...args: unknown[]) => void
318        getStdout: () => string
319        getStderr: () => string
320        clear: () => void
321      }
322    }
323    teamContext?: {
324      teamName: string
325      teamFilePath: string
326      leadAgentId: string
327      // Self-identity for swarm members (separate processes in tmux panes)
328      // Note: This is different from toolUseContext.agentId which is for in-process subagents
329      selfAgentId?: string // Swarm member's own ID (same as leadAgentId for leaders)
330      selfAgentName?: string // Swarm member's name ('team-lead' for leaders)
331      isLeader?: boolean // True if this swarm member is the team leader
332      selfAgentColor?: string // Assigned color for UI (used by dynamically joined sessions)
333      teammates: {
334        [teammateId: string]: {
335          name: string
336          agentType?: string
337          color?: string
338          tmuxSessionName: string
339          tmuxPaneId: string
340          cwd: string
341          worktreePath?: string
342          spawnedAt: number
343        }
344      }
345    }
346    // Standalone agent context for non-swarm sessions with custom name/color
347    standaloneAgentContext?: {
348      name: string
349      color?: AgentColorName
350    }
351    inbox: {
352      messages: Array<{
353        id: string
354        from: string
355        text: string
356        timestamp: string
357        status: 'pending' | 'processing' | 'processed'
358        color?: string
359        summary?: string
360      }>
361    }
362    // Worker sandbox permission requests (leader side) - for network access approval
363    workerSandboxPermissions: {
364      queue: Array<{
365        requestId: string
366        workerId: string
367        workerName: string
368        workerColor?: string
369        host: string
370        createdAt: number
371      }>
372      selectedIndex: number
373    }
374    // Pending permission request on worker side (shown while waiting for leader approval)
375    pendingWorkerRequest: {
376      toolName: string
377      toolUseId: string
378      description: string
379    } | null
380    // Pending sandbox permission request on worker side
381    pendingSandboxRequest: {
382      requestId: string
383      host: string
384    } | null
385    promptSuggestion: {
386      text: string | null
387      promptId: 'user_intent' | 'stated_intent' | null
388      shownAt: number
389      acceptedAt: number
390      generationRequestId: string | null
391    }
392    speculation: SpeculationState
393    speculationSessionTimeSavedMs: number
394    skillImprovement: {
395      suggestion: {
396        skillName: string
397        updates: { section: string; change: string; reason: string }[]
398      } | null
399    }
400    // Auth version - incremented on login/logout to trigger re-fetching of auth-dependent data
401    authVersion: number
402    // Initial message to process (from CLI args or plan mode exit)
403    // When set, REPL will process the message and trigger a query
404    initialMessage: {
405      message: UserMessage
406      clearContext?: boolean
407      mode?: PermissionMode
408      // Session-scoped permission rules from plan mode (e.g., "run tests", "install dependencies")
409      allowedPrompts?: AllowedPrompt[]
410    } | null
411    // Pending plan verification state (set when exiting plan mode)
412    // Used by VerifyPlanExecution tool to trigger background verification
413    pendingPlanVerification?: {
414      plan: string
415      verificationStarted: boolean
416      verificationCompleted: boolean
417    }
418    // Denial tracking for classifier modes (YOLO, headless, etc.) - falls back to prompting when limits exceeded
419    denialTracking?: DenialTrackingState
420    // Active overlays (Select dialogs, etc.) for Escape key coordination
421    activeOverlays: ReadonlySet<string>
422    // Fast mode
423    fastMode?: boolean
424    // Advisor model for server-side advisor tool (undefined = disabled).
425    advisorModel?: string
426    // Effort value
427    effortValue?: EffortValue
428    // Set synchronously in launchUltraplan before the detached flow starts.
429    // Prevents duplicate launches during the ~5s window before
430    // ultraplanSessionUrl is set by teleportToRemote. Cleared by launchDetached
431    // once the URL is set or on failure.
432    ultraplanLaunching?: boolean
433    // Active ultraplan CCR session URL. Set while the RemoteAgentTask runs;
434    // truthy disables the keyword trigger + rainbow. Cleared when the poll
435    // reaches terminal state.
436    ultraplanSessionUrl?: string
437    // Approved ultraplan awaiting user choice (implement here vs fresh session).
438    // Set by RemoteAgentTask poll on approval; cleared by UltraplanChoiceDialog.
439    ultraplanPendingChoice?: { plan: string; sessionId: string; taskId: string }
440    // Pre-launch permission dialog. Set by /ultraplan (slash or keyword);
441    // cleared by UltraplanLaunchDialog on choice.
442    ultraplanLaunchPending?: { blurb: string }
443    // Remote-harness side: set via set_permission_mode control_request,
444    // pushed to CCR external_metadata.is_ultraplan_mode by onChangeAppState.
445    isUltraplanMode?: boolean
446    // Always-on bridge: permission callbacks for bidirectional permission checks
447    replBridgePermissionCallbacks?: BridgePermissionCallbacks
448    // Channel permission callbacks — permission prompts over Telegram/iMessage/etc.
449    // Races against local UI + bridge + hooks + classifier via claim() in
450    // interactiveHandler.ts. Constructed once in useManageMCPConnections.
451    channelPermissionCallbacks?: ChannelPermissionCallbacks
452  }
453  
454  export type AppStateStore = Store<AppState>
455  
456  export function getDefaultAppState(): AppState {
457    // Determine initial permission mode for teammates spawned with plan_mode_required
458    // Use lazy require to avoid circular dependency with teammate.ts
459    /* eslint-disable @typescript-eslint/no-require-imports */
460    const teammateUtils =
461      require('../utils/teammate.js') as typeof import('../utils/teammate.js')
462    /* eslint-enable @typescript-eslint/no-require-imports */
463    const initialMode: PermissionMode =
464      teammateUtils.isTeammate() && teammateUtils.isPlanModeRequired()
465        ? 'plan'
466        : 'default'
467  
468    return {
469      settings: getInitialSettings(),
470      tasks: {},
471      agentNameRegistry: new Map(),
472      verbose: false,
473      mainLoopModel: null, // alias, full name (as with --model or env var), or null (default)
474      mainLoopModelForSession: null,
475      statusLineText: undefined,
476      expandedView: 'none',
477      isBriefOnly: false,
478      showTeammateMessagePreview: false,
479      selectedIPAgentIndex: -1,
480      coordinatorTaskIndex: -1,
481      viewSelectionMode: 'none',
482      footerSelection: null,
483      kairosEnabled: false,
484      remoteSessionUrl: undefined,
485      remoteConnectionStatus: 'connecting',
486      remoteBackgroundTaskCount: 0,
487      replBridgeEnabled: false,
488      replBridgeExplicit: false,
489      replBridgeOutboundOnly: false,
490      replBridgeConnected: false,
491      replBridgeSessionActive: false,
492      replBridgeReconnecting: false,
493      replBridgeConnectUrl: undefined,
494      replBridgeSessionUrl: undefined,
495      replBridgeEnvironmentId: undefined,
496      replBridgeSessionId: undefined,
497      replBridgeError: undefined,
498      replBridgeInitialName: undefined,
499      showRemoteCallout: false,
500      toolPermissionContext: {
501        ...getEmptyToolPermissionContext(),
502        mode: initialMode,
503      },
504      agent: undefined,
505      agentDefinitions: { activeAgents: [], allAgents: [] },
506      fileHistory: {
507        snapshots: [],
508        trackedFiles: new Set(),
509        snapshotSequence: 0,
510      },
511      attribution: createEmptyAttributionState(),
512      mcp: {
513        clients: [],
514        tools: [],
515        commands: [],
516        resources: {},
517        pluginReconnectKey: 0,
518      },
519      plugins: {
520        enabled: [],
521        disabled: [],
522        commands: [],
523        errors: [],
524        installationStatus: {
525          marketplaces: [],
526          plugins: [],
527        },
528        needsRefresh: false,
529      },
530      todos: {},
531      remoteAgentTaskSuggestions: [],
532      notifications: {
533        current: null,
534        queue: [],
535      },
536      elicitation: {
537        queue: [],
538      },
539      thinkingEnabled: shouldEnableThinkingByDefault(),
540      promptSuggestionEnabled: shouldEnablePromptSuggestion(),
541      sessionHooks: new Map(),
542      inbox: {
543        messages: [],
544      },
545      workerSandboxPermissions: {
546        queue: [],
547        selectedIndex: 0,
548      },
549      pendingWorkerRequest: null,
550      pendingSandboxRequest: null,
551      promptSuggestion: {
552        text: null,
553        promptId: null,
554        shownAt: 0,
555        acceptedAt: 0,
556        generationRequestId: null,
557      },
558      speculation: IDLE_SPECULATION_STATE,
559      speculationSessionTimeSavedMs: 0,
560      skillImprovement: {
561        suggestion: null,
562      },
563      authVersion: 0,
564      initialMessage: null,
565      effortValue: undefined,
566      activeOverlays: new Set<string>(),
567      fastMode: false,
568    }
569  }