本章总览
selectors.ts 提供纯函数,从 AppState 派生「当前查看的队友」「输入应路由到谁」等计算态;onChangeAppState.ts 是 setState 的 唯一集中副作用钩子,把 permission mode、model、verbose、settings 等变更同步到 globalConfig、userSettings、bootstrap override 与 CCR metadata。本章要求你能画出:任意 setAppState 调用如何触发 onChange diff,以及为何 permission mode 必须在此 choke point 通知 CCR。
学完本章你应该能
- 解释 getViewedTeammateTask 的三重 guard
- 说明 getActiveAgentForInput 判别联合的三种 type
- 描述 onChangeAppState 对 toolPermissionContext.mode 的 diff 逻辑
- 理解 mainLoopModel null vs 非 null 的双向 settings 同步
- 说出 externalMetadataToAppState 的逆向恢复用途
- 列举 settings 变更时清 auth cache 的原因
核心概念(先读懂这些)
selector 无副作用
selectors.ts 注释强调:keep selectors pure — 只读 AppState,不写磁盘、不调 API。副作用 belong in onChangeAppState 或 caller。这允许在 query 循环、测试里直接调用 getActiveAgentForInput(appState) 而不 mount React。
onChange 是跨系统事件总线
onChangeAppState 不是 Redux middleware 的泛型管道,而是针对已知字段的显式 diff 列表。新增需要「AppState 变更 → 外部系统」同步的字段时,应在此追加 if 块,而非在 20 个 setState 调用点各写一遍——permission mode 的 refactor 注释说明了 scattered callsites 曾导致 CCR stale 的教训。
external vs internal permission mode
toExternalPermissionMode 过滤 bubble、ungated auto 等内部模式名。CCR notify 仅在外部模式变化时发送;SDK notifyPermissionModeChanged 传 raw mode,print.ts listener 自行过滤。Ultraplan 首次进入 plan 时 is_ultraplan_mode 原子写入 metadata。
建议学习步骤
- 阅读 getViewedTeammateTask 逐步 guard
- 阅读 getActiveAgentForInput 分支顺序
- 阅读 onChangeAppState permission mode 块注释
- 阅读 mainLoopModel settings 双向同步
- 阅读 expandedView → globalConfig 映射
- 阅读 settings 变更 auth cache 清理
常见误区
注意
getViewedTeammateTask 只认 InProcessTeammateTask,local_agent 走另一分支
注意
onChange 比较 settings 用 !== 引用相等——mutate settings 对象不会触发
注意
tungstenPanelVisible 持久化仅 USER_TYPE === ant
注意
externalMetadataToAppState 供 worker restart 恢复,非日常路径
在架构中的位置
selector 与 onChange 在 REPL 输入路径中的位置:
用户按键 / 提交
→ getActiveAgentForInput(store.getState())
├─ leader → 主 query 队列
├─ viewed → InProcessTeammate pendingUserMessages
└─ named_agent → LocalAgentTask 路由
→ setAppState(...)
→ onChangeAppState diff
├─ notifySessionMetadataChanged (CCR)
├─ saveGlobalConfig / updateSettingsForSource
└─ setMainLoopModelOverride (bootstrap)
processTextPrompt、PromptInput、bridge control_request 均可能 setState;onChange 保证外部观察者不必 hook 每个 callsite。
getViewedTeammateTask
输入:Pick<AppState, 'viewingAgentTaskId' | 'tasks'>。
返回 InProcessTeammateTaskState 或 undefined。Guard 顺序:
viewingAgentTaskIdfalsy → undefined(leader 视图)tasks[id]不存在 → undefined(已被 evict)!isInProcessTeammateTask(task)→ undefined(可能是 local_agent)
用途:BackgroundTasksDialog 渲染 viewed transcript、useTeammateViewAutoExit 窄化 status、getActiveAgentForInput 优先分支。
与 viewSelectionMode 配合:enterTeammateView 设为 'viewing-agent';exit 恢复 'none'。selector 不读 viewSelectionMode——task 类型才是路由真相源。
源码引用: src/state/selectors.ts · 第 11–40 行(共 77 行)
11| /**
12| * Get the currently viewed teammate task, if any.
13| * Returns undefined if:
14| * - No teammate is being viewed (viewingAgentTaskId is undefined)
15| * - The task ID doesn't exist in tasks
16| * - The task is not an in-process teammate task
17| */
18| export function getViewedTeammateTask(
19| appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>,
20| ): InProcessTeammateTaskState | undefined {
21| const { viewingAgentTaskId, tasks } = appState
22|
23| // Not viewing any teammate
24| if (!viewingAgentTaskId) {
25| return undefined
26| }
27|
28| // Look up the task
29| const task = tasks[viewingAgentTaskId]
30| if (!task) {
31| return undefined
32| }
33|
34| // Verify it's an in-process teammate task
35| if (!isInProcessTeammateTask(task)) {
36| return undefined
37| }
38|
39| return task
40| }
getActiveAgentForInput 路由判别
返回 ActiveAgentForInput 联合类型:
| type | 含义 | 典型场景 |
|---|---|---|
leader | 输入进主 REPL 队列 | 默认 |
viewed | 输入进 in-process teammate | 用户按 → 查看队友 transcript |
named_agent | 输入进 LocalAgentTask | foreground local agent(非 in-process teammate 类型) |
算法:
- 先
getViewedTeammateTask— 命中则{ type: 'viewed', task } - 否则若 viewingAgentTaskId 指向
local_agent→named_agent - 默认
leader
注意:viewingAgentTaskId 同时服务 in-process teammate 与 local_agent,类型 guard 决定路由。错误窄化会导致消息进错队列(teammate 收不到 user ping)。
源码引用: src/state/selectors.ts · 第 42–76 行(共 77 行)
42| /**
43| * Return type for getActiveAgentForInput selector.
44| * Discriminated union for type-safe input routing.
45| */
46| export type ActiveAgentForInput =
47| | { type: 'leader' }
48| | { type: 'viewed'; task: InProcessTeammateTaskState }
49| | { type: 'named_agent'; task: LocalAgentTaskState }
50|
51| /**
52| * Determine where user input should be routed.
53| * Returns:
54| * - { type: 'leader' } when not viewing a teammate (input goes to leader)
55| * - { type: 'viewed', task } when viewing an agent (input goes to that agent)
56| *
57| * Used by input routing logic to direct user messages to the correct agent.
58| */
59| export function getActiveAgentForInput(
60| appState: AppState,
61| ): ActiveAgentForInput {
62| const viewedTask = getViewedTeammateTask(appState)
63| if (viewedTask) {
64| return { type: 'viewed', task: viewedTask }
65| }
66|
67| const { viewingAgentTaskId, tasks } = appState
68| if (viewingAgentTaskId) {
69| const task = tasks[viewingAgentTaskId]
70| if (task?.type === 'local_agent') {
71| return { type: 'named_agent', task }
72| }
73| }
74|
75| return { type: 'leader' }
76| }
onChangeAppState:permission mode 同步
核心 diff:
prevMode = oldState.toolPermissionContext.mode
newMode = newState.toolPermissionContext.mode
if prevMode !== newMode:
prevExternal = toExternalPermissionMode(prevMode)
newExternal = toExternalPermissionMode(newMode)
if prevExternal !== newExternal:
notifySessionMetadataChanged({ permission_mode, is_ultraplan_mode })
notifyPermissionModeChanged(newMode)
注释列举曾遗漏的路径:Shift+Tab、ExitPlanMode 对话框、/plan、rewind、REPL bridge onSetPermissionMode。集中到 onChange 后这些路径零改动即同步 CCR。
is_ultraplan_mode:仅当外部 mode 变为 plan 且 newState.isUltraplanMode 从 false→true 时写 true,否则 null(RFC 7396 删 key)。
源码引用: src/state/onChangeAppState.ts · 第 43–92 行(共 172 行)
43| export function onChangeAppState({
44| newState,
45| oldState,
46| }: {
47| newState: AppState
48| oldState: AppState
49| }) {
50| // toolPermissionContext.mode — single choke point for CCR/SDK mode sync.
51| //
52| // Prior to this block, mode changes were relayed to CCR by only 2 of 8+
53| // mutation paths: a bespoke setAppState wrapper in print.ts (headless/SDK
54| // mode only) and a manual notify in the set_permission_mode handler.
55| // Every other path — Shift+Tab cycling, ExitPlanModePermissionRequest
56| // dialog options, the /plan slash command, rewind, the REPL bridge's
57| // onSetPermissionMode — mutated AppState without telling
58| // CCR, leaving external_metadata.permission_mode stale and the web UI out
59| // of sync with the CLI's actual mode.
60| //
61| // Hooking the diff here means ANY setAppState call that changes the mode
62| // notifies CCR (via notifySessionMetadataChanged → ccrClient.reportMetadata)
63| // and the SDK status stream (via notifyPermissionModeChanged → registered
64| // in print.ts). The scattered callsites above need zero changes.
65| const prevMode = oldState.toolPermissionContext.mode
66| const newMode = newState.toolPermissionContext.mode
67| if (prevMode !== newMode) {
68| // CCR external_metadata must not receive internal-only mode names
69| // (bubble, ungated auto). Externalize first — and skip
70| // the CCR notify if the EXTERNAL mode didn't change (e.g.,
71| // default→bubble→default is noise from CCR's POV since both
72| // externalize to 'default'). The SDK channel (notifyPermissionModeChanged)
73| // passes raw mode; its listener in print.ts applies its own filter.
74| const prevExternal = toExternalPermissionMode(prevMode)
75| const newExternal = toExternalPermissionMode(newMode)
76| if (prevExternal !== newExternal) {
77| // Ultraplan = first plan cycle only. The initial control_request
78| // sets mode and isUltraplanMode atomically, so the flag's
79| // transition gates it. null per RFC 7396 (removes the key).
80| const isUltraplan =
81| newExternal === 'plan' &&
82| newState.isUltraplanMode &&
83| !oldState.isUltraplanMode
84| ? true
85| : null
86| notifySessionMetadataChanged({
87| permission_mode: newExternal,
88| is_ultraplan_mode: isUltraplan,
89| })
90| }
91| notifyPermissionModeChanged(newMode)
92| }
onChangeAppState:model 与 UI 偏好
mainLoopModel 双向同步:
- 变为 null →
updateSettingsForSource('userSettings', { model: undefined })+setMainLoopModelOverride(null) - 变为 非 null → 写入 userSettings.model + setMainLoopModelOverride
这连接 AppState(UI 当前选择)与 bootstrap override(query 读模型)与磁盘 settings(跨 session)。
expandedView 映射 legacy globalConfig:
'tasks'→ showExpandedTodos true'teammates'→ showSpinnerTree true- 其他 → 两者 false
verbose 直接 saveGlobalConfig。
tungstenPanelVisible ant-only 持久化到 globalConfig。
源码引用: src/state/onChangeAppState.ts · 第 94–152 行(共 172 行)
94| // mainLoopModel: remove it from settings?
95| if (
96| newState.mainLoopModel !== oldState.mainLoopModel &&
97| newState.mainLoopModel === null
98| ) {
99| // Remove from settings
100| updateSettingsForSource('userSettings', { model: undefined })
101| setMainLoopModelOverride(null)
102| }
103|
104| // mainLoopModel: add it to settings?
105| if (
106| newState.mainLoopModel !== oldState.mainLoopModel &&
107| newState.mainLoopModel !== null
108| ) {
109| // Save to settings
110| updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
111| setMainLoopModelOverride(newState.mainLoopModel)
112| }
113|
114| // expandedView → persist as showExpandedTodos + showSpinnerTree for backwards compat
115| if (newState.expandedView !== oldState.expandedView) {
116| const showExpandedTodos = newState.expandedView === 'tasks'
117| const showSpinnerTree = newState.expandedView === 'teammates'
118| if (
119| getGlobalConfig().showExpandedTodos !== showExpandedTodos ||
120| getGlobalConfig().showSpinnerTree !== showSpinnerTree
121| ) {
122| saveGlobalConfig(current => ({
123| ...current,
124| showExpandedTodos,
125| showSpinnerTree,
126| }))
127| }
128| }
129|
130| // verbose
131| if (
132| newState.verbose !== oldState.verbose &&
133| getGlobalConfig().verbose !== newState.verbose
134| ) {
135| const verbose = newState.verbose
136| saveGlobalConfig(current => ({
137| ...current,
138| verbose,
139| }))
140| }
141|
142| // tungstenPanelVisible (ant-only tmux panel sticky toggle)
143| if (process.env.USER_TYPE === 'ant') {
144| if (
145| newState.tungstenPanelVisible !== oldState.tungstenPanelVisible &&
146| newState.tungstenPanelVisible !== undefined &&
147| getGlobalConfig().tungstenPanelVisible !== newState.tungstenPanelVisible
148| ) {
149| const tungstenPanelVisible = newState.tungstenPanelVisible
150| saveGlobalConfig(current => ({ ...current, tungstenPanelVisible }))
151| }
152| }
onChangeAppState:settings 与 auth cache
当 newState.settings !== oldState.settings(引用变化):
- clearApiKeyHelperCache()
- clearAwsCredentialsCache()
- clearGcpCredentialsCache()
- 若 settings.env 变 → applyConfigEnvironmentVariables()
保证用户在 /config 或外部编辑 settings.json 后,下一次 API 调用用新 apiKeyHelper / AWS profile,无需重启进程。
try/catch 包裹,错误 logError——onChange 不可 throw 否则 setState 链断裂。
源码引用: src/state/onChangeAppState.ts · 第 154–171 行(共 172 行)
154| // settings: clear auth-related caches when settings change
155| // This ensures apiKeyHelper and AWS/GCP credential changes take effect immediately
156| if (newState.settings !== oldState.settings) {
157| try {
158| clearApiKeyHelperCache()
159| clearAwsCredentialsCache()
160| clearGcpCredentialsCache()
161|
162| // Re-apply environment variables when settings.env changes
163| // This is additive-only: new vars are added, existing may be overwritten, nothing is deleted
164| if (newState.settings.env !== oldState.settings.env) {
165| applyConfigEnvironmentVariables()
166| }
167| } catch (error) {
168| logError(toError(error))
169| }
170| }
171| }
externalMetadataToAppState 逆向映射
externalMetadataToAppState 供 remote worker / CCR control_request 把 SessionExternalMetadata 写回 AppState:
permission_modestring → toolPermissionContext.mode(permissionModeFromString)is_ultraplan_modeboolean → isUltraplanMode
返回 (prev) => AppState updater,与 setState 直接兼容。
与 onChange 正向流配对:CCR 推 metadata → setAppState(externalMetadataToAppState(meta)) → 若 mode 变 → onChange 可能再次 notify(但 external 相等则 CCR notify 跳过)。
Worker restart 场景用此恢复 UI 与 harness 一致。
源码引用: src/state/onChangeAppState.ts · 第 23–41 行(共 172 行)
23| // Inverse of the push below — restore on worker restart.
24| export function externalMetadataToAppState(
25| metadata: SessionExternalMetadata,
26| ): (prev: AppState) => AppState {
27| return prev => ({
28| ...prev,
29| ...(typeof metadata.permission_mode === 'string'
30| ? {
31| toolPermissionContext: {
32| ...prev.toolPermissionContext,
33| mode: permissionModeFromString(metadata.permission_mode),
34| },
35| }
36| : {}),
37| ...(typeof metadata.is_ultraplan_mode === 'boolean'
38| ? { isUltraplanMode: metadata.is_ultraplan_mode }
39| : {}),
40| })
41| }
扩展 onChange 的约定
新增 AppState 字段需持久化或通知外部系统时的 checklist:
- 是否已有 scattered callsites?若有,迁入 onChange diff
- diff 用
!==比较 primitive 或引用;nested mutate 不会触发 - 磁盘 IO 用 getGlobalConfig / saveGlobalConfig / updateSettingsForSource——避免在 selector 里写
- bootstrap 同步 import
setMainLoopModelOverride等 getter/setter,勿读 STATE 对象 - CCR/SDK 通知走 sessionState.ts 统一 API
selector 新增时保持纯函数,输入尽量 Pick<AppState, ...> 缩小测试 fixture。
本章小结与延伸
selectors = 纯派生;onChangeAppState = 持久化与 CCR/SDK 同步总线。下一章 teammate-state,读 transcript 视图状态机。 继续学习: