claude-code with OpenAI mode fix

This commit is contained in:
HitMargin
2026-04-04 01:21:00 +08:00
commit c9f95fc34d
3050 changed files with 557030 additions and 0 deletions

199
src/state/AppState.tsx Normal file
View File

@@ -0,0 +1,199 @@
import { c as _c } from "react/compiler-runtime";
import { feature } from 'bun:bundle';
import React, { useContext, useEffect, useEffectEvent, useState, useSyncExternalStore } from 'react';
import { MailboxProvider } from '../context/mailbox.js';
import { useSettingsChange } from '../hooks/useSettingsChange.js';
import { logForDebugging } from '../utils/debug.js';
import { createDisabledBypassPermissionsContext, isBypassPermissionsModeDisabled } from '../utils/permissions/permissionSetup.js';
import { applySettingsChange } from '../utils/settings/applySettingsChange.js';
import type { SettingSource } from '../utils/settings/constants.js';
import { createStore } from './store.js';
// DCE: voice context is ant-only. External builds get a passthrough.
/* eslint-disable @typescript-eslint/no-require-imports */
const VoiceProvider: (props: {
children: React.ReactNode;
}) => React.ReactNode = feature('VOICE_MODE') ? require('../context/voice.js').VoiceProvider : ({
children
}) => children;
/* eslint-enable @typescript-eslint/no-require-imports */
import { type AppState, type AppStateStore, getDefaultAppState } from './AppStateStore.js';
// TODO: Remove these re-exports once all callers import directly from
// ./AppStateStore.js. Kept for back-compat during migration so .ts callers
// can incrementally move off the .tsx import and stop pulling React.
export { type AppState, type AppStateStore, type CompletionBoundary, getDefaultAppState, IDLE_SPECULATION_STATE, type SpeculationResult, type SpeculationState } from './AppStateStore.js';
export const AppStoreContext = React.createContext<AppStateStore | null>(null);
type Props = {
children: React.ReactNode;
initialState?: AppState;
onChangeAppState?: (args: {
newState: AppState;
oldState: AppState;
}) => void;
};
const HasAppStateContext = React.createContext<boolean>(false);
export function AppStateProvider(t0) {
const $ = _c(13);
const {
children,
initialState,
onChangeAppState
} = t0;
const hasAppStateContext = useContext(HasAppStateContext);
if (hasAppStateContext) {
throw new Error("AppStateProvider can not be nested within another AppStateProvider");
}
let t1;
if ($[0] !== initialState || $[1] !== onChangeAppState) {
t1 = () => createStore(initialState ?? getDefaultAppState(), onChangeAppState);
$[0] = initialState;
$[1] = onChangeAppState;
$[2] = t1;
} else {
t1 = $[2];
}
const [store] = useState(t1);
let t2;
if ($[3] !== store) {
t2 = () => {
const {
toolPermissionContext
} = store.getState();
if (toolPermissionContext.isBypassPermissionsModeAvailable && isBypassPermissionsModeDisabled()) {
logForDebugging("Disabling bypass permissions mode on mount (remote settings loaded before mount)");
store.setState(_temp);
}
};
$[3] = store;
$[4] = t2;
} else {
t2 = $[4];
}
let t3;
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
t3 = [];
$[5] = t3;
} else {
t3 = $[5];
}
useEffect(t2, t3);
let t4;
if ($[6] !== store.setState) {
t4 = source => applySettingsChange(source, store.setState);
$[6] = store.setState;
$[7] = t4;
} else {
t4 = $[7];
}
const onSettingsChange = useEffectEvent(t4);
useSettingsChange(onSettingsChange);
let t5;
if ($[8] !== children) {
t5 = <MailboxProvider><VoiceProvider>{children}</VoiceProvider></MailboxProvider>;
$[8] = children;
$[9] = t5;
} else {
t5 = $[9];
}
let t6;
if ($[10] !== store || $[11] !== t5) {
t6 = <HasAppStateContext.Provider value={true}><AppStoreContext.Provider value={store}>{t5}</AppStoreContext.Provider></HasAppStateContext.Provider>;
$[10] = store;
$[11] = t5;
$[12] = t6;
} else {
t6 = $[12];
}
return t6;
}
function _temp(prev) {
return {
...prev,
toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext)
};
}
function useAppStore(): AppStateStore {
// eslint-disable-next-line react-hooks/rules-of-hooks
const store = useContext(AppStoreContext);
if (!store) {
throw new ReferenceError('useAppState/useSetAppState cannot be called outside of an <AppStateProvider />');
}
return store;
}
/**
* Subscribe to a slice of AppState. Only re-renders when the selected value
* changes (compared via Object.is).
*
* For multiple independent fields, call the hook multiple times:
* ```
* const verbose = useAppState(s => s.verbose)
* const model = useAppState(s => s.mainLoopModel)
* ```
*
* Do NOT return new objects from the selector -- Object.is will always see
* them as changed. Instead, select an existing sub-object reference:
* ```
* const { text, promptId } = useAppState(s => s.promptSuggestion) // good
* ```
*/
export function useAppState<R>(selector: (state: AppState) => R): R {
const $ = _c(3);
const store = useAppStore();
let t0;
if ($[0] !== selector || $[1] !== store) {
t0 = () => {
const state = store.getState();
const selected = selector(state);
if (false && state === selected) {
throw new Error(`Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`);
}
return selected;
};
$[0] = selector;
$[1] = store;
$[2] = t0;
} else {
t0 = $[2];
}
const get = t0;
return useSyncExternalStore(store.subscribe, get, get);
}
/**
* Get the setAppState updater without subscribing to any state.
* Returns a stable reference that never changes -- components using only
* this hook will never re-render from state changes.
*/
export function useSetAppState() {
return useAppStore().setState;
}
/**
* Get the store directly (for passing getState/setState to non-React code).
*/
export function useAppStateStore() {
return useAppStore();
}
const NOOP_SUBSCRIBE = () => () => {};
/**
* Safe version of useAppState that returns undefined if called outside of AppStateProvider.
* Useful for components that may be rendered in contexts where AppStateProvider isn't available.
*/
export function useAppStateMaybeOutsideOfProvider<R>(selector: (state: AppState) => R): R | undefined {
const $ = _c(3);
const store = useContext(AppStoreContext);
let t0;
if ($[0] !== selector || $[1] !== store) {
t0 = () => store ? selector(store.getState()) : undefined;
$[0] = selector;
$[1] = store;
$[2] = t0;
} else {
t0 = $[2];
}
return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, t0);
}

