DESIGN.md · git:20261002.8d8ddc9 · 2026-10-02 · sha256 c0df1fa170f64f3b

DESIGN.md git:20261002.8d8ddc9A

Immutable. This exact content never changes and is served at /api/v1/blob/c0df1fa170f64f3b.

# DSHana 设计

插件 id:`dshana`。把 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)接进 Hana。

## 文档与注释约定

**注释写现状与原因,不写演变史。** 代码注释回答“现在是什么、为什么这样”;“以前是什么、怎么变成
今天这样”属于 commit 与 CHANGELOG(仓库用 conventional-changelog:`pnpm run changelog` +
`scripts/release/version.mts`,历史自动成文)。

- 不写日期栈记(“2026-xx-xx 定调”)、不写迁移步骤编号(“步骤 3 接线”)、不写“已退役/已删除”的注记。
- 保留现状事实(依赖怎样装、路径在哪、失败向哪侧回落)、保留设计取舍的**理由**。
- 指向本地未入库文档(`specs/`、`SPECS.md`)的引用不入注释与公开文档:规划件带隐私信息、不入库,
  引用只会指向克隆者拿不到的路径。

**覆盖层是别人的文件。** `integrations/*/files/**` 是官方 DSH 文件的整体覆盖,里面只允许两类
内容:上游原有注释,以及我们**无注释的代码改动**。我们自己的说明(为什么改、改了什么、与样例的
差异)一律写在同包的 `integration.json` 的 notes 里——那里是我们的文件,且漂移闸也读它。

理由:覆盖层越像上游,与上游重新对齐越容易,漂移闸的 diff 也越可读;注释里的“我们/样例/当时”
会把一份官方文件变成半自述文件,下次对齐时全是噪音。

## 工具链前提

Node 版本下界是 `^22.22.2 || ^24.15.0 || ^26.8.1`,五件事各管一职:

- `.nvmrc`(26.8.1)是实际使用的版本,也是宿主(Hana 宿主进程)的 Node 版本,26 线的下界即取它;
- `package.json` 的 `engines.node` 是唯一真源(机器可读声明);
- `scripts/shared/root.mts` 加载时**从 `engines.node` 读范围**并断言版本,不满足即抛出可读错误,
  退出码 1(环境前提不满足,与“用户输入错误”的 2 分开);主要入口都 import 它,这是真正拦得住的
  那一处(pnpm 对**根项目**的 engines 不做强制,实测即便 `--engine-strict` 也照常安装);
- `scripts/check/engines.cjs`(`pnpm run check:engines`)扫依赖树报出各包的 `engines.node` 约束与
  多版本包,用来核对下界该取到哪:下界不得低于工具链自身声明的约束;
- `tests/build/node-version.test.mjs` 枚举 `package.json` 里所有以 TypeScript 直跑的入口,断言它们的
  import 闭包都触达 `shared/root.mts`——“新增入口忘了 import”这条只能靠它抦住。

下界的依据:三条线就是本仓已知的三条大版本,每条的下界不得低于依赖树里最严的那条约束。当前最严的
是 pnpm 自带包管理器引导层 `corepack` 的 `^22.22.2 || ^24.15.0 || >=26.0.0`——22 / 24 两线照抄它
的下界,26 线取 `.nvmrc` 的实际版本(`^26.8.1`,与宿主同步;`^` 不跨大版本,等于只承诺这三条线,
不预支 27+)。
类型剥离本身只要求 22.18 / 23.6(此前需要 `--experimental-strip-types`),低于依赖约束、不构成下界:
`scripts/**/*.mts` 与 `packages/app/src/build.ts` / `packages/app/src/cordis.ts` 都以 `node <file>` 直跑(`package.json`
的 scripts 都这么调),低于下界时这批脚本在运行期才炸,而 `scripts/check/type.mts` 只做静态检查、
管不到运行期。

范围的写法限定为 `^x.y.z` / `>=x.y.z` / `x.y.z` 并用 `||` 连接(见 `satisfiesNodeRange`);
遇到别的写法它当场抛错、不静默放行,也不引入 semver 依赖(这条字符串是本仓自己维护的,为一个
自己写的声明引一个包不划算)。代价是放宽写法必须同时改解析器与测试,这一步是有意的:只改
`package.json` 一个字符串就悄悄换了语义,正是这个仓库一直在避免的那类漂移。`.nvmrc` 保持单行版本号:解析方
(`actions/setup-node` 的 `node-version-file`、各家 nvm 实现)不保证忽略注释,往里写说明有让
CI 直接失败的风险。`engines.node` 不参与 lockfile 解析,改这个区间不需要、也不应期待
`pnpm-lock.yaml` 变化。

## App 契约的派生

`manifest.json`(仓库根)的两个字段都来自 derive,不手写:`version` 取主 `package.json`,
`minAppVersion` 取 `vendor/hana-app-sdk/source-manifest.json` 的 `packedVersion`(随包 SDK 快照
打包时的宿主版本,也就是 App 要求的最低宿主版本)。两者由**同一个** manifest 任务产出完整内容
(同文件两个任务会互相覆盖)。同步随包 SDK 后跑 `pnpm run derive`,`derive --check` 会在漂移时
报出来。

## 架构总览(受管 runtime)

```text
Hana 宿主进程(App 隔离进程内加载 dist/bin/index.mjs)
  ├─ App 侧:apply(ctx)
  │    ├─ ctx.tools.register(dshana)                 工具(单工具 + subcommand,六动作)
  │    ├─ ctx.routes.register(/dshana/*)             壳页/诊断面(boot-state|health|start|stop)
  │    └─ ctx.runtime.start({ runtime:"node", entry:"bin/dsh.mjs",
  │           cwd:ctx.dataDir, service:{ port:<随机>, readyMarker:"..." } })
  │              ↓ 受管 Node 子进程
  ├─ bin/dsh.mjs(dist/bin,rspack 产物,与 app 主体同一次构建)
  │    └─ 原生 import 安装目录 node_modules/@deepseek-ai/dsh/lib/profile-boot-*.js
  │         → runProfile() → cordis Context
  │              → 加载自有 profile $DSH_HOME/profiles/dshana(壳建并维护:层列钉住 base → web-app,
  │                 我们的强制配置以 patchFiles 排在所有层之上)
  │                   → dsh-* 官方插件 + @dshana/* 子插件(后者在安装目录 node_modules/@dshana)
  │         → HTTP 服务监听本地端口(宿主按 readyMarker 判定就绪)
  └─ 浏览器面:/api/apps/dshana/routes/_runtime/<runtimeId>/ 由宿主自动代理
```

- **受管 runtime**:DSH 跑在 `ctx.runtime.start` 拉起的独立 Node 子进程中(不再是宿主进程内 boot)。App 侧与子进程分责:App 管启动/停止/状态,子进程管 DSH 的 cordis 生命周期;崩溃可被父侧识别并重起。
- **依赖形态(自包含打包)**:DSH 及其依赖树由 `scripts/release/pack/index.mts` 在构建时物化进**安装目录** `node_modules`,运行时**不再安装、不再 spawn pnpm**(v1 的 `ensure-deps` / `lib/pnpm.js` / `lib/bootstrap.js` / `lib/errclass.js` 已删除)。运行时依赖的唯一真源是 `packages/host/package.json` 的内核声明(交付面的清单与锁都由 pack 现生成,树里不留第二份;根那份只留构建面,另留一条同名 devDependencies 供开发侧安装,两处版本由 integrations 闸守)。
- **更新 = 装新 App 包 + 重启宿主**:无独立升级通道;升级后需重启宿主以清掉旧模块缓存。
- **连接与鉴权交回官方**:`@dshana/bridge` 已退役;`dsh-web-app` 层的官方 connection(BrowserAuth token/cookie)与 frontend-static 各自负责其位,App 侧只经 runtime 中继补 cookie。
- **DSH Web UI**:DSH 前端以**同文档注入**方式挂进壳页(`dsh-inject.ts`:取 index → 搬 link/script → 装配 `__DSH_TRANSPORT__` + 流 mux),不再用 iframe 内嵌;到 runtime 的请求走宿主代理前缀 + 路径票据。流 mux 的失败按官方**跨 bundle 契约**打结构标记(页半与内核半类身份不通,DSH 只看标记不看 `instanceof`):载体丢失 `kind:'carrier'`(DSH 侧按可重试的载体丢失处理,自动重连续流),宿主交付的逻辑失败 `kind:'remote'` + 域码(原样重建成带码的 RemoteError)。少了 carrier 标,一次断链会被折成 `gateway/internal` 终态,会话历史流不再自愈。

## 工具

宿主 Agent 工具面为**单工具 `dshana`**(一个插件一个同名工具 + CLI subcommand:装配 `packages/tools/src/index.ts`,各动作 `packages/tools/src/actions/<action>.ts`,提交链 `packages/session/src/session-run.ts`,取消/审批编排 `packages/session/src/cancel-chain.ts` / `packages/session/src/approve-respond.ts`)。语义对齐 subagent(open/reply/close),另有 get/approve 两个特色动作。**完整调用手册见 [dshana skill](skills/dshana/SKILL.md)**:

| action | 用途 | 实现 |
| --- | --- | --- |
| `open` / `reply` | 开子代理+交首件活 / 续已有子代理(task 必填;open 另需 cwd) | `packages/tools/src/actions/open.ts` / `packages/tools/src/actions/reply.ts` → `packages/session/src/session-run.ts` |
| `get` | 回看某一轮最终结论(官方 `session/list` + `session/page`) | `packages/tools/src/actions/get.ts` |
| `close` | 取消正在跑的任务(taskId 句柄或 sessionId 凭证) | `packages/tools/src/actions/close.ts` → `packages/session/src/cancel-chain.ts` |
| `approve` | 应答挂起审批(allowed-once/rejected,决策看 args) | `packages/tools/src/actions/approve.ts` → `packages/session/src/approve-respond.ts` |

> 会话靠句柄(宿主 taskId)定位,工具面不设“先列清单再操作”的入口:“查任务”由宿主提供给 Agent 的内置任务查询工具(模型侧,本环境是 `check_pending_tasks`)承担——dshana 的 open/reply 建的就是本会话的后台任务,本来就在那份清单里;`sourceId` / `cursor` 这类为其配套的字段一并不要。

调用模型:句柄默认(taskId/approvalId,按宿主记录的来源会话校验归属)、凭证显式(sessionId = 我要跨对话)。每个子命令的参数在 `parameters.oneOf` 里单独成支(`additionalProperties:false`)。

提交链路:`ctx.tasks.create` → 受管 runtime 就绪 → `session.create` →(显式传 provider/model/effort 时才 `selectModel`)→ 绑定回写宿主任务记录(`ctx.tasks.update(taskId, { metadata: { dsh: {…} } })`,见 `packages/shared/src/task-binding.ts`)→ `session.prompt`(queue)→ runtime task-bridge 把任务状态与终态回投。

## DSH Web UI(DSHana 卡)

DSHana 以**单卡 + 自带功能面板**注册(manifest `contributes.cards[0]`:卡 id `dshana`、route `/main.html`、`functionPanel.route` = `/sidebar.html`;`siteNavEntry` 为本项目有意保留的形态差异):

- **自举台**:DSHANA 字标 + 细圆环 + 一行状态小字;报错时下方直接一块 `<pre>`(时间线/折叠详情已撤)。boot-state 由**主卡单独轮询**,FP 只读跨面共享快照(订阅,不重复取);owner 不在场(快照不存在/下线/过旧)时 FP 才自取。
- **三态 + 设置页**:default(full / detached 共用一页 = 整幅 DSH UI,顶部 44px 让位宿主 chrome)/ main(主卡,无 DSH 侧栏——侧栏归 FP)/ sidebar(FP,只有侧栏);settings 是 App 自己的设置页(`ui.route`,不注入 DSH)。面由**页面静态声明**(`<meta name="hana-dshana-role">` + `data-dshana-view`);映射到 DSH 侧上游角色词:default→standalone / sidebar→navigation / main→workspace。`?dshana-view=` 与 `@dshana/view` 均已退役。
- **注入鉴权**:壳页以 `appSurfaceSession` 作为 `_surface` 路径段取得运行时代理凭据(同源预请求种 `hana_app_runtime` cookie 兜住子请求);未取得票据时不下挂内容,面板上说明原因。装配只做一次(本文档里 DSH 前端实例、它的载体与监听都绑在这唯一一次装配上),而中继前缀里含 runtimeId:宿主重启或运行体重建之后这份前缀就是死端点(宿主对它的 WS 升级当场断开,DSH 的流载体连试两次后折成 `gateway/internal`,界面停在「历史加载失败」)。重载是唯一干净的出路——旧实例在文档里还活着,就地重注入会留下两套。故壳页在**取到的新快照说 `runtimeId` 与装配时不同**时重载本页;判断放在取到快照之后,凭据本已失效的页面不会去重载(那只会撞上宿主的 403),照旧停在凭据缺失的提示上。
- **会话卡(`ui/stream.html`)的一张卡两个挂载态**:工具回执的 `details.card` 指向这一页(App 不为它声明 manifest 卡),`open` 与 `reply` 都挂(`act=` 区分)。卡面抬头固定 `DSHana`;钉住的 DSH 会话 id 来自查询串(`?sid=`,工具出卡时写进 route);没带 `sid` 时跟随跨面共用的当前选中。页自己认挂载态(宿主契约见 APPS_EN.md「Mount-mode table」):**聊天流**里 `hana.envelope` 的 height 是 `flexible`,此时只画入口行(1 行抬头 + 3 行坐标,坐标由 `stream.html` 的内联脚本在求值时写就,不排在任何握手后面;高度按内容报给宿主 `hana.ui.resize`,宽度变窄也不把高度一起压扁),不装配中继、不注入;**取出到黑板 / 拆窗**后 height 是 `fixed`,此时才装配中继、注入完整 DSH 现场。两态各自的 DOM 按需实例化(`stream.html` 的 `<template>` + `stream-entry.ts` 的按需装载),认到哪个态才建哪块,不靠 `hidden` / `display` 藏一个留一个;重型半(React 三态 + dsh-inject + host-theme,`stream-stage.tsx`)是 `import()` 出来的独立 chunk,聊天流态的卡永不解析它。**跟踪态只在聊天流态**:`stream-entry.ts` 把 `GET /dshana/card-state` 的片段放进小标题行(只在 `tracked` / `cancelling` 期间慢轮询 4s,终态即停手),detail 不占版面、进悬停提示;取出的卡不带任何 App 状态行(那面就是那段会话本身),并保留输入卡:在那里打的字进的是这个 DSH 会话自己的一轮,不走 dshana 工具链。**终态之后不断流**:这段会话多半是用户主动打开来翻看的历史会话,断掉或拒绝重开就等于看不了。主卡与 FP 不带 `sid`,不挂跟踪态。
- **承载面分片(`api/remote.mux`)**:宿主对 App 受管服务的 WS 中继有 1 MiB **上游帧**上限(上游 ws 客户端 `maxPayload: 1024 * 1024`,超限即把下游连接 `close(1011, "Managed service stopped")`),而 DSH 的 mux 帧是原子 JSON——长会话打开时的首帧就是整段历史 snapshot,一帧就能超,DSH 又只立刻重试一次(两次都撞上限就折成 `gateway/internal` 终态),于是历史流永远打不开。分片点必须在宿主**之前**,而本 App 的运行时中继与页面载体正好夹在这条管子的两侧、都是本仓库代码(DSH 协议两端一行不改):中继把超限消息按尺寸切成二进制分片(信封与上限见 `packages/shared/src/mux-chunks.ts`),载体在页面侧**先拼字节再解码**、重组回一条完整消息交给 DSH。每片回一次执,中继只在 768 KiB 窗口内在途——宿主另有一条「转发下一帧前下游缓冲超 1 MiB 就掐」的守卫(同样报 1011),而且它判的是**线上字节**(一片 128 KiB 载荷在线上是 131,088 字节),所以窗口取 768 KiB(6 片 = 线上 786,528),给 live 帧与 ping 留 256 KiB;只把帧切小不够,发得快一样致命,尤其远端访问时页面排空更慢。开关显式:载体在 mux URL 上带 `dshanaMuxChunks=1` 声明(宿主只滤掉凭据类查询参数,其余原样转发),中继只在看到它时才启用帧搬运(`packages/host/src/mux-relay.ts`),否则保持原来的原始 socket 双向透传——旧文档与新中继不会互相看不懂。帧搬运只认 WS 帧头:未改动的帧原样转发(控制帧立即转,DSH 的 mux ping 不会被大消息卡住,pong 由宿主的 ws 客户端负责),只有超限消息被重写成分片;窗口用尽只是等回执,不断链。

### 设置面

