# dsh-sentry

[English](README.md) | 中文

## dsh 起不来怎么办

你正在看的报错就是这三行 ——

    Failed to load plugins
    web boot: 3 entries did not activate
    @citisen/dsh-sentry: pending (waiting for service: settingsScope)

「启用的条目却始终不激活」在 dsh 里算**启动失败**而不是警告：它会拒绝完成启动，而不是少个插件
照常起来。三条出路，从快到慢，**三条都在 dsh 起不来的情况下可用**。本插件在 profile 里的行是
`id: alert`，对应 `name: '@citisen/dsh-sentry'`。

**1. 禁用它 —— 在 profile 自己的补丁层里加一条**
（`$DSH_HOME/profiles/web/cordis.patch.yml`，这一层在所有 bundle 层之后应用）：

    - id: alert
      disabled: true

不用敲命令、不用联网、不用装东西；删掉这两行它就回来了。`dsh --profile web --dump-config`
会打印组装后的树 —— 每个行的 id 和包名，不管它属于谁 —— 补丁生效时这一行会带
`disabled: true`；它不加载任何插件，所以 dsh 起不来时也能用。

**2. 只影响这一次启动，什么都不改** —— 把同样两行写进你自己的文件，当叠加层传进去：

    dsh --profile web --patch ./no-sentry.yml web

**3. 卸载它** —— 一条命令同时摘掉依赖和 bundle 层（`dsh.profile.bundles` 会按已安装状态自动
对齐）。它只是转发给 profile 目录里的 pnpm，不组装 profile，所以 dsh 起不来时也能跑：

    dsh plugin --profile web remove @citisen/dsh-sentry

需要 `PATH` 上有 `pnpm`。没有的话，就手工从 `$DSH_HOME/profiles/web/package.json` 的
`dsh.profile.bundles`（以及对应的 `dependencies`）里删掉包名。

**或者先要一个能用的 dsh**：用官方模板起一个干净的 profile，它不带你装的任何 bundle：

    dsh --profile rescue --from-default-profile web

## 兼容性

本构建在两条 dsh 线上都能跑：**0.1.5-rc.x** 系列（也就是当前的 `latest` 和 `next`），以及
**0.1.7-alpha.1** —— 后者的设置模型它同样会说。两条线上，这份插件的 section 都叫同一个名字
`alert`：0.1.7 线按 Loader 条目 id 定位设置，而本 bundle 的补丁正是以这个名字插入条目；
0.1.5 线则把同一个名字注册为设置命名空间。

| 它读什么 | 0.1.5-rc.x | 0.1.7-alpha.1 |
| --- | --- | --- |
| 某个会话在等你 | `uiSession.pendingInteractions` | `uiSession.sessionStatus`，其值里带 `pendingInteraction` |
| 那份持久 section | `settingsScope.bind({ namespace: 'alert' })` | `configForms.get('alert')`，读条目自己的 `Config` |
| 宿主契约 | `settings.register('alert', schema)` | 导出的 `Config`，字段标记为 `.volatile()` |

两者都是**可选绑定**，所以两条服务都不提供的 dsh 也照样激活：插件不会一直 `pending`（那会直接
阻断启动），也不会在激活时抛错。它按出厂默认值工作，而你在设置行上第一次动开关时，它会告诉你为
什么存不下来。`0.1.1` 及更早的版本要求 0.1.5 那条服务，所以在 `0.1.7-alpha.1`
上被报成「未激活」的条目；`0.1.2` 两条线都会说。

等待/审批状态会按 dsh 实际提供的那一种形状去读，所以两条线上都会点亮标签页；这段映射由
`npm run verify` 覆盖。两者都不提供的 dsh 只会被报告一次「看不见状态」，而不会让激活失败 ——
失败的条目会直接阻断 web 启动。

### 0.1.7 改名时丢掉的设置

dsh 0.1.7 会把旧的 `$DSH_HOME/settings.yaml` 导入一次 —— 每个 section 写进同名条目 —— 并把文件
改名为 `settings.yaml.imported`。在 `0.1.2` 之前，本插件的条目叫 `sentry`，于是
`alert` 这个 section 无处可去，只留在改名后的文件里。现在名字对上了，dsh 自己的导入就能把
这些值放回去：

1. 把 `$DSH_HOME/settings.yaml.imported` **复制**成 `$DSH_HOME/settings.yaml`（是复制不是移动 ——
   导入跑之前，那个文件是唯一的记录），并且只保留条目现在仍然声明的键：dsh 会用条目自己的 schema
   校验这个 section，只要有一个不认识的键就整段拒绝，所以上面表格没列出的键都要删掉。旧的
   `alert` section 里只有 `style` 能留下 —— `volume`、`fishScale`、`title`、`sound` 和
   `doneWindowMs` 现在都是那份文档里的行。
