# @tsxapp/dsh-plugin

**简体中文** | [English](README.md)

DeepSeek Harness（DSH）插件：把 tsxapp 接进 DSH 的三处 —— **一个 skill**，**一个侧边栏标签页**，
**一个工具**。

- **skill 注入**：宿主半边注册一个 `tsxapp` 技能，agent 于是知道 `.app.tsx` 的契约：
  默认导出一个不接收参数的组件、能力从 `@tsxapp/runtime` 导入、权限模型、Tailwind。
  装插件即得技能，卸载即失效 —— 不往 `~/.dsh/skills/` 里写文件。
- **侧边栏**：浏览器半边注册一个标签类型，`patterns: ['*.app.tsx']`。在右侧文件树里
  点开任何 `*.app.tsx`，出来的不是纯文本，而是这个应用的标签页：**页面直接画在标签里**
  （内嵌 iframe，不弹系统浏览器）、启动动画、运行状态、实时日志、带行号的源码。
  顶栏右侧两颗工具：「重启并刷新」（停掉服务再起来，并把 iframe 整个换新 —— 改完源码不想等
  HMR、或页面状态已经跑飞时用）和「在浏览器打开」。关掉标签，服务跟着停。
  起不来时那颗「**帮我修复**」会在**当前工作区**里开一个新会话，让 agent 带着整个工程的上下文
  去修（不是只看 `foo.app.tsx` 那一层子目录）；应用**运行时**报错会报一条**不挡路的横幅**
  （宿主自带的 `Toast`），要看堆栈才展开详情。
- **文案跟随宿主的语言设置**：字典注册在 DSH 的 locale 服务上（命名空间 `tsxapp`，中英两份），
  界面按宿主当前语言显示，切语言即时生效 —— 不看浏览器语言。
- **工具**：`tsxapp_preview` —— agent 改完代码调一次：**类型检查 → 起服务 → 截图**。
  截图**作为图片直接回在工具结果里**（模型自己看得见页面），报告里给服务地址与尺寸；同时把标签
  在侧边栏打开，人和模型看的是同一个页面。类型检查没过就直接回报告（不起服务、不截图）。
  （上一版那个独立的 `tsxapp_check` 已经并进这里：对 agent 来说，动作永远只有「改完 → 看一眼」。）

## 源码与构建

源码在 `src/`：宿主半边是几个 `.ts`，浏览器半边是 `src/client/` 目录下的 tsx 组件。
两边**各有自己的 tsconfig**，因为它们活在两个世界里（Node vs DOM + JSX）：

| 入口 | 产物 | 跑在哪 | tsconfig |
| --- | --- | --- | --- |
| `src/index.ts` | `dist/index.js` | Node（DSH 宿主进程） | `tsconfig.host.json` |
| `src/client/index.tsx` | `dist/client.js` | 浏览器（DSH 客户端模块表） | `tsconfig.client.json` |

`tsconfig.json` 现在只是两者共用的 base（`files: []`，不自己编译任何东西）。

```bash
pnpm dsh:build       # tsdown -c packages/dsh/tsdown.config.ts（两份配置，先 host 后 client）
pnpm dsh:typecheck   # tsc -p tsconfig.host.json && tsc -p tsconfig.client.json
pnpm dsh:test        # 跑单元测试（vitest，会先 build）
```

四件值得知道的事：

- **skill 正文是静态导入的**。`src/skill.ts` 直接 `import '../skill/SKILL.md'`，由
  `tsdown.config.ts` 里的 `markdownText` 插件在构建时变成字符串常量 —— 运行期不读磁盘，
  少一个「文件不在原位」的失败点。`skill/` 目录仍然随包发布，因为 `resourceBase`
  指向它，模型按需读 `references/*.md` 时走的还是真实文件。