- **App 设置页**:`contributes.settings.ui.route`,宿主设置区渲染,不依赖 DSH 运行。两项常规(审批超时 / 任务超时,App 自持存储)+ 会话模型模式。模型候选读 `GET /dshana/models`,后端取**宿主模型目录**(`ctx.models.list`,能力面 `app/models.infer`)——宿主目录是「这条路走不走得通」的唯一事实源,页面因此不列 DSH 自己的目录,DSH 在不在跑都一样(`packages/models/src/model-catalog-view.ts` 归一化、按 provider 归组并附推理档)。
- **取出到黑板**:会话卡在聊天流里点右上角菜单 / 拖拽**取出**(纯宿主手势,不经 App 代码)——宿主把这张聊天卡(连 aspectRatio / cardForm)钉到黑板 / 拆窗上,本页那时才认到 `fixed` 挂载态并注入 DSH 现场。App 不声明卡、不调 `hana.cards.open`,因此卡片中心不占一格;会话历史归 DSH 自己的侧边栏(打开 DSH UI 即可翻看),App 不另存清单。
- **会话模型从哪来**:App 设置项 `sessionModelMode` 决定——`caller`(缺省,复用调用方那份)或 `custom`(`sessionModelProvider` / `sessionModelModel` / `sessionModelReasoningEffort` 固定一条,推理强度空串 = 不指定、由 DSH 决定)。优先级:工具入参显式 > App 自定义那条 > 用户手设的 DSH 默认(`caller` 模式下 user 层非空就不补) > **调用方那张角色卡**配的 `models.chat`(`agent:list` 的 `isCurrent` 优先,能力面 `app/agents.read`)。选出的那条**随会话请求带上**(集成层给 `session/create` 与 `session/prompt` 加了可选 `model` 字段,见 `integrations/api-session-controller`):只在会话内生效,不写 `settings.yaml` 的全局默认;只在 create 上补,send 沿用会话已有的选择。见 `packages/models/src/caller-model.ts`(决策)、`agent-models.ts`(读角色卡)、`host-models.ts`(宿主目录)。
- **DSH 自己的默认模型**(`agent-default-model`):本页不经手它,也不在 config.json 存副本。它的用户层有值、而已不在宿主目录里时,`packages/models/src/model-default-guard.ts` 在 runtime 就绪与宿主 `models-changed` 之后就地对账换一条可服务的(优先留在原 provider 里换,再退角色卡模型、目录第一条);用户层为空不动手——本形态里界面直接开的会话在 DSH 自己的模型选择器里选一条。

### 目录选择器

DSH 的 workspace 选择对话框来自 `directory-picker` seam(宿主半列目录或开系统弹窗,客户端半渲染)。官方 web-app 层挂的是 `dsh-host-directory-picker-auto`,它按启动时采样的一把宿主事实(bindHost / ssh / platform / DISPLAY)挑后端,win32 + loopback 必落 native。native 的客户端半优先读页面里的 `__DSH_DIRECTORY_PICKER__`(官方桌面壳由 preload 注入、弹 Electron 对话框),没桥才回落到宿主进程的 OS chooser——后者要在宿主进程里 spawn 一个子进程跑 `IFileOpenDialog`(koffi 走 COM,还先合成一次 Alt 把弹窗抢到前台),上游写明它只适合「操作者坐在宿主屏幕前」。本形态的受管 runtime 是沙箱里的后台子进程,那条回落路开不出来,客户端就把异常交给 owner 的 `onError`,表现成每次选目录弹一个错误。

壳页因此自己提供桥:在注入 DSH index 之前装 `__DSH_DIRECTORY_PICKER__`(`packages/ui/src/dsh-inject.ts` 的 `installDirectoryPickerBridge`),`pick()` 调宿主的 `hana.resources.pick({ mode: 'directory' })`。弹窗由宿主出、在用户自己的机器上,既不经沙箱,也不依赖 DSH 自己的桌面壳;roster 层不动,native 那一对照挂,只是其中宿主半永远不会被调到。

## 主题跟随

`@dshana/theme` 经 `tapIndex` 注入 index 响应:静态 fallback + 动态桥脚本,向壳页索取宿主主题 vars → 写 body 层 `!important` 覆盖 `--dsw-alias-*` / `--dsw-specific-*`。

**App 页面这一侧要自己贴样式表**:宿主把主题参数附在 App surface iframe 的 URL 上(`hana-theme` / `hana-css` / `hana-theme-appearance`),变化时再推 `hana.theme.changed`;但把样式表贴进页面这件事宿主不代劳,而 SDK 只在收到 `hana.theme.changed` 时才应用 `cssUrl`——页面不自己贴首帧,就会一路吃 HTML 里写死的纸张 fallback,直到第一次主题变化才跟上。壳页 / 设置页 / 会话卡共用 `packages/ui/src/host-theme.ts` 做这一步(首屏读快照 + URL 兜底 + 订阅)。

**跟随语义(有意自持)**:仅当 DSH 主题偏好为 `system` 时跟随宿主配色;显式 `light`/`dark` 时完全用 DSH 自己的主题,宿主配色不介入。偏好变更经事件驱动重读(不再周期轮询)。此语义与官方样例的「无条件双 palette 替换」不同,是保留项。

## 启动与状态

- **启动触发**:App `apply` 完成后经微任务触发一次 `ensureManagedRuntime`(single-flight,不占 apply 同步栈——宿主对 apply 有 60s bootstrap 超时);`dshana` 首调与 `POST /dshana/start` 是可重入的兜底入口。
- **就绪判定**:宿主 `ctx.runtime.get(runtimeId).state === "ready"`,配合子进程末行 `readyMarker`。
- **失败面**:`managed-runtime.ts` 的 `START_ERROR_HINTS` 按 code(port-busy / port-unreachable / boot-failed / deps / seed / not-authorized)给中文指引,壳页 action 态展示。

## 已知限制

- **升级 DSH = 装新 App 包 + 重启宿主**:宿主进程内的模块缓存无法从插件侧豁免。
- **Windows 上的命令执行是 `pwsh`**:base 组合按平台互斥挂载 shell 行(`tool-bash` / `bash-sandbox` 在 win32 停,`tool-pwsh` / `pwsh-sandbox` 只在 win32 开),派给子代理的命令按 PowerShell 写;文件读写仍走文件系统工具。
- **主题仅在 DSH 偏好为 system 时跟随宿主**(见上,有意为之)。
- 越界权限请求默认走审批:deferred 通知 → `dshana(action="approve")` 应答;无人应答按 `approvalTimeoutSec` 自动拒绝。

## 架构决策与落地(接口基线 Hana 0.1023.1)

DSHana 就是「Hana App v2(隔离 App 进程 + `apply(ctx)`)」,由 v1 宿主插件(宿主进程内 boot DSH)迁移而来;上方架构总览与本节描述的都是当前形态。

**manifest / apply 入口 / 设置 / 工具注册(迁移步骤 1):**

- `manifest.json`(仓库根,源码形态与产物形态对齐)是 App v2 契约:`version` 由 derive 取主 `package.json`,`minAppVersion` 取随包 SDK 快照(现 `0.1023.1`);capabilities 十一项(tools / tasks / session / models / agents.read / resources / runtime 三项 / ui 两项,清单见文件)。v1 专属字段(`author`、`trust`、`activationEvents`、`ui.hostCapabilities`、`network` 白名单)不在清单里。
- `packages/app/src/app.ts` 导出 `apply(ctx)`(兼导出 `default { apply }`);apply 注册完即返回。统一日志只走宿主 `ctx.logger`;globalThis 宿主单例退役 → `packages/runtime/src/app-runtime.ts` module-scope 运行包。
- 工具注册:`ctx.tools.register`,工具名 `dshana`(一个插件一个同名工具 + subcommand;v2 不自动加 `pluginId_` 前缀、重名被宿主当场拒)。动作五个:`open`/`reply`/`get`/`close`/`approve`,装配见 `packages/tools/src/index.ts`、手册见 `skills/dshana/SKILL.md`。
- 设置:`contributes.settings` 的 UI 由 App 自绘设置页承担(`ui.route: /settings.html`,宿主设置区渲染);键与缺省以 `packages/runtime/src/config.ts` 为准,读写落 `dataDir/settings.json`(旧 `config.json` 只在两键缺位时作读侧兼容)。
- 数据读路径迁到 `ctx.dataDir`(宿主 `app-data/<id>/`):list/get 读当前源的 `<DSH_HOME>/...`(projcache + jsonl zstd);旧插件数据迁移见 `packages/runtime/src/legacy-migrate.ts` 与 `scripts/migrate/legacy.mts`。
- 构建:`node packages/app/src/build.ts` 产物 `dist/` = App 安装目录形态(根只放宿主读的契约件与目录:`manifest.json` + `icon.png` + `skills/` + `ui/`;代码与 roster patch 全在 `bin/`:入口 `index.mjs` + 主体 `app.mjs` + runtime `dsh.mjs` + `cordis.patch.yml`)。源码形态与交付形态同形:`manifest.json` / `skills/` 与随包静态件都在仓库根(静态件在 `assets/` 下,其相对路径 = 产物里相对包根的路径:`assets/icon.png` → 产物根 `icon.png`,`assets/ui/cover.png` → 产物 `ui/cover.png`);`packages/app/src/` 放壳源与主体。壳的文档侧另出 `.cache/ui/`(`node packages/ui/src/build.ts`),App 域构建整树拷进 `ui/`。cordis 子插件包另出 `.cache/cordis/`(`node packages/app/src/cordis.ts`):它们不是安装态里的东西,出包时由 pack 落进包内 `node_modules/@dshana`。

