chore: initialize recovered claude workspace
This commit is contained in:
200
src/state/AppState.tsx
Normal file
200
src/state/AppState.tsx
Normal file
File diff suppressed because one or more lines are too long
569
src/state/AppStateStore.ts
Normal file
569
src/state/AppStateStore.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
import type { Notification } from 'src/context/notifications.js'
|
||||
import type { TodoList } from 'src/utils/todo/types.js'
|
||||
import type { BridgePermissionCallbacks } from '../bridge/bridgePermissionCallbacks.js'
|
||||
import type { Command } from '../commands.js'
|
||||
import type { ChannelPermissionCallbacks } from '../services/mcp/channelPermissions.js'
|
||||
import type { ElicitationRequestEvent } from '../services/mcp/elicitationHandler.js'
|
||||
import type {
|
||||
MCPServerConnection,
|
||||
ServerResource,
|
||||
} from '../services/mcp/types.js'
|
||||
import { shouldEnablePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js'
|
||||
import {
|
||||
getEmptyToolPermissionContext,
|
||||
type Tool,
|
||||
type ToolPermissionContext,
|
||||
} from '../Tool.js'
|
||||
import type { TaskState } from '../tasks/types.js'
|
||||
import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'
|
||||
import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'
|
||||
import type { AllowedPrompt } from '../tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
|
||||
import type { AgentId } from '../types/ids.js'
|
||||
import type { Message, UserMessage } from '../types/message.js'
|
||||
import type { LoadedPlugin, PluginError } from '../types/plugin.js'
|
||||
import type { DeepImmutable } from '../types/utils.js'
|
||||
import {
|
||||
type AttributionState,
|
||||
createEmptyAttributionState,
|
||||
} from '../utils/commitAttribution.js'
|
||||
import type { EffortValue } from '../utils/effort.js'
|
||||
import type { FileHistoryState } from '../utils/fileHistory.js'
|
||||
import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js'
|
||||
import type { SessionHooksState } from '../utils/hooks/sessionHooks.js'
|
||||
import type { ModelSetting } from '../utils/model/model.js'
|
||||
import type { DenialTrackingState } from '../utils/permissions/denialTracking.js'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
import { getInitialSettings } from '../utils/settings/settings.js'
|
||||
import type { SettingsJson } from '../utils/settings/types.js'
|
||||
import { shouldEnableThinkingByDefault } from '../utils/thinking.js'
|
||||
import type { Store } from './store.js'
|
||||
|
||||
export type CompletionBoundary =
|
||||
| { type: 'complete'; completedAt: number; outputTokens: number }
|
||||
| { type: 'bash'; command: string; completedAt: number }
|
||||
| { type: 'edit'; toolName: string; filePath: string; completedAt: number }
|
||||
| {
|
||||
type: 'denied_tool'
|
||||
toolName: string
|
||||
detail: string
|
||||
completedAt: number
|
||||
}
|
||||
|
||||
export type SpeculationResult = {
|
||||
messages: Message[]
|
||||
boundary: CompletionBoundary | null
|
||||
timeSavedMs: number
|
||||
}
|
||||
|
||||
export type SpeculationState =
|
||||
| { status: 'idle' }
|
||||
| {
|
||||
status: 'active'
|
||||
id: string
|
||||
abort: () => void
|
||||
startTime: number
|
||||
messagesRef: { current: Message[] } // Mutable ref - avoids array spreading per message
|
||||
writtenPathsRef: { current: Set<string> } // Mutable ref - relative paths written to overlay
|
||||
boundary: CompletionBoundary | null
|
||||
suggestionLength: number
|
||||
toolUseCount: number
|
||||
isPipelined: boolean
|
||||
contextRef: { current: REPLHookContext }
|
||||
pipelinedSuggestion?: {
|
||||
text: string
|
||||
promptId: 'user_intent' | 'stated_intent'
|
||||
generationRequestId: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export const IDLE_SPECULATION_STATE: SpeculationState = { status: 'idle' }
|
||||
|
||||
export type FooterItem =
|
||||
| 'tasks'
|
||||
| 'tmux'
|
||||
| 'bagel'
|
||||
| 'teams'
|
||||
| 'bridge'
|
||||
| 'companion'
|
||||
|
||||
export type AppState = DeepImmutable<{
|
||||
settings: SettingsJson
|
||||
verbose: boolean
|
||||
mainLoopModel: ModelSetting
|
||||
mainLoopModelForSession: ModelSetting
|
||||
statusLineText: string | undefined
|
||||
expandedView: 'none' | 'tasks' | 'teammates'
|
||||
isBriefOnly: boolean
|
||||
// Optional - only present when ENABLE_AGENT_SWARMS is true (for dead code elimination)
|
||||
showTeammateMessagePreview?: boolean
|
||||
selectedIPAgentIndex: number
|
||||
// CoordinatorTaskPanel selection: -1 = pill, 0 = main, 1..N = agent rows.
|
||||
// AppState (not local) so the panel can read it directly without prop-drilling
|
||||
// through PromptInput → PromptInputFooter.
|
||||
coordinatorTaskIndex: number
|
||||
viewSelectionMode: 'none' | 'selecting-agent' | 'viewing-agent'
|
||||
// Which footer pill is focused (arrow-key navigation below the prompt).
|
||||
// Lives in AppState so pill components rendered outside PromptInput
|
||||
// (CompanionSprite in REPL.tsx) can read their own focused state.
|
||||
footerSelection: FooterItem | null
|
||||
toolPermissionContext: ToolPermissionContext
|
||||
spinnerTip?: string
|
||||
// Agent name from --agent CLI flag or settings (for logo display)
|
||||
agent: string | undefined
|
||||
// Assistant mode fully enabled (settings + GrowthBook gate + trust).
|
||||
// Single source of truth - computed once in main.tsx before option
|
||||
// mutation, consumers read this instead of re-calling isAssistantMode().
|
||||
kairosEnabled: boolean
|
||||
// Remote session URL for --remote mode (shown in footer indicator)
|
||||
remoteSessionUrl: string | undefined
|
||||
// Remote session WS state (`claude assistant` viewer). 'connected' means the
|
||||
// live event stream is open; 'reconnecting' = transient WS drop, backoff
|
||||
// in progress; 'disconnected' = permanent close or reconnects exhausted.
|
||||
remoteConnectionStatus:
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'disconnected'
|
||||
// `claude assistant`: count of background tasks (Agent calls, teammates,
|
||||
// workflows) running inside the REMOTE daemon child. Event-sourced from
|
||||
// system/task_started and system/task_notification on the WS. The local
|
||||
// AppState.tasks is always empty in viewer mode — the tasks live in a
|
||||
// different process.
|
||||
remoteBackgroundTaskCount: number
|
||||
// Always-on bridge: desired state (controlled by /config or footer toggle)
|
||||
replBridgeEnabled: boolean
|
||||
// Always-on bridge: true when activated via /remote-control command, false when config-driven
|
||||
replBridgeExplicit: boolean
|
||||
// Outbound-only mode: forward events to CCR but reject inbound prompts/control
|
||||
replBridgeOutboundOnly: boolean
|
||||
// Always-on bridge: env registered + session created (= "Ready")
|
||||
replBridgeConnected: boolean
|
||||
// Always-on bridge: ingress WebSocket is open (= "Connected" - user on claude.ai)
|
||||
replBridgeSessionActive: boolean
|
||||
// Always-on bridge: poll loop is in error backoff (= "Reconnecting")
|
||||
replBridgeReconnecting: boolean
|
||||
// Always-on bridge: connect URL for Ready state (?bridge=envId)
|
||||
replBridgeConnectUrl: string | undefined
|
||||
// Always-on bridge: session URL on claude.ai (set when connected)
|
||||
replBridgeSessionUrl: string | undefined
|
||||
// Always-on bridge: IDs for debugging (shown in dialog when --verbose)
|
||||
replBridgeEnvironmentId: string | undefined
|
||||
replBridgeSessionId: string | undefined
|
||||
// Always-on bridge: error message when connection fails (shown in BridgeDialog)
|
||||
replBridgeError: string | undefined
|
||||
// Always-on bridge: session name set via `/remote-control <name>` (used as session title)
|
||||
replBridgeInitialName: string | undefined
|
||||
// Always-on bridge: first-time remote dialog pending (set by /remote-control command)
|
||||
showRemoteCallout: boolean
|
||||
}> & {
|
||||
// Unified task state - excluded from DeepImmutable because TaskState contains function types
|
||||
tasks: { [taskId: string]: TaskState }
|
||||
// Name → AgentId registry populated by Agent tool when `name` is provided.
|
||||
// Latest-wins on collision. Used by SendMessage to route by name.
|
||||
agentNameRegistry: Map<string, AgentId>
|
||||
// Task ID that has been foregrounded - its messages are shown in main view
|
||||
foregroundedTaskId?: string
|
||||
// Task ID of in-process teammate whose transcript is being viewed (undefined = leader's view)
|
||||
viewingAgentTaskId?: string
|
||||
// Latest companion reaction from the friend observer (src/buddy/observer.ts)
|
||||
companionReaction?: string
|
||||
// Timestamp of last /buddy pet — CompanionSprite renders hearts while recent
|
||||
companionPetAt?: number
|
||||
// TODO (ashwin): see if we can use utility-types DeepReadonly for this
|
||||
mcp: {
|
||||
clients: MCPServerConnection[]
|
||||
tools: Tool[]
|
||||
commands: Command[]
|
||||
resources: Record<string, ServerResource[]>
|
||||
/**
|
||||
* Incremented by /reload-plugins to trigger MCP effects to re-run
|
||||
* and pick up newly-enabled plugin MCP servers. Effects read this
|
||||
* as a dependency; the value itself is not consumed.
|
||||
*/
|
||||
pluginReconnectKey: number
|
||||
}
|
||||
plugins: {
|
||||
enabled: LoadedPlugin[]
|
||||
disabled: LoadedPlugin[]
|
||||
commands: Command[]
|
||||
/**
|
||||
* Plugin system errors collected during loading and initialization.
|
||||
* See {@link PluginError} type documentation for complete details on error
|
||||
* structure, context fields, and display format.
|
||||
*/
|
||||
errors: PluginError[]
|
||||
// Installation status for background plugin/marketplace installation
|
||||
installationStatus: {
|
||||
marketplaces: Array<{
|
||||
name: string
|
||||
status: 'pending' | 'installing' | 'installed' | 'failed'
|
||||
error?: string
|
||||
}>
|
||||
plugins: Array<{
|
||||
id: string
|
||||
name: string
|
||||
status: 'pending' | 'installing' | 'installed' | 'failed'
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
/**
|
||||
* Set to true when plugin state on disk has changed (background reconcile,
|
||||
* /plugin menu install, external settings edit) and active components are
|
||||
* stale. In interactive mode, user runs /reload-plugins to consume. In
|
||||
* headless mode, refreshPluginState() auto-consumes via refreshActivePlugins().
|
||||
*/
|
||||
needsRefresh: boolean
|
||||
}
|
||||
agentDefinitions: AgentDefinitionsResult
|
||||
fileHistory: FileHistoryState
|
||||
attribution: AttributionState
|
||||
todos: { [agentId: string]: TodoList }
|
||||
remoteAgentTaskSuggestions: { summary: string; task: string }[]
|
||||
notifications: {
|
||||
current: Notification | null
|
||||
queue: Notification[]
|
||||
}
|
||||
elicitation: {
|
||||
queue: ElicitationRequestEvent[]
|
||||
}
|
||||
thinkingEnabled: boolean | undefined
|
||||
promptSuggestionEnabled: boolean
|
||||
sessionHooks: SessionHooksState
|
||||
tungstenActiveSession?: {
|
||||
sessionName: string
|
||||
socketName: string
|
||||
target: string // The tmux target (e.g., "session:window.pane")
|
||||
}
|
||||
tungstenLastCapturedTime?: number // Timestamp when frame was captured for model
|
||||
tungstenLastCommand?: {
|
||||
command: string // The command string to display (e.g., "Enter", "echo hello")
|
||||
timestamp: number // When the command was sent
|
||||
}
|
||||
// Sticky tmux panel visibility — mirrors globalConfig.tungstenPanelVisible for reactivity.
|
||||
tungstenPanelVisible?: boolean
|
||||
// Transient auto-hide at turn end — separate from tungstenPanelVisible so the
|
||||
// pill stays in the footer (user can reopen) but the panel content doesn't take
|
||||
// screen space when idle. Cleared on next Tmux tool use or user toggle. NOT persisted.
|
||||
tungstenPanelAutoHidden?: boolean
|
||||
// WebBrowser tool (codename bagel): pill visible in footer
|
||||
bagelActive?: boolean
|
||||
// WebBrowser tool: current page URL shown in pill label
|
||||
bagelUrl?: string
|
||||
// WebBrowser tool: sticky panel visibility toggle
|
||||
bagelPanelVisible?: boolean
|
||||
// chicago MCP session state. Types inlined (not imported from
|
||||
// @ant/computer-use-mcp/types) so external typecheck passes without the
|
||||
// ant-scoped dep resolved. Shapes match `AppGrant`/`CuGrantFlags`
|
||||
// structurally — wrapper.tsx assigns via structural compatibility. Only
|
||||
// populated when feature('CHICAGO_MCP') is active.
|
||||
computerUseMcpState?: {
|
||||
// Session-scoped app allowlist. NOT persisted across resume.
|
||||
allowedApps?: readonly {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
grantedAt: number
|
||||
}[]
|
||||
// Clipboard/system-key grant flags (orthogonal to allowlist).
|
||||
grantFlags?: {
|
||||
clipboardRead: boolean
|
||||
clipboardWrite: boolean
|
||||
systemKeyCombos: boolean
|
||||
}
|
||||
// Dims-only (NOT the blob) for scaleCoord after compaction. The full
|
||||
// `ScreenshotResult` including base64 is process-local in wrapper.tsx.
|
||||
lastScreenshotDims?: {
|
||||
width: number
|
||||
height: number
|
||||
displayWidth: number
|
||||
displayHeight: number
|
||||
displayId?: number
|
||||
originX?: number
|
||||
originY?: number
|
||||
}
|
||||
// Accumulated by onAppsHidden, cleared + unhidden at turn end.
|
||||
hiddenDuringTurn?: ReadonlySet<string>
|
||||
// Which display CU targets. Written back by the package's
|
||||
// `autoTargetDisplay` resolver via `onResolvedDisplayUpdated`. Persisted
|
||||
// across resume so clicks stay on the display the model last saw.
|
||||
selectedDisplayId?: number
|
||||
// True when the model explicitly picked a display via `switch_display`.
|
||||
// Makes `handleScreenshot` skip the resolver chase chain and honor
|
||||
// `selectedDisplayId` directly. Cleared on resolver writeback (pinned
|
||||
// display unplugged → Swift fell back to main) and on
|
||||
// `switch_display("auto")`.
|
||||
displayPinnedByModel?: boolean
|
||||
// Sorted comma-joined bundle-ID set the display was last auto-resolved
|
||||
// for. `handleScreenshot` only re-resolves when the allowed set has
|
||||
// changed since — keeps the resolver from yanking on every screenshot.
|
||||
displayResolvedForApps?: string
|
||||
}
|
||||
// REPL tool VM context - persists across REPL calls for state sharing
|
||||
replContext?: {
|
||||
vmContext: import('vm').Context
|
||||
registeredTools: Map<
|
||||
string,
|
||||
{
|
||||
name: string
|
||||
description: string
|
||||
schema: Record<string, unknown>
|
||||
handler: (args: Record<string, unknown>) => Promise<unknown>
|
||||
}
|
||||
>
|
||||
console: {
|
||||
log: (...args: unknown[]) => void
|
||||
error: (...args: unknown[]) => void
|
||||
warn: (...args: unknown[]) => void
|
||||
info: (...args: unknown[]) => void
|
||||
debug: (...args: unknown[]) => void
|
||||
getStdout: () => string
|
||||
getStderr: () => string
|
||||
clear: () => void
|
||||
}
|
||||
}
|
||||
teamContext?: {
|
||||
teamName: string
|
||||
teamFilePath: string
|
||||
leadAgentId: string
|
||||
// Self-identity for swarm members (separate processes in tmux panes)
|
||||
// Note: This is different from toolUseContext.agentId which is for in-process subagents
|
||||
selfAgentId?: string // Swarm member's own ID (same as leadAgentId for leaders)
|
||||
selfAgentName?: string // Swarm member's name ('team-lead' for leaders)
|
||||
isLeader?: boolean // True if this swarm member is the team leader
|
||||
selfAgentColor?: string // Assigned color for UI (used by dynamically joined sessions)
|
||||
teammates: {
|
||||
[teammateId: string]: {
|
||||
name: string
|
||||
agentType?: string
|
||||
color?: string
|
||||
tmuxSessionName: string
|
||||
tmuxPaneId: string
|
||||
cwd: string
|
||||
worktreePath?: string
|
||||
spawnedAt: number
|
||||
}
|
||||
}
|
||||
}
|
||||
// Standalone agent context for non-swarm sessions with custom name/color
|
||||
standaloneAgentContext?: {
|
||||
name: string
|
||||
color?: AgentColorName
|
||||
}
|
||||
inbox: {
|
||||
messages: Array<{
|
||||
id: string
|
||||
from: string
|
||||
text: string
|
||||
timestamp: string
|
||||
status: 'pending' | 'processing' | 'processed'
|
||||
color?: string
|
||||
summary?: string
|
||||
}>
|
||||
}
|
||||
// Worker sandbox permission requests (leader side) - for network access approval
|
||||
workerSandboxPermissions: {
|
||||
queue: Array<{
|
||||
requestId: string
|
||||
workerId: string
|
||||
workerName: string
|
||||
workerColor?: string
|
||||
host: string
|
||||
createdAt: number
|
||||
}>
|
||||
selectedIndex: number
|
||||
}
|
||||
// Pending permission request on worker side (shown while waiting for leader approval)
|
||||
pendingWorkerRequest: {
|
||||
toolName: string
|
||||
toolUseId: string
|
||||
description: string
|
||||
} | null
|
||||
// Pending sandbox permission request on worker side
|
||||
pendingSandboxRequest: {
|
||||
requestId: string
|
||||
host: string
|
||||
} | null
|
||||
promptSuggestion: {
|
||||
text: string | null
|
||||
promptId: 'user_intent' | 'stated_intent' | null
|
||||
shownAt: number
|
||||
acceptedAt: number
|
||||
generationRequestId: string | null
|
||||
}
|
||||
speculation: SpeculationState
|
||||
speculationSessionTimeSavedMs: number
|
||||
skillImprovement: {
|
||||
suggestion: {
|
||||
skillName: string
|
||||
updates: { section: string; change: string; reason: string }[]
|
||||
} | null
|
||||
}
|
||||
// Auth version - incremented on login/logout to trigger re-fetching of auth-dependent data
|
||||
authVersion: number
|
||||
// Initial message to process (from CLI args or plan mode exit)
|
||||
// When set, REPL will process the message and trigger a query
|
||||
initialMessage: {
|
||||
message: UserMessage
|
||||
clearContext?: boolean
|
||||
mode?: PermissionMode
|
||||
// Session-scoped permission rules from plan mode (e.g., "run tests", "install dependencies")
|
||||
allowedPrompts?: AllowedPrompt[]
|
||||
} | null
|
||||
// Pending plan verification state (set when exiting plan mode)
|
||||
// Used by VerifyPlanExecution tool to trigger background verification
|
||||
pendingPlanVerification?: {
|
||||
plan: string
|
||||
verificationStarted: boolean
|
||||
verificationCompleted: boolean
|
||||
}
|
||||
// Denial tracking for classifier modes (YOLO, headless, etc.) - falls back to prompting when limits exceeded
|
||||
denialTracking?: DenialTrackingState
|
||||
// Active overlays (Select dialogs, etc.) for Escape key coordination
|
||||
activeOverlays: ReadonlySet<string>
|
||||
// Fast mode
|
||||
fastMode?: boolean
|
||||
// Advisor model for server-side advisor tool (undefined = disabled).
|
||||
advisorModel?: string
|
||||
// Effort value
|
||||
effortValue?: EffortValue
|
||||
// Set synchronously in launchUltraplan before the detached flow starts.
|
||||
// Prevents duplicate launches during the ~5s window before
|
||||
// ultraplanSessionUrl is set by teleportToRemote. Cleared by launchDetached
|
||||
// once the URL is set or on failure.
|
||||
ultraplanLaunching?: boolean
|
||||
// Active ultraplan CCR session URL. Set while the RemoteAgentTask runs;
|
||||
// truthy disables the keyword trigger + rainbow. Cleared when the poll
|
||||
// reaches terminal state.
|
||||
ultraplanSessionUrl?: string
|
||||
// Approved ultraplan awaiting user choice (implement here vs fresh session).
|
||||
// Set by RemoteAgentTask poll on approval; cleared by UltraplanChoiceDialog.
|
||||
ultraplanPendingChoice?: { plan: string; sessionId: string; taskId: string }
|
||||
// Pre-launch permission dialog. Set by /ultraplan (slash or keyword);
|
||||
// cleared by UltraplanLaunchDialog on choice.
|
||||
ultraplanLaunchPending?: { blurb: string }
|
||||
// Remote-harness side: set via set_permission_mode control_request,
|
||||
// pushed to CCR external_metadata.is_ultraplan_mode by onChangeAppState.
|
||||
isUltraplanMode?: boolean
|
||||
// Always-on bridge: permission callbacks for bidirectional permission checks
|
||||
replBridgePermissionCallbacks?: BridgePermissionCallbacks
|
||||
// Channel permission callbacks — permission prompts over Telegram/iMessage/etc.
|
||||
// Races against local UI + bridge + hooks + classifier via claim() in
|
||||
// interactiveHandler.ts. Constructed once in useManageMCPConnections.
|
||||
channelPermissionCallbacks?: ChannelPermissionCallbacks
|
||||
}
|
||||
|
||||
export type AppStateStore = Store<AppState>
|
||||
|
||||
export function getDefaultAppState(): AppState {
|
||||
// Determine initial permission mode for teammates spawned with plan_mode_required
|
||||
// Use lazy require to avoid circular dependency with teammate.ts
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const teammateUtils =
|
||||
require('../utils/teammate.js') as typeof import('../utils/teammate.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
const initialMode: PermissionMode =
|
||||
teammateUtils.isTeammate() && teammateUtils.isPlanModeRequired()
|
||||
? 'plan'
|
||||
: 'default'
|
||||
|
||||
return {
|
||||
settings: getInitialSettings(),
|
||||
tasks: {},
|
||||
agentNameRegistry: new Map(),
|
||||
verbose: false,
|
||||
mainLoopModel: null, // alias, full name (as with --model or env var), or null (default)
|
||||
mainLoopModelForSession: null,
|
||||
statusLineText: undefined,
|
||||
expandedView: 'none',
|
||||
isBriefOnly: false,
|
||||
showTeammateMessagePreview: false,
|
||||
selectedIPAgentIndex: -1,
|
||||
coordinatorTaskIndex: -1,
|
||||
viewSelectionMode: 'none',
|
||||
footerSelection: null,
|
||||
kairosEnabled: false,
|
||||
remoteSessionUrl: undefined,
|
||||
remoteConnectionStatus: 'connecting',
|
||||
remoteBackgroundTaskCount: 0,
|
||||
replBridgeEnabled: false,
|
||||
replBridgeExplicit: false,
|
||||
replBridgeOutboundOnly: false,
|
||||
replBridgeConnected: false,
|
||||
replBridgeSessionActive: false,
|
||||
replBridgeReconnecting: false,
|
||||
replBridgeConnectUrl: undefined,
|
||||
replBridgeSessionUrl: undefined,
|
||||
replBridgeEnvironmentId: undefined,
|
||||
replBridgeSessionId: undefined,
|
||||
replBridgeError: undefined,
|
||||
replBridgeInitialName: undefined,
|
||||
showRemoteCallout: false,
|
||||
toolPermissionContext: {
|
||||
...getEmptyToolPermissionContext(),
|
||||
mode: initialMode,
|
||||
},
|
||||
agent: undefined,
|
||||
agentDefinitions: { activeAgents: [], allAgents: [] },
|
||||
fileHistory: {
|
||||
snapshots: [],
|
||||
trackedFiles: new Set(),
|
||||
snapshotSequence: 0,
|
||||
},
|
||||
attribution: createEmptyAttributionState(),
|
||||
mcp: {
|
||||
clients: [],
|
||||
tools: [],
|
||||
commands: [],
|
||||
resources: {},
|
||||
pluginReconnectKey: 0,
|
||||
},
|
||||
plugins: {
|
||||
enabled: [],
|
||||
disabled: [],
|
||||
commands: [],
|
||||
errors: [],
|
||||
installationStatus: {
|
||||
marketplaces: [],
|
||||
plugins: [],
|
||||
},
|
||||
needsRefresh: false,
|
||||
},
|
||||
todos: {},
|
||||
remoteAgentTaskSuggestions: [],
|
||||
notifications: {
|
||||
current: null,
|
||||
queue: [],
|
||||
},
|
||||
elicitation: {
|
||||
queue: [],
|
||||
},
|
||||
thinkingEnabled: shouldEnableThinkingByDefault(),
|
||||
promptSuggestionEnabled: shouldEnablePromptSuggestion(),
|
||||
sessionHooks: new Map(),
|
||||
inbox: {
|
||||
messages: [],
|
||||
},
|
||||
workerSandboxPermissions: {
|
||||
queue: [],
|
||||
selectedIndex: 0,
|
||||
},
|
||||
pendingWorkerRequest: null,
|
||||
pendingSandboxRequest: null,
|
||||
promptSuggestion: {
|
||||
text: null,
|
||||
promptId: null,
|
||||
shownAt: 0,
|
||||
acceptedAt: 0,
|
||||
generationRequestId: null,
|
||||
},
|
||||
speculation: IDLE_SPECULATION_STATE,
|
||||
speculationSessionTimeSavedMs: 0,
|
||||
skillImprovement: {
|
||||
suggestion: null,
|
||||
},
|
||||
authVersion: 0,
|
||||
initialMessage: null,
|
||||
effortValue: undefined,
|
||||
activeOverlays: new Set<string>(),
|
||||
fastMode: false,
|
||||
}
|
||||
}
|
||||
171
src/state/onChangeAppState.ts
Normal file
171
src/state/onChangeAppState.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { setMainLoopModelOverride } from '../bootstrap/state.js'
|
||||
import {
|
||||
clearApiKeyHelperCache,
|
||||
clearAwsCredentialsCache,
|
||||
clearGcpCredentialsCache,
|
||||
} from '../utils/auth.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
||||
import { toError } from '../utils/errors.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js'
|
||||
import {
|
||||
permissionModeFromString,
|
||||
toExternalPermissionMode,
|
||||
} from '../utils/permissions/PermissionMode.js'
|
||||
import {
|
||||
notifyPermissionModeChanged,
|
||||
notifySessionMetadataChanged,
|
||||
type SessionExternalMetadata,
|
||||
} from '../utils/sessionState.js'
|
||||
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
||||
import type { AppState } from './AppStateStore.js'
|
||||
|
||||
// Inverse of the push below — restore on worker restart.
|
||||
export function externalMetadataToAppState(
|
||||
metadata: SessionExternalMetadata,
|
||||
): (prev: AppState) => AppState {
|
||||
return prev => ({
|
||||
...prev,
|
||||
...(typeof metadata.permission_mode === 'string'
|
||||
? {
|
||||
toolPermissionContext: {
|
||||
...prev.toolPermissionContext,
|
||||
mode: permissionModeFromString(metadata.permission_mode),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(typeof metadata.is_ultraplan_mode === 'boolean'
|
||||
? { isUltraplanMode: metadata.is_ultraplan_mode }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function onChangeAppState({
|
||||
newState,
|
||||
oldState,
|
||||
}: {
|
||||
newState: AppState
|
||||
oldState: AppState
|
||||
}) {
|
||||
// toolPermissionContext.mode — single choke point for CCR/SDK mode sync.
|
||||
//
|
||||
// Prior to this block, mode changes were relayed to CCR by only 2 of 8+
|
||||
// mutation paths: a bespoke setAppState wrapper in print.ts (headless/SDK
|
||||
// mode only) and a manual notify in the set_permission_mode handler.
|
||||
// Every other path — Shift+Tab cycling, ExitPlanModePermissionRequest
|
||||
// dialog options, the /plan slash command, rewind, the REPL bridge's
|
||||
// onSetPermissionMode — mutated AppState without telling
|
||||
// CCR, leaving external_metadata.permission_mode stale and the web UI out
|
||||
// of sync with the CLI's actual mode.
|
||||
//
|
||||
// Hooking the diff here means ANY setAppState call that changes the mode
|
||||
// notifies CCR (via notifySessionMetadataChanged → ccrClient.reportMetadata)
|
||||
// and the SDK status stream (via notifyPermissionModeChanged → registered
|
||||
// in print.ts). The scattered callsites above need zero changes.
|
||||
const prevMode = oldState.toolPermissionContext.mode
|
||||
const newMode = newState.toolPermissionContext.mode
|
||||
if (prevMode !== newMode) {
|
||||
// CCR external_metadata must not receive internal-only mode names
|
||||
// (bubble, ungated auto). Externalize first — and skip
|
||||
// the CCR notify if the EXTERNAL mode didn't change (e.g.,
|
||||
// default→bubble→default is noise from CCR's POV since both
|
||||
// externalize to 'default'). The SDK channel (notifyPermissionModeChanged)
|
||||
// passes raw mode; its listener in print.ts applies its own filter.
|
||||
const prevExternal = toExternalPermissionMode(prevMode)
|
||||
const newExternal = toExternalPermissionMode(newMode)
|
||||
if (prevExternal !== newExternal) {
|
||||
// Ultraplan = first plan cycle only. The initial control_request
|
||||
// sets mode and isUltraplanMode atomically, so the flag's
|
||||
// transition gates it. null per RFC 7396 (removes the key).
|
||||
const isUltraplan =
|
||||
newExternal === 'plan' &&
|
||||
newState.isUltraplanMode &&
|
||||
!oldState.isUltraplanMode
|
||||
? true
|
||||
: null
|
||||
notifySessionMetadataChanged({
|
||||
permission_mode: newExternal,
|
||||
is_ultraplan_mode: isUltraplan,
|
||||
})
|
||||
}
|
||||
notifyPermissionModeChanged(newMode)
|
||||
}
|
||||
|
||||
// mainLoopModel: remove it from settings?
|
||||
if (
|
||||
newState.mainLoopModel !== oldState.mainLoopModel &&
|
||||
newState.mainLoopModel === null
|
||||
) {
|
||||
// Remove from settings
|
||||
updateSettingsForSource('userSettings', { model: undefined })
|
||||
setMainLoopModelOverride(null)
|
||||
}
|
||||
|
||||
// mainLoopModel: add it to settings?
|
||||
if (
|
||||
newState.mainLoopModel !== oldState.mainLoopModel &&
|
||||
newState.mainLoopModel !== null
|
||||
) {
|
||||
// Save to settings
|
||||
updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
|
||||
setMainLoopModelOverride(newState.mainLoopModel)
|
||||
}
|
||||
|
||||
// expandedView → persist as showExpandedTodos + showSpinnerTree for backwards compat
|
||||
if (newState.expandedView !== oldState.expandedView) {
|
||||
const showExpandedTodos = newState.expandedView === 'tasks'
|
||||
const showSpinnerTree = newState.expandedView === 'teammates'
|
||||
if (
|
||||
getGlobalConfig().showExpandedTodos !== showExpandedTodos ||
|
||||
getGlobalConfig().showSpinnerTree !== showSpinnerTree
|
||||
) {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
showExpandedTodos,
|
||||
showSpinnerTree,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// verbose
|
||||
if (
|
||||
newState.verbose !== oldState.verbose &&
|
||||
getGlobalConfig().verbose !== newState.verbose
|
||||
) {
|
||||
const verbose = newState.verbose
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
verbose,
|
||||
}))
|
||||
}
|
||||
|
||||
// tungstenPanelVisible (ant-only tmux panel sticky toggle)
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
if (
|
||||
newState.tungstenPanelVisible !== oldState.tungstenPanelVisible &&
|
||||
newState.tungstenPanelVisible !== undefined &&
|
||||
getGlobalConfig().tungstenPanelVisible !== newState.tungstenPanelVisible
|
||||
) {
|
||||
const tungstenPanelVisible = newState.tungstenPanelVisible
|
||||
saveGlobalConfig(current => ({ ...current, tungstenPanelVisible }))
|
||||
}
|
||||
}
|
||||
|
||||
// settings: clear auth-related caches when settings change
|
||||
// This ensures apiKeyHelper and AWS/GCP credential changes take effect immediately
|
||||
if (newState.settings !== oldState.settings) {
|
||||
try {
|
||||
clearApiKeyHelperCache()
|
||||
clearAwsCredentialsCache()
|
||||
clearGcpCredentialsCache()
|
||||
|
||||
// Re-apply environment variables when settings.env changes
|
||||
// This is additive-only: new vars are added, existing may be overwritten, nothing is deleted
|
||||
if (newState.settings.env !== oldState.settings.env) {
|
||||
applyConfigEnvironmentVariables()
|
||||
}
|
||||
} catch (error) {
|
||||
logError(toError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/state/selectors.ts
Normal file
76
src/state/selectors.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Selectors for deriving computed state from AppState.
|
||||
* Keep selectors pure and simple - just data extraction, no side effects.
|
||||
*/
|
||||
|
||||
import type { InProcessTeammateTaskState } from '../tasks/InProcessTeammateTask/types.js'
|
||||
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'
|
||||
import type { LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
import type { AppState } from './AppStateStore.js'
|
||||
|
||||
/**
|
||||
* Get the currently viewed teammate task, if any.
|
||||
* Returns undefined if:
|
||||
* - No teammate is being viewed (viewingAgentTaskId is undefined)
|
||||
* - The task ID doesn't exist in tasks
|
||||
* - The task is not an in-process teammate task
|
||||
*/
|
||||
export function getViewedTeammateTask(
|
||||
appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>,
|
||||
): InProcessTeammateTaskState | undefined {
|
||||
const { viewingAgentTaskId, tasks } = appState
|
||||
|
||||
// Not viewing any teammate
|
||||
if (!viewingAgentTaskId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Look up the task
|
||||
const task = tasks[viewingAgentTaskId]
|
||||
if (!task) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Verify it's an in-process teammate task
|
||||
if (!isInProcessTeammateTask(task)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for getActiveAgentForInput selector.
|
||||
* Discriminated union for type-safe input routing.
|
||||
*/
|
||||
export type ActiveAgentForInput =
|
||||
| { type: 'leader' }
|
||||
| { type: 'viewed'; task: InProcessTeammateTaskState }
|
||||
| { type: 'named_agent'; task: LocalAgentTaskState }
|
||||
|
||||
/**
|
||||
* Determine where user input should be routed.
|
||||
* Returns:
|
||||
* - { type: 'leader' } when not viewing a teammate (input goes to leader)
|
||||
* - { type: 'viewed', task } when viewing an agent (input goes to that agent)
|
||||
*
|
||||
* Used by input routing logic to direct user messages to the correct agent.
|
||||
*/
|
||||
export function getActiveAgentForInput(
|
||||
appState: AppState,
|
||||
): ActiveAgentForInput {
|
||||
const viewedTask = getViewedTeammateTask(appState)
|
||||
if (viewedTask) {
|
||||
return { type: 'viewed', task: viewedTask }
|
||||
}
|
||||
|
||||
const { viewingAgentTaskId, tasks } = appState
|
||||
if (viewingAgentTaskId) {
|
||||
const task = tasks[viewingAgentTaskId]
|
||||
if (task?.type === 'local_agent') {
|
||||
return { type: 'named_agent', task }
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'leader' }
|
||||
}
|
||||
34
src/state/store.ts
Normal file
34
src/state/store.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
type Listener = () => void
|
||||
type OnChange<T> = (args: { newState: T; oldState: T }) => void
|
||||
|
||||
export type Store<T> = {
|
||||
getState: () => T
|
||||
setState: (updater: (prev: T) => T) => void
|
||||
subscribe: (listener: Listener) => () => void
|
||||
}
|
||||
|
||||
export function createStore<T>(
|
||||
initialState: T,
|
||||
onChange?: OnChange<T>,
|
||||
): Store<T> {
|
||||
let state = initialState
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
|
||||
setState: (updater: (prev: T) => T) => {
|
||||
const prev = state
|
||||
const next = updater(prev)
|
||||
if (Object.is(next, prev)) return
|
||||
state = next
|
||||
onChange?.({ newState: next, oldState: prev })
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
|
||||
subscribe: (listener: Listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
141
src/state/teammateViewHelpers.ts
Normal file
141
src/state/teammateViewHelpers.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { logEvent } from '../services/analytics/index.js'
|
||||
import { isTerminalTaskStatus } from '../Task.js'
|
||||
import type { LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
|
||||
// Inlined from framework.ts — importing creates a cycle through
|
||||
// BackgroundTasksDialog. Keep in sync with PANEL_GRACE_MS there.
|
||||
const PANEL_GRACE_MS = 30_000
|
||||
|
||||
import type { AppState } from './AppState.js'
|
||||
|
||||
// Inline type check instead of importing isLocalAgentTask — breaks the
|
||||
// teammateViewHelpers → LocalAgentTask runtime edge that creates a cycle
|
||||
// through BackgroundTasksDialog.
|
||||
function isLocalAgent(task: unknown): task is LocalAgentTaskState {
|
||||
return (
|
||||
typeof task === 'object' &&
|
||||
task !== null &&
|
||||
'type' in task &&
|
||||
task.type === 'local_agent'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the task released back to stub form: retain dropped, messages
|
||||
* cleared, evictAfter set if terminal. Shared by exitTeammateView and
|
||||
* the switch-away path in enterTeammateView.
|
||||
*/
|
||||
function release(task: LocalAgentTaskState): LocalAgentTaskState {
|
||||
return {
|
||||
...task,
|
||||
retain: false,
|
||||
messages: undefined,
|
||||
diskLoaded: false,
|
||||
evictAfter: isTerminalTaskStatus(task.status)
|
||||
? Date.now() + PANEL_GRACE_MS
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions the UI to view a teammate's transcript.
|
||||
* Sets viewingAgentTaskId and, for local_agent, retain: true (blocks eviction,
|
||||
* enables stream-append, triggers disk bootstrap) and clears evictAfter.
|
||||
* If switching from another agent, releases the previous one back to stub.
|
||||
*/
|
||||
export function enterTeammateView(
|
||||
taskId: string,
|
||||
setAppState: (updater: (prev: AppState) => AppState) => void,
|
||||
): void {
|
||||
logEvent('tengu_transcript_view_enter', {})
|
||||
setAppState(prev => {
|
||||
const task = prev.tasks[taskId]
|
||||
const prevId = prev.viewingAgentTaskId
|
||||
const prevTask = prevId !== undefined ? prev.tasks[prevId] : undefined
|
||||
const switching =
|
||||
prevId !== undefined &&
|
||||
prevId !== taskId &&
|
||||
isLocalAgent(prevTask) &&
|
||||
prevTask.retain
|
||||
const needsRetain =
|
||||
isLocalAgent(task) && (!task.retain || task.evictAfter !== undefined)
|
||||
const needsView =
|
||||
prev.viewingAgentTaskId !== taskId ||
|
||||
prev.viewSelectionMode !== 'viewing-agent'
|
||||
if (!needsRetain && !needsView && !switching) return prev
|
||||
let tasks = prev.tasks
|
||||
if (switching || needsRetain) {
|
||||
tasks = { ...prev.tasks }
|
||||
if (switching) tasks[prevId] = release(prevTask)
|
||||
if (needsRetain) {
|
||||
tasks[taskId] = { ...task, retain: true, evictAfter: undefined }
|
||||
}
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
viewingAgentTaskId: taskId,
|
||||
viewSelectionMode: 'viewing-agent',
|
||||
tasks,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit teammate transcript view and return to leader's view.
|
||||
* Drops retain and clears messages back to stub form; if terminal,
|
||||
* schedules eviction via evictAfter so the row lingers briefly.
|
||||
*/
|
||||
export function exitTeammateView(
|
||||
setAppState: (updater: (prev: AppState) => AppState) => void,
|
||||
): void {
|
||||
logEvent('tengu_transcript_view_exit', {})
|
||||
setAppState(prev => {
|
||||
const id = prev.viewingAgentTaskId
|
||||
const cleared = {
|
||||
...prev,
|
||||
viewingAgentTaskId: undefined,
|
||||
viewSelectionMode: 'none' as const,
|
||||
}
|
||||
if (id === undefined) {
|
||||
return prev.viewSelectionMode === 'none' ? prev : cleared
|
||||
}
|
||||
const task = prev.tasks[id]
|
||||
if (!isLocalAgent(task) || !task.retain) return cleared
|
||||
return {
|
||||
...cleared,
|
||||
tasks: { ...prev.tasks, [id]: release(task) },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Context-sensitive x: running → abort, terminal → dismiss.
|
||||
* Dismiss sets evictAfter=0 so the filter hides immediately.
|
||||
* If viewing the dismissed agent, also exits to leader.
|
||||
*/
|
||||
export function stopOrDismissAgent(
|
||||
taskId: string,
|
||||
setAppState: (updater: (prev: AppState) => AppState) => void,
|
||||
): void {
|
||||
setAppState(prev => {
|
||||
const task = prev.tasks[taskId]
|
||||
if (!isLocalAgent(task)) return prev
|
||||
if (task.status === 'running') {
|
||||
task.abortController?.abort()
|
||||
return prev
|
||||
}
|
||||
if (task.evictAfter === 0) return prev
|
||||
const viewingThis = prev.viewingAgentTaskId === taskId
|
||||
return {
|
||||
...prev,
|
||||
tasks: {
|
||||
...prev.tasks,
|
||||
[taskId]: { ...release(task), evictAfter: 0 },
|
||||
},
|
||||
...(viewingThis && {
|
||||
viewingAgentTaskId: undefined,
|
||||
viewSelectionMode: 'none',
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user