- **`client.js` 必须是经典脚本**。源码是普通 ESM（`import { useState } from 'react'`），
  构建用 cjs 格式让 rolldown 把 import 落成 `require(...)`，再由 `loaderEntry` 插件
  套上 `window.__ModuleLoader__.load({ id, factory })`。React 走 `deps.neverBundle`
  保持外置 —— 它只存在于宿主的模块表里，包里那点 `@types/react` 只为类型。
  JSX 用**生产态**运行时（`react/jsx-runtime`）：`react/jsx-dev-runtime` 不在模块表里，
  切过去就是白屏，自测里的 `require` 桩会把这条路堵住。
- **浏览器半边的类型也来自官方包**。`slot` 名、`useTabInfo`、标签定义的形状都是从
  `@deepseek-ai/dsh-client-ui-{sidebar-right,renderer,slots}` 合并来的：
  `PropsRuntime<'sidebar.right.pane.tab'>` 就是正文的 props，`priority` 只能是
  `'extension' | 'builtin' | 'fallback'` —— 手抄一份的话这几个字面量早写错了。
- **宿主半边的类型同理**。`src/types.ts` 里没有一行手写的 service 声明：`import type`
  官方包触发 cordis 的声明合并（`declare module '@deepseek-ai/cordis'`），再从 `Context`
  上切出插件用到的那几个方法。官方改了签名，`dsh:typecheck` 立刻报错。这些包在
  `devDependencies` 里，且全是 `import type` —— 宿主产物里一行 `@deepseek-ai` 都不会出现。
  浏览器那半边有一个**例外**：`@deepseek-ai/dsh-client-ui-primitives` 是真的要 `require`
  的 —— 报错横幅用的 `Toast` / `Modal` / `Button` 必须是宿主的**同一份实例**（它们在 DSH 的
  冻结模块表里，见 `client/web/src/seed.ts` 的 `PLATFORM_MODULES`）。自己再打一份，等于在
  DSH 的设计语言旁边另立一套，还要多背一份 `react-dom` 和一套焦点管理。

## 装

```bash
# 先构建（DSH 加载的是 dist/）
pnpm dsh:build

# 在 DSH 里（profile web 才有浏览器界面）
dsh plugin --profile web add /abs/path/to/tsxapp/packages/dsh
# 重启 dsh web，刷新页面
```

确认挂上了：

```bash
dsh --profile web --dump-config | grep -A2 tsxapp-dsh
```

开发时改完代码 `pnpm dsh:build` 再重启 DSH —— DSH 在启动时锁定插件集合，
热改插件本身不会生效。

## 怎么起服务：直接调内核，不开子进程

插件**不 spawn `tsxapp` 命令**，而是在 DSH 宿主进程里直接调内核 —— `src/runs.ts` 顶部
就是一句静态导入：

```ts
import { chooseLocale, startApp } from '@tsxapp/core'

const handle = await startApp({ file, cwd, version, options, logger, banner: false })
handle.url     // 带一次性令牌的宿主页地址
await handle.close()
```

当初把 CLI 和 core 拆开就是为了这个：命令行那层壳（解析参数、打横幅、装 SIGINT 钩子）
对嵌入方全是负担 —— 横幅会刷乱宿主终端，`process.exit(0)` 更会把 DSH 一起关掉。
插件只拿内核交回的 `AppHandle`，没有 pid、没有 exit code。

`startApp` 就住在 `packages/core/src/run.ts`；命令行走的 `runApp` 只是它 + 信号钩子。

### 谁负责收尾：一条 effect 管一个应用

启动是异步的（要编译），而「停止」随时可能先到。这套时序没有手写状态机 —— 每个文件
对应**一条 cordis effect**：

```ts
run.release = ctx.effect(() => () => {
  ac.abort('stopped')   // 让还在飞的启动立刻放弃
  return slot.close()   // 关句柄；收摊之后才到的句柄，进门那一刻关掉
}, `tsxapp-dsh: ${basename(absolute)}`)
```

