/** * 终端控制权的**唯一**状态机。 * * 这里住着两半,它们是同一件事的两个视角,所以放在同一个文件里,共用同一套模式 * 词汇(`TerminalPaneMode`): * * ① 面板侧({@link terminalControlReducer})—— 这块终端此刻的租约状态。 * ② 外层侧({@link toggleTerminalIntent} 等)—— 行内「终端」按钮的开关意图: * 面板开在哪个会话上。接管不在这一侧,它只由面板标题栏的「接管输入」驱动。 * * 之所以要一个显式状态机,而不是几个 useState 拼起来:控制权同时被四种事件推着走 * ——首屏 GET、15 秒观察轮询、用户的写操作(takeover/release)、以及面板自己的重挂 * ——它们会互相抢状态。散着写时踩过的坑逐条列在这里,每一条都对应下面的一段实现: * * - **观察与写共用一个 generation**:15 秒轮询一发,正在飞的 takeover 回执就被 * 判成过期丢掉,界面停在只读,服务端却已经把写租约发出去了。→ 观察 epoch 与 * 写 epoch 彻底分开:写只被**更新的写**作废,轮询作废不了它;反过来,有在途写 * 时轮询读数一律丢弃(它描述的是写之前的世界)。 * - **只看「有没有在途写」拦不住旧读数**:takeover 发起**之前**就出门的那发轮询, * 若在接管回执结算之后才回来,此刻 pending 已经清空,于是这份写之前的读数被当 * 成权威,界面从 controlled 倒写成 readonly(release 方向对称:把已经还掉的租约 * 倒写回「可输入」)。→ 读数按**发出时刻**盖写:观察发出时记下当时最新那次**已 * 发起**的写 epoch,回来时早于当前的写 epoch 就丢掉。基准取「已发起」而不是「已 * 结算成功」:写失败(回执丢了、超时)同样把世界推进了一格,而那恰恰是最需要 * unknown 防线的场景——用「已结算」当基准时,旧 poll 会在写失败清掉 pending 之后 * 把 unknown 一把抹回只读。复核 GET 不受影响:它发出于 write-start 之后,快照与 * 当前相等,照旧能把 unknown 收敛掉。 * - **把「还不知道」当成只读**:首屏 GET 没回来时对外说 readonly,用户第二次点 * 「终端」就被判成「模式没变 → 重挂」而不是关闭。→ `loading` 是一个显式状态, * 没有权威读数时同一个按钮再点一次一律按关闭处理。 * - **写失败就乐观宣称只读**:release 失败后界面说只读,可写租约其实还在服务端 * 挂着,用户对着一个「只读」的终端照样能打字。→ 写失败进 `unknown`:保留最后 * 一次权威读数、立刻发一次 GET 复核,期间由调用方把可写 iframe 遮住。 * * 状态本身是纯函数,不碰 React、不碰网络:所有异步都由调用方发起,回执以带 epoch * 的事件喂回来,过期事件在这里被丢掉。 */ import type { TerminalControlState } from './agent-workbench-api.js'; /** * 面板此刻的控制权阶段。 * loading 首屏 GET 还没回来,什么都不知道(≠ 只读,徽标也不许写「只读」)。 * readonly 权威读数:没有可写租约。 * controlled 权威读数:这个浏览器握着写租约(平台所有者的恒可写身份也算)。 * taking-over 写在途:POST takeover 还没回执。 * releasing 写在途:POST release 还没回执。 * unknown 权威状态未知(写失败 / 读失败)。**不许**当成只读展示。 */ export type TerminalControlPhase = 'loading' | 'readonly' | 'controlled' | 'taking-over' | 'releasing' | 'unknown'; export type TerminalWriteAction = 'takeover' | 'release'; export interface TerminalControlModel { phase: TerminalControlPhase; /** 最后一次**权威**读数(GET 或写回执)。unknown 阶段照旧保留它——「不知道现在 * 怎么样」不等于「回到出厂只读」,复核 GET 回来之前它是唯一有依据的东西。 */ authoritative: TerminalControlState | null; /** 观察 epoch(首屏 / 轮询 / 复核)。只作废观察,作废不了写。 */ observeEpoch: number; /** 当前这发观察**发出时**,最新那次**已发起**的写的 epoch。读数描述的是「发出那 * 一刻的世界」,所以它是否过期只能拿发出时刻去比,不能拿回来时的 pending 去比。 * * 基准是「已发起」而不是「已结算成功」:写**失败**同样把世界推进了一格——回执丢了 * 不代表服务端没受理,那恰恰是最需要 unknown 这道防线的场景。用「已结算成功」当 * 基准时,接管之前发出的旧 poll 会在写失败清掉 pending 之后被当成权威,把 unknown * 倒写回只读,遮罩跟着撤掉,用户对着一个写着「只读」的终端照样能打字。 * 写失败后的复核 GET 不受影响:它发出于 write-start 之后,快照与当前 writeEpoch * 相等,照旧放行去收敛 unknown。 */ observeIssueEpoch: number; /** 这发观察发出时有没有写在途。有的话它描述的是一个「还没定」的世界,回来时无论 * 那次写成没成都不作数。 */ observeIssuedDuringWrite: boolean; /** 写 epoch(takeover / release),write-start 就 +1。只被更新的写作废,轮询碰不到它。 */ writeEpoch: number; /** 在途写。非空期间轮询读数一律丢弃。 */ pending: { action: TerminalWriteAction; epoch: number; } | null; /** 这块 iframe **可能**仍然可写(拿过租约,或有过一次可能已经生效的接管)。 * unknown 阶段靠它决定要不要遮住 iframe:从来没碰过写的面板不需要遮, * 一次读失败不该把只读终端也糊上一层。 */ mayWrite: boolean; /** 最近一次失败。`from: 'write'` 的错误要挺过随后的复核 GET——control_busy 正是 * 这样一条「读数没问题、但你的动作没成」的消息,被复核清掉就等于静默吞掉。 */ error: { text: string; from: 'observe' | 'write'; } | null; /** iframe 重挂计数:租约变化必须换一块 iframe,否则页面还挂着旧 grant。 */ frameGeneration: number; /** 每需要立刻发一次复核 GET 就 +1(写失败后)。 */ reconcileNonce: number; } export type TerminalControlEvent = { type: 'observe-start'; epoch: number; source: 'load' | 'poll' | 'reconcile'; } | { type: 'observe-settled'; epoch: number; control: TerminalControlState; } | { type: 'observe-failed'; epoch: number; error: string; /** 这个身份**有没有可能**握着写租约(能 takeover 才有)。首屏就读不到时它是 * 唯一的判据:能握租约的身份必须按未知处理(可能真的能打字),永远握不到的 * 身份说未知只是虚惊一场,还会把它唯一能用的只读终端也撤下来。 */ canHoldLease: boolean; } /** 没有凭证 / 没有终端可框:控制权接口只会 401,直接落到只读,不发请求。 */ | { type: 'settle-readonly'; } | { type: 'write-start'; epoch: number; action: TerminalWriteAction; } | { type: 'write-settled'; epoch: number; control: TerminalControlState; } | { type: 'write-failed'; epoch: number; error: string; }; export declare function initialTerminalControlModel(): TerminalControlModel; /** 有在途写。行内 / 标题栏的写按钮此刻必须禁用或串行——不然快速双击会发出两次 * takeover,第一次的租约没有任何界面在管。 */ export declare function terminalControlBusy(model: TerminalControlModel): boolean; /** 已经有过权威读数(开场意图只在这之后兑现一次)。 */ export declare function terminalControlSettled(model: TerminalControlModel): boolean; /** 未知且这块 iframe 可能是可写的 → 调用方必须遮住它。 */ export declare function terminalControlNeedsMask(model: TerminalControlModel): boolean; /** 用户眼里的四种模式。写在途时沿用最后一次权威读数:接管过程中说「只读」, * 与紧接着的回执自相矛盾。 */ export type TerminalPaneMode = 'loading' | 'readonly' | 'controlled' | 'unknown'; export declare function terminalControlMode(model: TerminalControlModel): TerminalPaneMode; export declare function terminalControlReducer(state: TerminalControlModel, event: TerminalControlEvent): TerminalControlModel; /** * 终端页 → 工作台的一次「这条通道到底能不能写」回报。 * * 这个字符串是跨文件契约:终端页那侧的字面量写在 worker.ts 的模板字符串里(那段 * 代码不能出现反引号与插值序列,所以没法 import 这个常量),两处必须一起改。 * 跨文件接缝测试按这个名字比对 worker.ts。 */ export declare const WORKBENCH_TERM_WRITE_MESSAGE = "botmux:wb-terminal-write"; /** 认领:这块面板可能握着 `sessionId` 的写租约。返回撤销函数(卸载时调用)。 */ export declare function claimTerminalWrite(sessionId: string, claim: object): () => void; export declare function hasLiveTerminalWriteClaim(sessionId: string): boolean; /** 面板给外层的回执。外层的行内按钮只认它,不认自己当初递进去的意图——标题栏的 * 「接管输入 / 释放输入」不经过外层,恒可写身份更是从头到尾没发过任何请求。 */ export interface TerminalPaneControlMode { sessionId: string; /** 用户眼里的模式(含触屏那层「这条通道到底能不能写」的覆盖)。 */ mode: TerminalPaneMode; /** 恒可写身份(平台所有者):没有租约可接管 / 可释放,也就没有只读模式可切。 */ fixed: boolean; /** 这块面板切不出第二种模式(触屏只读通道、无 canControl 能力、fixed 身份)。 * 此时行内按钮只能是开 / 关,拿模式去比会永远不相等、面板再也关不掉。 */ toggleOnly: boolean; /** 有在途写:这一刻行内按钮必须禁用或串行。 */ busy: boolean; } /** * 终端面板的完整意图:开在**哪个会话**上,以及面板回执回来的真实模式。两者必须 * 原子地一起变——拆开写时踩过的坑:先 `selectSession` 把面板迁到 B(压成只读)、 * 再判断「B 行的终端按钮」→ 迁移后的状态刚好等于只读,于是被判成同按钮二次点击, * 面板被关掉。 * * 行内**没有**接管入口(产品决策:接管只走终端面板标题栏的「接管输入」),所以 * 这里也不再记「这次开带不带接管」——行内点开的终端一律是只读打开。 */ export interface WorkbenchTerminalIntent { sessionId: string; mode: TerminalPaneMode; fixed: boolean; toggleOnly: boolean; busy: boolean; /** 每换一次模式 +1。面板 key 带上它,切模式才会真的重挂,面板里那个一次性的 * 开场意图也才有机会重新兑现(只读意图靠它把还攥着的租约真的还回去)。 */ generation: number; } /** 一次新的打开 / 重挂。`mode` 先记成 loading:面板还没说话之前我们**不知道** * 它是什么模式,把意图当成模式正是「第二次点击重挂而不是关闭」的来源。 */ export declare function openTerminalIntent(sessionId: string, generation?: number): WorkbenchTerminalIntent; /** * 普通选中(行点击 / j-k / 路由):已经开着的面板跟到新会话,但**只跟到只读**。 * 接管属于「你在那块面板标题栏按下接管」的那个会话,普通选中不是要写权限的表示。 */ export declare function followTerminalIntent(current: WorkbenchTerminalIntent | null, sessionId: string): WorkbenchTerminalIntent | null; /** * 行内「终端」按钮:选中会话 + 只读打开终端面板的那一次原子更新。 * 返回 `null` = 关掉面板。 */ export declare function toggleTerminalIntent(current: WorkbenchTerminalIntent | null, sessionId: string): WorkbenchTerminalIntent | null; /** 面板回执:把真实模式记回意图。只改回执那几个字段,不动 sessionId / * generation —— 那两个决定面板的 key,被回执带着走就会无谓重挂、终端连接跟着断。 */ export declare function receiveTerminalIntentMode(current: WorkbenchTerminalIntent | null, receipt: TerminalPaneControlMode): WorkbenchTerminalIntent | null; //# sourceMappingURL=agent-workbench-terminal-control.d.ts.map