# OpenCode TUI 协议探针与兼容矩阵 > 本文件是 `PLAN.md` 的配套文档。执行 P0 前必须先读本文件。 > 功能状态入口:先读 [FEATURES.md](FEATURES.md),再回到本文件核对路由/协议细节。 > 探针基准:`opencode-ai@1.18.18`(GitHub Release `v1.18.18`,commit `4643e65`)。 ## 0.2.0-rc.4 实现基线 当前 prerelease 面向 dsh `>=0.1.5-rc.2`。下文早期表格中保留的 `apiProxy` 字样是历史 设计来源;当前实现已完成 PR #1/#2/#3 的语义重整合,不再依赖 `@deepseek-ai/dsh-host-apiproxy`: RC.2 包含 Issue #4 的 `POST /session/:id/shell`;RC.3 补齐官方 TUI prompt 顶层 `variant` 的 reasoning effort 同步;RC.4 适配 dsh 0.1.5 的 host ABI 与 assistant 流式模型(见 §11)。 - Session/list/history/prompt/cancel/model/fork/rename 直连 `sessionController`;agent/preset、goal、skill 分别直连对应 host services。 - SSE 由 `session/event`、`agent/assistant-stream`、 `sessionController.control`、approval/question answerer 事件翻译组成;不再消费旧 `apiProxy.events.mux()` envelope。 - `/session/status` 使用 dsh `api-session/status` 的内存 authoritative map, 冷启动最多一次 `session.list` seed,避免 TUI 等待轮询触发全库 I/O。 - SSE 客户端支持有界 `Last-Event-ID` ring 回放;游标淘汰时只回放当前 status/control snapshot,不扫描 history。 --- ## 1. 为什么需要兼容 v1 和 v2 官方 TUI 当前同时使用两代 API: - `packages/tui/src/context/sync.tsx` 使用 `@opencode-ai/sdk/v2` 的 client,但调用的是 **v1 路径**:`/session`、`/config`、`/provider` 等。 - `packages/tui/src/context/data.tsx` 调用 **v2 路径**:`/api/location`、`/api/session/{id}`、`/api/permission` 等。 - `packages/tui/src/routes/session/*` 与 `component/prompt/index.tsx` 的交互(permission/question 回复、session create/prompt)也走 **v1 路径**。 因此 dsh-oc bridge 必须同时暴露两套前缀,共享同一实现。 --- ## 2. 探针方法(可复现) ### 2.1 准备 ```bash # 安装官方二进制 npm pack opencode-ai@1.18.18 # 或者直接解包 node_modules/opencode-ai/bin/opencode.exe # clone opencode 源码用于对照 git clone --depth 50 https://github.com/anomalyco/opencode.git ../opencode git -C ../opencode checkout 4643e65 ``` ### 2.2 记录 TUI 启动请求 ```bash # 1. 启动真实 opencode server(只用于录协议,不是 dsh-oc 运行时) DEEPSEEK_API_KEY=test \ OPENCODE_CONFIG_CONTENT='{"provider":{"deepseek":{"options":{"baseURL":"http://127.0.0.1:19876","apiKey":"test-key"}}},"enabled_providers":["deepseek"],"model":"deepseek/deepseek-chat","autoupdate":false,"share":"disabled"}' \ opencode serve --port 4097 # 2. 启动日志代理:19877 -> 127.0.0.1:4097,打印 method/url/headers/status node tools/log-proxy.mjs # 3. PTY 中启动 attach script -qec "opencode attach http://127.0.0.1:19877" /dev/null # 4. 抓取 /global/event SSE 与各响应 body,存为 tests/fixtures/opencode/ ``` ### 2.3 抓取 schema 权威 schema 来源: - `../opencode/packages/sdk/openapi.json` - `../opencode/packages/sdk/js/src/v2/gen/types.gen.ts`(生成后) - npm 包 `@opencode-ai/sdk@1.18.18` 的 `dist/**/*.d.ts` --- ## 3. 实测启动路由清单(P0 必须全部响应) `opencode attach` 启动阶段真实请求(记录于 2026-08-15,TTY 环境): ```text GET /path GET /project/current GET /config/providers GET /provider GET /experimental/capabilities GET /experimental/console GET /agent GET /config GET /global/event # SSE 长连接 GET /project/global/directories GET /session?start=...&path=... GET /api/location GET /api/agent GET /api/integration GET /api/model GET /api/provider GET /api/reference GET /api/command GET /api/skill GET /command GET /lsp GET /mcp GET /experimental/resource GET /formatter GET /session/status GET /provider/auth GET /vcs GET /experimental/workspace GET /experimental/workspace/status GET /api/model?location[directory]=... GET /api/provider?location[directory]=... GET /api/reference GET /api/integration?location[directory]=... ``` > 不同目录/工作区模式下还会重复请求 `/api/model`、`/api/provider` 若干次。实现必须幂等。 --- ## 4. 路由分类 图例: - **MAP**:必须翻译到 dsh 服务。 - **STUB**:返回 schema-valid 空数据。 - **LATER**:首版可 501/空,但记录在测试中。 ### 4.1 v1 路由 | 路由 | 分类 | dsh 数据源 / stub 形状 | |---|---|---| | `GET /path` | MAP | cwd、worktree、directory | | `GET /project/current` | STUB | 单项目对象 | | `GET /project/global/directories` | STUB | `[]` | | `GET /config` | STUB | `{}` | | `GET /config/providers` | STUB | `[]` 或从 host model catalog 转 | | `GET /provider` | MAP | `sessionController.modelCatalog` | | `GET /provider/auth` | STUB | `{}` | | `GET /agent` | STUB/MAP | 首版 `[]` | | `GET /command` | MAP | 注册 `/preset`、`/goal`、`/help`(TUI slash 弹层) | | `GET /session` | MAP | `sessionController.list` | | `GET /session/status` | MAP | list 的 running 状态 | | `POST /session` | MAP | `sessionController.create` | | `POST /session/{id}/fork` | MAP | `sessionController.fork`(opencode `messageID` 换算为 dsh `atSeq`) | | `POST /session/{id}/summarize` | MAP | dsh `/compact` command registry(TUI `/compact` 实际调用此路由) | | `POST /session/{id}/compact` | MAP | 同上(v1 兼容别名) | | `POST /session/{id}/command` | MAP | `/preset`、`/goal`(dsh command registry)、`/help`(bridge 本地) | | `POST /session/{id}/shell` | MAP | 官方 TUI `!` shell mode;经 Agent `runMaintenance` 取得 idle ownership 后启动当前 OS shell,返回 OpenCode `WithParts` `{ info, parts }` 并映射 user/assistant/tool 卡片 | | `GET /session/{id}` | MAP | history + summary | | `PATCH /session/{id}` | MAP | `sessionController.rename` | | `GET /session/{id}/message` | MAP | `sessionController.follow/page` history | | `GET /session/{id}/message/{messageID}` | MAP | 复用 v1 转换,按 `info.id` 单条查询;未找到 404 | | `POST /session/{id}/prompt` | MAP | `sessionController.prompt` | | `POST /session/{id}/abort` | MAP | `sessionController.cancel` | | `POST /session/{id}/init` | MAP | no-op 成功 `true`(dsh 会话创建即初始化) | | `GET /session/{id}/todo` | MAP | dsh `todos` projection + `goal` 投影/事件(goal 为首条) | | `GET /session/{id}/diff` | MAP/LATER | produced-files 投影或 `[]` | | `GET /permission` | MAP | pending approval map | | `POST /permission/{id}/reply` | MAP | approval answerer + `permission.replied` | | `POST /session/{id}/permissions/{permissionID}` | MAP | SDK v2 权限回复别名(body `response`:once/always/reject),同 `permissionReply` | | `GET /question` | MAP | pending question map | | `POST /question/{id}/reply` | MAP | question answerer + `question.replied` | | `POST /question/{id}/reject` | MAP | question answerer cancelled | | `GET /global/event` | MAP | host session/control/answerer events + SSE | | `GET /lsp` | STUB | `[]` | | `GET /mcp` | STUB | `{}` | | `GET /formatter` | STUB | `[]` | | `GET /experimental/resource` | STUB | `[]` | | `GET /experimental/console` | STUB | `{ consoleManagedProviders: [], switchableOrgCount: 0 }` | | `GET /experimental/capabilities` | MAP | `{ backgroundSubagents: true }`(dsh 后台子代理真实可用) | | `POST /experimental/session/{id}/background` | MAP | no-op 成功 `true`(dsh 会话服务端常驻) | | `GET /vcs` | MAP | 真实 git 信息:`{ branch?, default_branch? }`(`git branch --show-current` + origin HEAD) | | `GET /vcs/status` | MAP | 真实文件状态 `VcsFileStatus[]`(staged+unstaged,跳过 untracked) | | `GET /vcs/diff` | MAP | 每文件 unified diff(`mode=git|branch`、`context` 可选) | | `GET /vcs/diff/raw` | MAP | 原始 unified diff 文本 | | `GET /experimental/workspace` | STUB | `[]` | | `GET /experimental/workspace/status` | STUB | 空状态 | | 其他未列路由 | LATER | 501 或 schema-valid 空响应 | ### 用户 `!` Shell mode 官方 OpenCode 1.18.18 在输入框按 `!` 进入 shell mode,提交时发送 `POST /session/{id}/shell`。请求体必须包含非空的 `agent` 和 `command`;`model` 可选。`agent` 必须是当前 session 的 agent(若指定),且必须是可用 preset;空值、 不匹配或不可用 agent 返回 400。 工作目录只通过 query `directory` 传入(不是 `cwd`,也不是 body 的 `workdir`): 已记录的 session cwd 优先;只有冷启动尚未记录 session cwd 时才使用该 query 值, 相对 query 路径相对 bridge cwd 解析,绝对路径直接使用;无 query 时回退 bridge cwd。`--dir` 改变 bridge cwd,但不会覆盖已记录的 session cwd。 dsh-oc 通过 dsh Agent 的 `runMaintenance` 取得真实 idle ownership;随后以当前 OS 用户身份启动 shell,因为用户显式输入 `!` 才是此路径的授权边界。POSIX 仅在 `$SHELL` 是绝对路径时使用它,否则回退 `/bin/sh`;Windows 使用 `ComSpec`,缺失 时回退 `cmd.exe`。该路径不宣称 dsh model-tool approval 或 sandbox 保护,也不伪造 dsh turn/tool 持久事件。SSE 先发布 running tool part,完成后发布 completed/error tool part、authoritative idle status 与恰好一次 `session.idle`。 stdout/stderr 每路最多保留 1 MiB;超过后继续读取并在输出中写入截断标记,不会因 输出量超过阈值主动 kill 子进程。 该 shell path 只在子进程结束后发布保留输出,不提供实时 stdout/stderr 流。 `POST /session/{id}/abort` 会先中止该 bridge/session 登记的精确 shell 子进程/进程组, 随后仍调用 dsh `session.cancel`;不会按进程名搜索或结束其他进程。POSIX 终止自有 process group;Windows 终止自有 PID 的 `taskkill /T` 树,并不把 Windows shell 伪装成 dsh `pwsh` sandbox。 这里刻意不调用 `ctx.tools.execute({ agent, name: 'bash' })`:dsh-tools 在 PTC presentation 下会把 model-direct root bash 判为 `UNKNOWN_TOOL`,而其 nested parent token 只能由真实 `run_code` transport 创建,bridge 不能伪造;同时 dsh-user-approval 的 `approval.request()` 要求 open turn,shell maintenance 本身不是 model turn。因此没有一个合法的 user-shell host seam 可以同时声称 model-tool approval/sandbox 语义,bridge 采用上游同等的显式用户授权 + 精确子进程 归属,并在缺少 `Agent.runMaintenance` ABI 时明确报错。 该请求不属于模型 agent-loop 的 turn/step,因此不能伪造 dsh `tool/call` / `tool/result` 持久事件;完成卡片只保存在每 session 有界的 bridge 内存 command-result store(最近 20 条),不会写入 dsh durable history。它会注入 v1 的 `GET /session/{id}/message`(包括单条 message 查询)和 v2 的 `GET /api/session/{id}/message` hydration;不会注入 v2 的 `/api/session/{id}/history`、`/context` 或单条 `/message/{messageID}`,这些接口只 返回 durable dsh history。命令完成后,bridge 通过 dsh Agent 的 `inject()` 追加一条 非唤醒的 plugin context(`plugin: dsh-oc`、`form: notice`),内容明确标注为“用户手动 执行的命令”以及 stdout/stderr、截断标记和退出状态;模型注入有独立的 UTF-8 预算: 命令最多 16 KiB、stdout/stderr 各最多 64 KiB,完整 context 文本最多约 144 KiB;TUI 工具卡仍保留每路 1 MiB 的显示预算。shell 输出会发送给当前配置的模型,命令或输出 可能含 token、密码、个人数据等敏感信息,使用者必须先确认再执行 `!`。若注入失败, shell 卡仍会 completed,但输出会追加 `[dsh-oc] ... was not added to model context`。 该 context 首先持久化为合法的 dsh `agent/inbox/spliced`(`next-step` pending)事件, 只有在后续 step claim 后才进入该 step 的 model request;注入本身不会唤醒 Agent、不会 打开新的 turn,也不会自动触发模型回复。对同一 session,下一次真实 prompt 保证能看到 该 context(除非 session 被清理);重启/恢复会重建尚未 claim 的 inbox。claim 后如发生 compaction,dsh 可能摘要或丢弃逐字原文,因此不保证压缩后仍逐字长期保留。 若 Agent 已有 turn/maintenance,接口返回 409;若 session 不存在返回 404;缺少/非法 `agent` 或空 command 返回 400。 ### 4.2 v2 `/api` 路由 | 路由 | 分类 | dsh 数据源 / stub 形状 | |---|---|---| | `GET /api/location` | MAP | `{ directory: cwd }` | | `GET /api/health` | MAP | `{ healthy: true }`(客户端探活) | | `GET /api/agent` | STUB/LATER | `[]` | | `GET /api/integration` | STUB | `[]` | | `GET /api/model` | MAP | `sessionController.modelCatalog` | | `GET /api/provider` | MAP | `sessionController.modelCatalog` providers | | `GET /api/provider/{id}` | MAP | 单 provider `{ location, data: ProviderV2Info }`;未找到 404 | | `GET /api/reference` | STUB | `[]` | | `GET /api/command` | MAP | 注册 `/preset`、`/goal`、`/help` | | `GET /api/skill` | STUB/LATER | `[]` | | `GET /api/session` | MAP | 同 v1 | | `GET /experimental/session` | MAP | GlobalSession 列表(搜索/目录过滤/limit 子集,复用 `convertSessionSummary`) | | `GET /api/session/active` | MAP | 当前活动会话,仅当实际 `running` 时返回 `{ data: { [sessionId]: { type: "running" } } }`,否则 `{}` | | `POST /api/session/{id}/wait` | MAP | 等待 agent 循环空闲:≤30s 轮询 `session.list`,空闲 204、超时 503、会话不存在 404 | | `POST /api/session` | MAP | 同 v1 | | `POST /api/session/{id}/fork` | MAP | 同 v1 fork,返回 v2 信封 | | `POST /api/session/{id}/compact` | MAP | 同 v1 summarize/compact(SDK v2 路由,204) | | `POST /api/session/{id}/interrupt` | MAP | 同 v1 abort:`sessionController.cancel`(SDK v2 打断入口,204) | | `POST /session/{id}/command` | MAP | `/preset`、`/goal` 经 dsh command registry 执行并广播 busy/idle;`/help` 本地返回能力摘要 | | `GET /session/{id}/children` | MAP | 会话列表中 `parentSessionId == id` 的 subagent 子会话(`convertSessionSummary`) | | `GET /api/session/{id}` | MAP | 同 v1;subagent child 保留 `parentID` 与 `metadata.origin`(即使 SDK v2 生成类型未声明该扩展字段) | | `GET /api/session/{id}/message` | MAP | 同 v1;`cursor.previous` 提供上一页锚点 | | `GET /api/session/{id}/history` | MAP | v2 历史分页:`limit` + `after`(独占上界,映射 dsh `beforeSeq` 向后翻页),返回 `{ data, hasMore, next }` | | `GET /api/session/{id}/context` | MAP | `{ data: SessionMessage[] }`(复用 v2 消息转换,无游标) | | `GET /api/session/{id}/message/{messageID}` | MAP | `{ data: SessionMessage }` 单条查询;未找到 404 | | `POST /api/session/{id}/prompt` | MAP | v2 prompt(同 v1,`messageID` 可选;slash 命令经 command registry) | | `POST /api/session/{id}/model` | MAP | v2 模型选择(含 `variant`,`reconcileModelSelection` 兜底) | | `POST /api/session/{id}/agent` | MAP | v2 agent 切换(`switchAgentPreset` + 广播 `session.updated`) | | `GET /api/session/{id}/event` | MAP | 按会话过滤的 SSE 事件流(`/global/event` 子集) | | `GET /api/session/{id}/permission` | MAP | pending approvals per session | | `GET /api/session/{id}/permission/{rid}` | MAP | 单条 pending approval(session 不匹配 404) | | `POST /api/session/{id}/permission/{rid}/reply` | MAP | approval answerer | | `GET /api/session/{id}/question` | MAP | pending questions per session | | `POST /api/session/{id}/question/{rid}/reply` | MAP | question answerer | | `POST /api/session/{id}/question/{rid}/reject` | MAP | question answerer cancelled | | `GET /api/permission/request` | MAP | SDK v2 全局 pending approvals 别名,`{ location, data }` | | `GET /api/question/request` | MAP | SDK v2 全局 pending questions 别名,`{ location, data }` | | `GET /api/permission/saved` | MAP | 内存中的 always 授权列表(`PermissionSavedInfo` + `sessionID/grantedAt`) | | `DELETE /api/permission/saved/{id}` | MAP | 删除 `sessionID:toolName` 内存授权(不存在 404) | | `GET /api/fs/read/{path}` | MAP | 读取工作区内文件原始字节(`*` 通配路径,越界 400,上限 5 MiB) | | `GET /api/fs/list` | MAP | 列目录(`{ location, data: FileSystemEntry[] }`,目录优先) | | `GET /api/fs/find` | MAP | 递归查找(query/type/limit,跳过依赖与构建目录) | | `GET /global/health` | MAP | `{ healthy: true, version }` | | `POST /global/dispose` / `POST /instance/dispose` | MAP | no-op 确认 `true`(dsh 拥有进程生命周期) | | 其他未列路由 | LATER | 501 或 schema-valid 空响应 | > `after` 为**独占上界**:返回事件 seq 严格小于 `after` 的消息页,`next` > 为本页最旧锚点 seq(供独立客户端连续向前翻页);`after` 非负整数否则 > 400。分页边界会切开工具回合时,跨页游标保证无漏页/重页(恢复 e2e 覆盖)。 --- ## 5. SSE 事件映射表 当前 dsh 0.1.2 由 Cordis `session/event`、`sessionController.control` 与 approval/question answerer 产出 host frames,oc-bridge 翻译为 opencode `GlobalEvent`;旧版 `apiProxy.events.mux()` 仅作为历史协议来源。 | DSH mux frame | opencode GlobalEvent | |---|---| | `session/event: session/created` | `session.created`(properties.sessionID, info=Session) | | `session/event: session/title` | `session.updated` | | `session/event: turn/start` | `session.status`(busy) | | `session/event: turn/end` | `session.status`(idle) + `session.idle` | | `session/event: user/message` | 重建 Session 后发 `message.updated` | | `session/event: assistant/message` | `message.updated` + `message.part.updated` | | `session/event: assistant/chunk (tool-call-delta)` | `session.next.tool.input.started/delta/ended` + v1 ToolPart 增量(节流合并) | | `session/event: tool/call` | `session.next.tool.called` + `progress` + `message.part.updated`(ToolPart pending;dsh `subagent*` 为 OpenCode `task`) | | `session/event: tool/result` | `session.next.tool.success/failed` + `message.part.updated`(ToolPart completed/error;Task child `sessionId` 从 host child lineage 关联,不依赖 result meta) | | `session/event: todo/write` | `todo.updated` | | `session/event: goal/change` | `todo.updated`(合并 goal 为首条) | | `session/event: approval/asked\|decided` | 持久化记录,bridge 静默(权限交互由 mux 帧 `approval/requested\|resolved` 驱动) | | `approval/requested` | `permission.asked` | | `approval/resolved` | `permission.replied` | | `question/requested` | `question.asked` | | `question/resolved` | `question.replied` / `question.rejected` | | `session/projection: todos` | `todo.updated` | | `session/projection: goal` | `todo.updated`(合并 goal 为首条) | | `session/projection: produced-files` | `session.diff` | | `session/queue`(订阅初始化) | 首次为该 session 时镜像 pending inbox → 每条用户消息 `message.updated` + `message.part.updated`(TUI 显示 QUEUED) | | `session/event: agent/inbox/spliced` | 增量:插入用户消息 → `message.updated`;`outcome: canceled` 移除 → `message.removed`;claim(无 outcome)不删除,等 `user/message` 同 id upsert | | `session/jobs` | 忽略 | > **Task 子代理关联**:OpenCode 1.18.18 TUI 读取 `ToolPart.state.input.description` / > `subagent_type` 渲染 Task,并读取 `ToolPart.state.metadata.sessionId` 导航 child。 > dsh `api-session/added`、`subagent/descriptor` 与 `subagent` projection 可能跨 > session 到达,bridge 按 parent pending call 做暂存,历史则按 label/FIFO > 做 parent-local 绑定;所有 child 的 v1/v2 Session 与 `session.updated` 替换都保留 > `parentID` 与 `metadata.origin`。 > **排队消息可见性**:bridge 丢弃旧的 `session/queue` 会导致排队中的 prompt 在 > TUI 无任何反馈,用户以为发送失败而重发,队列积压后模型回复旧消息。现在 > `session/queue` 只做首次投影(全量帧无法区分 claim 与 cancel),后续由 > `agent/inbox/spliced` 增量维护;含未完成工具调用的 assistant 消息不设置 > `time.completed`,`step/end`/`turn/end` 时再补,让官方 TUI 的 QUEUED 判定 > (最后一个未完成 assistant 之后的用户消息)生效。 > **prompt 送达语义(steer)**:用户 prompt 一律以 dsh `session.prompt` > `mode: 'steer'` 提交(等价官方 opencode“追加进会话流、运行中 loop 下一步 > 即处理”的语义)。运行中的 turn 在下一个 step 边界就能看到插入的消息, > 而不是像 `mode: 'queue'` 那样要等整轮结束(曾导致用户在长搜索轮中插入 > 标题/纠正信息后,模型继续空转数分钟才看到,见 session e0336d8b)。 > 多条排队消息会在下一个 step 按序合并进同一次请求;QUEUED 徽标仍由 TUI > 按时间线自行判定。 > **已知行为(文本 delta 成对重复)**:旧 dsh/dcp rc.6 对同一段流式文本同时下发 > `assistant/chunk`(text-delta)与 packed `text-chunks` 两种编码,且新 mux 订阅 > 会先重放历史再进入实时,因此 bridge 的 `message.part.delta` 可能把同一字符发送 > 两次(两种编码分块/偏移不同,无法无损合并)。opencode 1.18.18 TUI 以最终 > `message.updated` 的全量文本为准,实测渲染正常、无重复;`e2e-tui-abort.sh` 的 > v2 段按“新 SSE 流出现 delta”检测,不依赖 delta 文本连续性。 GlobalEvent 必需字段: ```ts { directory: string, project?: string, workspace?: string, payload: { id: string, type: ..., properties: ... } } ``` --- ## 6. opencode 关键类型位置 以下类型是实现 `convert/*` 的权威依据: ```text @opencode-ai/sdk/dist/v2/gen/types.gen.d.ts: Session, Message, UserMessage, AssistantMessage, Part, TextPart, ReasoningPart, FilePart, ToolPart, ToolState, StepStartPart, StepFinishPart, Provider, Model, ModelRef, Agent, Command, Todo, SessionStatus, PermissionRequest, PermissionV2Reply, QuestionRequest, QuestionInfo, QuestionAnswer, GlobalEvent ``` dsh 类型位置: ```text deepseek-harness/packages/core/session/src/types.ts: SessionEvent, SessionEventMap, SurfaceOp, UserMessage, AssistantMessage deepseek-harness/packages/host/apiproxy/src/api/: sessions.ts, events.ts, llm.ts, approvals.ts, questions.ts, rpc.ts deepseek-harness/packages/host/apiproxy/src/api-proxy.ts: createApiProxy / ApiProxyService ``` --- ## 7. opencode asset manifest 生成 `opencode-assets.json` 结构: ```json { "version": "1.18.18", "assets": { "linux-x64": { "platform": { "os": "linux", "arch": "x64", "baseline": false, "musl": false }, "npm": "opencode-linux-x64", "npmIntegrity": "sha512-...", "url": "https://github.com/anomalyco/opencode/releases/download/v1.18.18/opencode-linux-x64.tar.gz", "sha256": "0cddc222418b8553669905a8980c0cda7088f00da24d83d6ac76b01c9fdb2aaf", "size": 60386126 } } } ``` 生成命令: ```bash gh api repos/anomalyco/opencode/releases/tags/v1.18.18 \ --jq '[.assets[] | select(.name | test("^opencode-(linux|darwin|windows)-(x64|arm64)(-baseline)?(-musl)?\\.(tar\\.gz|zip)$")) | {name, digest, size, browser_download_url}]' ``` - 将 `digest` 的 `sha256:` 前缀去掉写入 manifest。 - `platform` 记录该 key 的 os/arch/baseline/musl;`npm` 是对应官方 npm 平台包名, `npmIntegrity` 是 npm registry 的 tarball sha512,由包管理器在安装时校验。 - 必须覆盖以下平台: - `linux-x64`、`linux-x64-baseline`、`linux-x64-musl`、`linux-x64-baseline-musl` - `linux-arm64`、`linux-arm64-musl` - `darwin-x64`、`darwin-x64-baseline`、`darwin-arm64` - `windows-x64`、`windows-x64-baseline`、`windows-arm64` ### 7.1 多 arch 二进制分发 `src/tui/binary.ts` 的优先级(详细见 README 与 FEATURES.md): ```text env DSH_OC_OPENCODE_BIN → 版本化缓存 → PATH → 官方 npm 平台包 → profile 内 opencode-ai 包 → GitHub Release(per-platform sha256) ``` 官方 npm 平台包安装到 `$DSH_HOME/opencode/packages/`,候选顺序与 官方 `postinstall.mjs` 的 platform/arch/musl/AVX2 选择一致;GitHub asset 只作为 fallback,且每个平台使用各自 manifest 条目独立校验,不是全局单一 hash。 --- ## 8. 已知协议限制(首版) 1. **`always` 权限降级**:opencode TUI 提供 `Allow always`,dsh approval 只有 `allowed-once / rejected`。首版将 `always` 映射为 `allowed-once`,并在 TUI 外日志中提示。后续若 dsh 增加持久权限预设,再改回真 `always`。 2. **attach 参数受限**:`opencode attach` 只接受 `--continue/--session/--fork/--dir/--mini/--password/--username`。`dsh --profile oc --model X` 等参数首版打印警告并忽略;模型切换走 TUI 内模型选择器。 3. **文件附件**:`file` part 支持文本(data URL / cwd 内本地文件)与图片(data URL),映射为 dsh `text`/`image` part;PDF 等二进制附件返回 400。 4. **session diff**:无 produced-files 投影时返回 `[]`,不伪造 diff。 5. **opencode v1/v2 双协议**:任何升级必须重新跑第 2 节探针并更新本文件。 ## 9. e2e 实测实现状态(2026-08-15) 下列状态来自 `chore-release` 分支 profile-fix 之后的真实 e2e(mock LLM + dsh 0.1.2-rc.1 + opencode 1.18.18,TUI 与 bridge 均实际跑通): | 路由/能力 | 状态 | 备注 | |---|---|---| | §3 启动 GET 矩阵(31 条,含带 query 的 `/session`、`/api/model?location[...]` 等) | 200 + JSON | 全部经 curl+jq 断言 | | `GET /global/event` | 200 SSE | `retry: 3000` 首帧;每帧含 `directory` | | `GET /agent`、`GET /api/agent` | MAP | 首版返回单个 `build` 主 agent(含 `model`),否则 TUI prompt 无法提交 | | `POST /session/{id}/message` | MAP | opencode SDK v1 实际 prompt 路由 | | `POST /session/{id}/fork`、`POST /api/session/{id}/fork` | MAP | dsh fork;child session 的 `parentID` 正确 | | `POST /session/{id}/summarize`、`POST /session/{id}/compact`、`POST /api/session/{id}/compact` | MAP | TUI `/compact` 走 summarize 路由,经 dsh command registry 执行 `/compact` | | `GET /session`、`GET /api/session` | MAP | child session 输出 `parentID`;subagent 带 `metadata.origin` 与标题标识 | | `POST /session/{id}/prompt` | 别名 | 官方 SDK 无此路由;dsh-oc 提供 v1 兼容别名(e2e 矩阵使用) | | `POST /api/session/{id}/prompt` | MAP | opencode SDK v2 官方路由,返回 `{ data: SessionInputAdmitted }` | | `GET /provider` | MAP | 返回 `ProviderListResponse` 对象 `{ all, default, connected }`(协议对象,非裸数组) | | `GET /api/model` | MAP | 返回 `{ location, data: ModelV2Info[] }`(协议对象,非裸数组) | | `GET /permission` + `POST /permission/{id}/reply` | MAP | 需保持至少一个 SSE 连接(与真实 TUI 一致)才能收到 mux approval 帧 | | `GET /session/{id}/todo` + `goal/change` | MAP | goal 与 todos 合并(goal 首条);`scripts/e2e-api-goal.sh` | | `POST /session/{id}/command` `/goal` | MAP | 创建/查看 goal;真实 TUI sidebar 可见(`scripts/e2e-tui-goal.sh`) | | `DSH_PERMISSION_MODE=ask` | 不支持 | dsh 只接受 `read-only`/`workspace-write`/`danger-full-access`;approval=ask 用 `workspace-write` | | oc profile 宿主行 | 已并入 bundle patch | `storage`/`storage-json`/`storage-domain`/`webserver` 已由 `cordis.patch.yml` 挂载;`dsh --profile oc` 直接启动(无需宿主 overlay) | | `--print-logs` | 透传 | oc-tui 已把 `--print-logs` 传给 `opencode attach`(opencode 顶层全局选项,设置 `OPENCODE_PRINT_LOGS=1`) | | 时间戳 | 默认开启 | `DSH_OC_TUI_TIMESTAMPS=1` 写入 `kv.json` 的 `timestamps: show` 并绑定 `ctrl+shift+t` / `/timestamps`;e2e 见 `scripts/e2e-tui-timestamps.sh` | | tarball 安装 e2e | PASSED | `DSH_OC_E2E_ADD_SPEC=` 下 `e2e-api.sh` / `e2e-tui-boot.sh` / `e2e-tui-turn.sh` / `e2e-tui-timestamps.sh` 全部通过;profile 安装的是 npm tarball,不再使用本地路径 | --- ## 10. 自动化协议探针 `scripts/probe-opencode.mjs` 对照 `tests/fixtures/opencode/routes.json` (本文件 §3/§4 的 TUI 真实请求清单)检查 oc-bridge 是否注册了全部路由,并校验 opencode 二进制与 `@opencode-ai/sdk` 的版本锁定: ```bash pnpm run probe node scripts/probe-opencode.mjs --version 1.18.18 --bin /path/to/opencode \ --out .e2e/protocol-probe.json ``` 输出:缺失路由(`FAIL missing-route` + 修复建议)、二进制/SDK 版本不匹配; 全绿时退出码 0 并打印 `PASSED`。升级 opencode 的流程: 1. 更新 `opencode-version.json`; 2. `node scripts/update-opencode-assets.mjs` 重新生成 asset manifest; 3. `pnpm install`(SDK 版本)后运行 `pnpm run probe`; 4. 按探针结果补齐路由或 stub,并更新本文件的兼容矩阵。 --- ## 11. dsh 0.1.5 迁移要点(0.2.0-rc.4) dsh 0.1.5 对 dsh-oc 使用到的 host ABI 有两处破坏性变更;opencode 侧协议与 1.18.18 探针矩阵不变。 ### 11.1 session-controller 的 fileUploads `dsh-api-session-controller` 的 `static inject` 新增 `fileUploads`。官方 provider `@deepseek-ai/dsh-client-file-upload` 是浏览器传输插件(inject `connection`), oc-bridge 不挂载 Web transport,因此 bundle patch 在 `session-controller` 之前 插入 headless 行: ```yaml - id: oc-file-uploads name: '@chiro2001/dsh-oc/file-uploads' ``` 服务实现(`src/bridge/file-uploads.ts`): - `registerAgentResolver` / `retirePrompt` 为 no-op; - `bindPrompt` 对无 receipt 的 prompt 返回 `{ commit(), [Symbol.dispose]() }` —— 0.1.5 用显式资源管理(`using`)包裹该返回值,缺 `Symbol.dispose` 会让 prompt 以 `session/agent-busy` 失败; - `resolve` / `uploadStream` 对真实文件回执 fail loudly(bridge 无上传路由, opencode 附件在 convert 层映射为 prompt content,不经过 dsh 上传)。 ### 11.2 assistant 流式与历史 | 维度 | dsh 0.1.2 | dsh 0.1.5(当前) | |---|---|---| | 实时增量 | 持久化 `assistant/chunk` 会话事件 | 进程内 `agent/assistant-stream` 帧(`start`→`chunk`→`end`;`chunk.chunk` 仍是同一 `StreamChunk` 联合) | | 尝试结算 | 无 | `assistant/attempt`(无 surface,忽略) | | 历史记录 | `SessionHistoryRecord = SessionEventEntry \| SessionChunkRun`(`chunkrow/*`) | 只有 `SessionEventEntry`;`assistant/message.stream` / `assistant/attempt.stream` 内嵌 packed run + raw chunk | | 部件时长 | block-start 事件时间 | live 帧 `time`;冷读 `assistantStreamTiming` 读 raw `block-start`/`block-end` | | finish reason | live `finish` chunk | 同上 | | 去重键 | `sessionId:seq` | live `${sessionId}:${attemptId}:${revision}:${index}`;历史行仍按 seq | `src/bridge/rpc.ts#expandRecord` 把内嵌 run 还原成 0.1.2 时代翻译层本来就消费的 `text-chunks`/`reasoning-chunks`/`tool-call-chunks` 行,并追加结算事件本身; `src/bridge/convert/message.ts` 用 `assistantStreamTiming` 合并 raw 部件边界。 因此 history v1/v2、SSE ring 回放与 golden trace 的 opencode 语义保持稳定。 ### 11.3 文件工具 dsh 0.1.5 的 `str_replace_editor` 不再由 dsh-base 挂着(0.1.2 base 行、0.1.5 需 显式 opt-in);默认 preset(`minimal`)只含持久 shell,host 层仍提供 `dsh-tool-fs` 的 `read`/`write`/`edit`。bridge 的工具名映射已覆盖 `write`/`edit`,并在新文件 `write` 的 result meta 为空 diffs 时从调用参数合成 `metadata.diff`,e2e 的 untracked write 场景改用 `write`(`str_replace_editor` 映射与单测保留给显式 opt-in 的部署)。