**受管 Node runtime:local-machine/external + readyMarker 就绪门(迁移步骤 2):**

- 受管 runtime 入口 `bin/dsh.mjs`(源码 `packages/host/src/main.ts`,与 app 主体同一次 rspack 构建 → `dist/bin/dsh.mjs`,见 `packages/app/src/rspack.config.mts`):App 自有配置解析(唯一 argv = 私有运行时配置文件路径,schema 见 `packages/host/src/options.ts`,与 `packages/runtime/src/managed-runtime.ts buildRuntimeConfig()` 对偶)→ `connectAppRuntime()`(无父 IPC fd → 可操作报错 + 退出码 3,不假装能跑)→ 进程级 env(`DSH_HOME=<dataDir>/.dsh`、`DSHANA_HOME=<dataDir>`,不改宿主进程环境)→ 依赖随包就位(安装目录 `node_modules`,无运行时安装)→ profile 种子化(`initProfile` + `node_modules/@dshana` scope 链接 → installDir `cordis/`,junction/拷贝回退)→ 动态定位 DSH(`locate.ts`,profile-boot/app-boot,webpackIgnore 原生 import)→ `runProfile`(profile dshana、配置中的 dshPort、`--no-open`)→ **就绪门**(webServer 服务端口 === 期望端口 且 HTTP 探测成功)→ stdout 打 `readyMarker`(唯一出口;失败路径绝不打印 READY)→ SIGTERM/SIGINT/父断连有序释放(关 DSH fiber → 再 `hana.close()`;拿到流式响应不能立刻 close,本步未接流)。退出码契约:2=usage/3=IPC 不可用/4=deps/5=seed/6=boot/7=port。
- App 侧封装 `packages/runtime/src/managed-runtime.ts`:`ensureManagedRuntime()`(单例 single-flight:**一个 App runtime 服务多个 DSH 会话**,首次 create 触发启动——设计见模块头注释与 tools/actions 的提交链)父进程随机选取中继端口与 DSH 内部端口(区间 38000..52000,见 `choosePort`/`pickPorts`;宿主 service 端口契约只收确定整数,故不能交给宿主分配)→ `ctx.runtime.start({ runtime:"node", entry:"bin/dsh.mjs", profile:"local-machine", network:"external", cwd:dataDir, service:{ port:中继端口, readyMarker:带随机 opaque }, args:[私有配置文件路径] })`(契约禁止 readRoots/writeRoots/callToken/taskId,故一律不带) → `ctx.runtime.get` 轮询到 ready(不能把 runtimeId 当就绪;端口占用 port-busy 自动换随机端口重试,上限 3 次)→ 失败归类(`err.code`:port-busy/deps/seed/boot-failed/not-authorized/timeout/unknown,message 带用户指引)+ runtime watch 日志尽力镜像(src=dsht 进 App 会话日志);每次命中 ready 缓存先经 runtime.get 探活,子进程崩溃/被宿主回收则清单例并重起;失败路径把端口与两把 key 归零(`bridgeAccess()` 不再放出死端口);`disposeManagedRuntime()`/`stopManagedRuntime()`(App 卸载/更新前停 runtime,Windows .node 锁纪律);`choosePort`/`pickPorts`/`makeReadyMarker`/`classifyRuntimeFailure` 纯函数可单测。
- `packages/tools/src/actions/*.ts` 接 `packages/session/src/session-run.ts`(open/reply 提交链)、`packages/session/src/cancel-chain.ts`(close 取消链)与 `packages/session/src/approve-respond.ts`(approve 应答);`get`/`list` 离线可读。`packages/app/src/app.ts` disposer 接 disposeManagedRuntime。
- `packages/app/src/build.ts`:两入口一次 rspack 构建(主体 `bin/app.mjs` / 受管 runtime `bin/dsh.mjs`),产物 `bin/index.mjs` 是它写出的静态两行壳(re-export 主体,与主体同在 `bin/`,跨构建字面不变),先清 `dist` 再写入;壳的文档侧不在本域编译,从 `.cache/ui/` 拷进交付目录 `ui/`(缺件即拒),随后统一做 URL 回写 / terser / 断言。
- 单测 `tests/**/*.test.mjs`(node --test,分组见 tests/README.md):child options parse、managed-runtime 端口/参数/错误归类、readyMarker 构造。本地验证:`node packages/app/src/build.ts` 通过;`node dist/bin/dsh.mjs` 直跑给出清晰报错(无父 IPC / 缺参)。真机 AppHost 验收仍待装包(边界清单见本节末)。

**依赖部署:随包物化(自包含打包)**

- **形态:DSH 依赖树在打包时逐目标物化进 zip 根 `node_modules/`,运行时零安装(不 spawn pnpm)。** 受管 runtime 的 `depsRoot` 默认 = 安装目录 `<installRoot>/node_modules`,`--deps-root` 可覆盖(调试)。
- 物化方式(`scripts/release/pack/materialize.mts` + `index.mts`):逐目标(`package.json` 的 `package:<target>` 脚本 → `pack/targets.mts` 目标表)在 `.cache/pkg-root/<target>/` 隔离工位跑一次干净安装;工位是一个独立项目——三件都现生成(工位清单 + 以仓库锁文件为种子重解析出的锁 + 按目标替换过 supportedArchitectures 的 `pnpm-workspace.yaml`),先 `pnpm install --lockfile-only` 再 `pnpm install --prod --frozen-lockfile`,交付面的生产闭包由此落进工位根 `node_modules`,得只含该平台资产的 hoisted 树(顶层真实目录、无软链接——软链进 zip 跨机解压即断),`node_modules` 下的点号条目(`.bin` / `.pnpm` / `.pnpm-workspace-state-v1.json` 这类 pnpm 账本)排除后拷入包根。
- 为什么随包而不在运行时安装:① App 安装目录在运行时只读(App 进程 fs-write 白名单只有 dataDir),`pnpm install` 无处落盘;② native 产物(node-pty/koffi/sharp 等)按平台/ABI 区分,逐平台出包才能各带各的 addon;③ 只物化生产闭包(不含 devDeps),体量可控。
- 版本单一事实源 = `packages/host/package.json` 的 `@deepseek-ai/dsh`(交付面的清单与锁由 pack 从它现生成;根 `devDependencies` 里那条同名声明须与它一致,闸守);无独立 DSH 升级通道,升级 dsh = 装新 App 包 + 重启宿主。
- 定位:`bin/dsh.mjs` 在 depsRoot 下经显式路径解析 DSH(`packages/host/src/locate.ts`,`createRequire` + `.pnpm` 枚举 + `webpackIgnore` 原生 import);profile boot 的模块回退 farm(dsh-app-boot `healProfilesModuleFallback`,把 dsh 安装闭包镜像成 `$DSH_HOME/profiles/node_modules` 链接)覆盖官方插件树解析。
- `@dshana/*` 子插件随包落在安装目录 `node_modules/@dshana`(与 `@deepseek-ai/*` 同锚点)——DSH 的 runtime 解析模式从安装树 + bundle 依赖图算解析代、**不建任何链接**;我们的 roster patch 随包一份 `cordis.patch.yml`(源码在仓库根,装在与本 runtime 入口同目录的 `bin/`),由 runtime 经 `runProfile` 的 `patchFiles` 作启动期 overlay 传入(层序在所有层之上),我们因此不写 DSH_HOME 里的任何东西。
- Windows native 文件锁(指南 §4):依赖变更即整包替换,替换前必须先停占用 `.node` 的 DSH 进程/worker/终端——受管形态下 DSH 只跑在单例 runtime,App 卸载/更新/停止统一先 `ctx.runtime.stop`。


**provider adapter(DSH 推理走 hana.models)+ 任务回投与工具循环(迁移步骤 3):**

