# my-dpsk-harness-tui 合约速查表(contracts.md) > TUI 渲染层仍消费的 harness 类型地图。dsh 版本兼容由社区 std adapter > (`@dsh-std/adapter-dsh`,profile 动态链接)负责;本表不再钉死某一 rc。 > 类型文本引自当前开发依赖 `@deepseek-ai/*/lib/types/*.d.ts`。 > 上游接口变更时先更新本表再改 `src/dsh-host/index.ts` 的投影。 > 包根:`node_modules/@deepseek-ai`(本仓库 pnpm 安装)与全局 dsh 安装目录中的 `node_modules/@deepseek-ai` --- ## 1. 会话与事件 — `@deepseek-ai/dsh-session` ### 1.1 SessionEventMap(append-only 事件日志,事件源真相) ```ts export interface SessionEventMap { 'turn/start': { turn: number }; 'turn/end': { turn: number; reason: TurnEndReason }; 'step/start': { turn: number; step: number }; 'step/end': { turn: number; step: number }; 'user/message': UserMessage; // 直接人类提示 / 注入上下文 / goal 续轮;data.source 区分 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }; 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }; 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }; 'tool/result': { turn: number; step: number; message: ToolResultMessage; error?: { name: string; code: string }; meta?: JsonValue }; 'todo/write': { todos: TodoItem[] }; // 整表快照,后写覆盖;仅 UI 状态,不进派生历史 'request/header': { header: EpochHeader; reason: RequestHeaderReason }; 'request/context': RequestContext; 'session/end-seed': Record; // 插件合并扩展:dsh-agent 追加 'agent/inbox/spliced';compaction/goal 各自追加(见 §8) } ``` 插件扩展方式:`declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { ... } }`。 rc8 已知新事件(log-only,TUI 默认忽略):`team/member`、`team/message/delivered`、`team/message/queued`、`team/task`。 ### 1.2 TurnEndReason ```ts export interface TurnEndReasonMap { completed: { kind: 'completed' }; aborted: { kind: 'aborted'; reason: TurnEndCancelCause }; // user|parent|hook|disposed|legacy blocked: { kind: 'blocked' }; error: { kind: 'error'; error: LlmFailure }; 'max-tokens': { kind: 'max-tokens' }; interrupted: { kind: 'interrupted' }; // 崩溃孤儿轮次,重启时由持久化后端闭合 } ``` ### 1.3 SessionEvent 信封(判别联合,switch 收窄) ```ts export type SessionEvent = { [K in SessionEventType]: { type: K; seq: number; // 单调连续 = log.length time: number; // Unix 毫秒 data: SessionEventMap[K]; ignorable?: true; // 缺省 = 必需事件;不认识的必需事件必须拒建会话 } & (K extends SurfaceEventType ? { sourceEventSeqs?: number[]; surfaceOp?: SurfaceOp; } : object); }[T]; ``` - SurfaceEventType = `'user/message' | 'assistant/message' | 'tool/result'`(唯一可上模型可见面的三类) - SurfaceOp = `'append' | { op: 'replace'; start: number; end: number }`(compaction 用 replace 删除被遮蔽节点) - 派生历史 = 按 surfaceOp 顺序走 surface 节点投影(`Session.deriveMessages()`) 人类 transcript 必须注意首步事件顺序:`turn/start → step/start → user/message* → assistant/chunk* → assistant/message`。 因此不能在 `step/start` 时把空助手组件插入视图;应在首个 assistant payload 到达时再挂载,否则助手输出会排到本轮用户输入之前。 ### 1.4 Session(活会话) ```ts class Session { readonly header: SessionHeader; // 创建元数据(version/cwd/lineage/seedLength/createdAt) get id(): SessionId; readonly firstLiveSeq: number; // 本进程首个 seq(种子长度) get events(): readonly SessionEvent[]; // 深冻结不可变快照 get seq(): number; get surface(): SessionSurface; append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [SurfaceIntent] : []): SessionEvent; requestHeader(): EpochHeader | undefined; requestContext(): RequestContext | undefined; deriveMessages(): Message[]; // 缓存增量投影;返回新数组、共享深冻结 Message deriveEventMessage(event: SessionEvent): Message | null; static create(id, seed?, header?): Session; static fromRestore(id, seed, header): Session; } ``` ### 1.5 SessionStore(`ctx.sessions`) ```ts class SessionStore extends Service { create(id?: SessionId, options?: CreateSessionOptions): Session; // 便捷:创建+入店+宣布 prepare(id?, options?: PrepareSessionOptions): Session; // 不入店;配合 enter/announce enter(session: Session): () => void; // 装发布钩子+入店,返回 detach announce(session: Session): void; // 发 session/created flush(session: Session): Promise; // 持久化屏障:唯一入口 get(id: SessionId): Session | undefined; list(): Session[]; fork(source: Session | SessionId, boundary?: number, childSessionId?: SessionId): Session; } ``` ### 1.6 cordis 事件(dsh-session 声明合并) | 事件 | 签名 | 语义 | |---|---|---| | `session/created` | `(session: Session) => void` | 同步抛错可否决发布(回滚) | | `session/disposed` | `(session: Session) => void` | 离店通知 | | `session/event` | `(session: Session, event: SessionEvent) => void` | **追加后 fire-and-forget 馈送(TUI 渲染主通道)**;种子重放不发出 | | `session/flush` | `(session: Session) => Promise \| void` | 并行耐久检查点 | ### 1.7 TodoItem ```ts export interface TodoItem { content: string; status: 'pending' | 'in_progress' | 'completed'; } ``` --- ## 2. Agent — `@deepseek-ai/dsh-agent` ### 2.1 Agent(活代理句柄) ```ts export interface Agent { readonly id: SessionId; readonly options: AgentOptions; // { provider?, model?, maxTokens? } readonly session: Session; // 同一 id 的活会话 readonly inbox: Inbox; readonly status: AgentStatus; // 'idle' | 'running' readonly ctx: Context; // agent 作用域上下文 cancel(cause: AgentCancelCause, options?: CancelOptions): void; // cause: {kind:'user'|'parent'|'hook'|'disposed'} whenIdle(): Promise; runMaintenance(task: (signal: AbortSignal) => Promise): Promise; send(message: UserMessage, target: InboxTarget, wakeup: boolean): void; followup(message: UserMessage): void; // 排队普通后续轮并唤醒 steer(message: UserMessage): void; // 提交最近 step 的 steering inject(message: UserMessage): void; // 排队模型可见上下文,不唤醒 } export type InboxTarget = 'next-turn' | 'next-step'; ``` ### 2.2 AgentRegistry(`ctx.agents`) ```ts class AgentRegistry extends Service { create(options: CreateAgentOptions): Promise; // 新建 agent+session(factory 路径) resume(options: ResumeAgentOptions): Promise; // 从持久化会话恢复(factory 路径) register(agent: Agent): () => void; get(id: SessionId): Agent | undefined; list(): Agent[]; roots(): Agent[]; isOwnedBy(id: SessionId, owner: Agent): boolean; currentInitiator(): Agent | undefined; requireInitiator(): Agent; withInitiator(agent: Agent, operation: () => T): T; } export interface AgentHandle { agent: Agent; dispose(): Promise; } ``` - `CreateAgentOptions`: `{ sessionId, meta?: { cwd?, parentSession?, seedLength?, origin?, delegationDepth?, agentPreset? }, seed?, agentOptions?, signal?, setup? }` - `ResumeAgentOptions`: `{ resumeSessionId, agentOptions?, signal?, setup? }`(内部先 `ctx.sessionPersistence.prepare`) ### 2.3 模型选择 ```ts export interface ModelSelection { provider: string; model: string; reasoningEffort?: ReasoningEffortId; } export interface ModelSelectionRef { current: ModelSelection | undefined; assembled: ModelSelection | undefined; } export declare function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void; ``` ### 2.4 cordis 事件(dsh-agent) | 事件 | 载荷 | TUI 用途 | |---|---|---| | `agent/created` / `agent/disposed` | `{ agent }` | 生命周期 | | `agent/status` | `{ agent, status }` | **编辑框/指示器状态切换(idle/running)** | | `agent/inbox/inserted` / `claimed` / `discarded` | `{ agent, message, turn? }` | 队列显示 | | `agent/session-start` | `{ agent, source: 'startup'\|'resume'\|'clear'\|'compact' }` | 首轮前通知 | --- ## 3. 命令行 — `@deepseek-ai/dsh-cmdline` ```ts export interface CmdlineArgs { get(): readonly string[]; } // 启动器 flag 之后的所有参数原样 export interface AppExit { (code: number): void; } declare module '@deepseek-ai/cordis' { interface Context { cmdlineArgs?: CmdlineArgs; appExit?: AppExit; } } export declare function parseCmdline(ctx: Context, program: Command): void; // commander 程序;help/version/语法错=进程终止 ``` - TUI 自持 `--resume ` / `--session ` / `--help`:注入 `cmdlineArgs`,用自己的 commander program 解析,在 action 中发布 `tuiStartup` 服务;普通行 `inject: [tuiStartup]` 惰性读配置。 --- ## 4. 提问 — `@deepseek-ai/dsh-user-questions` ```ts export interface AskUserQuestionItem { id: string; question: string; detail?: string; header?: string; options?: AskUserQuestionOption[]; // { label, description? } multiSelect?: boolean; intent?: AskUserQuestionIntent; // { kind: 'plan-review', approve: string } } export interface AskUserQuestionAnswerItem { id: string; selected: string[]; custom?: string; } export interface AskUserQuestionAnswer { answers: AskUserQuestionAnswerItem[]; } export interface AskUserQuestionRequest { questions: AskUserQuestionItem[]; agent?: Agent; signal?: AbortSignal; } export interface UserQuestionProvider { ask(request: AskUserQuestionRequest): Promise; } class UserQuestionService extends Service { // ctx.userQuestions registerProvider(provider: UserQuestionProvider): () => void; // 单 provider ask(request: AskUserQuestionRequest): Promise; // 错误码:CALLER_NOT_LIVE / DELEGATED_CALLER } ``` - 模型侧工具在 `@deepseek-ai/dsh-tool-ask-user`;TUI 注册唯一 UI provider,渲染对话框并返回答案。 --- ## 5. LLM 词汇 — `@deepseek-ai/dsh-llm` ### 5.1 内容块(ContentBlock,合并可扩展) ```ts export interface TextBlock { type: 'text'; text: string; } export interface ReasoningBlock { type: 'reasoning'; text: string; } export interface ImageBlock { type: 'image'; attachment: ImageAttachmentRef; } export interface ToolCallBlock { type: 'tool-call'; id: CallId; name: string; arguments: string; } export interface ToolResultBlock { type: 'tool-result'; toolCallId: CallId; content: ContentBlock[]; isError?: boolean; } export type ContentBlock = ContentBlockMap[ContentBlockType]; // 按 type 判别 ``` ### 5.2 消息 ```ts export interface Message { readonly id: MessageId; readonly role: 'system' | 'user' | 'assistant'; readonly content: ContentBlock[]; readonly source: MessageSource; // user {kind:'user'} | plugin {kind:'plugin',plugin,...form} | model | tool } export interface UserMessage extends Message { readonly role: 'user'; } export interface AssistantMessage extends Message { readonly role: 'assistant'; readonly source: ModelMessageSource; } export interface ToolResultMessage extends Message { readonly role: 'user'; readonly content: [ToolResultBlock]; readonly source: ToolMessageSource; } export declare function createUserMessage(input: T & {...}): T & Pick; ``` ### 5.3 流块(StreamChunk) ```ts export type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } | { type: 'text-delta'; index: number; text: string } | { type: 'reasoning-delta'; index: number; text: string } | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string } | { type: 'block-end'; index: number; block: ContentBlock } | { type: 'usage'; usage: TokenUsage } | { type: 'finish'; reason: FinishReason; replayState?: ReplayEnvelope }; ``` `ReplayEnvelope = { response: unknown; blocks?: readonly unknown[] }`:适配器私有重放状态,按块位置与 `BlockAssembler.blocks()` 同步裁剪;长度不匹配则整体丢弃。 ### 5.4 用量与失败 ```ts export interface TokenUsage { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number; reasoningTokens?: number; // 计数不相交 } export interface LlmFailure { message: string; code: string; status?: number; providerRetryAfterMs?: number; requestId?: string; } ``` --- ## 6. dsh-base 默认行清单(bundle 层参考) `$PKG/dsh-base/cordis.patch.yml`(一行 insert)已挂载:timer、hmr、llm、session、typert 三件套、 session-title(+llm)、user-questions、agent、agent-default-model、jobs、llm-retry、settings、 credentials、llm-pi-ai、session-persistence-jsonl(root=`dshHomePath('sessions')`)、attachment-local、 session-query-sqlite(path `:memory:`、openAt `never`)、session-projection、session-telemetry-otel、 subprocess、sandbox、sandbox-policy(mode `workspace-write`,workspaceRoot=process.cwd())、 bash-sandbox(win32 禁用)、pwsh-sandbox(仅 win32)、approval、permission、shell-env、 tool-bash(win32 禁用)、tool-pwsh(仅 win32)、tool-pwsh-persistent(仅 win32)、tool-jobs、fs-observation-policy、tool-fs、 tool-fs-search、agent-instructions、skill、skill-filesystem、skill-badge(disabled)、tool-skill、 commands、command-feedback、goal、goal-round-driver、command-goal、plan-mode、token-meter、 compaction-basic、command-compact、subagent 三件套、tool-subagent-control(+list-agents)、 tool-subagent、tool-subagent-fork、tool-subagent-report、workflow、tool-workflow、timeout-policy、 spill-local、spill-policy(maxInlineBytes 50000)、session-checkpoint-policy、tool-result-pruner、 tool-todo、tool-goal、tool-ralph、tool-str-replace-editor、repeat-tool-reminder、web_search 系列。 → TUI bundle 的 patch 只需**覆盖** `agent-loop`/`system-prompt`/`llm-deepseek`/`fs-sandbox`/`tools` 行 + **插入** session-reference/storage 三件套/session-projection-cache/tmux-context/tui 行。 --- ## 7. 会话持久化/投影/查询(scout 汇总,rc.8 逐字) ### 7.1 SessionPersistence(`ctx.sessionPersistence`,抽象服务) 0.5.0 锁定的是 dsh 0.2 的句柄式接口。实现位于 profile 的后端包中;本仓库只通过 `src/dsh-host/session.ts` 暴露类型: ```ts abstract class SessionPersistence extends Service { create(header: SessionHeader, options?): Promise open(id: SessionId, access: "read" | "write", options?): Promise flush(): Promise // service-wide durability barrier stat(id, options?): Promise list(options?): Promise } interface SessionHandle { readonly id: SessionId readonly header: SessionHeader readonly inheritedEventCount: SessionLogOffset readonly access: "read" | "write" read(offset?, length?, options?): Promise<{ eventState, events }> append(events, options?): Promise // best effort; visible in-process flush(options?): Promise // materializes, including an empty session close(): Promise // drains pending durability, then releases ownership } ``` - `create()` 在本进程内立刻可见,但物理物化可能推迟;`flush()` 和 `close()` 都可能把只有 setup 事件的空会话写到磁盘。 - 本仓库的 `lazySessionPersistence` 行包装 `create()`:setup append 留在内存,第一条 `user/message` / `assistant/message` / `tool/call` 到达时才调用真实 `create()` 并按序回放缓冲;从未出现对话的 handle 在 flush/close 时直接丢弃。`stat` / `list` / `open` 仍委托真实后端,因此未物化的空会话对读路径不可见。 - host 的 `/resume` 在展示前读取日志头部确认会话是否空白;启动迁移会清理后端 `list()` 能看见、目录名严格匹配 `tui-*`、 且日志头部确认没有对话事件的历史目录。读失败或当前 live 的会话一律保留。 - `SessionListPayload.sessions[]` 带可选 `lastActivityAt`:live 会话取最后事件的 `time`,persisted 会话对 `(id, revision)` 缓存后只读 `eventCount - 1` 的一条 tail event;缺失时 host 与渲染器都回退 `createdAt`。 ### 7.2 SessionProjectionRegistry(`ctx.sessionProjections`) ```ts interface SessionProjectionMap {} // merge-extensible:goal/title/tokenUsage/contextPressure/contextBreakdown 等键 interface ProjectionDefinition { key; schema; init(); apply(state, event): S; view(state): V; stateVersion: number; } class SessionProjectionRegistry extends Service { register(definition): () => void; onChanged(listener: (session, key, value, seq) => void): () => void; snapshot(session): ProjectionSnapshot; // { asOfSeq, values: Partial } checkpoint(session): ProjectionCheckpoint; restoreFloor(checkpoint): number | undefined; viewCheckpoint(checkpoint): Partial; restore(checkpoint, events, baseSeq): { snapshot; checkpoint }; } ``` ### 7.3 SessionProjectionCache(`ctx.sessionProjectionCache`) ```ts class SessionProjectionCache extends Service { cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined; // 零 I/O 列表读 write(session): Promise; // 先 ctx.sessions.flush 再整记录替换 coldSnapshot(id, signal?): Promise; } // Config(必填):writeEveryEvents / writeIntervalMs(组合示例 200 / 5000) // 必写点:turn/end、会话 detach。行绑定日志生命周期(createdAt+cwd 见证),ver 不匹配即弃。 ``` ### 7.4 SessionQueryEngine(`ctx.sessionQuery`,抽象服务) ```ts abstract class SessionQueryEngine extends Service { abstract searchSessions(request, exec?): Promise>; abstract searchEvents(request, exec?): Promise; listSessions(signal?): Promise; // 轻量;live 优先 readSession(sessionId): Promise; filterSessions(filters, signal?): Promise; readTitle(sessionId, signal?): Promise; readTitleSnapshots(sessionIds, signal?): Promise; listEvents(sessionId): Promise; readSurface(sessionId): Promise; traceSession(sessionId, signal?): Promise; traceEvent(request, signal?): Promise; readEvent(request, signal?): Promise; // before/after ≤ readWindowMax=50 } ``` - 过滤器:`{kind:'id'|'cwd'|'created-at'|'parent'|'availability'}` / 事件 `{kind:'seq'|'time'|'type'|'surface'|'text'}`;AND 语义、同子句 OR。 - TUI 消费:`/resume` 列表用 `listSessions()`/`filterSessions([{kind:'availability',values:['persisted']}])` + `readTitleSnapshots`;预览用 `readSurface`。错误码闭集 18 个 `SESSION_QUERY_*`。 --- ## 8. 状态与能力面(scout 汇总) ### 8.1 dsh-commands(`ctx.commands`) ```ts class CommandRuntime extends Service { register(definition: CommandDefinition): () => void; // { name, description, input?, recordInput?, handler } list(agent: Agent): readonly CommandDescriptor[]; find(agent: Agent, name): CommandDefinition | undefined; execute(agent, line, images, signal): Promise; // rc8 新增 images: base64 图片批次;语法/未知名 → undefined 不记日志 } parseCommand(line: string): ParsedCommand | undefined; // SessionEvent:'command/run' { commandId, name, args?, source } / 'command/done' { commandId, kind, text?, sourceEventSeq? } // CommandResult: { kind:'success', text?, sourceEventSeq? } | { kind:'error', text } ``` ### 8.2 dsh-goal(`ctx.goals`) ```ts // SessionEvent:'goal/change' = 全量快照变更(kind:'goal/change', version:1, operation: create|edit|pause|resume|complete|block|clear, goal: GoalSnapshot, …)或 clear tombstone foldGoal(events): FoldedGoal; // 严格重放;TUI 直接消费 goal/change 的 goal 快照渲染面板 interface TodoItem { content: string; status: 'pending' | 'in_progress' | 'completed'; } // 真身在 dsh-session interface GoalSnapshot extends GoalRef { objective; phase: 'active'|'paused'|'blocked'|'complete'; blockedReason?; maxGoalRounds } ``` ### 8.3 dsh-compaction(`ctx.compaction`,抽象) ```ts // SessionEvent:'compaction/start'|'summary'|'end'|'prune'(log-only 锁括号 + 阴影价协议) // 成功序列:start → summary(shadowedRange/shadowedSeqs/shadowedTokenCount)→ user/message(surfaceOp replace, source=compactCheckpointSource) → end // TUI:start 起显示 "Context being compacted" 状态;end 或 error 清除;replace 事件由事件驱动的 transcript 天然处理 ``` ### 8.4 dsh-token-meter(`ctx.tokenMeter`) ```ts measure(session, requestHeader?): TokenMeasurement; // { totalTokens, surfaceTokens, … } // 投影键:tokenUsage(四桶 disjoint: uncachedInput/output/cacheRead/cacheWrite)、contextPressure、contextBreakdown // 占用率 UI:projectedTokens / 独立解析的 capacity(requestContext().contextWindow 或 LlmResolvedModelInfo.context) ``` ### 8.5 dsh-system-prompt(`ctx.systemPrompt`) ```ts // Config: { includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder? } // persona → order-0 'deployment:persona' section;agent-scoped 同名 section 遮蔽全局 renderPrompt(assembly: PromptAssembly): string; // 严格 {{variable}} 插值 ``` ### 8.6 dsh-session-title(`ctx.sessionTitle`) ```ts // SessionEvent:'session/title'(last-wins,log-only);投影键 title: string | null foldSessionTitle(events): SessionTitleSnapshot | undefined; // 服务:get(session) / rename(session, title)(pin 住)/ refresh(session, signal?) / register(provider) ``` ### 8.7 dsh-session-reference(`ctx.sessionReferenceResolver`) ```ts parseSessionReferenceText(text): { text; references: SessionReferenceInput[] }; // @[label](dsh-session:) listCandidates(agent, query?, limit?, signal?): Promise; prepare(agent, content, references, signal?): Promise; // 快照+上下文,错误码 SESSION_REFERENCE_* // 限制:MAX_REFERENCES=3、DEFAULT_CANDIDATE_LIMIT=50、DEFAULT_MAX_REFERENCE_BYTES=65536 ``` ### 8.8 dsh-skill(`ctx.skills`) ```ts class SkillRegistry extends Service { list(options?): Promise; // { name, description, whenToUse?, invocation, source, provider } snapshot(options?): Promise; // { skills, complete } get(name, options?): Promise; } isUserInvocable(skill): boolean; // TUI `/skill:` 列表过滤 ``` --- ## 9. 配置行 schema(cordis.patch.yml 编写依据) ### 9.1 agent-loop 行 ```ts interface Config { maxParallelToolCalls?: number; // default 10,min 1;1=串行 agents: (AgentOptions & { id: string; // required,稳定 label sessionId?: SessionId; // 稳定身份;与 resumeSessionId 互斥 cwd?: string; // 仅新建会话 resumeSessionId?: SessionId; // 加载既有持久化会话 })[]; } // AgentOptions: { provider?, model?, maxTokens? } // 无 sessionId/resumeSessionId → ${id}-session- 每次重启新会话;配置 agent 自动启动 ``` ### 9.2 其他行 - `agent-default-model`:`{ provider: string (req), model: string (req) }`;settings 分节另有 `reasoningEffort?` - `agent-instructions`:`maxBytes` 必填;其余可选(projectRootMarkers 默认 ['.git']、candidates [AGENTS.md, CLAUDE.md]…) - `tools`:`{ mode?: 'native'|'code'|'both' (default native), maxParallelSubCalls?: number (default 10) }` - `system-prompt`:`{ persona?, toolOrder?, includeHarnessIdentity?, includeRuntimeContext? }` - `fs-sandbox`:`{ cwd }`(workspace 根) - `llm-deepseek`:`{ apiKeyEnv?, baseURL?, thinking?, reasoningEffort?, maxRequestImageBytes? }`;`reasoningEffort` 支持 `off | low | high | max`;`models[]` 可声明 `inputModalities: ['text'] | ['text','image']`(settings 分节 llm-deepseek 可热覆盖) - `dsh-terminal`:无配置;**TUI 不直调**(owner 绑定,供 dsh-tool-terminal 消费) --- ## 10. TUI 接线备忘(从合约到实现) 1. **启动**:`tui-startup` 行 inject `cmdlineArgs`,commander 解析 `--resume`/`--session`/`--help` → 提供 `tuiStartup`(sessionId / resumeSessionId);agent-loop 行 `inject: [tuiStartup]` 惰性读。 2. **取 agent**:`ctx.agents.get(sessionId)` → `Agent`;`agent.session.events` 为不可变日志快照。 3. **渲染主通道**:`session/event` 事件(追加后馈送)+ 初始 `agent.session.events` 重放(种子不发出)。 4. **状态**:`agent/status` 事件 → 编辑框边框/指示器;`agent.session.header.cwd` 为 workspace。 5. **提交输入**:空闲时 `agent.followup(createUserMessage({ content, source: { kind: 'user' } }))`;生成中改为 `agent.steer(...)` 把消息送进下一步插话队列。 6. **中断**:`agent.cancel({ kind: 'user' })`。 7. **提问**:`ctx.userQuestions.registerProvider(provider)`;对话框完成后 resolve `AskUserQuestionAnswer`。 8. **命令**:`ctx.commands.execute(agent, line, images, signal)`(rc8 起必须传 `images`,TUI 当前传 `[]`);`/help` 列表用 `ctx.commands.list(agent)`。 9. **模型选择**:`installModelSelection(agent.ctx, selectionRef)` + `agentDefaultModel.currentSelection()/saveSelection()`。 10. **投影消费**:`ctx.sessionProjections.snapshot(session)` 或 `sessionProjectionCache.cachedSnapshot(header)`(列表零 I/O)。 ## 11. 社区生态适配器(dsh-ecosystem-spec v0.15) 本节记录 my-dpsk-harness-tui 对齐 dsh-TUI std 适配器规范后的边界与接缝,作为上游接口之外的补充合约。 ### 11.1 适配器边界(ADAPTER.md 对齐) - 公共适配层:`src/dsh-adapter/` 只保留 `@dsh-std/adapter-dsh` 的动态链接与入口(std-link / std-adapter / std-profile-loader);本仓库不再维护私有 DSH 适配器。 - 宿主渲染接缝:`src/dsh-host/` 是唯一允许 `import @deepseek-ai/*` 的目录;UI、微信桥、host、extensions 一律通过 `src/dsh-host/index.ts` 间接接触上游。std 协议尚未覆盖的渲染/交互面(session/event、agent、llm/stream、user-questions、permission-presets、settings、skill、session-query、cmdline、tools、session-title)留在这一层,不作为适配器。 - 门禁:`pnpm run verify:boundary` 扫描全部源码,`@dsh-std/adapter-dsh` 越出 `src/dsh-adapter/`、`@deepseek-ai/*` 越出 `src/dsh-host/` 即失败。 - 上游版本:由动态链接的 `@dsh-std/adapter-dsh` 吸收;本仓库不再按 dsh rc 钉版本,`verify:contract` 只校验 `@dsh-std/{core,manifest}` 与框架 major。 ### 11.2 插件准入与协商 - `dsh-plugin.json` 声明 `facets.host`、`requires.contracts`(optional 引用必须带 fallback)、`permissions`、`subscriptions`;`provides`/`requires.services` 在 v0.15 直接拒绝。 - `src/plugin-spec/` 提供 manifest 结构校验、协议目录(registry)、五态协商(`compatible / compatible_degraded / waiting_authorization / rejected / unknown`,优先级 unknown > rejected > waiting_authorization > compatible_degraded > compatible)与 Host Descriptor 构建。 - 私有坐标固定为 `tui.dsh/v1alpha1`(`DecisionEvents`、`Channel`、`SettingsSection`、`Scene`);私有定义通过 SHA-256 profile digest 固定,漂移即 fail-closed 剔除。 ### 11.3 宿主平面(src/host/,由 cordis.patch.yml 挂载) | 服务 | 落点 | 权限 | |---|---|---| | `tuiPluginHost` | admit / registerCommand / subscribeDecision / selfCheck / Host Descriptor | 见下 | | `tuiPluginStorage` | `~/.dsh-tui/plugin-storage/.json`(256 键 / 256 KiB / 精确 JSON / 原子写) | `storage.local.read` / `storage.local.write`(默认拒绝) | | `tuiMessageObserver` | `user/message → message.received`、`assistant/message → message.sent`;scope=`session:` | `messages.observe.read`(默认拒绝) | | `tuiEffectLedger` | `~/.dsh-tui/effect-ledger.jsonl` append-only,写前过 allowlist | — | | grant store | `~/.dsh-tui/extension-grants.json`;缺失=全默认,损坏=全部拒绝(fail closed) | 8 个注册权限;`commands.invoke` 默认允许,其余默认拒绝 | 决策事件权限映射:`tui/input → session.input.intercept`、`tui/rewind-prompt → session.rewind.intercept`、`tui/session-switch → session.switch.intercept`、`tui/compact → session.compact.intercept`;通知类事件(`tui/rewind-done`、`tui/session-switched`)不要求 intercept grant。 ### 11.4 扩展接缝(src/extensions/,由 TUI 行提供) - `ctx.tuiDialogs`:select / confirm / input,FIFO 排队,渲染器对话框后端(`extension-dialog` 下游 + `extension-dialog-response` 上游,request id 关联);渲染器未挂载时 fail-closed 落定取消值;畸形请求落定取消值,不抛错。 - `ctx.tuiStatus`:键控状态行(首次设置顺序,` · ` 连接),key 规则 `/^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)*$/`,最多 20 键 / 200 cell。 - `ctx.tuiShortcuts`:组合键注册,保留位拒绝;渲染器把带修饰键的按键规范化成 `ctrl+alt+shift+base` 串走 `shortcut` 上游,宿主把注册串两侧规范化后匹配并 dispatch。 - `ctx.tuiRenderers`:插件 log-only 事件 → 纯文本转录行;输出在渲染器边界消毒截断。渲染器 transcript 没有插件行角色(`transcriptEvents` 不产插件行),插件静态行不进渲染器 transcript。 - `ctx.tuiSettingsSections`:/settings 中展示插件设置区块(本版本为只读展示)。 - `ctx.tuiScenes`:**已知偏差** —— 渲染器没有 scene host(其界面是固定的 transcript/composer/palette 布局),场景后端 fail-closed:`open` 返回 false,插件场景在渲染器路径不显示;本主机场景面使用 pi-tui `Component`,不接受 dsh-TUI 的 React 场景组件;工厂函数可接收 `{ close, palette, t }` 并返回 `Component`。 ### 11.5 已验证状态 - 门禁:`verify:boundary`、`verify:contract`、`verify:plugin-spec` 已挂入 `pnpm run check` / `pnpm run build`。 - 测试:`tests/plugin-spec.spec.ts` 与 `tests/extensions.spec.ts` 覆盖 manifest/协商/Host Descriptor 与 sanitize/status/shortcuts/renderers/dialogs。 - 运行时接入:`tui/input`(submit 与 steer 前)、`tui/session-switch`(/new、/resume 前)、`tui/session-switched`(切换后广播)、`tui/compact`(/compact 前)。rewind 系列事件在 omp-tui 未实现 rewind,宿主已具备订阅与分发能力,待 rewind 功能落地后接入。 ## 12. 渲染协议 v2(宿主 ↔ 渲染器) 0.5.0 把渲染层从 `@earendil-works/pi-tui` 迁出,改为独立的 OpenTUI 渲染器子进程。宿主(本 bundle,运行在 dsh 的 Node 进程里)与渲染器(Bun 编译的单文件产物,拥有 TTY)之间只通过 IPC 通道(fd 3)交换 NDJSON 信封。本节是这条契约的权威定义,`pnpm run verify:ipc-protocol` 逐条检查。 ### 12.1 信封与分帧 - 信封:`{ protocolVersion, seq, kind, payload }`。`protocolVersion` 当前为 `2`(`RENDERER_PROTOCOL_VERSION`);`seq` 由发送方按发送顺序自增,用于排序与结果关联;`kind` 是非空字符串;`payload` 必须存在(可为 `null`)。 - 分帧:一条信封一行(NDJSON)。Node IPC 通道本身按整条消息投递,行形式用于日志文件与未来的 socket 传输;`encodeLine` / `decodeLine` 是唯一的编解码入口。 - 传输:内嵌模式使用 Node 子进程 IPC;默认的守护进程模式由 `scripts/daemon-client.js` 在本地命名管道(Windows)或 Unix domain socket 上代理同一批 NDJSON 信封,握手令牌来自 runtime 目录且不开放 TCP。 - 畸形消息一律丢弃并通过日志报告原因:渲染器发坏数据不能拖垮宿主,宿主发坏数据不能拖垮渲染器。 ### 12.2 方向与 kind 清单 下行(宿主 → 渲染器,41 个):`hello-ack`、`session-snapshot`、`session-frame`、`session-list`、`model-catalog`、`provider-catalog`、`reasoning-efforts`、`workspace-files`、`permission-presets`、`agent-presets`、`settings`、`i18n`、`command-result`、`command-catalog`、`dialog-response`、`extension-dialog`、`boot-notice`、`turn-state`、`wechat-output`、`form-spec`、`question-request`、`question-close`、`question-result`、`approval-request`、`approval-close`、`subagent-list`、`skills-list`、`usage`、`git-branch`、`mcp-servers`、`host-plugins`、`todos`、`shell-jobs`、`interject-list`、`model-discovery`、`model-list`、`log`、`bye`、`terminal-frame`、`terminal-end`。 上行(渲染器 → 宿主,28 个):`hello`、`send-message`、`interject-edit`、`interject-delete`、`shell-job-op`、`attachment-read`、`session-op`、`set-selection`、`update-settings`、`dialog-request`、`shortcut`、`extension-dialog-response`、`form-submit`、`model-discover`、`model-list-request`、`model-edit`、`question-answer`、`approval-answer`、`wechat-op`、`doctor-check`、`terminal-scheme`、`terminal-size`、`bye`、`terminal-op`、`question-timeout`、`shutdown`、`autostart-op`、`interrupt`。 - 渲染器不直接触碰 dsh 服务:所有会话、模型、权限、设置、微信桥操作都以 kind 请求宿主执行。 - 每个 kind 在 `src/ipc/kinds.ts` 有一个 `interface Payload`,并在 `KIND_PAYLOAD_NAMES` 登记。加 kind 必须同时登记 payload,否则门禁失败。 - 可选 kind(接收方可以不认识):下行 `log`、`dialog-response`、`i18n`、`wechat-output`、`form-spec`、`question-request`、`subagent-list`、`skills-list`、`model-discovery`、`model-list`;上行 `dialog-request`、`form-submit`、`question-answer`、`wechat-op`、`doctor-check`、`model-discover`、`model-list-request`、`model-edit`。不认识的下行 kind 由宿主跳过不发,可选的上行 kind 由宿主降级为不可用功能。 - **`subagent-list`**:宿主把子代理描述符列表推下来(`label` / `provider` / `mode`)。订阅时先发当前列表,之后每次变化重发;`stop` 里退订。同一份 `subagent/descriptor` 会话事件喂宿主 subagent 集(`hostEventEffects`)与渲染器,`addSubagent` 只有一处入口。渲染器画 `↳ label · provider · mode` 行,空列表不画。 - **`skills-list`**:宿主把技能快照(`name` / `description`)推下来,渲染器命令面板的 skills 分类每行 `skill ` + description,选中发 `send-message` 的 `skill:` 行——与 composer 补全接受的是同一行,宿主的命令注册表拥有 `skill:` 调用。同一份快照喂两条路径,`stop` 里退订。 - **`model-discover` / `model-discovery`**:供应商表单的**发现探测是宿主的**(`ctx.llm.discoverModels` + 发现命名空间查找 + 凭据规则)。渲染器没有按钮基元,所以探测是字段清单里的一行(`kind: 'action'`):Enter 在该行把表单当前的字段值送上去(`model-discover`),**表单保持打开**;宿主跑探测、把发布的 id 合并进扁平行,用 `model-discovery` 把合并后的行发回,落进 `models` 字段。扁平行 `id|name|level=wire;...|effort|context|max` 的编解码只在宿主一侧,渲染器原样替换值。部署没有探测服务时宿主回答一个告警通知,不发 `model-discovery`。 - **`model-list-request` / `model-list` / `model-edit`(逐模型嵌套编辑器)**:供应商表单的 `models` 字段是扁平行,逐模型编辑走三步。① 字段清单里的 `edit-models` action 行:Enter 把当前字段值送上去(`model-list-request {form, fields}`),**表单保持打开**;宿主用 `parseModelsLine` 把扁平行解析成模型清单(`id` + `label`=name),用 `model-list` 发回,渲染器为它开自己的选择框(行 `id (label)`)。② 选中一行发 `model-edit {form, id, models}`,`models` 是**打开编辑器时的那条扁平行**(渲染器记住它),所以合并发生在屏幕上的值上;宿主用 `modelEdit` 为选中的模型建六字段表单(`id` / `name` / `efforts` / `effort` / `context` / `max`,全 `text`),用 `form-spec`(`form: 'model'`)发回。③ 模型表单的提交走同一个 `form-submit`;宿主的 `formSubmit` 在 `form === 'model'` 分支把编辑合并进**记住的那条扁平行**、替换被选中的模型(提交的 `id` 可重命名,空 `id` 保留原 id;`efforts` 为 `none` 写 `false`,空写不改动;`context` / `max` 是数字),过 `normalizeProviderModels` 归一,报告通知,**返回合并后的行**;mount 把该字符串作为 `model-discovery` 发回,落进 provider 表单的 `models` 字段(走发现探测的写入器路径)。解析、编码、合并都在宿主一侧,`encodeEfforts` / `parseModelsLine` / `encodeModelsLine` 与表单提交共用;渲染器只画选择框与字段行,不复制任何编码规则。宿主用 `modelLines` / `modelEditIds` 记住「打开编辑器时的那条扁平行」与「被选中的模型 id」——`form-submit` 只带字段值,不带 provider。 - **`turn-state`**:宿主报告前台会话是否在生成。渲染器的 composer 据此决定 Enter 是普通发送还是插话,不猜 agent 状态。 - **`command-catalog`**:宿主把斜杠命令表推下来,渲染器只做前缀补全与 Tab 接受,命令本身由宿主执行(`send-message` 把整行送回去)。带 `argumentHint` 的命令在补全接受时带尾随空格。**技能命令(`skill:` 条目)随目录重发抵达**:技能快照是异步的,在 ack 之后才落地,目录只在 ack 时发一次的话渲染器永远看不到技能。`refreshSkillCommands` 改完 `commandEntries` 后重发 `command-catalog`。 - **`interject-edit` / `interject-delete`**:`{sessionId, id?}`;省略 `id` 等于 `Alt+Up` 的最早一条。宿主先按 `sessionId` + 消息 id 在自己的队列快照与 agent inbox 里移除,成功后才把文本作为该请求的 `command-result.value` 回给渲染器(渲染器再回填草稿);不是有效队列项时返回真实错误,不乐观改草稿。`interject-delete {sessionId, id}` 只删指定 id。 - **`dialog-request` / `dialog-response`**:宿主-owned 的表单(供应商编辑、自定义模型)由渲染器发起请求,宿主执行后按**请求的 seq** 回答。宿主自己的发送计数器与请求 seq 无关。 - **`set-selection`**:必填 `sessionId`,且只允许一组值(`provider`+`model` / `effort` / `permission` / `preset`);`provider`+`model` 走宿主的 selection seam,`effort` 走 effort seam,`permission` 与 `preset` 复用宿主的斜杠命令路径(`/permission` 需要 `registryPermissionName` 翻译,`/mode` 只在会话空白时允许换预设)。空 patch 不调用宿主。 - **`TranscriptEvent` 完整投影**:宿主不再按显示策略裁剪行。`reasoning`、`toolOutput`、`diff` 全部发送原样;行还带真实 `sourceSeq` / `createdAt` / `turn` / `step` / `callId`、完整 JSON-safe `toolArguments` 与 `content`(图片/文件块只留引用,不含路径与字节)。折叠、verbosity、`session.thinking`、TPS 与 diff 换行都在渲染期决定,切换这些不需要重跑模型,也不需要重新拉取被丢掉的文本。 - **`TranscriptEvent.toolInput`**:工具行的字符串标量参数(shell 的 `command`/`workdir`、`read` 的 `path`/`offset`/`limit`、`grep` 的 `pattern`/`path`、`subagent` 的 `description` 等,最多 6 个键,数字转十进制字符串)。渲染器用它画上游同形的工具标题(`$ `、`Read `、`Glob "" in `);标题不出现 JSON 片段,需要嵌套结构的卡片读 `toolArguments`。 - **`TranscriptEvent.reasoningDone`**:只出现在**流式**行上。推理还在流时行是 `streaming: true` 且没有这个标记,渲染器画上游的 `Thinking`(转圈;有摘要时 `Thinking: 摘要`);第一条**答案** delta 到达时宿主置 `reasoningDone: true`,标题翻成 `Thought[: 摘要][ · 时长]` —— 判据是**推理部分**结束而不是整步结束(上游 `ReasoningPart.isDone`),所以答案还在流时标题已经稳定。交错思考里再来的 `reasoning-delta` 会清掉它,标题退回 `Thinking`;稳定行(`streaming` 缺省)不看这个标记,一律按 `Thought` 渲染,时长仍取该步的 `durationMs`。 - **`TranscriptEvent.bodyAt` / 流式 `createdAt`**:host 在流式行第一次创建时写 `createdAt`,第一条答案 delta 写 `bodyAt`; durable `assistant/message` 替换流式行时把 `bodyAt` 带到同一 id。渲染器用 `createdAt - durationMs` 反推 step 起点, 正文出现时用 `bodyAt ?? createdAt` 作为终点;回放没有 `bodyAt` 时用 durable `createdAt` 近似,因此实时与回放的思考时长 可能相差正文生成耗时,但不会显示 `0s` 或负数。 - **`session-list.lastActivityAt`**:会话列表行新增可选最后交互时间。live 会话取 `session.seq - 1` 的事件 `time`; persisted 会话用 `(id, revision)` 缓存,并只从 persistence handle 读最后一条事件;没有 `eventCount`/tail 读取失败时省略, host 与 renderer 均回退 `createdAt`。host 对 `session-list` 与 `/resume` 使用同一降序排序(同时间按 createdAt、id 稳定), 渲染器只消费下发顺序;today/yesterday 分组也用 `lastActivityAt ?? createdAt`。 - **`session-list.blank`(live 壳)**:host 对每个无标题 live agent 先读内存 `sessionEvents()`;没有任何 `user/message` / `assistant/message` / `tool/call` 时标 `blank: true`,persisted 行仍以日志头扫描兜底。 renderer 的 `/sessions`、picker 与 tab catalog 过滤全部 blank 行(包括 current);Home 草稿是 renderer 本地 synthetic tab, 不进入 `blank` 行、不触发 `session-op new`、不写 `tabs.json`。 - **`session-list` 排序与 tab 顺序**:`session-list` 的 lastActivity 降序只是 picker/catalog 顺序;renderer 顶部 tab 的 手动顺序由本地纯 `session-tabs-model` 维护,catalog 刷新只更新标题/状态/清理被删除 id,绝不重排 strip。 - **`update-settings.pathDisplay`**:值 `tilde` / `full`,默认 `tilde`,volatile。仅控制用户可见路径格式化; `session-list`、`workspace-files`、`send-message`、`session-op`、terminal、附件、剪贴板和导出仍传递原始绝对路径。 格式化函数按平台使用 win32/posix 规则,Windows 大小写不敏感,只缩写位于 home 内部或以 home 结尾的路径。 - **`update-settings.newDesigns`**:只表示 transcript 的 classic/compact 呈现,不再控制 `opencode2` 主题候选,也不会切换 当前主题;`showReasoning: false` 只关闭自动预览/正文,compact 标题仍可点击展开。 - **`session-op`**:`new` / `switch` / `resume` 都落进宿主自己的会话生命周期(`activateAgent` / `createAgent` / `switchAgent`),未保存改动的确认、标题刷新、微信桥跟随会话、token 计数重置、渲染器快照重推因此只有一处实现;宿主成功切换后用 `session-snapshot` + `session-list` 重推新会话(渲染器的转写、标题、路由、轨道随之整体替换)。带 id 的 `/resume ` 与裸 `/resume` 在渲染器侧分别走上面的 `session-op` 与选择器,宿主侧的同名命令保留给微信桥与其他入口。 - **`workspace-files` / `session-snapshot.workspace`**:宿主拥有工作区索引(会话 cwd、跳过点目录、目录优先、最多 2000 项)并以 `workspace-files {sessionId, files}` 下发;渲染器的 `@` 补全只过滤这份清单(不遍历磁盘),补全后的文本由 `send-message` 送回宿主执行。快照先到、会话级目录随后(渲染器在切换快照时清空 per-session 目录)。`@[label](dsh-session:…)` 会话引用语法由宿主的解析器产生,渲染器只补路径。 - **`settings` / `update-settings`**:`update-settings` 必填 `requestId`;宿主写入后回同 id 的 `settings {requestId, values, error?}`,失败也回当前真实值加 `error`,渲染器按 id resolve/reject。初始与订阅推送不带 id。`settings.values` 是 resolved 默认值与已持久化覆盖值的合并结果(含 `themeName` / `themeMode` / `newDesigns` / 提示模板)。主题由渲染器原生 provider 拥有,宿主不再下发布局调色板。 - **`wechat-output`**:微信桥的持久输出(`log` / `qr` / `error` / `status`)。桥在订阅前缓冲、订阅时按序回放,所以启动期产生的二维码不会在「桥已出码、渲染器尚未存在」的窗口里丢掉;订阅排在快照之后,扫码块不会先于 transcript 到达。`qr` 画成无边框等宽文本(与 0.4.0 同一规则:卡片边框会干扰手机扫码),`status` 是 footer 的一行,`log` / `error` 进通知块。`wechat-op` 的 `op` 是 `/wechat-` 之后的命令名(login / status / pair / allow / deny / list / logout / notify / config),宿主拼成整行过自己的命令路径,参数解析与未知命令拒绝都留在宿主。 - **`doctor-check` / `command-result`**:渲染器请求 `/doctor` 或 `/plugins`,宿主跑自己的检查并把报告文本作为 `command-result` 按**请求的 seq** 回答。渲染器按 seq 领取答案:seq 不在挂起表里的结果直接丢弃(宿主可能回答渲染器已经不再关心的请求),领过一次即删除,同一 seq 不会弹两次报告框。运行失败回答 `ok: false` + `error`(取 `error.message`,不是 `String(error)`)。报告行由 `doctorRows` / `pluginRows` 一处产生,渲染器 alert 与宿主 toast 通知不可能漂移。 - **`dialog-request` / `form-spec` / `form-submit`**:宿主-owned 的表单(供应商编辑)由渲染器发起 `dialog-request {kind}`。宿主有字段清单时把 `form-spec`(`title` + `fields`)发下去,渲染器画表单;收集的值作为 `form-submit {form, values}` 送回,宿主过自己的 settings seam 写入。宿主没有该 form 的字段清单时回落到 `dialogRequest`(宿主自己跑表单,按**请求的 seq** 用 `dialog-response` 回答)。字段类型只有 `text` / `secret` / `select`:**没有多行字段**——OpenTUI 的 `textarea` 在 solid reconciler 里不映射 `value` 与 `onInput`(只有 `initialValue` / `onSubmit` 是访问器),所以模型清单是单行、以空格分隔,宿主按空白切分。`providerDraftFor` / `saveProvider` 在插件作用域(不在 `runCommand` 里),因为渲染器 mount 在命令处理器之外接线,两条路径必须读写同一份 profile。 - **`question-request` / `question-answer`**:宿主的 `user-questions/request` 处理器是**阻塞**的——它要等人类答完才返回。`mountRenderer` 返回 `ask(questions)`:宿主调用它,渲染器收到 `question-request`(带 mount 自己生成的 `request` id),逐题提问(有选项的题走选择框、无选项的题走单行输入),全部答完把 `answers` 作为 `question-answer` 按 `request` id 送回,`ask` 的 promise 解析。id 不在挂起表里的答案直接丢弃,领过一次即删除。渲染器路径下 `AskUserQuestionAnswerItem.selected` 是可变数组,IPC 形状是 readonly,宿主映射时拷贝。微信桥的 `askWithFallback` 把这条本地路径与 peer 的回复赛跑,两条渲染路径共用一个入口(`askLocally`)。 - **`extension-dialog` / `extension-dialog-response`(插件对话框后端)**:宿主把 `ctx.tuiDialogs` 的 select / confirm / input 请求作为 `extension-dialog {requestId, kind, title, options, confirmLabel, cancelLabel, initial, placeholder}` 发下去,渲染器用自己的对话框栈画(select→选择框、confirm→确认框、input→单行输入);答案作为 `extension-dialog-response {requestId, value}` 按同一 request id 送回(选中项 id、`'yes'`/`'no'`、输入文本,取消是 `undefined`)。宿主保留 request id → resolver 表;渲染器未挂载时后端 fail-closed 落定取消值。 - **`shortcut`(插件快捷键)**:渲染器把带修饰键的按键规范化成 `ctrl+alt+shift+base` 串(`meta` 记作 `alt`)走 `shortcut {combo}` 上游;宿主把注册串两侧规范化(修饰键集合 + 基键,固定顺序 ctrl→alt→shift)后匹配 `ctx.tuiShortcuts` 注册并 dispatch。渲染器自己的 keymap 是固定 leader 表,不读 settings 页的 `key tools` / `key reasoning` 两行。 ### 12.3 五态协商 `hello` 携带 `protocolVersion` / `rendererId` / `rendererVersion` / `capabilities` / `kinds`。宿主回答 `hello-ack`,状态取五态之一(与 plugin-spec 准入同一套词表): | 状态 | 判定 | 行为 | |---|---|---| | `compatible` | 版本一致、必需能力齐、全部 kind 已知 | 交接终端,开始推送 | | `compatible_degraded` | 版本一致、必需能力齐,缺可选能力或可选 kind | 交接终端,报告缺什么;宿主跳过未识别 kind | | `waiting_authorization` | 兼容但终端未交接 | 不交接终端,等待启动器授权 | | `rejected` | 协议版本不一致,或缺 `transcript` / `composer` 之一 | 渲染器退出,宿主打印原因 | | `unknown` | `hello` payload 畸形 | 渲染器退出,宿主打印原因 | 必需能力:`transcript`、`composer`(`DEFAULT_REQUIRED_CAPABILITIES`)。能力词表:渲染器 `transcript`、`composer`、`dialog`、`scrollbar`、`mouse`;宿主 `session-events`、`model-catalog`、`provider-catalog`、`permission-presets`、`agent-presets`、`settings-write`、`wechat-bridge`、`doctor`、`i18n`、`subagents`、`skills`、`interjection`、`terminal-size`。 ### 12.4 通道与终端交接 - 宿主 spawn 渲染器时 `stdio: ['inherit','inherit','ipc']`:渲染器继承 TTY 并拥有它。 - 渲染器侧用 `process.send(...)` 发送、`process.on('message')` 接收;宿主侧用 `child.send(...)` 发送、`child.on('message')` 接收。Bun 的 `process.channel.send` 不存在,收消息必须用 `process.on('message')`。 - **Bun 在子进程第一次写 fd 2(stderr)时关闭自己的 IPC 通道**(`stdio` 为 inherit 或 pipe 都成立,编译产物与 `bun` 跑脚本都成立)。渲染器因此**永不写 stderr**:`process.stderr.write` 在启动时被替换成通知块入口,Node 的警告(`MaxListenersExceededWarning`、`process.emitWarning` 的报告)走 `process.stderr.write`,所以被接住、画在屏上,通道保持打开;渲染器自己的失败报告写 stdout(渲染器尚未拥有 TTY 时 stdout 就是终端)。同时渲染器把 `process` 与渲染器事件发射器的 max listeners 设为无限:每个打开的对话框通过 `useTerminalDimensions` 订阅一个 resize 监听,嵌套流(供应商表单 → 模型清单 → 模型表单)会越过默认上限 10,那条上限打印的警告就是关闭通道的触发器。 - **通道被关、进程活着时 `child.on('exit')` 不触发**:渲染器继续画、继续收键,`child.on('disconnect')` 才是信号。宿主在 disconnect 上排一次 kill + 终端恢复,并在退出已落地时跳过(正常退出也会先 disconnect)。 - 宿主侧 `child.send` 在通道关闭时抛 `ERR_IPC_CHANNEL_CLOSED`,作为子进程的未捕获 `error` 事件——真实插件里这会拖垮宿主。`childChannel.send` 包 try/catch:首次失败标记通道已关、后续发送一律丢弃、原因只报告一次。`bye` 走同一条 send,所以也不抛。 - dsh 的 stdout 在渲染器接管 TTY 后改道写入 `$DSH_HOME/logs/tui-.log`,并以 `log` kind 投渲染器的 console overlay;宿主不得直接写 TTY。 - 终端恢复三条路径都要覆盖:正常退出(渲染器 `destroy()` 发出恢复序列)、渲染器崩溃(宿主在子进程退出后补发恢复序列)、Ctrl+C(`exitOnCtrlC` 走正常退出)。 ### 12.5 门禁与 fixtures - `pnpm run verify:ipc-protocol`:kind 清单无重复、可选子集在全集内、每个 kind 的 payload interface 已声明、fixtures 每行是合法信封且方向正确、`handshake.ndjson` 恰为 `hello` → `hello-ack`;`renderer/src/protocol/kinds.ts` 存在时校验渲染器镜像的 kind 集合。 - fixtures:`tests/ipc/fixtures/handshake.ndjson`(握手对)、`downstream.ndjson`(全部下行 kind)、`upstream.ndjson`(全部上行 kind)。 - `pnpm run verify:boundary` 扩展:`@opentui/*` 与 `solid-js` 只允许在 `renderer/` 内 import;`renderer/**` 不得 import `@deepseek-ai/*`。