于是「用户点停止」（手动调 `release()`）和「插件被卸载」（fiber 逆序调它）是同一段代码。
插件里因此没有「卸载时遍历在跑的服务」这种循环，也不需要代号去判断一个刚回来的句柄是不是
已经过气 —— 那件事由句柄槽的 `retired` 一次说清（`src/runs.ts`）。

`ctx.getEffects()` 里，每个在跑的应用都是一条带文件名的 effect。

### 内核在哪儿

内核是本包的**普通依赖** —— `package.json` 里写着 `"@tsxapp/core": "workspace:*"`，
pnpm 把它链接到 `packages/core`。代码里就是一句静态 `import … from '@tsxapp/core'`：
仓库内开发、装进 DSH profile、别人机器上 `npm install`，走的都是同一条 Node 解析规则。
**没有** `config.core`、**没有**候选路径列表、**没有**「找不到就试下一个」的兜底 ——
所以也不会出现「内核太旧 / 位置猜错了」这类只在运行期才炸的分支。

代价是失败形态变了：core 装不上时 `dist/index.js` 自己就 import 失败，插件整条不激活
（DSH 会报 `1 entry did not activate`），而不是起来一半再回一句「找不到内核」。
这比静默退化好定位 —— 一眼看出是依赖没装。

记得先 `pnpm build`：内核的入口是 `dist/index.js`，也是类型声明的来源。

## 配置

`cordis.patch.yml` 里那一行就是配置，`config` 是**整行替换**语义（不是深合并）。
键名与 `tsxapp` 的命令行参数一一对应：

```yaml
- insert:
    - id: tsxapp-dsh
      name: '@tsxapp/dsh-plugin'
      config:
        port: 5173            # 起始端口，被占用时内核会顺延
        allow: []             # 启动即放行的规则，写法同 --allow
        deny: []
        startTimeoutMs: 20000 # 等内核起好的上限
```

可用的键：`port` `host` `hmr` `accessControl` `typecheck` `persistPermissions`
`allow` `deny` `title` `verbose` `locale` `startTimeoutMs`。

没有 `core`（内核是静态依赖，位置不开放配置），也没有 `open`（打开浏览器是顶栏那颗链接
的事，内核自己不管）。

`typecheck`（默认 `true`）就是启动前那次类型 / 语法检查：有错时启动会失败，侧边栏的
日志里是那份报告（和命令行同一份文案），改好再点一次就行；要绕过就写 `typecheck: false`
（等价于命令行的 `--no-typecheck`）。
`allow` 适合「打开就该能用」的能力，例如：

```yaml
config:
  allow: ['fs.list:.', 'fs.read:./data/**']
```

没写进 `allow` 的能力，仍然是运行时弹窗授权（宿主页 Monitor 面板里可以点「始终允许」/
「始终拒绝」把这一次的选择沉淀成规则）。

## 侧边栏里是什么

点开任意 `.app.tsx`，标签会**自动启动**它，然后把页面直接画在标签里 —— 不再弹系统浏览器。

**启动中整块标签只有一个加载动画**：一圈渐隐尾巴的环在转，没有文案、没有按钮、没有顶栏和
面板 —— 编译这几秒里没什么值得占位置的信息。跑起来之后才是下面这套外壳：

| 区域 | 内容 |
| --- | --- |
| 顶栏 | 左边一行文件路径（`…/sales/demo.app.tsx`，超长省略、悬停看全路径），右边「重启并刷新」/「截图」/「在浏览器打开」三颗图标（`space-between`） |
| 舞台 | 应用页面（`<iframe>` 指向宿主页）；起不来时（超时 / 失败）是标题 + **完整错误原因** + 「帮我修复」/「重试」 |
| 底部面板 | 「源码」（宿主的 `CodeBlock`：语法高亮 + 行号 + 复制，前 512 KB）/「运行日志」（内核 `logger`，环形 400 行，展示 200 行）两个胶囊 + 一颗展开 / 收起，**默认折叠** |

没有状态胶囊、没有启动 / 停止按钮组：启动是打开标签就做的，停止是关标签。正常启动路径上
没有任何按钮 —— 出错那一屏才有「帮我修复」和「重试」两颗（前者实底，后者描边）。

界面只有三档（`use-run.ts` 的 `Phase`）：**启动中**（整块标签只有一圈加载环 —— 打开标签、
点「重启并刷新」、被停掉后自动拉起，都是这一档）、**有页面**、**起不来**。服务被停掉
（关掉同地址的另一个标签、宿主重启过插件）不会摆一句「服务已停止」让你再点一次，
标签自己会把它重新拉起来。

### 起不来那一屏

标题一句（「起不来」/「内核迟迟没起来」），下面是**错误原因本身**：内核把完整报告
（类型检查那一坨、或者一句 `file not found`）压成一条日志，这里取最后一条原样铺开，
左对齐、等宽、超长自己滚 —— 不再把用户赶去底部的日志面板里翻。

原因下面是两颗按钮：**帮我修复**（宿主的 primary 填充 + 四角星图标）把路径、状态、
失败原因和日志尾巴打包成一条消息，在当前工作区开一个新会话交给 agent；**重试**
（描边 + 回头箭头）重新发一次启动请求。

### 两颗「截图」：把链路分段量

「截图」有三跳，任何一跳断了都会变成工具那句 `no browser answered within 20s`。所以
正文顶栏有一颗「截图」，宿主页 Monitor 面板头右侧也有一颗：

| 按钮 | 在哪 | 量的那一跳 |
| --- | --- | --- |
| 侧边栏顶栏那颗 | 正文顶栏，「重启并刷新」右边 | 侧边栏 → 宿主页 → 应用页 |
| 宿主页顶栏那颗 | 宿主页顶栏，「重载」右边（Monitor 那颗的左边） | 宿主页 → 应用页（不经父窗口） |

两颗都不走工具那套信箱（工具在宿主侧，只能靠 2.5 秒轮询接件）。所以：
**侧边栏那颗通、工具不通** ⇒ 问题在信箱 / 轮询 / 「有人打开这个标签」；
**两颗都不通** ⇒ 问题在「宿主页 ⟷ 应用页」（看宿主页 Monitor 日志页签里那条红色 `截图`）。

成功即下载一张 PNG；失败侧边栏弹一条横幅，宿主页往日志页签写一条 error 并把页签顶出来。

两颗都在**顶栏**上，不要放进默认收起的 Monitor 面板 —— 一颗要先展开面板才看得见
的按钮，等于没有（第一版就踩了这个坑：放在面板头右侧，没人找得到）。

想脱离侧边栏单独看，用右上角的「在浏览器打开」—— 那是一颗**真链接**（`<a target="_blank">`），
交给宿主的「新窗口」处理。桌面端把页面挂在 `file://` 上，`window.open` 在那里常被拦成 `null`
（点了没反应），链接才是「用系统浏览器打开」的那条路。应用还没起来时它没有 `href`，点不动。

## Autofix：起不来就交给 agent

失败那一屏有一颗 **Autofix**：点它会在**当前工作区**里**建一个新会话**（拿不到工作区时才退回
应用所在目录），把「文件、状态、失败原因、日志尾巴」打包成一条消息发给 agent，然后把主视图
切到那个会话 —— 修的过程和结果都看得见。

- 走的是 DSH 的公开客户端服务，不碰宿主内部：`ctx.sessions.create()` →
  `sessions.using(id, { source: 'tsxappAutofix' }, …)` 里 `session.prompt(content, 'queue')`
  → `ctx.uiWorkspace.openSession(id)`（见 `src/client/autofix.ts`）。
- **一个新会话，不是往当前会话里塞**：修 bug 这轮对话有自己的上下文，也不受当前会话是否正忙影响。
- 宿主没有 `sessions` / `uiWorkspace`（老版本、被裁剪的发行版）时按钮**不出现**，而不是点了报错。

## 运行时错误：报一条横幅，不挡路

应用跑起来之后**崩了**（未捕获异常、没接的 Promise、React 渲染错误）由宿主页转上来，
侧边栏报一条 **DSH 自带的 `Toast`**（`@deepseek-ai/dsh-client-ui-primitives`，
`createPortal` 到 `document.body`）：顶部一行「应用报错：…」，后面跟两个内联动作
「或 帮我修复」「· 错误详情」，几秒后自己淡出。**它不挡应用** —— 报错是通知，不是拦截。

点「帮我修复」走的还是 `autofix.ts` 那条路，只是消息里写的是 `kind: 'runtime'` ——
「应用在跑但报错」和「起不来」是两种事，agent 该看的话不一样。点「错误详情」（或者修复
没能起来）才展开 `Modal`：错误摘要 + 堆栈，卡片放开宽度、内容区自己滚，底下两颗按钮
「关闭」/「帮我修复」—— 看完堆栈就在这儿修，不用先关掉详情、等横幅重新滑进来。那一步
是用户自己点的，弹窗于是合理。

边界在哪：

- **只认「崩了」那几类**（`uncaught` / `rejection` / `render`）。应用自己 `console.error`
  的一句话、或一张图 404，不报：开发期的 Vite 也会往控制台打 error（`--no-hmr` 那条
  `[vite] failed to connect to websocket` 就是），拿它们问「要不要修」只会变成噪音；
- **每一条都报**，同一句话再出现也照报 —— 降噪在宿主那一侧做（`#pushLog` 把 3 秒内
  同签名的重复合并掉，合并的那些不上行），所以每帧报同一句也只落成一条通知；
- 只在**有人能修**（`sessions` / `uiWorkspace` 都在）时给「帮我修复」那颗动作，
  没有就只留「错误详情」；
- 横幅淡出就什么都不发生，不打断用户看应用。

## 工具：`tsxapp_preview`

钩子里注册了一个 agent 可调的工具，一次做三件事，严格按「先便宜后贵」排：

```
tsxapp_preview({ file: 'examples/demo.app.tsx', limit: 30 })
```

1. **类型检查** —— 直接调内核的 `typecheckApp()`，和启动前那次是同一份口径（采纳工程里的
   `tsconfig`、JSX 自动运行时、模块解析兜底）。有错时回 `core.formatReport()` 那份人读报告
   （`文件:行:列  TS码  原因` + 代码帧）。**有错就到此为止**：工具正常返回，但不起服务、
   不截图 —— 见下面「为什么类型检查在第一」；
2. **起服务**（已经在跑就复用，不重编译）；
3. **截图** —— 请侧边栏把标签打开、由应用页自己把 DOM 画成 PNG；图**作为工具结果里的图片块
   直接回给模型**，报告里只留尺寸与体积。

**为什么类型检查在第一**：它是唯一一步「不开进程、不碰浏览器、几十毫秒出结果」的动作，而结论
决定后面两步值不值得做。反过来做会踩两个坑：类型错的应用照样能起（Vite 不做类型检查），但截
出来是**满屏错误浮层** —— agent 很容易把浮层当页面，得出「渲染没问题」；而且每改错一次就白跑
一遍编译加截图。

报告的顺序就是实际发生的顺序：`file:` → `type check:` → `status:` / `preview:` → `screenshot:`。
类型没过时后两段不出现（若这个应用本来就在跑，会多一行 `preview: … (already running — not
restarted)`，免得被当成这次调用的成果）。

为什么把类型检查并进这里，而不是留一个独立工具：agent 的真实动作永远是「改完 → 看一眼对不对」，
类型错只是「不对」的一种。分成两个工具，它得记住先调哪个；合成一个，它只要记住一条。

**图是回给模型的**：工具的规范值是一段报告 +（截到图时）一张图的引用，`output.render` 把后者
拼成 `ImageBlock` 一起进 `content`。图片块只认宿主的 durable 附件引用，没有内联 base64 那条路
—— 所以 `execute` 里先 `ctx.attachments.saveImage()` 把 PNG 交给附件服务，再拿它发回来的
`attachmentId` 建块。截图也**不落盘**：`%TEMP%` 里堆 PNG 只会在用户机器上留垃圾。这也是
`tools` 与 `attachments` 在同一次 `ctx.inject([…])` 里要的原因 —— 少了附件服务，这个工具就
没有「让模型看见」的最后一环。

**截图失败不算工具失败**：侧边栏没开、浏览器不支持时，类型检查与服务地址仍然有效，报告里写成
`screenshot: unavailable — …`，并且**不带图片块**（不拿个空引用糊弄模型）。把整条调用变成错误
只会让 agent 白跑一趟。

### 截图是怎么走通的

工具在**宿主进程**里，截图只能在**浏览器**里做，两边没有长连接 —— 中间靠一个信箱
（`src/shots.ts`）接起来：

```
工具 request(path) ──► 信箱（id, path, promise）
                        │
   客户端常驻轮询 GET /tsxapp/shots ──► 看到 id ⇒ sidebarRight.openResource(地址) 开标签
                        │
   标签正文轮询快照看到 shots:[id] ──► postMessage 给宿主页 ──► 应用页 domToPng
                        │
   POST /tsxapp/shot { id, dataUrl } ──► settle(id) ──► 工具的 promise 落地
```

- **一定要有超时**（`previewTimeoutMs`，默认 20s）：标签可能根本没开，没有人会来取这封信；
- **同一路径只截一次**：后到的请求复用前一个在飞的；
- **卸载即清空**：插件卸载时在等的全部兑现成失败，别让工具的 promise 永远挂着。

截图能力本身在 `@tsxapp/runtime` 的 `captureScreenshot()`（`modern-screenshot` 的 `domToPng`，
动态 `import()` 不占首屏）：最长边压到 1600、超 3 MB 依次降档，背景色取根节点的实际底色
（否则暗色主题会截出一张白底黑字）。

## 三层窗口之间说什么

```
DSH 侧边栏（父窗口，可能是 file://）
   └─ iframe ─► 宿主页  localhost:P   （唯一持令牌）
        └─ iframe ─► 应用页 app.localhost:P  （跨源，跑用户代码）
```

- 侧边栏 ⇄ 宿主页：`{ __tsxapp: 1, t: 'screenshot', id }` 下发、`screenshot-result` 回来；
  `log` / `error` 上行（宿主页 `bridge.ts` 的 `#postToParent`）。只流这三样，没有令牌、没有凭据；
- 宿主页 ⇄ 应用页：原来的 RPC 那条线，截图指令与结果都是新加的两个消息类型。

实现上有个刻意的取舍：**没有 import `defineTool()`**。它是运行时代码，引进来会把 schema 编译器
整段打进 `dist/index.js`（60 KB → 400 KB），而它对这个工具的贡献只是把参数 spec 编译成一段
JSON Schema —— 手写一遍更小。代价是参数不做 schema 校验，所以 `limitOf` / `resolveTarget`
自己逐字段收一遍。`@deepseek-ai/dsh-tools` 只留作类型来源（devDependency）。

注册走的是 `ctx.inject(['tools'], …)`，不是直接 `ctx.tools.register()`。原因只有一个：cordis 的
ctx 是 Proxy，**读一个没写进插件 `inject` 的服务名会当场抛**
`cannot get property "tools" without inject`，`?.` 拦不住（异常出在取属性那一步），而这一次抛错
会把整个 `apply` 打断 —— 六条路由和侧边栏一起不激活，DSH 只报「1 entry did not activate」。
把 `tools` 写进插件 `inject` 又太重：它只是可选服务，缺席不该让整条插件陪着等。
`ctx.inject` 两头都不占：服务就绪后跑回调，晚到会重跑一次，缺席就只是少一个工具。