2. 用你平时用的 profile 启动一次 dsh 0.1.7。凡是现在有条目对应的 section —— 包括
   `alert` —— 都会写进那个 profile 的 Cordis patch。
3. dsh 仍然不认的 section 会被报告出来，并继续留在 `settings.yaml.imported` 里；所以**在你把需要
   的东西取出来之前，别删那个文件**。

**DeepSeek Harness Web 界面的标签页哨兵**：你在别的标签页时，它替你盯着所有会话，该你出手的时候叫你回来。

界面在你**正看着它**的时候已经把一切都说了 —— 侧边栏每个会话有状态点，对话自己有流式指示。但只要标签页退到后台，它就什么都不说了，而这恰好是长任务运行的时候。这个插件的职责是从房间另一头回答一个问题 —— *有事情需要我吗？* —— 用的是后台标签页仅有的三个通道：**图标**、**标题**、**声音**。

这是一个第三方 [dsh](https://github.com/deepseek-ai/deepseek-harness) profile bundle。它以一个双面包的形式发布：Node 半边持有持久化的设置命名空间，浏览器半边运行哨兵逻辑并注册设置行。

需要 dsh `0.1.5-rc.1` 或更高的 `0.1.5-rc.x`；它用到 `settings.general.item` 插槽、`settingsScope` 服务、`sessions` 与 `uiSession` 服务，以及 `ctx.effect`，这些在 `latest` 和 `next` 渠道里都有。

## 它加了什么

*设置 → 通用* 里多出一行 **标签页提醒**，以及三个在页面处于后台时仍然有效的提醒通道：

| 通道 | 表现 |
| --- | --- |
| 标签图标 | 原 favicon 的鱼,从一块彩色背景里镂空出来:颜色和形状表示状态,执行中时鱼会转动,右上角数字是待回答的问题个数 |
| 标签标题 | 一个状态前缀，让准确的数字能被读出来：`① 等待回答 · 我的会话 — DeepSeek Harness` |
| 声音 | 合成提示音 —— 三段公有领域的古典乐句：提问时是一次敲门，审批时是一段下行，完成时是一句安静的收束 |

这一行里**没有开关**：四个状态长什么样、响什么声音，全部写在一份文本配置里（见 [样式文档](#样式文档)）。设置行本身就是那份文档的编辑器，编辑器下面是四个状态的**实时预览** —— 32 像素，和标签页里画的一样大，动效按文档播放，每一张卡片还能单独试听它那一声。

## 状态模型

每个会话恰好处于一种状态，优先级就是整个设计的核心：

| 状态 | 判定依据 | 信号 |
| --- | --- | --- |
| 等待回答 | 待处理交互的 kind 为 `question` | 琥珀，呼吸（`blink`，1.1s 一个周期）；计入角标数字 |
| 等待审批 | 待处理交互的 kind 为 `approval` | 琥珀，呼吸得慢一些（`blink`，1.9s） |
| 执行中 | `summary.running` | 蓝色，鱼在转（`turn`，一圈 3s） |
| 刚刚完成 | `running → idle` 的**下降沿**，在衰减窗口内 | 绿色，背景色在绿与蓝之间往复（`pulse`，1.6s） |
| 空闲/空会话 | — | 什么都不显示 |

表里有三个决定值得解释，因为每一个都否决了一个替代方案：

- **正在执行且正在等待的会话，算"等待"。** 正在跑的那部分不是需要你的那部分。
- **"刚刚完成"是一个沿，不是 `summary.completed`。** 那个标志会一直为真直到你点开该会话，直接采信会让标签页永远停在绿色，这个信号就失去了全部意义。插件改为在 `running → idle` 时打时间戳，并保留 `keep-done`（默认 `60s`）。
- **空会话不是信息。** 一个创建了但没用过的会话，否则会在你新建标签页的瞬间给一个空对话套上"执行中"的环。

## 图标

鱼是产品自己的图形,由 `scripts/fish-path.mjs` 从已安装的 `dsh-web-frontend/dist/favicon.svg` **逐字节**提取 —— 不重画、不简化、也不是"长得像"的替代品。它以**满尺寸**绘制,即 50×50 的画布放进 32 像素就是 `32/50`;并且是按**图形自己的中心**居中的 —— 那个中心在它自己的坐标里是 25,不是 16。这两个数字来自同一个常量,因为把它们混起来正是本插件出过的一个 bug:把一个 50 单位的画按 32 单位去居中,鱼就被按在背景的右下角,嘴和尾被边框切掉;而当时所有测试都没发现,因为每个测试都在重算 bug 自己那套算式。现在由像素检查去量真实渲染出来的几何。

然后它是**从背景里镂空出来的**,不是画在背景上的。整个图标的立足点就在这个决定上:

```
   ╭───────────────╮          鱼是负空间,所以它的轮廓就是
   │   ▄▄▄▄▄▄▄     │          标签栏透出来的颜色 —— 任何颜色、
   │  ██ ●  ● ██   │          任何主题下都清晰,而且 16px 下
   │   ▀▀▀▀▀▀▀     │          没有描边会和它抢位置
   ╰───────────────╯
```

反过来"把鱼画上去"的做法试过两次:白鱼画在彩色盘上,边缘在 favicon 尺寸下糊成一团;黑鱼画在暗盘上,整个消失。两次都发布了,两次收到的反馈都是"一个白色的圆"。

状态由背景的**颜色与形状**、**动效**,以及右上角回答数角标承载。图标上不画别的东西:曾经在鱼周围镂空过的表盘类图案 —— 辐条、时针分针、花瓣、风车、圆点、光芒 —— 每一个都在真实的 16px 下渲染过,每一个都只是让鱼看不清的噪点。所以配置文档里已经没有"图案"这个属性了:它记录的是几次已经被回答过的提问,而不是一个留着以后可能有用的槽位。

### 动效

`turn` 旋转的是**鱼本身**,绕画布中心 —— 这个图标就是在讲这条鱼,所以动的是它,不需要再画任何标记来表示"正在工作"。旋转时鱼会略缩小(`FISH_TURN_SCALE`,由图形**实测**的半宽推导),让它的四角扫过的圆仍落在背景内;满尺寸旋转每圈会有两次切到边缘,在 16px 下读起来是闪烁而不是转动。

动效由**插件驱动**,不在 SVG 里声明。favicon 渲染在页面并不拥有的文档里,而各类动效在那里待遇不同:早先的版本把旋转交给 `<animateTransform>`,结果它**完全不动**,而同期的颜色脉动是有效的。所以现在动效是"从 tick 到画面"的函数 —— `blink` 变暗、`pulse` 换色、`turn` 步进角度 —— 引擎在有东西动时每 120ms 重绘一次。`prefers-reduced-motion` 会停掉定时器,空闲的标签页一个定时器都不持有。

一个诚实的后果:鱼只在标签栏颜色与背景不同时才可见。若标签栏恰好同色,背景看起来就是一个纯色形状。页面无法知道浏览器 chrome 的颜色,这就是为什么状态靠动效和剪影承载,而不是只靠背景。

## 声音

用 Web Audio 合成 —— 不打包任何音频文件，也不联网取任何东西。**音符是配置文档里的字面量**，不再是插件里写死的一张表。出厂的三段旋律都是公有领域的古典乐句，各按它那个状态的含义挑的：

| 状态 | 出厂声音 | 为什么 |
| --- | --- | --- |
| 等待回答 | 贝多芬《第五交响曲》op.67：`G G G Eb`（命运动机），默认音量 0.5 | 贝多芬自己说这是"命运在敲门"—— **敲门**就是"有人在外面等你"，而这是最高频的状态，所以只取四音动机（1.2 秒） |
| 等待审批 | 巴赫《d 小调托卡塔与赋格》BWV 565 开头：长音 A + 下行音阶，`volume 0.45` | 庄严的**下行**：这不是叫你一声，而是"有件事需要你定"（1.3 秒） |
| 刚刚完成 | 贝多芬《第九交响曲》"欢乐颂"主题，低八度，`volume 0.25` | 温暖、解决、上行收束；完成属于背景信息，所以它明显更轻（2.1 秒） |
| 执行中 | 不发声 | 一轮开始不是这个插件会打扰你的事件；`running` 块里写 `chime`（或 `tone`）会被报成"写了但没有用"，而不是被悄悄忽略 |

它们就是文档里的三行 `chime`（外加两行 `tone`、两行 `volume`）—— 插件里没有编译进任何一段旋律，所以**设置 → 通用 → 标签页提醒**就是换掉它们的地方：

```
volume 0.5                              // 默认音量：没在自己块里写 volume 的状态用它
waiting  { chime G4:170ms G4:170ms G4:170ms Eb4:680ms   tone triangle }
approval { chime A5:350ms G5:95ms F5:95ms E5:95ms D5:95ms C#5:95ms D5:500ms   tone triangle   volume 0.45 }
done     { chime E4:230ms E4:230ms F4:230ms G4:230ms G4:230ms F4:230ms E4:230ms D4:460ms       volume 0.25 }
```

音名（`A5`、`E6`、`C#4`、`Bb3`）或者频率（`880 1318.5`）都可以，按写下的顺序播放。**每个音还可以在冒号后写自己的时值**，用的就是文档里那套时长写法：

```
chime A5 E6                        // 两个没写时值的音：各 130ms、相隔 90ms，叠着出来
chime A5:200ms E6:200ms            // 同样两个音，一个接一个
chime A5:120ms -:80ms E6:240ms     // 一个休止，然后一个更长的音
```

写了时值的项目就占那么长：它响这么久，下一个项目在它结束时起音 —— 这才叫节奏。没写时值的项目沿用出厂步调（一个 130ms 的音，后一个晚 90ms 起音），所以**在这次改动之前写下的每一条 chime，听起来和以前一模一样**。出厂的这三段旋律把每个音的时值都写了出来 —— 正因为如此，三段古典乐句才只是三行文本，而不是三个音频文件。`-` 是休止：照样占时间的静音，和音一样写时值；整条 chime 全是休止，就等于 `off` 的那个"不出声"。

音色也是一行，形状和 `volume` 完全一样 —— 顶层是默认值，块里那个是这个状态自己的：

```
tone triangle                      // 所有出声状态的默认音色
waiting { tone square }            // 这个状态自己的，会替换掉上面那行
```

四个音色就是 Web Audio 自己的四个：`sine`、`triangle`、`square`、`sawtooth`。出厂的两段急事用 `triangle`（在纯音和芯片音之间），完成那段什么都不写、于是拿默认的 `sine` —— 那正是这个插件一直以来的音色，卡片上它带的「（默认）」标记说的就是这件事。文档**不**管的是包络：短促的起音和衰减到静音在任何波形上都归插件，因为包络是合成器音色，不是通知设置。

频率走等程律，A4 = 440Hz。`chime off` 让这个状态单独闭嘴。

`volume` 在两个地方出现，但**只有一个含义：音量**。顶层那行是默认值，块里那行是覆盖值 —— 块里的**替换**它，不与它相乘。所以预览卡片上印的百分比，永远等于文档里某一行的数字（顶层那行，或者这个状态自己那行）；两边都没写时用出厂 0.5，卡片会标「（默认）」。这一条是被改掉的设计：早先版本顶层是"总音量"、块里是"倍数"，而**每状态的倍数还写死在插件里** —— 于是你写 `volume 1`，卡片上却出现 85% 和 45%，文档里任何地方都找不到这两个数。`tone` 走的是同一套规则，卡片上也标同一个「（默认）」。

一条会有代价的取舍值得说明：出厂的旋律最长 2.1 秒，而 `done` 是每轮结束都响的 —— 卡片上那行音名也因此变长（它印的是文档里写的每一个音，不是摘要）。想换回短促的提示，把 `done` 的 `chime` 改成 `A4:180ms` 这种一两个音就行。

两条规则决定它什么时候允许发声，现在同样是文档里的行：

- **前台即静音。** 你正看着界面时，图标和标题已经说明了一切，再响一声就是打扰。声音是给隐藏或失焦的页面的；`sound background` 就是这条规则（也是出厂值），`sound always` 关掉它，`sound off` 则整个静音。
- **一次爆发只响一次。** 模型会连着问好几个问题，三秒钟三声提示音读起来像故障。间隔写成 `chime-gap`（默认 `1.5s`），比较用的是 `Date.now()` 而不是定时器，因为后台标签页会把 `setTimeout` 节流到分钟级，定时器版的间隔会迟到甚至不触发。

### 关于自动播放策略，说明白

**在你至少点击过这个界面一次之前，任何提示音都发不出来。** 在没有任何用户手势之前，浏览器会把 `AudioContext` 建成挂起状态并且不允许它启动；这是浏览器的规则而不是插件的，也绕不过去。插件改为这样做：

- 播放器在第一次 `pointerdown`/`keydown` 时请求 resume，所以你在界面里任何地方点一下，就为本会话解锁了音频；
- 在上下文仍然挂起时请求的提示音会被**丢弃**而不是排队 —— 一声在解锁它的那次点击之后两分钟才响的提示音，比不响更糟；
- 预览卡片上的 **试听** 按钮会播一次这个状态自己的那一声，它同时充当解锁音频的那个手势，以及用耳朵调音量、调音程的手段。所以设置行里没有单独的"试听"按钮：每一张卡片就是它自己的试听。

在解锁之前，图标和标题依然承担全部信息。

## 标题

标签标题属于 `ui-layout` 的 `DocumentTitle`，它会在每次会话切换时重写标题，所以这个插件是**与它合成**而不是接管：把状态段放在已有内容前面，并在自己合成出的标题**末尾**放一个零宽空格。

那个标记就是全部的诀窍。没有它就无从区分"这个前缀是插件装的"和"应用自己的标题里恰好有一个 ` · `" —— 而这个区别决定了去掉前缀时去掉的是插件的贡献还是应用标题的一个真实片段。如果按形状判断，`Part one · Part two — DeepSeek Harness` 这样的标题看起来和前缀一模一样，于是每次渲染都会再吃掉它一段。标记把"这是我的"变成了字符串自身的属性，因此它能挺过应用重写标题；而关掉这个通道时会真的把前缀去掉，而不是把它永远留在标签上。

## 设置

*设置 → 通用 → 标签页提醒。* 这一行里是三件东西：那份配置文档的编辑器、四个状态的实时预览、一个"全部恢复默认"。**没有一个开关** —— 上一版这里有十个（三个通道、四个声音门控、音量、完成窗口），现在它们是文档里的行，因为"文档说一套、开关说另一套"这种分歧不该有存在的机会。

编辑器随文档长高，不给自己加滚动条：页面本来就在滚，一份文档两条滚动条只会让人抓错那一根。留下来的上限是为了别让一份几百行的粘贴把设置页拉成一条长线，而不是一个显示决策 —— 出厂的文档（45 行）一定完整可见。

每张预览卡片上有两个按钮：**试听**（播这个状态的那一声）和**预览**（让真正的标签页显示这个状态 —— 图标和标题都换过去，实时的）。第二个是这份配置唯一可用的验收方式：标签图标平时只显示*当前活着*的状态，你要改的那个状态可能此刻根本没有会话处于其中，于是"我改的到底生效了吗"永远没有答案。再点一次、离开设置页、或者切走这个标签页，就恢复真实状态 —— 页面不在视野里时标签页必须说真话，这正是这个插件存在的理由。

持久化的一半放在 `$DSH_HOME/settings.yaml` 的 `alert` 命名空间下，而且只有一个字段：

```yaml
alert:
  style: |
    icon on
    sound background
    keep-done 60s

    waiting {
      shape rounded
      color amber
      motion blink
      speed 1.1s
      chime G4:170ms G4:170ms G4:170ms Eb4:680ms
      tone triangle
    }
    // …剩下三个状态
```

`style` 是文本，也只有文本：host 这半边不再解析它。文档有自己的读取器，带真正的报错，在浏览器半边 —— 一个 host 端的 schema 如果也去管文档的内容，那就是对一门它看不见的语言发表第二意见，而语言每长出一个属性，两者就会分歧一次。

这个命名空间刻意不用 `ui-*`：dsh 把那个前缀留给了自己内置的界面。

## 样式文档

文档是行式的，而且只有两种行 —— 从行本身就能看出来是哪一种：

```
// 文档级设置在前，一行一条
icon on
title on
sound background
chime-gap 1.5s
keep-done 60s
volume 0.5

// 然后是每个状态一段
waiting {
  shape rounded
  color amber
  motion blink
  speed 1.1s
  chime G4:170ms G4:170ms G4:170ms Eb4:680ms
  tone triangle
}
```

**每个属性都是具名的。** 没有位置槽，没有一个词在这里是一个意思、在那里是另一个意思，也没有 `键=值` 和裸词两种写法要选：一行就是 `名字 值`，而一个不属于当前作用域的名字会被报出来，并附上该作用域接受的名字清单。这就是重写的全部理由 —— 上一版的语法靠"猜这个词属于哪张词表"来摆放裸词，于是 `running circle blue turn 3` 是连续四次猜测，读的人得把槽位顺序记在脑子里。

| 文档级设置 | 取值 | 作用 |
| --- | --- | --- |
| `icon` | `on` / `off` | 是否画标签图标（`off` 就恢复原 favicon） |
| `title` | `on` / `off` | 是否给标签标题加状态前缀 |
| `sound` | `off` / `background` / `always` | 提示音的总体策略；`background` 是出厂值，只在本页隐藏或失焦时发声 |
| `chime-gap` | 时长 | 两次提示音之间的最小间隔 |
| `keep-done` | 时长 | "刚刚完成"保持绿色多久；写成 `0` 就永远不出现 |
| `volume` | 0–1 | 默认音量：没在自己块里写 `volume` 的状态用它 |
| `tone` | `sine` / `triangle` / `square` / `sawtooth` | 默认音色：没在自己块里写 `tone` 的状态用它 |

| 状态属性 | 取值 | 作用 |
| --- | --- | --- |
| `shape` | `circle` / `rounded` / `square` / `none` | 背景形状 |
| `color` | 预设色名 | 背景颜色（只接受预设：本插件出过的两次事故都是对比度问题） |
| `motion` | `still` / `turn` / `blink` / `pulse` | 什么在动 |
| `speed` | 时长 | 动效速率：`turn` 是一圈几秒，其余是一个周期几秒 |
| `chime` | 音名或频率序列（可带 `:时值`）、`-:时值` 休止 / `off` | 这个状态响什么；写了时值的项目占那么长、下一个紧接着起音，没写的沿用出厂步调 |
| `tone` | `sine` / `triangle` / `square` / `sawtooth` | 这个状态自己的音色，覆盖顶层默认值（不是混合）；两边都没写才用出厂 `sine`，卡片会标「（默认）」 |
| `volume` | 0–1 | 这个状态自己的音量，覆盖顶层默认值（不是相乘）；两边都没写才用出厂 0.5，卡片会标「（默认）」 |

三条规矩值得单独说：

- **时长省略单位就是秒。** `3` 和 `3s` 一样是 3 秒；`300ms`、`2m` 是为了另外两处（提示音间隔、完成窗口）而存在的写法。
- **注释是 `//`，故意不是 `#`。** 因为 `#` 是音名的一部分：`C#4` 是一个音，不是一段注释的开头。
- **写错的那一行会被指出来，并且只退回那一项。** 读取器是完备的：它不认识的属性报出行号和期望的取值，然后那一个属性沿用出厂值 —— 一个设置文件里的笔误不该让标签页没有图标。设置行上同时会列出这些问题（带行号），所以粘进 `settings.yaml` 的文档、从没在编辑器里打开过，也一样会被看见。

出厂的文档（也是"全部恢复默认"回到的那一份）就是四个状态各一段，每段的样子在上面、声音是那三段公有领域的古典乐句：

```
// dsh-sentry: how each session state looks and sounds.
// Durations are seconds unless a unit is written: 1.5s, 300ms, 2m.
// Every chime is a public-domain classical phrase, chosen for what its state means.

icon on
title on
sound background
chime-gap 1.5s
keep-done 60s
volume 0.5

waiting {
  shape rounded
  color amber
  motion blink
  speed 1.1s
  chime G4:170ms G4:170ms G4:170ms Eb4:680ms // Beethoven, Symphony No.5 op.67 - the knock
  tone triangle
}

approval {
  shape rounded
  color amber
  motion blink
  speed 1.9s
  chime A5:350ms G5:95ms F5:95ms E5:95ms D5:95ms C#5:95ms D5:500ms // Bach, Toccata and Fugue in D minor, BWV 565
  tone triangle
  volume 0.45
}

running {
  shape circle
  color blue
  motion turn
  speed 3s
}

done {
  shape circle
  color green
  motion pulse
  speed 1.6s
  chime E4:230ms E4:230ms F4:230ms G4:230ms G4:230ms F4:230ms E4:230ms D4:460ms // Beethoven, Symphony No.9 - Ode to Joy
  volume 0.25
}
```

编辑器不是装饰：它知道这门语言的每一个词。补全列表按行给（行首是状态或文档级设置，块里是该状态的属性，属性后面是它接受的取值），选中一个状态会顺手把 `{` 写上，悬停在任何词上会说明它是什么 —— 而且它和引擎读的是**同一份读取器**（见下），所以它不会建议一个引擎随后会拒绝的词。

## 安装

```sh
dsh plugin --profile web add @citisen/dsh-sentry
```

或者直接来自 GitHub（同一个包，不走 registry）：

```sh
dsh plugin --profile web add github:citisen/dsh-sentry
```

然后重启 Web 界面：

```sh
dsh --profile web
```

`dsh plugin` 会在 profile 目录里转发给 pnpm，然后对齐 `dsh.profile.bundles`；因为本包声明了 `dsh.bundle`，安装它就会自动追加这一层，不需要手工改 `cordis.patch.yml`。

### 从本地检出安装

在 Windows 上，当 profile 与检出位于**不同盘**时，`dsh plugin --profile web add <路径>` 不可靠：pnpm 会把 link 解析成一个并不存在的路径，于是对齐逻辑认为该包没有声明 `dsh.bundle`，就不把它写进 `bundles`。请自己建链接：

```sh
cd "$DSH_HOME/profiles/web"
pnpm add "D:/path/to/dsh-sentry"      # 写入依赖
# pnpm 的 link 指向 <profile>/D:/path/... 这个不存在的路径，替换掉它：
cmd /c rmdir node_modules\@citisen\dsh-sentry
cmd /c mklink /J node_modules\@citisen\dsh-sentry D:\path\to\dsh-sentry
# 然后在 package.json 的 dsh.profile.bundles 里加上 "@citisen/dsh-sentry"
```

用 `node scripts/verify-profile.mjs` 检查结果：如果这一行没出现在组装好的条目列表里，它会大声失败。

## 开发

```sh
node scripts/fish-path.mjs   # 把已安装 dsh 的 favicon 里的鱼提取到 src/fish.txt
npm run build                # src/client.js -> lib/client.js
npm run check                # 发布闸门：产物同步 + 两个半边
npm run check:all            # 外加一次真实的 profile 组装检查（需要本地 dsh）
npm run verify               # 只跑校验脚本
npm run watch                # 保存即重建，配合 dsh-client-hmr
```

五处检查各管一段，每一段都是前一段查不到的：

| 检查 | 运行方式 | 覆盖 |
| --- | --- | --- |
| `verify-host.mjs` | Node + 桩 | schema 是否只守住"这个字段是文本"，以及 `alert` 这个命名空间、出厂文档是否和浏览器半边声明的是同一份 |
| `verify-settings.mjs` | Node + 真实 dsh 服务 | 界面依赖的那个命名空间契约：真实 settings 服务接受 `alert`、生效方式是 `live`，以及这个检查存在的理由 —— **宿主解析出的默认值**与**浏览器半边回退的默认值**必须是同一个值。它们处在无法共享模块的两个 bundle 里，只改一边的默认值会变成"渲染一份文档、执行另一份" |
| `verify-client.mjs` | Node + 桩 | 全部纯决策：状态优先级、完成沿、图标几何、标题合成、声音门控，**文档读取器**（块语法、时长与音名、每音时值、休止、音色、每一类报错的行号与回退）、**编辑器的语法**（着色、诊断范围、补全列表、词汇表确实来自插件的常量而不是自带一份），以及设置行（四张预览卡片画的确实是标签页会画的那张图、试听按钮播的确实是卡片上印的那一声、问题列表带行号），最后是 `apply()` 对桩服务的端到端接线 |
| `browser-check.mjs` | 无头 Chrome | 桩判断不了的那几件事：浏览器是否**真的解码**了 favicon 的 data URL（一个没编码的 `#` 会在那里截断成半条鱼，且哪里都不报错）、DOM 契约是否成立（自己的 `<link>` 挂到 head、不碰应用自己的那个、自己移除自己的元素）、`MutationObserver` 的标题守卫能否挺过浏览器自己的调度时机，以及写一次配置是否真的重绘了图标、点一次「预览」是否真的到了标签页 |
| `verify-profile.mjs` | Node + 真实 dsh profile | loader 是否真的组装了这个 bundle：`cordis.patch.yml` 那一行能解析、`dsh.profile.bundles` 里有它、浏览器名册能找到并投递 `lib/client.js` |

后两项在缺少所需条件时（没有 Chromium、没有本地 dsh）会干净地跳过，因此 CI 里是安全的；设 `DSH_REQUIRE=1` 可以把任一种跳过变成失败。

`src/client.js` 是浏览器半边的唯一来源。它写成 ES 模块便于阅读，但 DSH 客户端 bundle 是**经典脚本**，只能通过 `window.__ModuleLoader__` 注册一个惰性 CommonJS 工厂，所以 `scripts/build-client.mjs` 会套上那个外壳并改写静态 import（这也是里面没有 JSX 的原因 —— 那个变换刻意做得很窄，遇到它表达不了的形式就直接失败）。`src/style-grammar.js` 同样被构建期**拼接**进同一个作用域，而不是被 `require`：shell 的模块表里没有它，而一个多出来的 `<script>` 是一个多出来的名册行。

`src/fish.txt` 是生成物：它是已发布 favicon 的路径，从安装好的 dsh 里提取而不是手抄，因为一个手抄的 3400 字符路径就是一条抄错了一位的路径。构建时它被替换进 bundle，`verify-client.mjs` 则把提交的副本与安装的图形对比。

### 一份读取器，两个调用方

`src/style-grammar.js` 里的 `readStyleDocument` 是**唯一**的结构遍历：它一边决定文档是什么意思，一边记下哪里不对（带范围）。引擎的 `resolveStyle` 调它拿到可绘制的值，编辑器的语法把 `analyze` 指向它来着色、补全、解释同一段文本。上一版这里有两份实现 —— 一份给引擎、一份给编辑器 —— 而两份实现就是两个意见，编辑器迟早会建议一个读取器随后拒绝的属性。现在这两件事不可能分歧，因为它们本来就是同一件事。

### 为什么引擎里没有 React

哨兵要反应的状态可以直接从 cordis 上下文拿到：`ctx.sessions.list` 和 `ctx.uiSession.pendingInteractions` 都是 Web 组装必定安装的服务所持有的 `{ getSnapshot(), subscribe() }` 可观察对象 —— 与 `ui-session` 交给 `useSessions` / `useSessionPendingInteraction` 这两个 hook 座位的是同一批对象。所以整个引擎就是普通 DOM 加两个订阅，React 只出现一次，在设置行里，因为插槽系统本身是 React。这也是本插件不注册常驻组件、不占全局插槽的原因：第三方 bundle 只要待在渲染树之外，能与界面冲突的方式就少得多。

每个决定都是把输入作为参数的纯函数 —— 时钟、存储、可见性与焦点位、减动效位、音符序列。`apply()` 是唯一不纯的部分，而且刻意写得很薄。`scripts/verify-client.mjs` 在 Node 里驱动这半个纯函数，于是一次回归是一次失败的检查，而不是一个悄悄变绿的 favicon。

## 包结构

| 路径 | 是什么 |
| --- | --- |
| `lib/index.js` | Node 半边：`alert` 设置命名空间（一个 `style` 字段），由 loader 加载。 |
| `lib/client.js` | 浏览器半边，**由 `src/client.js` 生成**并投递给界面。 |
| `src/client.js` | 浏览器半边源码：状态模型、三个通道、文档词汇表与解析结果、设置行与预览。 |
| `src/style-grammar.js` | 文档读取器（引擎与编辑器共用）与编辑器的语法。 |
| `src/fish.txt` | 生成物：已发布 favicon 的鱼路径。 |
| `cordis.patch.yml` | 这个 bundle 贡献的 profile 层。 |
| `scripts/` | 构建、生成与校验脚本。 |
| `PUBLISHING.md` | 可信发布：配置方式，以及它防不住什么。 |
| `RELEASING.md` | 发布一个改动的操作手册。 |

## 已知限制

- **favicon 是共享资源，而本插件在有话要说的时候占用它。** 它从不碰应用自己的那个 link：每次画面变化都挂一个自己的新 `<link rel="icon">`、把上一个摘掉（标签栏跟的是文档里 icon link 的**集合**，就地改 `href` 不一定会被当成变化 —— 这正是"改了配置但标签图标没跟着变"的来源），标签安静下来时把自己的移除。若另一个插件也画 favicon，因为本插件每次重绘都会重新挂载，它会一直赢；这属于要在界面层面解决的冲突，而不是靠让本插件变安静来躲开。
- **后台标签页的定时器会被节流。** 标签隐藏时浏览器的重绘间隔被拉长 —— 图标仍然是那个状态、颜色也对，但转动不流畅，这是浏览器的省电策略而不是缺陷。设置行里的预览是同样的道理反过来：那是前台页面，所以它按 `TICK_MS` 如实播放。
- **标题标记是一个零宽空格。** 它不可见，并且让合成变得诚实；但任何逐字复制 `document.title` 的工具（书签名称、窗口标题监视器）会多看到一个 U+200B。
- **等待数上限是 3。** 16px 下两位数的角标就是一团糊，所以 4 个及以上不画数字 —— 环仍然表示"若干个"，标题仍然带着准确数字。
- **"刚刚完成"需要一个衰减窗口才有意义。** 把 `keep-done` 设为 `0`，绿色信号就永远不会出现 —— 而完成提示音走的是同一个下降沿，所以也会一起安静下来。这是一种合法配置而不是故障，但要知道它实际上是一个名字管着两个通道。
- **`running` 块里的 `chime`/`tone`/`volume` 会被报成"没有用"。** 这不是错误而是警告：写成 `chime off` 也好、删掉也好，它都不会响，因为"一轮开始"不是这个插件会打扰你的事件。让它在文档里是合法的，比让读取器假装没看见要好。
- **单一取值只有文档这一个入口。** 没有颜色选择器、没有速率滑块：改一个状态的颜色就是改一行，而"出厂是什么就用什么"的唯一写法是那一行不写。这是文档做的取舍 —— 四个状态的样子和声音写在一处，而不是十几个控件各说一小块。
- **卡片不是标签栏。** 它用同一个构建函数、同样的 32 像素，所以卡片里不对的状态在标签页里也不对 —— 这正是「预览」按钮存在的理由。它和标签页都画不出来的是某个浏览器的 chrome 会把它变成什么样 —— 而那件事图标自己也无从知道。
- **声音需要一次点击来解锁**，这是浏览器的自动播放策略。插件不能、也不试图绕过它。预览卡片上的试听按钮就是最方便的那次点击。
- **dsh 的某次发布可能让某个通道变成空操作。** 如果 `ui-layout` 不再写 `document.title`，就没有东西可以合成；如果会话列表或待处理交互服务的形状变了，引擎会看到空快照并什么都不报告，而不是在浏览器里抛异常。这两种都是预期的失败方式 —— 安静，而不是崩坏。
- **设置行只有中英双语**，与内置的语言对一致；文档里的诊断信息是英文，和编辑器里的一样。

## 许可

MIT