- **manifest 增顶层 network 声明**(决策 A):{ allowedHosts:["127.0.0.1"], methods:["GET","POST"], allowLocalhost:true, defaultTimeoutMs:60000, maxResponseBytes:8388608 }——App 进程(Node Permission Model,无 --allow-net,唯一出网面 = ctx.network.fetch 宿主门)到受管 runtime DSH web 服务的 loopback RPC 通道。v2 manifest 校验只认 allowedHosts/allowLocalhost/methods/defaultTimeoutMs/maxResponseBytes 五键(别名 hosts 会被拒);127.0.0.1 属私网且 http 非 https,缺 allowLocalhost:true 必被拒(核对记录,见 packages/runtime/src/managed-runtime.ts 头注释与 manifest 注释)。
- **决策 A(App → 受管 runtime 指令通道)= runtime service loopback HTTP RPC,复用 v1 信封/翻译器协议**(packages/shared/src/rpc-envelope.ts 纯函数:client-request 信封 + session.* 的 request/_request 包装 + requestId 注入;DSH 网关校验 body.method === 端点路径段(斜杠形态,official rpc-host 同款);响应 rpcId 回显 + result.ok)。**不用** 宿主 /api/apps/<appId>/routes/_runtime/<runtimeId>/ 代理:那是浏览器 surface(HttpOnly cookie + surface 授权 + WS upgrade)的 UI 通道(步骤 4/5 用),App 进程无 cookie 会话面且受管 runtime 无 getService 之类 App 门(hostCall 白名单只有 tasks/models/network)。App 侧 fetch 经 ctx.network.fetch(唯一出网面,宿主代执行),服务端 = 子进程 DSH web /api 原生端点(零新增 server 面)。
- **决策 B(provider adapter 分界)**:模型推理在受管子进程内经 connectAppRuntime().models 发起;requestId 由 adapter 自管(models.cancel(requestId) 定向);身份二选一(《DSHana 调用 Hana 模型接口指南》§3/§5,packages/provider/lib/identity.ts):按宿主任务记录的 `metadata.dsh` 判三态(`resolveSessionIdentity` 经 runtime 挂的绑定索引读;索引缺位报 `BINDING_UNAVAILABLE`,见 `packages/shared/src/task-binding.ts`)——无绑定(用户在 DSH Web UI 自建会话)或绑定指向的任务已终结(委派任务收尾后用户接着在 Web UI 跑)⇒ App 身份(callToken/taskId 都不传);绑定在且任务活动 ⇒ 传 `taskId`(宿主校验属主);绑定读不出 ⇒ 报错 `TASK_MAP_BROKEN`(不降级成 App 身份,否则活着的任务会静默丢绑定)。stream 不接受 scope(那是 models.utility 的参数)。provider/model 显式选择:目录 = hana.models.list() 投影,provider/model id 原样透传(宿主逐条 n.provider===provider && n.id===model 匹配),不做二次命名。@dshana/provider 插件 v2 重写为自实现 LlmAdapter(dsh-llm 动态 import,同 v1 profiles 基座解析):listModels/resolveModel 读目录快照;stream() 把 DSH Message 转换(user/assistant/toolResult + 回放签名,lib/messages.ts)→ hana.models.stream({requestId, ...身份, provider, model, messages, systemPrompt, tools, reasoningEffort?, maxTokens?, temperature?}) → NDJSON 逐行解析(跨 chunk 半行余量,lib/ndjson.ts;**HTTP 非 2xx 先报状态+响应体**,不吞成 STREAM_CLOSED)→ 事件处理(start/text-delta/reasoning-delta/tool-call/done/error);error = 失败不算成功;done.assistant 完整保存回放(textSignature/signature/thoughtSignature 续接签名进 ReplayEnvelope{kind:hana},DSH assistant source.replayState)并原样产块(block-start/delta/block-end/usage/finish,工具 arguments 为 JSON 字符串);流未以 done 结束 → STREAM_CLOSED;图片 base64+MIME(attachment store readImageRequest,不传路径/URL;store 缺失报 UNSUPPORTED_CONTENT)。DSH 自己的工具循环不变(Hana 不执行传入 tools schema,仅声明);工具执行后 role:toolResult + toolCallId/toolName/content/isError 放回 messages(lib/messages.ts 拆分)。hana client 句柄 = globalThis.__dshanaHana(bin/dsh.mjs connectAppRuntime 后、runProfile 前设置,与子插件同进程共享;释放顺序:停 task-bridge → ctx dispose → 清句柄 → hana.close,main.ts 实现)。
- **模型请求字段校验与限额(宿主 `app/models.infer` 默认值,可按 App 注入覆盖)**:宿主对 `models.stream` 的请求字段有硬校验(allowed keys 白名单;requestId/provider/model 为 1-128 ASCII id;messages 1..128;tools ≤64;systemPrompt 与文本块 ≤250000 字符;temperature ∈[0,2];reasoningEffort 必须在模型 thinking levels 内)。限额默认值:`maxConcurrentStreams: 8`、`maxRequestBytes: 32e6`、`maxImageBytes: 1e7`、`maxEventBytes: 4e6`、`maxTokens: 65536`、`timeoutMs: 10 * 6e4`(单请求 10 分钟)——宿主构造 App 模型服务时按 `{...默认, ...limits}` 合并,所以这些是**默认值,不是写死的闸**(宿主可按 App 覆盖)。`maxTokens` 的取法:超过 65536 → **不传该字段**(收敛到 65536 等于把输出悄悄砍到 64k,而不传则把上限交回模型/供应商默认);未超闸但超该模型 published 上限 → 按模型上限收敛(模型自身硬限);非正整数 → 不发字段。目录投影 `resolveModel` 声明模型真实 `maxTokens`(不夹宿主闸),DSH 因此看到真实能力。temperature 越界收敛到 [0,2]。收敛提示按 provider/model 去重一次,不逐次刷屏。
- **open/reply 业务链**(packages/session/src/session-run.ts submitDshTask → packages/tools/src/actions/open.ts / reply.ts):execute(含宿主 context.callToken)→ ctx.tasks.create({callToken, label, metadata})(callToken 只此一次消费,不落盘不落日志)→ ensureManagedRuntime({taskId})(未起则启动到 ready,失败归类 err.code 并 fail(taskId))→ 会话建立(create=session.create{cwd, agentPreset?};send=session.list 查持久会话(带 cwd)则 session.create resume,活跃 Map 会话直接 prompt,list 不含=不存在由 prompt admission 报 session/not-found)→ 显式 provider/model/effort 才 session/selectModel(model-unavailable 降级不带 effort 重试)→ 绑定回写宿主任务记录(先于 prompt)→ session.prompt fire(mode:queue, content:[{type:text}], requestId=rpcId)。返回 { promise, ready }:ready 在 prompt accepted 后 resolve 定位键 {action, sessionId, rpcId, taskId, cwd}(execute 随即返回,v1 语义不变);promise 后台等 Hana task 终态(child task-bridge complete/fail → 宿主 deferred:resolve/fail 投递来源会话)并释放同会话串行化锁。
- **task-bridge(受管 runtime 内,packages/host/src/task-bridge.ts)**:DSH boot 就绪后、readyMarker 前挂载,ctx.on 订阅 api-session/status|error|activity + session/event(进程内直订——turn 生命周期不经 $events);按宿主任务记录的 `metadata.dsh.sessionId`(App 提交前写入;本进程 hana.tasks 直读,见 `packages/shared/src/task-binding.ts`)把事件回投:running 进度 ctx.tasks.update、终态 complete(taskId, minimal 定位结果)/fail(taskId, message)。事件按 sessionId 路由到唯一当前任务,天然隔离多会话(决策 C/D)。终态判定语义与 v1 run.js consume 对齐:api-session/status false / session/event turn/end(reason.kind=error → 失败;completed → 成功,pendingFailure 兜底判失败);先到先收、幂等;终态后失效绑定索引缓存。
- **决策 C(绑定事实源 = 宿主任务记录)**:taskId↔dshSessionId↔rpcId 与协调字段(cancel 等)都落在宿主任务记录的 `metadata.dsh`(`packages/shared/src/task-binding.ts`;写入方一律给全量 dsh 对象——`tasks.update` 的 metadata 合并语义宿主契约没写死)。受管 runtime 子进程的 hana client 本来就有 `tasks.list/get/update`(`AppRuntimeTasksV2`),直接读同一份记录:`createTaskBindingIndex` 用 `tasks.list()` 建 sessionId→任务索引 + 进程内短 TTL 缓存(模型请求热路径不每请求往返宿主),句柄解析/取消标记/终态判定走 `fresh` 直读;runtime 把索引挂到 `globalThis.__dshanaTaskBindings` 供 cordis 子插件 @dshana/provider 的身份判定读(两 bundle 同进程不能互相 import)。任务记录的终结/取消状态由宿主 `status` 承担,不另造结束标记。64KiB 任务 JSON 限制由宿主门强制——metadata/result 只放小定位键。callToken 不落盘不落日志(指南 §5 硬约束)。
- **决策 D(并发纪律)**:同 DSH session 的 create/send 由 App 进程内串行化(packages/session/src/session-serialize.ts,与 v1 withSessionTurn 同语义;锁持有到任务终态——否则同一 session 两个任务会互吃对方的终态事件/映射);不同 session 互不共享「当前任务」(绑定按 `metadata.dsh.sessionId` 归属);一个 runtime 服务多会话时各请求自带 taskId(models.stream.taskId、task 记录 taskId、prompt 信封 rpcId 作 jsonl data.source.rpcId 关联键)。
- **工具接线**:open/reply → submitDshTask(返回语义与 v1 一致:fire 即回、终态投递来源会话、内容走 `action=get`);close → `packages/session/src/cancel-chain.ts`;approve → `packages/session/src/approve-respond.ts`;list/get 离线读(projcache + jsonl zstd)。
- 端口不再有设置项:servicePort/nodejsPath 随 T5 裁撤(父进程区间随机选端口 38000..52000 + 占用换端口重试;用户不再需要配置端口)。
- 单测新增(node --test 全绿 62 例):rpc-envelope(信封/网关 method 斜杠/requestId 注入)、task-binding(归一/索引/TTL/绑定读不出时 fail-closed)、session-serialize(同会话串行/跨会话并行/槽位)、provider-ndjson(跨 chunk 半行/flush/坏行)、provider-catalog(routes/efforts/元数据)、provider-messages(assistant 签名/tool-result 拆分/图片/UNSUPPORTED_CONTENT)、provider-stream(done→chunks/回放信封/EMPTY_RESPONSE/max-tokens)、task-bridge(事件归类)。构建:node packages/app/src/build.ts 与 node packages/app/src/cordis.ts 通过(dist 内含 task-binding/task-bridge/__dshanaHana 标记)。
- 已测/未测边界(步骤 3):真机 AppHost 实跑 create/send 模型流未在本刀跑通(无宿主环境/网络),装包后由主上下文验收:① tasks 生命周期与结果投递;② task-bridge 终态对账;③ provider adapter 被 DSH agent 循环调用的消息/块序与跨 turn done.assistant 回放;④ 同会话两次 send 串行;⑤ DSH 图片附件 base64 路径;⑥ 两会话并发(宿主模型流并发上限 2);⑦ runtime 中途重启后 send 的 resume 路径;⑧ 执行超时/取消(依赖步骤 4 session.cancel)。本步模型流不逐块实时打字(done 时一次性产块,功能等价;DSH Web UI 实时性属步骤 4/5 面)。