样式走一次性 `<style>` 注入，判据是**内容**而不是「元素在不在」——`ensureStyles` 只有
在页面里那份和当前这版逐字一致时才跳过。写成「有 id 就 return」的版本会让插件升级后
（重挂插件、不整页刷新）新规则一条都不生效，页面上留着上一版的样式，新类名没样式、
按钮退回浏览器默认外观 —— 看起来就是「布局全乱」。

**服务跟着标签活，不跟着标签显示活**：标签类型声明了 `keepMounted: true` —— 宿主默认
只把「可见」的正文留在树上，切到别的标签、收起侧边栏、换 session 都会卸载正文，而正文
一卸就会发停止请求（以前「切一下 tab 应用就没了」正是这个）。`keepMounted` 让访问过的正文
在隐藏期间继续挂着，只有**真的关标签**才卸载。

**关掉标签就停服务**：`useRun` 在卸载时会发一次 `POST /tsxapp/stop`。等 260ms 再发是为了
避开 React 严格模式的「挂载 → 卸载 → 再挂载」—— 立刻发会把刚起来的服务自己打掉。
正文被隐藏时（不是卸载）只暂停状态轮询，服务照跑。

**页面为什么能嵌进来**：宿主页**不设** `frame-ancestors` —— 谁能嵌它由**令牌**决定。
要渲染必须带 URL 里的 `?t=…`，别的页面拿不到、猜不出，嵌进去只有一张「缺少访问令牌」
的空壳。反过来用白名单会把自己锁死：浏览器校验的是整条祖先链（DSH 的 `localhost:3080`
→ 宿主页 → 应用页，漏一环就白屏），Electron 里页面 origin 又是 `"null"`，host-source
根本匹配不上。所以插件也不用上报自己的 origin。

同一文件重复点「启动」不会起第二个服务；关标签、点停止、插件卸载，三条路都会把服务
`close()` 掉。启动超时（默认 20s）时，如果内核后来又起来了，插件会立刻把它关掉 ——
不留没人认领的端口。

## 宿主路由

六条，都先过连接层的信任围栏（`ctx.connection.requestRejection`），没声明的一律不认：

| 方法 | 路径 | 作用 |
| --- | --- | --- |
| POST | `/tsxapp/start` | `{ address }` → 起服务，回状态快照 |
| POST | `/tsxapp/stop` | `{ address }` → 关服务 |
| GET | `/tsxapp/state?address=…` | 状态 + 日志 + **在等的截图 id**（`shots`） |
| GET | `/tsxapp/source?address=…` | 源码（≤ 512 KB，带 `truncated`） |
| GET | `/tsxapp/shots` | 全宿主还在等的截图请求（客户端常驻轮询，用来开标签） |
| POST | `/tsxapp/shot` | `{ id, dataUrl, width?, height? }` → 兑现工具那边等着的 promise |

`address` 是 DSH 的资源地址 `dsh-resource://file/session/<sessionId>/<path>` 或
`…/absolute/<path>`。session 作用域由**宿主**按该 session 的 cwd 落成绝对路径 ——
浏览器那一半不知道、也不需要知道工作区在哪。只接受 `.tsx`。

客户端用**相对路径**（`tsxapp/state`，无前导斜杠）请求：桌面端页面跑在 `file://` 上、
请求走 IPC 桥，带前导斜杠会被解析成盘符根。

## skill 内容从哪来

包内的 `skill/` 就是**唯一一份**，没有副本要同步。它同时扮演两个角色：

- `src/skill.ts` 静态 import 这三个 `.md`，构建时被内联成字符串常量（正文与磁盘无关）；
- `resourceBase` 指向这个目录，模型按需读 `references/*.md` 时走的是真实文件。

所以改技能直接改 `skill/`，构建与发布读的都是它。

插件在通用技能正文后面追加一段 DSH 专属说明（存成 `*.app.tsx`、启动交给侧边栏、
不要自己跑命令），见 `src/skill.ts` 的 `SKILL_APPENDIX`。技能正文只讲**应用怎么写**，
运行方式（命令行参数、面板、调试）不进技能，留在这份文档与根 README 里。

## 依赖