569
src/state/AppStateStore.ts Normal file
View 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 buddy_react API (src/buddy/companionReact.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,
}
}

View File

@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import { createStore } from "../store";
describe("createStore", () => {
test("returns object with getState, setState, subscribe", () => {
const store = createStore({ count: 0 });
expect(typeof store.getState).toBe("function");
expect(typeof store.setState).toBe("function");
expect(typeof store.subscribe).toBe("function");
});
test("getState returns initial state", () => {
const store = createStore({ count: 0, name: "test" });
expect(store.getState()).toEqual({ count: 0, name: "test" });
});
test("setState updates state via updater function", () => {
const store = createStore({ count: 0 });
store.setState(prev => ({ count: prev.count + 1 }));
expect(store.getState().count).toBe(1);
});
test("setState does not notify when state unchanged (Object.is)", () => {
const store = createStore({ count: 0 });
let notified = false;
store.subscribe(() => { notified = true; });
store.setState(prev => prev);
expect(notified).toBe(false);
});
test("setState notifies subscribers on change", () => {
const store = createStore({ count: 0 });
let notified = false;
store.subscribe(() => { notified = true; });
store.setState(prev => ({ count: prev.count + 1 }));
expect(notified).toBe(true);
});
test("subscribe returns unsubscribe function", () => {
const store = createStore({ count: 0 });
const unsub = store.subscribe(() => {});
expect(typeof unsub).toBe("function");
});
test("unsubscribe stops notifications", () => {
const store = createStore({ count: 0 });
let count = 0;
const unsub = store.subscribe(() => { count++; });
store.setState(prev => ({ count: prev.count + 1 }));
unsub();
store.setState(prev => ({ count: prev.count + 1 }));
expect(count).toBe(1);
});
test("multiple subscribers all get notified", () => {
const store = createStore({ count: 0 });
let a = 0, b = 0;
store.subscribe(() => { a++; });
store.subscribe(() => { b++; });
store.setState(prev => ({ count: prev.count + 1 }));
expect(a).toBe(1);
expect(b).toBe(1);
});
test("onChange callback is called on state change", () => {
let captured: any = null;
const store = createStore({ count: 0 }, ({ newState, oldState }) => {
captured = { newState, oldState };
});
store.setState(prev => ({ count: prev.count + 5 }));
expect(captured).not.toBeNull();
expect(captured.oldState.count).toBe(0);
expect(captured.newState.count).toBe(5);
});
test("onChange is not called when state unchanged", () => {
let called = false;
const store = createStore({ count: 0 }, () => { called = true; });
store.setState(prev => prev);
expect(called).toBe(false);
});
test("works with complex state objects", () => {
const store = createStore({ items: [] as number[], name: "test" });
store.setState(prev => ({ ...prev, items: [1, 2, 3] }));
expect(store.getState().items).toEqual([1, 2, 3]);
expect(store.getState().name).toBe("test");
});
test("works with primitive state", () => {
const store = createStore(0);
store.setState(() => 42);
expect(store.getState()).toBe(42);
});
test("updater receives previous state", () => {
const store = createStore({ value: 10 });
store.setState(prev => {
expect(prev.value).toBe(10);
return { value: prev.value * 2 };
});
expect(store.getState().value).toBe(20);
});
test("sequential setState calls produce final state", () => {
const store = createStore({ count: 0 });
store.setState(prev => ({ count: prev.count + 1 }));
store.setState(prev => ({ count: prev.count + 1 }));
store.setState(prev => ({ count: prev.count + 1 }));
expect(store.getState().count).toBe(3);
});
});

View 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
View 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' }
}

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type Notification = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type TodoList = any;

34
src/state/store.ts Normal file
View 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)
},
}
}

View 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',
}),
}
})
}