**遗留(步骤 4b/5 收口已由本刀合入代码侧——见文末「步骤 4b/5 收口(代码侧)」;此处只剩真机/后续刀项):**

- 真机 AppHost 验收(装包后,主上下文与姐姐协调):① 宿主审批通知形态与 watch SSE 实测对账;② 宿主取消 UI 端到端;③ 重启恢复(App 进程重启后 in-process 队列/后台 watcher 重建 + 绑定缺失判定——仍属后续刀);④ ui/ 壳页到 App routes 的 surface 授权/cookie 形态;⑤ DSH Web UI 在代理前缀下的资源/API/WS base 适配(宿主不重写任意 SPA——壳页就绪态已就位,DSH 侧 base 适配待对账);⑥ 旧数据迁移真机执行(--apply 停机协调)。
- activation on-demand(manifest activation.mode)决策仍留稳定后(指南 §11)。
## 步骤 4a 架构决策(approve / cancel / 执行超时 / watch SSE 消费侧)

本刀合入内容对应迁移指南 §5(每 send 新 task、串行化)、§8(models.cancel(requestId))、
§9(审批与取消全文)在 v2 接线中的逐条落点与决策。**已测/未测边界在下方单列**;以下
决策是「当前实现按宿主 0.930.1 d.ts 契约写、真实宿主形态待装包对账」的依据。

- **决策 E(取消链分侧与顺序 = App 发起 RPC、DSH 真中止后宿主才 canceled)**:
  App 主进程(packages/tools/src/actions/close.ts / session-run 超时看门狗)经 loopback HTTP RPC 直调
  DSH web /api/session/cancel(lib/dsh-rpc.ts rpcSessionCancel + rpc-envelope 复用——与
  create/send 同一条指令面),并先写取消标记(宿主任务记录的 `metadata.dsh.cancel`)。受管 runtime
  task-bridge 在 DSH turn/end(aborted)(或自然终态但已有 cancel 标记,v1 cancelledRequested
  同款语义)时把宿主任务结算成 hana.tasks.cancel——**canceled 只在 DSH 真中止后标记**
  (指南 §9 第 4 步:不能宿主标 canceled 而 DSH 还在跑)。宿主侧取消反向触发(Hana task
  canceled/aborted,来源会话停止按钮等)= task-bridge 对 running 任务经 hana.tasks.watch
  (taskId) SSE 观察,取消到达 → 本进程 DSH session.cancel + cancelSessionModelRequests
  (只停本会话 requestId;单例 runtime 不误停他人会话)。取消确认窗口(App 侧
  CANCEL_CONFIRM_MS=15s 轮询 tasks.get)超窗未确认 → 升级 ctx.tasks.cancel 兜底并如实告知
  (残余风险:DSH 进程若真未响应,宿主已 canceled——写入本刀真机边界验收项)。**窗口只在后台
  结算里走**:工具路径(requestCancel)写标记 + 发 RPC 后立即返回,终态结算丢后台;把 15s
  等待放进工具回调就是占着宿主回调(多张卡同时取消会堵住宿主通道,SKILL 里那条 30s 回调上限
  就是这么撞的)。执行超时看门狗(cancelSessionWork)本就在后台,才等满窗口求收尾判断。
- **决策 F(watch SSE 消费侧 = 受管 runtime 子进程)**:需要「宿主审批 outcome → DSH
  approval/request 等待者」与「宿主 task 取消 → DSH session.cancel」的都是 runtime 内模块
  (approval-bridge / task-bridge);App 主进程不消费 SSE(应答走 ctx.tasks.respondApproval
  的返回值即权威,取消终态用轮询 tasks.get)。故 watch-SSE 解析/对账(packages/shared/src/watch-sse.ts)
  打进受管 runtime bundle(packages/host/src 与它同图内联),消费处只有子进程。SSE 与
  NDJSON 分开解析(指南 §9):models.stream 是逐行 NDJSON(provider lib/ndjson.ts);
  watch 是 event:/data: 多行块(createSseDecoder),snapshot 首条 + app-task 后续 + reset
  (缓冲溢出)与断线都要先 get() 对账(runWatchReconcile)。
- **决策 G(审批分侧 = runtime 创建与等待、App 应答)**:DSH 审批请求(sandbox 升级
  approval/policy=ask)→ ApprovalService 走 ctx.waterfall(scopeTarget(agent),
  'approval/request')——approval-bridge 以 ctx.on('approval/request', …, { global: true,
  prepend: true }) 认领(v1 实证:无 scope ctx.on 因 context filter 收不到 agent-scope
  瀑布事件)。有绑定(dshana 发起的会话,见 `packages/shared/src/task-binding.ts`)→ hana.tasks.requestApproval({taskId,
  label, details:{dshSessionId,rpcId,toolName,callId,reason,args}, timeoutMs})(以父 taskId
  为范围,不需 callToken;timeoutMs 快照随 `metadata.dsh` 下传,0=宿主不自动拒绝)→ 挂起 ApprovalOutcome 承诺 watch(approvalId)
  等终态:allowed-once/rejected 原样投给该 approvalId 的 DSH 等待者(承诺闭包天然定向,
  不广播);终态无 outcome(父任务结束/撤销/审批超时)→ rejected(fail closed,绝不隐式
  放行;指南 §9)。App 侧 dshana(action=approve) = approve-respond.ts 经宿主审批记录的 parentTaskId
  → 父任务 `metadata.dsh.sessionId` 校验归属(记录无 outcome 才允许应答)→
  ctx.tasks.respondApproval({approvalId,outcome}) →
  runtime watch 观察 outcome 投递给 DSH。DSH 请求侧 abort(回合取消)→ 宿主审批收尾应答
  rejected(不留孤儿)+ resolve 'cancelled'(取消绝不当授权)。审批等待不计入执行超时:
  超时 cancel 链会让 req.signal 中止走此路径;宿主审批由 timeoutMs 独立自动拒绝。
- **决策 H(协调字段随宿主任务记录)**:timeoutSec/approvalTimeoutMs(提交快照,跨进程下传
  App settings)、cancel{at,reason}(取消标记)都落在 `metadata.dsh`,写入方读-改-写全量
  metadata(`dshMetadataFor`);审批不另存清单——宿主审批记录(`approvalId`/`parentTaskId`/
  `outcome`)就是唯一决策事实源(`approvalId` → `parentTaskId` → 父任务 `metadata.dsh.sessionId`)。
  DSH 侧事件单飞 + App 同会话串行化使并发写窗口极小;读取失败一律 fail-closed。callToken 依旧绝不落盘。
- **决策 I(执行超时 = 走 cancel 链,不是只 fail task)**:session-run 提交后台
  waitTaskTerminalWithTimeout:timeoutSec(显式参数或 App defaultTimeoutSec,非法/0 回落到
  同一缺省 1800)超时 → cancelSessionWork(reason='timeout')(标记 + session.cancel +
  确认窗口)→ DSH 真中止后宿主 canceled。审批等待期间不触发(DSH turn 未结束 + 宿主审批
  timeoutMs 独立拒绝;DSH 回合中止经由 abort 信号路径)。
