# dsh-hooks

[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)（dsh）的配置驱动生命周期 hooks 插件。

直接在 profile 的 `cordis.patch.yml` 里声明「事件 → 命令」——就像 Codex CLI / OpenCode 的 hooks，但属于 dsh。不需要写插件代码。

[English](README.md) | [设计](#设计) | [飞书示例](examples/notify-feishu.mjs)

## 安装

一个包搞定全部（hook 引擎 + Web GUI 设置页）：

```sh
dsh plugin --profile web add dsh-hooks           # 从 npm 安装
# 或直接从 git 安装：
dsh plugin --profile web add github:PeterBon/dsh-hooks
```

重启 `dsh web` 生效。安装后设置面板里会出现「Hooks」分区（见 [Web GUI](#web-gui)）。

## 配置

在你的 profile 的 `cordis.patch.yml` 里添加配置块：

```yaml
- id: dsh-hooks
  name: dsh-hooks
  config:
    hooks:
      - on: 'turn/end'
        when: 'completed'            # 可选：只在回合正常完成时触发
        run: 'node examples/notify-feishu.mjs'
        timeoutMs: 10000             # 可选，默认 10000
      - on: 'approval/asked'
        run: 'powershell -Command "Add-Content hooks.log approval-requested"'
      - on: 'tool/call'
        match:                       # 可选：字段 → 正则，全部匹配才触发
          tool: '^(rm|git|ssh)'
        run: 'node examples/notify-webhook.mjs --slack'
      - on: 'turn/end'
        when: 'completed'
        run: 'node examples/notify-feishu.mjs'
        retries: 2                   # 可选：非零退出码重试（默认 0 不重试）
        retryDelayMs: 1000           # 可选：重试基础间隔，每次翻倍（默认 500）
      - on: 'turn/end'
        input: 'stdin'               # 可选：把完整上下文 JSON 写入命令 stdin
        run: 'node my-hook.mjs'
      - on: 'approval/asked'
        notify:                      # 内置通知：与 run 二选一，无需外部脚本
          channel: 'desktop'         # 桌面气泡/toast 通知
      - on: 'turn/end'
        when: 'completed'
        notify:
          channel: 'webhook'         # POST JSON 到任意 HTTP 端点
          url: 'https://hooks.slack.com/services/…'
          slack: true                # 可选：改为 { text } 单行摘要（Slack 风格）
      - on: 'step/end'
        run: 'node examples/log-step.mjs'
        debounceMs: 500              # 可选：高频事件去抖，窗口内合并为一次
        maxConcurrent: 2             # 可选：并发上限，超出的触发被丢弃
      - on: 'tool/result'
        match:
          toolDurationMs: '>10000'   # 数值比较（也支持 { gt: 10000 } 对象语法）
        run: 'node examples/notify-slow-tool.mjs'
      - on: 'turn/end'
        enabled: false               # 可选：停用但不删除（跳过派发）
        cwd: 'session'               # 可选：在会话工作目录执行
        run: 'node examples/log-turn.mjs'
```

每个 hook 的完整字段：

| 字段 | 含义 | 默认 |
| --- | --- | --- |
| `on` | 触发事件（见上方事件表） | 必填 |
| `when` | 对 `turn/end` 按结束原因过滤 | 全部原因 |
| `match` | 字段 → 正则或数值比较，全部匹配才触发；字段为上下文键（`tool`/`sessionName`/`sessionId`/`error`/`source`/`cwd`/`content`/`reason`/`turn`/`durationMs`/`toolDurationMs`…），上下文中不存在的字段视为不匹配。正则匹配字段的字符串表示；数值比较（`{ gt: 10000 }` 或 `'>10000'`，支持 `gt`/`gte`/`lt`/`lte`/`eq` 组合）只对数字字段生效，非数字字段上的比较永不匹配 | 不过滤 |
| `run` | 通过系统 shell 执行的命令（与 `notify` 二选一） | 二选一必填 |
| `notify` | 内置通知（与 `run` 二选一）：`channel: webhook`（HTTP JSON，`url` 可省略用 `DSH_HOOKS_WEBHOOK_URL`，`slack: true` 换单行摘要）或 `channel: desktop`（系统气泡/toast） | 二选一必填 |
| `input` | `env` 只传 `DSH_HOOK_*` 环境变量；`stdin` 额外把完整上下文 JSON 写入命令标准输入 | `env` |
| `timeoutMs` | 单次执行超时（毫秒），超时终止进程树 | 10000 |
| `retries` | 非零退出码的重试次数（spawn 失败与超时不重试） | 0 |
| `retryDelayMs` | 重试基础间隔（毫秒），每次翻倍 | 500 |
| `enabled` | `false` 停用该 hook：配置保留、静默跳过派发（不计入失败） | `true` |
| `cwd` | 命令执行目录：`session` 在会话工作目录执行；绝对路径在指定目录执行（只作用于 `run`） | 插件进程目录 |
| `maxConcurrent` | 该 hook 允许的最大并发进程数；超出的触发被丢弃（执行历史记 `skipped`） | 不限 |
| `debounceMs` | 去抖窗口（毫秒）：高频事件（`step/end`、`tool/*`…）窗口内的多次触发合并为一次 trailing 执行，携带最新上下文 | 0（不去抖） |

## 事件（v1）

| 事件 | 触发时机 | 有用上下文 |
| --- | --- | --- |
| `turn/start` | 回合开始（若有 `turn/start` hook，派发延迟到本回合的首条用户消息分类后，把触发文本注入 `DSH_HOOK_CONTENT`；无用户消息的回合在 `turn/end` 时无内容派发，见下方说明） | 会话 id、回合号、触发消息文本 |
| `turn/end` | 回合结束（`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`） | reason、回合号、耗时、内容、本回合 token 用量、运行中子代理数 |
| `tree/settled` | 回合结束后把工作交给子代理的会话，其整个子代理树全部落定（无存活子代理仍在运行） | 子代理总数、交接到落定的耗时 |
| `step/end` | 回合内一步结束（一次模型调用 + 其工具执行） | 回合号、步号 |
| `tool/call` | 模型请求一次工具调用 | 工具名、调用 id、原始参数 JSON |
| `tool/result` | 工具调用完成 | 工具名（自动反查）、结果文本、失败标识、工具耗时（配对失败时无耗时） |
| `user/message` | 会话表面出现用户角色消息 | 来源 kind（`user` / `plugin` / …）、消息文本 |
| `approval/asked` | 工具调用请求用户审批 | 工具名、调用 id、审批 id、原因 |
| `approval/decided` | 待审批项得出结果（与 `approval/asked` 按 id 配对） | 结果 outcome、工具名（自动反查）、调用 id、审批 id |
| `session/title` | 会话标题更新（显式改名 / LLM 生成 / 回退） | 新标题、来源 kind |
| `session/created` | 会话发布 | 会话 id、cwd |
| `session/disposed` | 会话离开注册表 | 会话 id、cwd |
| `agent/created` | Agent 发布 | 会话 id |
| `agent/disposed` | Agent 离开注册表 | 会话 id |
| `agent/error` | Agent 循环报错 | 错误文本 |
| `agent/status` | Agent 状态切换 | 状态 |
| `hook/failed` | 同一 hook 连续失败达到 `failedAlertThreshold`（默认 3；合成事件，从结果流发射） | 失败 hook 摘要、连续失败次数 |
| `usage/daily` | 本地日历日翻篇后的下一个事件（合成事件，无定时器）：报告刚结束那一天的 token 用量 | 覆盖日期、当日回合数、贡献会话数、当日 token 明细 |

`turn/end` 的 `when` 匹配结束原因（`completed`、`error`…）；其他事件的 hook 无条件执行。

## 命令执行

- 每个命中的 hook 通过系统 shell 执行 `run`，**fire-and-forget**：失败只 `console.warn`、默认不重试、绝不阻塞 agent 循环。命令的 stdout/stderr 会被捕获（各 64 KiB 上限），非零退出码时把 stderr 尾部写进告警日志。
- **重试**（`retries` / `retryDelayMs`）对两个执行通道都生效，都是「首次尝试之后再重试 N 次」、间隔按 `retryDelayMs` 逐次翻倍（默认 500ms）：
  - `run`：只重试**非零退出码**（spawn 失败与超时不重试）。
  - `notify` 的 webhook 渠道：重试**传输失败**（连接被重置、超时）与 HTTP **408 / 429 / 5xx**；其余 4xx 表示请求本身有问题，不重试。**默认 `retries: 0` 表示只尝试一次**——0.13 之前 webhook 曾硬编码「传输失败自动再试一次」，现在这层兜底已并入 `retries`，需要它的老配置请显式写 `retries: 1`。
  - `notify` 的 desktop 渠道是本地弹窗，不重试。
- 上下文通过**环境变量**传递（数据不拼接进 shell 字符串，防注入）：

| 变量 | 含义 |
| --- | --- |
| `DSH_HOOK_EVENT` | 事件类型，如 `turn/end` |
| `DSH_HOOK_SESSION_ID` | 会话 id |
| `DSH_HOOK_SESSION_NAME` | 会话可读标题（最新 `session/title` 日志事件，或首个用户消息回退） |
| `DSH_HOOK_CWD` | 会话工作目录 |
| `DSH_HOOK_TURN` | 回合号（回合 / 步骤 / 工具事件） |
| `DSH_HOOK_STEP` | 步号（步骤 / 工具事件） |
| `DSH_HOOK_REASON` | 回合结束原因 |
| `DSH_HOOK_TOOL` | 工具名（审批 / 工具事件） |
| `DSH_HOOK_CALL_ID` | 工具调用 id（审批 / 工具事件） |
| `DSH_HOOK_TOOL_ARGS` | 工具原始参数 JSON（tool/call） |
| `DSH_HOOK_TOOL_ERROR` | 工具失败标识 `名称: 代码`（tool/result 出错时） |
| `DSH_HOOK_TOOL_DURATION_MS` | 工具执行耗时毫秒（tool/result；配对 tool/call 丢失时无此变量） |
| `DSH_HOOK_SOURCE` | 消息 / 标题来源 kind（`user`、`plugin`、`fallback`、`provider`…） |
| `DSH_HOOK_DURATION_MS` | 回合耗时毫秒（turn/end） |
| `DSH_HOOK_STATUS` | Agent 状态（agent/status） |
| `DSH_HOOK_ERROR` | 错误文本（agent/error，以及 turn/end 出错时的失败详情） |
| `DSH_HOOK_CONTENT` | 事件内容快照：回合最后助手文本、工具结果文本、用户消息文本、回合触发消息文本（turn/start） |
| `DSH_HOOK_USAGE_INPUT_TOKENS` | 输入 token 总量（turn/end 为本回合、逐 step 聚合；usage/daily 为当日聚合） |
| `DSH_HOOK_USAGE_OUTPUT_TOKENS` | 输出 token 总量（同上） |
| `DSH_HOOK_USAGE_CACHE_READ_TOKENS` | 缓存读 token（有上报时，同上） |
| `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 缓存写 token（有上报时，同上） |
| `DSH_HOOK_USAGE_REASONING_TOKENS` | 思考 token（有上报时，同上） |
| `DSH_HOOK_RUNNING_SUBAGENTS` | 本会话下仍在运行的存活子代理数（turn/end；`0` = 无——让 hook 能区分「工作已交给后台子代理」与「回合真正结束」） |
| `DSH_HOOK_PARENT_SESSION_ID` | 父会话 id（子代理谱系；顶层会话无此变量） |
| `DSH_HOOK_SUBAGENT` | 会话为子代理时为 `1`，否则 `0` |
| `DSH_HOOK_DELEGATION_DEPTH` | 会话头中的委托深度（`0` = 顶层会话） |
| `DSH_HOOK_SESSION_CREATED_AT` | 会话创建时间，epoch 毫秒 |
| `DSH_HOOK_AGENT_PRESET` | 组合该会话 Agent 的预设 id（有值时） |
| `DSH_HOOK_APPROVAL_ID` | 审批审计 id（`approval/asked` 与 `approval/decided` 共用） |
| `DSH_HOOK_APPROVAL_OUTCOME` | 审批结果 outcome（`approval/decided`） |
| `DSH_HOOK_TOTAL_SUBAGENTS` | 已落定树中的子代理总数（`tree/settled`） |
| `DSH_HOOK_TREE_DURATION_MS` | 父回合结束 → 树落定的耗时（毫秒，`tree/settled`） |
| `DSH_HOOK_FAILED_HOOK` | 连续失败的 hook 身份摘要（`hook/failed`） |
| `DSH_HOOK_FAILURES` | 告警触发时的连续失败次数（`hook/failed`） |
| `DSH_HOOK_USAGE_DAY` | 报告覆盖的本地日历日 `YYYY-MM-DD`（`usage/daily`） |
| `DSH_HOOK_USAGE_TURNS` | 当日计入的回合数（`usage/daily`） |
| `DSH_HOOK_USAGE_SESSIONS` | 当日贡献用量的会话数（`usage/daily`） |
| `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |

- `run` 里的 `{{变量}}` 占位符会从同一上下文替换，例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
- 失败告警：fire-and-forget 的 hook 失败本来就是静默的，插件因此同时监视结果流——同一 hook 连续失败 `failedAlertThreshold` 次（`spawn-failed` / `exit-nonzero` / `timeout` / `send-failed`；一次逻辑执行的最终结果计一次，内部重试不另计）后发射合成事件 `hook/failed`，每条失败链只发一次；成功会清零计数并解除去抖。用普通 hook 接告警即可：

```yaml
config:
  failedAlertThreshold: 3   # 可选，默认 3
  hooks:
    - on: 'hook/failed'
      notify: { channel: 'desktop' }
    - on: 'turn/end'
      run: 'node my-hook.mjs'
```

- `turn/end` 的 hook 在运行中子代理计数解析完成后才派发，比其他事件晚一个异步跳——同会话紧随其后的事件（如下一轮 `turn/start`）可能先执行。

`DSH_HOOK_RUNNING_SUBAGENTS` 的典型用法：后台子代理还在运行时抑制回合结束通知，只在本会话回合真正落定时才通知。注意父会话只会收到一次 `turn/end`（此时计数 > 0）；「全部落定」的信号由最后一个子会话自己的 `turn/end`（计数为 `0`）送达：

```yaml
- on: 'turn/end'
  match: { runningSubagents: '^0$' }  # 正则要锚定：裸 '0' 也会匹配 '10'
  run: 'node examples/notify-webhook.mjs'
```

如果只想要「整棵树落定才通知一次」的简单模式，合成事件 `tree/settled` 帮你做了监视：插件跟踪回合结束时仍有运行中子代理的会话，树归零时对该会话发射 `tree/settled`：

```yaml
- on: 'tree/settled'
  notify: { channel: 'webhook', url: 'https://hooks.slack.com/services/…' }
```

已落定但闲置（idle）的 continuable 子代理不计入运行中，不会一直压住通知。落定监视是事件驱动且 best-effort 的：插件重启后监视集合丢失；重查失败会静默放弃该监视（不会补发迟到的通知）。

### usage/daily：跨日 token 日报

`turn/end` 只回答「这个回合花了多少」。要按天看成本，用合成事件 `usage/daily`：插件在内存里按**本地日历日**累计每个 `turn/end` 上报的 token（子代理会话的回合一并计入——同一个账号），日期翻篇后对下一个到达的事件发射一次日报，报告刚结束的那一天。检测纯事件驱动、无定时器、无定时任务。

```yaml
- on: 'usage/daily'
  match: { usageInputTokens: '>0' }     # 可选：跳过没有用量的日子
  run: 'node examples/log-usage.mjs'    # 或 notify: { channel: 'webhook', url: '…' }
```

`DSH_HOOK_USAGE_DAY` 是报告覆盖的日期（`YYYY-MM-DD`）；`DSH_HOOK_USAGE_TURNS` / `DSH_HOOK_USAGE_SESSIONS` 是当日计入的回合数与贡献会话数；token 明细沿用 `turn/end` 的 `DSH_HOOK_USAGE_*` 变量名（`usageInputTokens` / `usageOutputTokens` / `usageCacheReadTokens` / `usageCacheWriteTokens` / `usageReasoningTokens`），语义变为「该日聚合」。

三条边界（按设计，不是 bug）：

- **内存累计**：插件进程重启会丢掉进行中那一天的累计（重启后从新的一天、从零开始）；已发出的日报不受影响。
- **事件驱动而非定时**：一天的用量要等下一个事件到达才报告，所以跨夜后若一直没动静，日报会推迟到下一次有事件时补发；那一天从未有回合上报用量则不发射（空日报是噪声）。
- **零开销**：没有声明任何 `usage/daily` hook 时，插件完全不做累计与跨日检测。

`dsh-hooks dry-run usage/daily` 用「昨天」和非零 token 模拟一次日报，可先验证 match 与命令。

### match 数值比较

对数字字段（`turn`、`step`、`durationMs`、`toolDurationMs`、`usage*`、`runningSubagents`…）可以直接写数值比较，不用绕正则：

```yaml
- on: 'tool/result'
  match: { toolDurationMs: '>10000' }   # 字符串语法：> >= < <= =
  run: 'node examples/notify-slow-tool.mjs'

- on: 'tool/result'
  match:
    toolDurationMs: { gt: 10000, lt: 60000 }   # 对象语法：gt/gte/lt/lte/eq 可组合
  run: 'node examples/notify-slow-tool.mjs'
```

规则：

- 比较语义只对**数字字段**生效；字段是字符串时比较**永不匹配**（不会把字符串转数字强比）。
- 字符串语法以 `>`/`>=`/`<`/`<=`/`=` 开头且后跟数字才算比较（如 `'>10000'`）；其余字符串仍是普通正则。
- 字段缺失照旧视为不匹配。空对象 `{}` 恒真（无任何条件）。

### 执行选项：enabled / cwd / maxConcurrent / debounceMs

每个 hook 都可以独立微调执行方式：

- **`enabled: false`**：停用该 hook 但保留配置。跳过是静默的——不记执行历史、不计入失败链（`hook/failed` 不会因停用的 hook 触发）。dry-run 会标出 `enabled: false（已停用）`。
- **`cwd: 'session'`**：在会话工作目录（agent 正在工作的项目目录）执行 `run`，方便 hook 脚本直接读写当前项目文件；`cwd` 也接受绝对路径。缺省在插件进程目录执行。
- **`maxConcurrent`**：并发进程上限。超过上限的触发被丢弃并记入执行历史（`skipped`，不触发失败告警）；一次逻辑执行（含其内部重试）始终占用一个名额。
- **`debounceMs`**：去抖窗口。高频事件（`step/end`、`tool/*`…）窗口内的多次触发合并为一次 **trailing** 执行，携带最新一次的上下文；被合并掉的触发完全静默，不会刷日志/历史。窗口结束后的新触发正常执行。

防 `step/end`/`tool/*` spawn 风暴的推荐组合：

```yaml
- on: 'step/end'
  run: 'node examples/log-step.mjs'
  debounceMs: 500       # 半秒内的连续步结束只跑一次
  maxConcurrent: 2      # 兜底：命令变慢时并发不超过 2
```

### turn/start 的触发内容

会话日志先记录 `turn/start`、后记录该回合的 `user/message`，所以回合开始那一刻还读不到触发文本。插件因此把 `turn/start` 的派发**延迟到本回合首条直接用户消息分类后**，把消息文本注入 `DSH_HOOK_CONTENT`（截断 2000 字符）：

```yaml
- on: 'turn/start'
  match: { content: '部署|发版' }   # 只关心包含关键词的回合
  notify: { channel: 'desktop' }
```

时序说明：

- 有 `turn/start` hook 时才启用延迟；没有的话派发保持原样（立即执行，无内容）。
- 注入文本只认 `source.kind === 'user'` 的直接用户消息；系统注入（agent/plugin 来源）不会完成派发。
- 回合内没有直接用户消息（如目标续跑回合）时，`turn/start` 在 `turn/end` 时**不带内容**派发；新回合开始会先冲掉上一个未认领的 `turn/start`。
- 直接用户回合中，延迟通常只有几毫秒（`user/message` 紧随 `turn/start`），先于任何步骤/工具事件。

## 执行历史

每次 hook 触发都会记入内存环形缓冲（默认 500 条），并 best-effort 追加到 `~/.dsh/dsh-hooks/history.jsonl`（权限 0600）——供未来 UI 与调试使用。环形缓冲在启动时从 JSONL 回填，且 Web 面板每次读取时增量同步磁盘上新增的记录（包括其他 dsh 进程的追加，如任务看板 Host），因此重启后历史不会消失。记录不含 secret（环境变量从不入记录）：

```yaml
- id: dsh-hooks
  name: dsh-hooks
  config:
    history:
      enabled: true        # 可选：持久化到磁盘（默认 true）
      max: 500             # 可选：内存环形缓冲条数
      # path: '…'          # 可选：自定义 JSONL 路径（默认 ~/.dsh/dsh-hooks/history.jsonl）
    hooks: […]
```

每条记录：时间戳、kind（run/notify）、事件、命令、会话、结果（spawned / exit-0 / exit-nonzero / timeout / skipped / sent / send-failed…）、退出码、耗时、stderr 尾部。写盘失败静默吞掉，绝不阻塞 hook。

终端里想边跑边看，用 `tail`（Ctrl+C 退出）：

```sh
dsh-hooks tail                                  # 回放最近 10 条，然后实时跟进
dsh-hooks tail --event turn/end --outcome exit-nonzero   # 只看失败的回合结束
dsh-hooks tail --hook notify-feishu --n 50 --json         # 50 条起，输出原始 JSONL 供 jq
```

`tail` 的 JSONL 路径取自 profile 配置的 `history.path`（未配置或配置读不出来时回落到默认路径，不会因为配置文件半途改动而报错）。它只读新增字节、容忍半行（等换行再输出）、文件被截断/轮转时自动从 0 重新跟进。

## dry-run：验证配置

配置完先用 `dry-run` 模拟事件，看哪些 hook 会触发、哪些被过滤：

```sh
dsh-hooks dry-run turn/end --reason completed --profile web
# ✅ [1] [turn/end when=completed] run: node notify-feishu.mjs
# ⏭ [2] [turn/end when=error] run: … —— when 不匹配（期望 error，实际 completed）
# ⏭ [3] [tool/call] run: … —— 事件不匹配（tool/call ≠ turn/end）
# 共 1 个 hook 会触发。加 --execute 实际执行（真实副作用！）

dsh-hooks dry-run tool/call --tool ssh_exec --execute   # 端到端真跑匹配的 hook
```

**模拟数值字段**：数字类上下文（`runningSubagents`、`durationMs`、`toolDurationMs`、`usage*`…）可以直接给定值，用来验证基于数值的 `match`：

```sh
dsh-hooks dry-run turn/end --running-subagents 3
dsh-hooks dry-run turn/end --duration-ms 1250 --usage-input 120000 --usage-output 45000
dsh-hooks dry-run tool/result --tool-duration-ms 15000
dsh-hooks dry-run usage/daily --field usageCacheReadTokens=90000   # 通用写法：--field <名>=<值>
```

可模拟字段白名单：`turn`、`step`、`durationMs`、`toolDurationMs`、`runningSubagents`、`totalSubagents`、`treeDurationMs`、`usageTurns`、`usageSessions`、`usageInputTokens`、`usageOutputTokens`、`usageCacheReadTokens`、`usageCacheWriteTokens`、`usageReasoningTokens`。非白名单字段或非法数字不会被静默丢弃——报告里会列出「已忽略无法模拟的字段」。

模拟上下文与运行时保持一致：`turn/end` 一定带 `runningSubagents`（默认 0），所以 README 推荐的 `match: { runningSubagents: '^0$' }` 在 dry-run 里也能命中；`usage/daily` 带「昨天」与非零 token 明细。

`dry-run` 直接读 profile 的 `cordis.patch.yml`（`id: dsh-hooks` 配置块），配置校验（非法正则等）会在这一步报错。

## Web GUI

安装后，dsh web 的设置面板里会出现「Hooks」分区（与「通用」「插件」平级）：

- **状态徽章**：插件版本、hook 数、历史条数，以及运行诊断（正在执行的 hook 数、最近失败数）
- **手动测试**：选事件（18 类）+ reason/tool，并可用「模拟字段」一行填入 `runningSubagents` / `durationMs` / usage 输入输出等数值上下文；「模拟」看逐 hook 匹配报告，「执行」真实触发；切换输入自动清空旧结果
- **通知渠道测试**：向 webhook（可选 Slack 摘要）/ desktop 渠道发一条测试通知，显示发送内容预览
- **飞书通知**：网页内扫码连接飞书——显示二维码（含有效期倒计时、可取消），扫码后自动创建应用、写入凭据与 hook 配置；已连接后显示应用摘要，可一键发送测试卡片、调整卡片截断长度（50–5000 字符，默认 300，带正文预览）、重新扫码换绑或断开连接（可选一并移除飞书 hooks）
- **当前 hooks**：只读清单（事件/when/match/run/notify + 超时重试参数），一键「复制 YAML」；点「编辑」进入表单编辑器，增删改 hook 后写回 `cordis.patch.yml`（自动备份原文件、写前校验正则与 run/notify 二选一，保存即热加载）
- **执行历史时间线**：位于分区底部、**默认折叠**（展开状态记忆于 localStorage），5 秒自动刷新。展开后可按**事件 / 结果 / 会话**过滤（条件同样记忆在 localStorage，附「显示 N / 共 M 条」计数与「清空过滤」），并把当前视图**导出为 JSONL**（与磁盘上的 `history.jsonl` 同格式，文件名带本地时间戳）——面板一次拉取最近 200 条，过滤在浏览器侧完成

CLI/headless 环境完全不受影响：浏览器半只在 web 加载，核心零 UI 运行时依赖。

## Web profile HTTP 路由

web profile 里（存在共享 webServer 服务时）dsh-hooks 自动注册 `/dsh-hooks/*` 路由，默认仅允许本地回环地址，可通过下述环境变量调整——CLI/headless 环境完全无感：

| 路由 | 方法 | 用途 |
| --- | --- | --- |
| `/dsh-hooks/status` | GET | 插件版本、hook 数、历史条数、**当前 hooks 清单**与运行统计 |
| `/dsh-hooks/history?n=50` | GET | 最近 N 条执行历史（JSON envelope） |
| `/dsh-hooks/test` | POST | 模拟事件评估：`{"event":"tool/call","tool":"ssh_exec","execute":false}` 返回逐 hook 匹配报告；可用 `fields` 覆盖数值上下文（`{"event":"turn/end","fields":{"runningSubagents":2}}`，非法字段返回 400）；`execute: true` 真跑匹配的 hook |
| `/dsh-hooks/notify/test` | POST | 向指定渠道发测试通知：`{"channel":"webhook","url":…,"slack":true}` 或 `{"channel":"desktop"}`，返回发送内容预览 |
| `/dsh-hooks/hooks/save` | POST | 保存 hook 列表：`{"profile":"web","hooks":[…]}`——校验（事件/when/正则/run-notify 二选一）后写回 cordis.patch.yml，自动备份原文件 |
| `/dsh-hooks/feishu/status` | GET | 飞书连接摘要（app id / 目标均已打码，绝不返回 secret）+ 扫码会话快照 + 截断长度 + 正文预览 |
| `/dsh-hooks/feishu/setup` | POST | 启动扫码会话：`{"profile":"web","resultMaxChars":800}`，返回二维码 URL / PNG data URL / 有效期；进行中时再次请求返回 409 |
| `/dsh-hooks/feishu/cancel` | POST | 取消进行中的扫码会话（中止 registerApp 等待） |
| `/dsh-hooks/feishu/config` | POST | 更新卡片截断长度：`{"resultMaxChars":800}`（50–5000），即时生效，保留凭据 |
| `/dsh-hooks/feishu/test` | POST | 用已存凭据发送测试卡片 |
| `/dsh-hooks/feishu/disconnect` | POST | 断开连接：删除凭据文件，`removeHooks: true` 时一并移除 patch 中引用 notify-feishu.mjs 的 hooks（带备份） |

所有访问模式下，POST 仍必须使用 `application/json`（防跨站表单 CSRF）。同时 web profile 下会向 agent 注入一段 systemPrompt 公告，说明插件存在与协作方式。

### 配置 HTTP 来源 IP 限制

在**运行 `dsh web` 的进程环境**中设置 `DSH_HOOKS_ALLOWED_IPS`。这不是 `cordis.patch.yml` 的配置字段，不需要修改 hooks 配置。

| 环境变量值 | 行为 |
| --- | --- |
| 未设置、空字符串或只有空白 | 仅允许 `127.0.0.1`、`::1`、`::ffff:127.0.0.1`，保持默认行为 |
| `*` | 不限制来源 IP |
| `192.168.1.100,10.0.0.2` | 只允许逗号分隔列表中的 IP |

变量值首尾空白会被去除。`local`、`all` 不是特殊值；除空值和单独的 `*` 外，其他值都作为 IP 列表匹配。白名单模式**不会额外放行本地连接**，如需保留本地访问，请显式加入 `127.0.0.1,::1`。

匹配时会忽略每项首尾空白、字母大小写及 `::ffff:` 前缀，例如 `192.168.1.100` 可以匹配 `::ffff:192.168.1.100`。不支持域名、端口、CIDR 网段或列表内通配符；无效条目不会自动回退到仅本地或不限制模式。IPv6 采用上述规则处理后的字符串比较，不会统一展开/压缩写法，请使用与服务端所见地址一致的写法。

#### 直接启动

PowerShell：选择一种设置，在**同一终端**启动服务。

```powershell
# 仅本地（不设置该变量也可以）
$env:DSH_HOOKS_ALLOWED_IPS = ''

# 或：允许指定客户端，并保留本地访问
# $env:DSH_HOOKS_ALLOWED_IPS = '192.168.1.100,127.0.0.1,::1'

# 或：不限制来源 IP（请先确保外部访问控制可靠）
# $env:DSH_HOOKS_ALLOWED_IPS = '*'

dsh web
```

Linux/macOS shell：以下命令三选一。

```sh
DSH_HOOKS_ALLOWED_IPS='' dsh web
DSH_HOOKS_ALLOWED_IPS='192.168.1.100,127.0.0.1,::1' dsh web
DSH_HOOKS_ALLOWED_IPS='*' dsh web
```

修改终端或服务管理器中的环境变量后，需要重新启动对应的 `dsh web` 进程；已运行的进程不会自动继承新值。单独把变量写入 `.env` 不代表已经传入进程，需要由启动器或容器配置明确加载。

#### Docker Compose

将变量加入**实际运行 DSH 的服务**的 `environment`，保留原有镜像、端口、卷等配置。以下 `dsh` 为示例服务名，请替换成自己的服务名：

```yaml
services:
  dsh:
    environment:
      DSH_HOOKS_ALLOWED_IPS: "192.168.1.100,127.0.0.1,::1"
      # 仅本地用 ""；不限制用 "*"（星号必须加引号）
```

修改后重新创建该服务的容器以应用新环境变量，例如 `docker compose up -d --force-recreate dsh`；仅重启已有容器不会更新容器环境配置。若使用 Compose 的 `.env` 文件，也需要在服务中通过 `environment` 引用变量或通过 `env_file` 传入。

#### 代理、安全与验证

- 检查的是 `req.socket.remoteAddress`，不读取 `X-Forwarded-For`、`X-Real-IP` 或 `Forwarded`。Docker/NAT/反向代理下，该地址可能是网关或代理 IP，而不是浏览器所在机器的 IP。
- 放行代理 IP 会放行经该代理转发的所有客户端；本机代理也可能将外部请求转发为回环连接。因此，代理后的客户端限制应在代理层执行。“仅本地”指服务端（或容器内）的回环连接，不等于只允许本机浏览器。
- `*` 会放开读取历史、修改配置、执行 hook 等敏感接口的来源 IP 限制；IP 白名单不是身份认证，请用可信网络或外部认证保护这些接口，不要直接暴露到不可信网络。
- 此变量只影响 `/dsh-hooks/*`，不会改变服务监听地址、端口、防火墙规则或其他插件的权限。

可从允许及不允许的客户端分别请求 `GET /dsh-hooks/status`（主机和端口替换为实际地址）。通过本插件的检查时返回正常状态 JSON；被本插件拒绝时返回 HTTP 403：

```json
{"ok":false,"error":{"code":"forbidden","message":"IP not allowed"}}
```

若白名单配置后仍收到该错误，先确认变量已传入实际服务进程，再检查服务端看到的是客户端 IP 还是代理/网关 IP。若连接超时或被拒绝连接，则还需检查监听地址、端口映射和网络规则。

## 通用 webhook 示例

除了飞书，`examples/notify-webhook.mjs` 把完整 hook 上下文作为一份 JSON POST 到任意 HTTP 端点——Slack 入站 webhook、Discord、企业微信/钉钉自定义机器人、ntfy、Bark、n8n 都能接：

```yaml
- id: dsh-hooks
  name: dsh-hooks
  config:
    hooks:
      - on: 'turn/end'
        when: 'completed'
        run: 'node examples/notify-webhook.mjs --url https://hooks.slack.com/services/…'
      - on: 'tool/result'        # 工具连续失败时告警
        run: 'node examples/notify-webhook.mjs --slack'
```

URL 也可放在 dsh 进程环境的 `DSH_HOOKS_WEBHOOK_URL`（不要写进配置文件）。`--slack` 把 payload 换成一行摘要的 `{ text }` 格式；`--timeout <ms>` 控制超时（默认 10000，传输失败自动重试一次）。

## 飞书通知示例

两种接入方式任选：**Web GUI 扫码**（推荐，无需终端）或 **setup CLI**——扫码自动创建飞书应用并写好全部 hook 配置。

### 方式一：Web GUI 扫码

打开 dsh web 设置 → 「Hooks」分区 → 「飞书通知」，填好 profile（默认 `web`）点「扫码连接飞书」：

1. 面板内显示飞书授权二维码（含有效期倒计时）
2. 用飞书扫码，自动创建名为「DSH 通知机器人」的应用（仅 `im:message:send_as_bot` 权限），扫码者本人为通知接收人
3. 连接完成后显示应用摘要，可「发送测试卡片」验证、直接修改卡片截断长度（50–5000 字符，默认 300，即时生效），「重新连接」可换绑新应用
4. 重启 `dsh web` 生效

### 方式二：setup CLI

```sh
dsh-hooks feishu-setup                 # 默认 profile：web
dsh-hooks feishu-setup --profile work  # 指定其他 profile
dsh-hooks feishu-test                  # 用已存凭据发送测试卡片验证
```

`feishu-setup` 会打印二维码（并在浏览器中打开），等你用飞书扫码后，自动创建名为「DSH 通知机器人」的应用（带消息发送权限）。

两种方式写入的文件相同：

| 文件 | 用途 |
| --- | --- |
| `~/.dsh/dsh-hooks/feishu-config.json` | app id/secret 与你的 open_id（通知目标），权限 0600，严禁提交；`result_max_chars` 控制卡片内容截断长度（默认 300，可在 Web GUI 中修改） |
| `~/.dsh/dsh-hooks/notify-feishu.mjs` | hook 引用的通知脚本稳定副本 |
| `~/.dsh/profiles/<profile>/cordis.patch.yml` | dsh-hooks 配置块：`turn/end`（completed/error/aborted）+ `approval/asked` + `agent/error` 卡片 hook |

完成后重启 `dsh web`——回合结束、请求审批、agent 出错时就会收到卡片通知。

![飞书卡片示例](assets/screenshot-1.jpg)

### 方式三：手动配置

想自己接线？见 [`examples/notify-feishu.mjs`](examples/notify-feishu.mjs)——零依赖脚本，通过飞书**应用 API**（不需要群自定义机器人）发送回合完成 / 审批通知。配置示例：

```yaml
- id: dsh-hooks
  name: dsh-hooks
  config:
    hooks:
      - on: 'turn/end'
        when: 'completed'
        run: 'node D:/path/to/examples/notify-feishu.mjs'
      - on: 'approval/asked'
        run: 'node D:/path/to/examples/notify-feishu.mjs --approval'
```

同时在 dsh 进程环境中提供 `DSH_HOOKS_FEISHU_APP_ID` / `DSH_HOOKS_FEISHU_APP_SECRET` / `DSH_HOOKS_FEISHU_TO`（绝不能写进配置文件）。

## 安全

Hook 会以 dsh 进程的权限执行任意命令，只配置你信任的命令。Secret 放环境变量或 dsh 凭据存储——永远不要写进 `cordis.patch.yml`。

## 设计

遵循 dsh 插件约定：`dsh.bundle.patch` 挂载插件行；插件监听持久 `session/event` firehose 与 agent 生命周期事件；发射是不可逆副作用，补偿而非阻塞（失败仅警告、绝不重试）。

## 开发

```sh
pnpm install
pnpm run check     # typecheck + test + build
```

发布与 CI 运维（Trusted Publishing、安全扫描、踩坑记录）：见 [docs/RELEASING.md](docs/RELEASING.md)。

## License

MIT