本包是正常的 workspace 成员，两类依赖各管一件事：

| 依赖 | 谁提供 | 用途 |
| --- | --- | --- |
| `@tsxapp/core`（`"workspace:*"`） | pnpm 链接到 `packages/core` | 运行期调 `startApp()` |
| `@deepseek-ai/cordis` + `@deepseek-ai/dsh-{host-webserver,skill,session,client-connection}`（`devDependencies`） | 本仓库 `pnpm install` | 宿主半边的类型，编译后全部擦除 |
| `@deepseek-ai/dsh-client-ui-{sidebar-right,renderer,slots}` + `@types/react`（`devDependencies`） | 同上 | 浏览器半边的类型（slot 名、`useTabInfo`、标签定义），同样只到编译期 |
| `@deepseek-ai/dsh-client-ui-primitives`（`devDependencies`） | 同上 | 报错横幅与详情弹窗的 `Toast` / `Modal` / `Button`：类型来自这里，运行时 `require` 到的是宿主模块表里那一份 |

`@deepseek-ai` 那几个的版本跟着 DSH 桌面版走（当前 `0.2.0-rc.2`，cordis `4.0.5`）。
DSH 升级后如果 service 签名变了，`pnpm dsh:typecheck` 会先报出来 —— 那时同步版本或改用法，
而不是回来把类型抄一遍绕过去。

装进 DSH profile 时这些 `devDependencies` 不会跟过去：宿主自己就有一份，重复装只会多出
第二个实例。**React 也是同理**：包里没有 `react`，只有它的类型，运行时那个 `require('react')`
由宿主的模块表回答。

## 自测

```bash
pnpm dsh:test    # = pnpm dsh:build && vitest run --project dsh
```

单元测试是 **vitest**（`test/plugin.test.ts`，TypeScript），配置在包内的
`vitest.config.ts`；在包目录里直接 `pnpm test` 只跑这一份。

没有真宿主，用桩把两个半边都跑起来：

- **宿主半边**：喂假 `ctx` 后**真的**调那六条路由。启动换成 `test/stub-core.ts`
  这个**桩内核**，于是「起服务 → 拿句柄 → running → stop 关句柄」这条链路是真跑的，
  还能演失败 / 超时两种内核行为。工具那节连截图回路一起验：挂请求 → 看到 id →
  `POST /tsxapp/shot` 交一张假 PNG → 报告里出现落盘路径。
  插件里是静态 import、没有「换内核」的开关，所以桩是在**模块解析层**装进去的：
  `vitest.config.ts` 里的 `resolve.alias` 把 `@tsxapp/core` 指到桩上 ——
  测试设施，产物里一行都看不到。
- **真内核**：端到端那一条让桩把 `startApp` 转给**真** `@tsxapp/core`，真起一个 Vite
  服务，取一次宿主页确认 200，`stop` 之后确认端口不再通；另有一条拿真 `tsc` 验
  `typecheckApp`，还有一条直接读产物，钉住「是顶层静态 import、配置里没有 core 项」。
  这是「不调命令行」的验收点。
- **浏览器半边**：桩 `window.__ModuleLoader__` 截住打包后的 `dist/client.js`，检查注册的
  kind / patterns / priority / title 与正文座位；再验「有人等截图 ⇒ 按会话地址开标签」。

测的是 **dist 产物**而不是源码：真正被 DSH 加载的就是那两份文件，源码类型对了、
打包后跑不起来的情况只有在这一层才抓得住。

## 已知限制

- 只在 web profile 有用（TUI / headless 没有侧边栏）。
- 标签标题在打开时定格为文件名；文件改名不会更新已开的标签。
- 一个 `.app.tsx` 一份服务，同进程内共享；同一文件被两个标签打开时状态也是同一份。
- 端口由内核顺延（`port` 只是起始值），插件只如实显示最终地址。
- 服务与 DSH 同进程：DSH 崩了服务一起没（这正是「嵌入」的语义，但也意味着没有进程隔离）。