- **决策 J(活动模型 requestId 注册表 = globalThis,跨 bundle 共享)**:provider adapter
  流开始把 requestId 记入 globalThis.__dshanaActiveModelRequests(Map<sessionId,Set>),
  流收尾注销;task-bridge 取消时按会话定向 hana.models.cancel(lib/model-requests.ts 只读
  消费)。provider(cordis bundle)与 task-bridge(dsh-host bundle)不能互相 import——
  globalThis 约定(与 __dshanaHana 同款),键名两侧字面一致。

**步骤 4a 已落地清单:**

- packages/shared/src/task-binding.ts:绑定归一 + 索引/TTL 缓存 + 取消标记写入(宿主任务记录)。
- packages/shared/src/watch-sse.ts:SSE 解码/帧解释(snapshot/app-task/reset/结构兜底)、终态判定、
  审批 outcome 映射(fail-closed)、runWatchReconcile(先 get 对账、reset/断线重连退避)。
- packages/shared/src/dsh-rpc.ts 与 packages/runtime/src/service-base.ts:注入式 /api RPC(App ctx.network.fetch 与 runtime
  Node fetch 共用);rpcSessionCancel + cancelAccepted。
- packages/session/src/cancel-chain.ts:cancel 编排(planCancel/executeCancel/awaitCancelTerminal/
  cancelSessionWork)、任务/审批超时解析(App settings 注入);session-run 的 rpcCall 收敛
  到 dsh-rpc,serviceBase 拆叶子模块(防环)。
- packages/session/src/approve-respond.ts:dshana approve 应答(归属校验 + respondApproval + 回填)。
- packages/host/src/model-requests.ts:活动 requestId 注册表消费侧(运行时 bundle)。
- packages/host/src/approval-bridge.ts:DSH approval/request global+prepend 认领 → requestApproval
  → watch(approvalId) 对账 → outcome 只投正确等待者;tool-call 缓存供 args
  证据;signal abort → 宿主 rejected 收尾 + DSH 'cancelled'。
- packages/host/src/task-bridge.ts:cancel 标记结算(DSH 中止后 canceled);宿主任务 watch 反向
  cancel(session.cancel + 定向 models.cancel,只本会话);settle 幂等。
- packages/host/src/main.ts:挂 approval-bridge;task-bridge 传 serviceBaseUrl;shutdown 停两桥。
- packages/provider/index.ts:活动模型流注册/注销(globalThis 注册表)。
- packages/tools/src/actions/{close,approve}.ts:取消/应答分支接线(移「未接线」);manifest description 更新。
- 单测新增 21 例(watch-sse 9 / task-map-ext 4 / cancel-chain 3 / approval-bridge 5)+
  packages/provider 注册表不新增测试面(纯注册)。既有 104 例 + 新增 21 例 = 125 全绿;
  node packages/app/src/build.ts 与 node packages/app/src/cordis.ts 通过(dist 内含 approval-bridge/requestApproval/
  cancel 链标记)。

**真机/后续验收边界(本刀代码侧未跑通宿主,装包后由主上下文验收):**

1. 宿主审批通知形态:requestApproval 创建后来源会话如何收到「待审批」通知(文案/审批 UI/
   卡片),dshana(action=approve) 是否被模型正确选用——approve 分支无本地依赖,纯
   应答宿主;通知形态属宿主侧。
2. watch SSE 实测对账:host watch(taskId/approvalId) 的事件名/载荷是否确为 snapshot/
   app-task/reset(按指南措辞实现 + 结构兜底);get(approvalId) 与 get(taskId) 是否都受理。
3. 宿主取消 UI(来源会话停止按钮)路径端到端:host task canceled → runtime watch 反向
   session.cancel → DSH turn/end aborted → 宿主 canceled,两会话并发不串扰。
4. 取消确认窗口超窗升级(CANCEL_CONFIRM_MS=15s)的实测触发面与文案。
5. 审批 timeoutMs 自动拒绝是否即时进入 watch 流(host 行为);超时后 approve 应答的宿主
   报错文案形态。
6. 执行超时走 cancel 链端到端(DSH agent 正在跑工具/长推理时被 session.cancel 中止),
   超时后任务终态 = canceled 且内容不误标成功。
7. DSH Web UI 直开会话的审批(无绑定 → next() 委托 → 无应答者 fail-closed)与 DSH
   自身 approval/policy 语义核对。
8. 重启恢复(App 重启后 in-process 队列重建)仍属后续刀(本刀未动 apply 进程内协调态)。


## 步骤 4b/5 收口(代码侧)——routes/UI/cards + 数据迁移 + 打包(本刀合入)

指南 `10`(Web UI/HTTP/SSE/WS 迁移)、`12`(旧数据迁移)、`13` 步骤 5 的「代码侧」在本刀收口。
真机 AppHost 验收(DSH UI 交互、surface 授权、代理前缀端到端)留装包后由主上下文做——下方
「已测/未测边界」单列。

### 宿主实证记录(server 0.930.1 bundle,只读查证)

- **受管服务代理 = 宿主自动暴露,App routes 不需要转发**:bundle 含
  `const Uee = "hana_app_runtime"`、`u1e(appId, runtimeId) = /api/apps/<appId>/routes/_runtime/<runtimeId>/`
  与代理 express 路由 `/apps/:appId/routes/_runtime/:runtimeId/*`(ayr 函数族):请求带
  `upgrade: websocket` 走 upgradeWebSocket 转发(二进制/文本帧,缓冲上限 1MB),其余方法按
  runtime 记录端口 fetch 到 `127.0.0.1:<port>`(重定向 Location 重写回代理前缀;剥离
  authorization/cookie/host 等请求头;strip `appSurfaceSession`/iframe ticket/agentId 查询参)。
  首次携带 `?appSurfaceSession=` 或 `X-Hana-App-Surface-Session` 的响应会 Set-Cookie
  `hana_app_runtime=<surfaceToken>; Path=/api/apps/<id>/routes/_runtime/<rid>/; HttpOnly;
  SameSite=Strict` 供后续子请求。因此本 App 的 ctx.routes **只做壳页/诊断面**;DSH Web UI/
  API/SSE/WS 一律指向该前缀。
- **ctx.routes.register**:单 bundle 只能 register 一次;registrar 收到 Hono sub-app(SKILL/
  SDK d.ts:`register(registrar:(app)=>unknown)`,可返回 Promise 由宿主 await 后发布);
  公开 URL `/api/apps/<appId>/routes/<subpath>`,鉴权 app_route(宿主登录或本 App surface
  会话)。处理函数在 App 进程内执行(与 ctx.tools.execute 同生命周期,共享 App 运行包/受管
  runtime 单例——路由模块直接调 packages/runtime/src/managed-runtime.ts 是本设计前提)。
- **ui/ 静态树**:宿主 `GET/HEAD /apps/:appId/ui/*` 直接服务 App 安装目录 `ui/` 子树
  (Z8t 防目录逃逸;scoped surface 路径 `/api/apps/<appId>/ui/_surface/<sessionToken>/...`
  由 iframe-ticket 端点下发 uiBasePath——页面相对资源在此 base 下继承授权)。App 自身 ui/
  页面资源一律相对路径(指南 `10` 禁根绝对 URL);face 映射 `/api/apps/<appId>/ui/<image>`。
- **contributes.cards v2 字段白名单**(宿主 readManifest 实证):
  `id/title/description/route/embedUrl/cardForm/titlebar/realization/pageOf/siteNavEntry/
  fpFullPanel/functionPanel/face/formFactors`——realization:"page" + siteNavEntry、pageOf 均
  可用(宿主 schema 校验同 SKILL)。route 值 = ui/ 相对路径(须对应真实文件,宿主按文件名
  服务,无扩展名推断——route 用 `/dshana/main.html` 形态)。

### 交付 1:ctx.routes.register 单 registrar(壳页/诊断面)

- 新 packages/tools/src/routes/dshana-routes.ts(v1 routes/webui.js + card.js 两工厂合并语义):端点
  `GET /dshana/boot-state`、`GET /dshana/health`、`POST /dshana/start`(fire-and-forget,
  202 即回,壳页轮询跟进)、`POST /dshana/stop`。依赖注入(getSnapshot/start/stop)可单测;
  packages/app/src/app.ts apply 用 defaultDshanaRouteDeps(ctx) 接真实实现(读 managedRuntimeDetails)。
- packages/runtime/src/boot-state.ts:归一化快照 `{phase,ready,runtimeId,proxyPrefix,service,error,
  note,updatedAt}` 与 runtimeProxyPrefix()(前缀宿主契约单点)。**ready 门**:service.state
  === ready 才给 proxyPrefix(绝不因 runtimeId 存在就展示端点——宿主在 readyMarker 后才发布
  服务)。阶段文案覆盖 idle/starting/ready/error/stopped。
- packages/app/src/app.ts:ctx.routes.register 缺失(宿主过旧)→ warn 降级(DSH 仅 dshana 可用);
  registrar 抛错 → 抬高中止 App 加载(显式失败优于静默残缺);disposer 注销。

### 交付 2:contributes.cards 回归(v2 schema)

- manifest 单卡:id dshana(route `/dshana/main.html`,realization:"page" +
  siteNavEntry + fpFullPanel,cardForm:"flush"),同卡 `functionPanel`
  `{ id: "dshana-sidebar-panel", label: "DSHana 状态", route: "/dshana/sidebar.html" }`
  ——FP 由主卡自带(宿主 0.944.2+ 的 route 形态),不再用 `pageOf` 同伴卡过渡。
- 壳页三态(免交互,无手动启动按钮):idle(只报状态;打开卡页补一次 POST /dshana/start)、
  starting(轮询 boot-state,台面只有 loader + 状态行)、error/action(错误码 + 用户可读指引,
  装在一坧 `<pre>` 里)、ready(同文档注入 DSH index,请求走宿主代理前缀)。**DSH UI 的 SPA base 适配**
  (资源/API/WS 前缀)宿主不代做,真机对账(见下)。

### 交付 3:ui/ 静态树归位

- packages/ui/src/{main.html, sidebar.html, app-shell.ts}(build:ui 出 .cache/ui,build:app 整树拷到 dist/ui/):
  页面同层相对引用(`./app-shell.js`),无根路径绝对 URL;appId/路由前缀由页面
  location.pathname 推导(/api/apps/<appId>/... 段),不硬编码整 URL。壳页轮询 boot-state、
  POST start/stop;主题桥(同文档,`dshHanaThemeRequest`)best-effort。剪贴板已改由
  `@dshana/clipboard` 的 client 半 shadow `navigator.clipboard.writeText`,原生被据时直调
  `__DSHANA__.clipboardWrite`(旧 `__dshCopy` 消息协议已删)。

### 交付 4:旧插件数据迁移(交付代码与 --check 路径,本刀不真跑)

- packages/runtime/src/legacy-migrate.ts(纯 node 内置)+ scripts/migrate/legacy.mts CLI:
  `--check`(默认,只读计划)/`--apply`/`--force`/`--source|--hanako-home`/`--target`
  (缺省取 DSHANA_LEGACY_HOME/DSHANA_DATA_DIR)。流程 = 备份(目标数据区
  `migration-backup/`,**已存在不覆盖唯一备份**)→ 复制 dsh-home/{sessions,storages,
  settings.yaml,.anonymous-user-id} 与 logs、config.json(参考拷贝
  legacy-config.json + 设置建议输出;不代写宿主 preferences)→ 校验(会话数/workspace.json
  可解析/marker 落位,verifyMigration)→ 幂等标记 `dataDir/dshana/migrated.json`
  {source,at,stats,backupDir}。**profiles/ 不迁移**(v2 的 profile 由壳自己建并维护:
  <DSH_HOME>/profiles/dshana 的层列钉在壳里,见 packages/host/src/main.ts;旧数据里那份同名
  目录不从源复制,也不替它清理);node_modules/.node 不迁移
  (App 依赖随包物化在安装目录,无需迁移)。源只读不删(回退材料 = 旧插件数据原地保留);--apply 打印停机指引
  (先停旧插件写入,主上下文与姐姐协调)。Windows:path.join 原生分隔符、junction 在跳过
  列表、reparse 不入复制。

### 交付 5:pack 与派生同步收口(版本线)

- 版本线为单一 1.x 线(开发期停在最后已发布基线、发版经 `pnpm version` 推进、DSH 跟随策略);
  cordis 包(roster 一份 patch + 三个插件包:`@dshana/clipboard` / `provider` / `theme`)**等值跟随**
  主版本(无独立版本线);
  build metadata(+dsh-<dsh 依赖>)由 version-hook 发版时统一拼回再同步。
  版本线语义见 scripts/shared/version.mts 头注释。
- pack(scripts/release/pack/index.mts):静态项补 THIRD_PARTY_NOTICES.md;cordis 产物断言按清单校验(现 3 包);
  新增 ui/ 静态树断言(route 资源 fail-closed);zip 根级 = manifest.json + bin/(index.mjs / app.mjs /
  dsh.mjs / cordis.patch.yml)+ icon.png + skills/ + ui/ + NOTICE/THIRD_PARTY_NOTICES.md + 物化的 node_modules/
  (不带 package.json:入口以 .mjs 定 ESM)。

### 交付 6:@dshana/* 子插件 v2 收敛判断(落到 DESIGN;代码侧不动 roster,防 boot 破坏)

逐插件职责与 v2 判断(profile cordis.patch.yml 行序保留——移除任何行都需要真机 boot 验证,
本刀只做收敛判断与惰性标注,物理退役随 UI 真机验收刀收口):

| 子插件 | v1 职责 | v2 判断 | 说明 |
|---|---|---|---|
| provider | host provider 目录直连(apiKey/baseURL) | **保留必装(已 v2 重写)** | hana.models 推理 adapter(迁移步骤 3) |
| bridge | 免鉴权 connection 等价服务 + /api HTTP 载体(DSH Web 数据面必需) | **保留(v2 仍必需)** | DSH UI 经代理前缀直连 /api/* 时同款免 401/403 依赖;App→runtime RPC 走官方 gateway /api 端点同依赖其 interceptor 面 |
| app | serve fork 官方前端到根路径(宿主 WebUI iframe 载体) | **保留(惰性优先)** | DSH Web UI 根页面服务仍走 webserver fallback;v2 经代理前缀访问不变;内容面待 UI 真机验收 |
| view | client 端 root 装配(main/sidebar 视图、layout 服务) | **保留(惰性优先)** | DSH UI 视图装配属 DSH 侧;URL 三态参数与壳页 ?dshana-view 约定一致即可 |
| theme | 壳页 postMessage 主题注入(tapIndex 注入桥) | **保留(桥壳页侧需对账)** | v2 壳页 app-shell.ts 已实现 dshHanaThemeRequest 应答(best-effort);TOKEN_MAP 对齐待真机 |
| clipboard | 剪贴板桥(宿主 capability 写剪贴板) | **保留(改为 client 半 shadow)** | v2 无宿主 capability 白名单——壳页 navigator.clipboard/execCommand 兜底,失败如实回执;正式路径待 hana.clipboard 能力面确认 |
| settings | DSH Web 设置页「DSHana 设置」分页(默认模型/版本卡 + 更新总线) | **已退役(2026-09-12,改由 App 自己设置页承担)** | v1 更新链路(dshana.bus → 宿主)v2 无宿主侧;本地版本卡/默认模型 UI 保留;更新段退役(App 发版即 DSH 升级)——真机验收刀随 UI 修剪 |
| logger | DSH 内日志收集 → dshanaBus → 宿主会话文件 | **已退役(2026-09-12)** | bus 一走它只剩“写一行到 cordis logger”,无存在价值;唯一消费者 theme 改为直接用内建 LoggerService(行首 `[theme]`) | v2 无宿主 WS 连接,总线缓冲不再送达;受管 runtime stdout 由宿主 runtime 日志承载(App 侧不再落盘,见 spec §8 j)——真机后移除总线转发段 |
| bus | dshana.bus WS 服务端(宿主插件 IPC 通道) | **已退役(2026-09-12)** | v2 宿主不再连 dshana.bus:App→runtime = loopback HTTP RPC(决策 A),runtime→宿主 = connectAppRuntime(tasks/models)。无消费方即死代码——真机确认 logger/settings 无注入依赖后从 patch.yml 移除 |
| acp-assist | dsh-acp agent 工厂 setup 后置(补 ACP 会话缺的默认 preset) | **已退役(2026-09-10)** | v2 不装载 dsh-acp,patch 对象不存在;roster 行与插件目录已删 |

### 已测/未测边界(本刀)

- 已测:node packages/app/src/build.ts 与 node packages/app/src/cordis.ts 通过(dist 含 ui/ + manifest cards +
  dshana-routes 接线);单测 143 例全绿(新增 boot-state 7 + dshana-routes 6 + legacy-migrate
  5 = 18 例;既有 125 例保持)。pack 的版本断言/静态清单逻辑改动后未实跑(留发版时验证)。
- 未测(真机 AppHost 装包后由主上下文验收):① ui/ 壳页 → /routes/dshana/* 的 surface
  授权形态(scoped uiBasePath/iframe ticket → cookie/hana.api,代码按同源相对 fetch 写,
  实证文件见 bundle iFt/eFt/uVe);② DSH Web UI 在代理前缀下 SPA 资源/API/WS 的 base 适配
  (宿主不重写 HTML/根路径——需 DSH 侧或 App 侧改写,见 manifest/壳页注释);③ /dshana/start
  fire-and-forget 的宿主 route 并发语义(boot 数分钟窗口内宿主对 App route 请求无超时回收);
  ④ 壳桥 theme vars 的 TOKEN_MAP 对齐 + clipboard 正式路径;⑤ migration --apply 真机执行
  (含 Windows junction/文件锁/旧插件停机协调)。