dsh-hooks 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,12 +75,14 @@ Every hook field:
75
75
  | Event | When it fires | Useful context |
76
76
  | --- | --- | --- |
77
77
  | `turn/start` | A turn begins | session id, turn |
78
- | `turn/end` | A turn ends (`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason, turn, duration, content, turn token usage |
78
+ | `turn/end` | A turn ends (`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason, turn, duration, content, turn token usage, running subagents |
79
+ | `tree/settled` | A watched session's whole subagent tree settles (no live child still running) after a turn ended with work handed off | total subagents, handoff→settle duration |
79
80
  | `step/end` | One step of a turn ends (one model call plus its tool executions) | turn, step |
80
81
  | `tool/call` | The model requests one tool invocation | tool name, call id, raw arguments JSON |
81
82
  | `tool/result` | A tool call completes | tool name (resolved), result text, failure identity |
82
83
  | `user/message` | A user-role message appears on the surface | source kind (`user` / `plugin` / …), message text |
83
- | `approval/asked` | A tool call requests user approval | tool name, call id, reason |
84
+ | `approval/asked` | A tool call requests user approval | tool name, call id, approval id, reason |
85
+ | `approval/decided` | A pending approval gets its outcome (paired with `approval/asked` by id) | outcome, tool name (resolved), call id, approval id |
84
86
  | `session/title` | The session title updates (explicit rename / LLM title / fallback) | new title, source kind |
85
87
  | `session/created` | A session is published | session id, cwd |
86
88
  | `session/disposed` | A session leaves the registry | session id, cwd |
@@ -120,6 +122,15 @@ The `when` filter for `turn/end` matches the `reason.kind` value (`completed`, `
120
122
  | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | aggregated cache-write tokens, when reported |
121
123
  | `DSH_HOOK_USAGE_REASONING_TOKENS` | aggregated reasoning tokens, when reported |
122
124
  | `DSH_HOOK_RUNNING_SUBAGENTS` | live subagents still running under this session (turn/end; `0` = none — lets a hook tell "work handed off to background subagents" apart from "the turn finished for real") |
125
+ | `DSH_HOOK_PARENT_SESSION_ID` | parent session id (subagent lineage; absent for top-level sessions) |
126
+ | `DSH_HOOK_SUBAGENT` | `1` when the session is a subagent child, `0` otherwise |
127
+ | `DSH_HOOK_DELEGATION_DEPTH` | delegation depth from the session header (`0` = top-level session) |
128
+ | `DSH_HOOK_SESSION_CREATED_AT` | session creation time, epoch ms |
129
+ | `DSH_HOOK_AGENT_PRESET` | agent preset id composing the session's agent, when known |
130
+ | `DSH_HOOK_APPROVAL_ID` | approval audit id (`approval/asked` + `approval/decided`) |
131
+ | `DSH_HOOK_APPROVAL_OUTCOME` | approval decision outcome (`approval/decided`) |
132
+ | `DSH_HOOK_TOTAL_SUBAGENTS` | total subagents in the settled tree (`tree/settled`) |
133
+ | `DSH_HOOK_TREE_DURATION_MS` | parent turn/end → tree settle duration, ms (`tree/settled`) |
123
134
  | `DSH_HOOK_TIMESTAMP` | ISO timestamp |
124
135
 
125
136
  - `{{var}}` placeholders inside `run` are substituted from the same context, e.g. `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`.
@@ -133,7 +144,14 @@ A common use for `DSH_HOOK_RUNNING_SUBAGENTS` is suppressing the end-of-turn not
133
144
  run: 'node examples/notify-webhook.mjs'
134
145
  ```
135
146
 
136
- Settled-but-idle continuable children do not count as running, so they don't keep suppressing the notification.
147
+ For the simpler "notify only once the whole tree settles" pattern, the synthetic `tree/settled` event does the watching for you — the plugin tracks sessions whose turn ended with running subagents and fires `tree/settled` on that session when the tree reaches zero:
148
+
149
+ ```yaml
150
+ - on: 'tree/settled'
151
+ notify: { channel: 'webhook', url: 'https://hooks.slack.com/services/…' }
152
+ ```
153
+
154
+ Settled-but-idle continuable children do not count as running, so they don't keep suppressing the notification. The settle watch is event-driven and best-effort: it survives until the plugin restarts, and a failed re-check drops the watch silently (no late notification).
137
155
 
138
156
  ## Generic webhook example
139
157
 
package/README.zh.md CHANGED
@@ -75,12 +75,14 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
75
75
  | 事件 | 触发时机 | 有用上下文 |
76
76
  | --- | --- | --- |
77
77
  | `turn/start` | 回合开始 | 会话 id、回合号 |
78
- | `turn/end` | 回合结束(`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason、回合号、耗时、内容、本回合 token 用量 |
78
+ | `turn/end` | 回合结束(`completed` / `error` / `aborted` / `blocked` / `max-tokens` / `interrupted`) | reason、回合号、耗时、内容、本回合 token 用量、运行中子代理数 |
79
+ | `tree/settled` | 回合结束后把工作交给子代理的会话,其整个子代理树全部落定(无存活子代理仍在运行) | 子代理总数、交接到落定的耗时 |
79
80
  | `step/end` | 回合内一步结束(一次模型调用 + 其工具执行) | 回合号、步号 |
80
81
  | `tool/call` | 模型请求一次工具调用 | 工具名、调用 id、原始参数 JSON |
81
82
  | `tool/result` | 工具调用完成 | 工具名(自动反查)、结果文本、失败标识 |
82
83
  | `user/message` | 会话表面出现用户角色消息 | 来源 kind(`user` / `plugin` / …)、消息文本 |
83
- | `approval/asked` | 工具调用请求用户审批 | 工具名、调用 id、原因 |
84
+ | `approval/asked` | 工具调用请求用户审批 | 工具名、调用 id、审批 id、原因 |
85
+ | `approval/decided` | 待审批项得出结果(与 `approval/asked` 按 id 配对) | 结果 outcome、工具名(自动反查)、调用 id、审批 id |
84
86
  | `session/title` | 会话标题更新(显式改名 / LLM 生成 / 回退) | 新标题、来源 kind |
85
87
  | `session/created` | 会话发布 | 会话 id、cwd |
86
88
  | `session/disposed` | 会话离开注册表 | 会话 id、cwd |
@@ -120,6 +122,15 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
120
122
  | `DSH_HOOK_USAGE_CACHE_WRITE_TOKENS` | 本回合缓存写 token(有上报时) |
121
123
  | `DSH_HOOK_USAGE_REASONING_TOKENS` | 本回合思考 token(有上报时) |
122
124
  | `DSH_HOOK_RUNNING_SUBAGENTS` | 本会话下仍在运行的存活子代理数(turn/end;`0` = 无——让 hook 能区分「工作已交给后台子代理」与「回合真正结束」) |
125
+ | `DSH_HOOK_PARENT_SESSION_ID` | 父会话 id(子代理谱系;顶层会话无此变量) |
126
+ | `DSH_HOOK_SUBAGENT` | 会话为子代理时为 `1`,否则 `0` |
127
+ | `DSH_HOOK_DELEGATION_DEPTH` | 会话头中的委托深度(`0` = 顶层会话) |
128
+ | `DSH_HOOK_SESSION_CREATED_AT` | 会话创建时间,epoch 毫秒 |
129
+ | `DSH_HOOK_AGENT_PRESET` | 组合该会话 Agent 的预设 id(有值时) |
130
+ | `DSH_HOOK_APPROVAL_ID` | 审批审计 id(`approval/asked` 与 `approval/decided` 共用) |
131
+ | `DSH_HOOK_APPROVAL_OUTCOME` | 审批结果 outcome(`approval/decided`) |
132
+ | `DSH_HOOK_TOTAL_SUBAGENTS` | 已落定树中的子代理总数(`tree/settled`) |
133
+ | `DSH_HOOK_TREE_DURATION_MS` | 父回合结束 → 树落定的耗时(毫秒,`tree/settled`) |
123
134
  | `DSH_HOOK_TIMESTAMP` | ISO 时间戳 |
124
135
 
125
136
  - `run` 里的 `{{变量}}` 占位符会从同一上下文替换,例如 `run: 'echo {{DSH_HOOK_SESSION_ID}} >> log.txt'`。
@@ -133,7 +144,14 @@ dsh plugin --profile web add github:PeterBon/dsh-hooks
133
144
  run: 'node examples/notify-webhook.mjs'
134
145
  ```
135
146
 
136
- 已落定但闲置(idle)的 continuable 子代理不计入运行中,不会一直压住通知。
147
+ 如果只想要「整棵树落定才通知一次」的简单模式,合成事件 `tree/settled` 帮你做了监视:插件跟踪回合结束时仍有运行中子代理的会话,树归零时对该会话发射 `tree/settled`:
148
+
149
+ ```yaml
150
+ - on: 'tree/settled'
151
+ notify: { channel: 'webhook', url: 'https://hooks.slack.com/services/…' }
152
+ ```
153
+
154
+ 已落定但闲置(idle)的 continuable 子代理不计入运行中,不会一直压住通知。落定监视是事件驱动且 best-effort 的:插件重启后监视集合丢失;重查失败会静默放弃该监视(不会补发迟到的通知)。
137
155
 
138
156
  ## 执行历史
139
157
 
package/lib/client.js CHANGED
@@ -1336,7 +1336,7 @@ window.__ModuleLoader__.load({
1336
1336
  }
1337
1337
  //#endregion
1338
1338
  //#region src/client/settings-card.module.css?inline
1339
- var settings_card_module_default = ":root {\n --dsh-hooks-border: #80849038;\n --dsh-hooks-muted: #767c85;\n --dsh-hooks-accent: #4d8df7;\n --dsh-hooks-ok: #3fb56b;\n --dsh-hooks-bad: #e5534b;\n --dsh-hooks-warn: #d9a13c;\n}\n\n.dh-card {\n flex-direction: column;\n gap: 14px;\n padding: 12px 4px;\n font-size: 13px;\n line-height: 1.5;\n display: flex;\n}\n\n.dh-card-head {\n align-items: center;\n gap: 10px;\n display: flex;\n}\n\n.dh-card-title {\n font-size: 14px;\n font-weight: 600;\n}\n\n.dh-badges {\n gap: 6px;\n display: flex;\n}\n\n.dh-badge {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n background: #80849029;\n border-radius: 9px;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-section-title {\n color: var(--dsh-hooks-muted);\n text-transform: uppercase;\n letter-spacing: .04em;\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-section-head {\n justify-content: space-between;\n align-items: center;\n gap: 8px;\n margin: 0 0 8px;\n display: flex;\n}\n\n.dh-section-head .dh-section-title {\n margin: 0;\n}\n\n.dh-toggle {\n padding: 2px 10px;\n font-size: 11px;\n}\n\n.dh-field-narrow {\n flex: 0 0 150px;\n}\n\n.dh-feishu-row {\n align-items: flex-end;\n gap: 8px;\n display: flex;\n}\n\n.dh-timeline {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-record {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n gap: 8px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-record-main {\n flex: 1;\n min-width: 0;\n}\n\n.dh-record-top {\n align-items: baseline;\n gap: 6px;\n display: flex;\n}\n\n.dh-record-time {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n font-size: 11px;\n}\n\n.dh-record-event {\n white-space: nowrap;\n text-overflow: ellipsis;\n font-weight: 600;\n overflow: hidden;\n}\n\n.dh-record-command {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-outcome {\n white-space: nowrap;\n border-radius: 9px;\n align-self: flex-start;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-outcome-ok {\n color: var(--dsh-hooks-ok);\n background: #3fb56b29;\n}\n\n.dh-outcome-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-outcome-warn {\n color: var(--dsh-hooks-warn);\n background: #d9a13c29;\n}\n\n.dh-outcome-neutral {\n color: var(--dsh-hooks-muted);\n background: #80849029;\n}\n\n.dh-record-error {\n color: var(--dsh-hooks-bad);\n white-space: pre-wrap;\n word-break: break-all;\n margin-top: 4px;\n font-size: 11px;\n}\n\n.dh-empty {\n color: var(--dsh-hooks-muted);\n padding: 6px 2px;\n font-size: 12px;\n}\n\n.dh-test-form {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-test-row {\n gap: 8px;\n display: flex;\n}\n\n.dh-field {\n flex-direction: column;\n flex: 1;\n gap: 3px;\n min-width: 0;\n display: flex;\n}\n\n.dh-field-label {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-input, .dh-select {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n box-sizing: border-box;\n background: #8084901f;\n border-radius: 5px;\n outline: none;\n width: 100%;\n padding: 5px 8px;\n font-size: 12px;\n}\n\n.dh-input:focus, .dh-select:focus {\n border-color: var(--dsh-hooks-accent);\n}\n\n.dh-buttons {\n gap: 8px;\n display: flex;\n}\n\n.dh-button {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n cursor: pointer;\n background: #8084901f;\n border-radius: 5px;\n padding: 5px 12px;\n font-size: 12px;\n}\n\n.dh-button:hover {\n background: #80849038;\n}\n\n.dh-button-primary {\n background: var(--dsh-hooks-accent);\n border-color: var(--dsh-hooks-accent);\n color: #fff;\n}\n\n.dh-button-primary:hover {\n background: #3c7de8;\n}\n\n.dh-test-results {\n flex-direction: column;\n gap: 4px;\n display: flex;\n}\n\n.dh-test-line {\n word-break: break-all;\n border-radius: 5px;\n padding: 4px 8px;\n font-size: 12px;\n}\n\n.dh-test-line-match {\n color: var(--dsh-hooks-ok);\n background: #3fb56b24;\n}\n\n.dh-test-line-skip {\n color: var(--dsh-hooks-muted);\n background: #8084901a;\n}\n\n.dh-error-banner {\n color: var(--dsh-hooks-bad);\n background: #e5534b1f;\n border: 1px solid #e5534b66;\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n\n.dh-feishu {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-feishu-form, .dh-feishu-status, .dh-feishu-qr {\n flex-direction: column;\n align-items: flex-start;\n gap: 8px;\n display: flex;\n}\n\n.dh-feishu-qr-img {\n border: 1px solid var(--dsh-hooks-border);\n box-sizing: border-box;\n background: #fff;\n border-radius: 8px;\n width: 220px;\n height: 220px;\n padding: 6px;\n}\n\n.dh-feishu-line {\n font-size: 12px;\n}\n\n.dh-feishu-ok {\n color: var(--dsh-hooks-ok);\n}\n\n.dh-feishu-error {\n color: var(--dsh-hooks-bad);\n word-break: break-all;\n font-size: 12px;\n}\n\n.dh-feishu-hint {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-feishu-link {\n color: var(--dsh-hooks-accent);\n font-size: 12px;\n}\n\n.dh-badge-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-error-retry {\n flex-shrink: 0;\n margin-left: 8px;\n padding: 2px 10px;\n font-size: 11px;\n}\n\n.dh-check {\n color: var(--dsh-hooks-muted);\n align-items: center;\n gap: 6px;\n font-size: 12px;\n display: flex;\n}\n\n.dh-button-small {\n padding: 2px 8px;\n font-size: 11px;\n}\n\n.dh-button-danger {\n color: var(--dsh-hooks-bad);\n border-color: #e5534b80;\n}\n\n.dh-button-danger:hover {\n background: #e5534b24;\n}\n\n.dh-hook-list, .dh-hook-editor {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-hook {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n flex-direction: column;\n gap: 4px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-hook-editing {\n border-color: #4d8df766;\n gap: 8px;\n padding: 9px;\n}\n\n.dh-hook-head {\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n display: flex;\n}\n\n.dh-hook-event {\n white-space: nowrap;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-hook-action {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n flex: 1;\n min-width: 0;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-hook-match {\n color: var(--dsh-hooks-accent);\n word-break: break-all;\n font-size: 11px;\n}\n\n.dh-hook-meta {\n align-items: center;\n gap: 8px;\n display: flex;\n}\n\n.dh-match-key {\n flex: 0 0 130px;\n}\n\n.dh-feishu-preview {\n color: var(--dsh-hooks-muted);\n border-left: 2px solid var(--dsh-hooks-border);\n word-break: break-all;\n max-height: 120px;\n padding: 4px 8px;\n font-size: 11px;\n overflow: hidden;\n}\n";
1339
+ var settings_card_module_default = ":root {\n --dsh-hooks-border: #80849038;\n --dsh-hooks-muted: #767c85;\n --dsh-hooks-accent: #4d8df7;\n --dsh-hooks-ok: #3fb56b;\n --dsh-hooks-bad: #e5534b;\n --dsh-hooks-warn: #d9a13c;\n}\n\n.dh-card {\n flex-direction: column;\n gap: 14px;\n padding: 12px 4px;\n font-size: 13px;\n line-height: 1.5;\n display: flex;\n}\n\n.dh-card-head {\n align-items: center;\n gap: 10px;\n display: flex;\n}\n\n.dh-card-title {\n font-size: 14px;\n font-weight: 600;\n}\n\n.dh-badges {\n gap: 6px;\n display: flex;\n}\n\n.dh-badge {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n background: #80849029;\n border-radius: 9px;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-section-title {\n color: var(--dsh-hooks-muted);\n text-transform: uppercase;\n letter-spacing: .04em;\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-section-head {\n justify-content: space-between;\n align-items: center;\n gap: 8px;\n margin: 0 0 8px;\n display: flex;\n}\n\n.dh-section-head .dh-section-title {\n margin: 0;\n}\n\n.dh-toggle {\n padding: 2px 10px;\n font-size: 11px;\n}\n\n.dh-field-narrow {\n flex: 0 0 150px;\n}\n\n.dh-feishu-row {\n align-items: flex-end;\n gap: 8px;\n display: flex;\n}\n\n.dh-timeline {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-record {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n gap: 8px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-record-main {\n flex: 1;\n min-width: 0;\n}\n\n.dh-record-top {\n align-items: baseline;\n gap: 6px;\n display: flex;\n}\n\n.dh-record-time {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n font-size: 11px;\n}\n\n.dh-record-event {\n white-space: nowrap;\n text-overflow: ellipsis;\n font-weight: 600;\n overflow: hidden;\n}\n\n.dh-record-command {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-outcome {\n white-space: nowrap;\n border-radius: 9px;\n align-self: flex-start;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-outcome-ok {\n color: var(--dsh-hooks-ok);\n background: #3fb56b29;\n}\n\n.dh-outcome-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-outcome-warn {\n color: var(--dsh-hooks-warn);\n background: #d9a13c29;\n}\n\n.dh-outcome-neutral {\n color: var(--dsh-hooks-muted);\n background: #80849029;\n}\n\n.dh-record-error {\n color: var(--dsh-hooks-bad);\n white-space: pre-wrap;\n word-break: break-all;\n margin-top: 4px;\n font-size: 11px;\n}\n\n.dh-empty {\n color: var(--dsh-hooks-muted);\n padding: 6px 2px;\n font-size: 12px;\n}\n\n.dh-test-form {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-test-row {\n gap: 8px;\n display: flex;\n}\n\n.dh-field {\n flex-direction: column;\n flex: 1;\n gap: 3px;\n min-width: 0;\n display: flex;\n}\n\n.dh-field-label {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-input, .dh-select {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n box-sizing: border-box;\n background: #8084901f;\n border-radius: 5px;\n outline: none;\n width: 100%;\n padding: 5px 8px;\n font-size: 12px;\n}\n\nbody[data-ds-dark-theme] .dh-select {\n color-scheme: dark;\n}\n\n.dh-select option {\n background-color: var(--dsw-specific-menu, transparent);\n color: inherit;\n}\n\n.dh-input:focus, .dh-select:focus {\n border-color: var(--dsh-hooks-accent);\n}\n\n.dh-buttons {\n gap: 8px;\n display: flex;\n}\n\n.dh-button {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n cursor: pointer;\n background: #8084901f;\n border-radius: 5px;\n padding: 5px 12px;\n font-size: 12px;\n}\n\n.dh-button:hover {\n background: #80849038;\n}\n\n.dh-button-primary {\n background: var(--dsh-hooks-accent);\n border-color: var(--dsh-hooks-accent);\n color: #fff;\n}\n\n.dh-button-primary:hover {\n background: #3c7de8;\n}\n\n.dh-test-results {\n flex-direction: column;\n gap: 4px;\n display: flex;\n}\n\n.dh-test-line {\n word-break: break-all;\n border-radius: 5px;\n padding: 4px 8px;\n font-size: 12px;\n}\n\n.dh-test-line-match {\n color: var(--dsh-hooks-ok);\n background: #3fb56b24;\n}\n\n.dh-test-line-skip {\n color: var(--dsh-hooks-muted);\n background: #8084901a;\n}\n\n.dh-error-banner {\n color: var(--dsh-hooks-bad);\n background: #e5534b1f;\n border: 1px solid #e5534b66;\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n\n.dh-feishu {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-feishu-form, .dh-feishu-status, .dh-feishu-qr {\n flex-direction: column;\n align-items: flex-start;\n gap: 8px;\n display: flex;\n}\n\n.dh-feishu-qr-img {\n border: 1px solid var(--dsh-hooks-border);\n box-sizing: border-box;\n background: #fff;\n border-radius: 8px;\n width: 220px;\n height: 220px;\n padding: 6px;\n}\n\n.dh-feishu-line {\n font-size: 12px;\n}\n\n.dh-feishu-ok {\n color: var(--dsh-hooks-ok);\n}\n\n.dh-feishu-error {\n color: var(--dsh-hooks-bad);\n word-break: break-all;\n font-size: 12px;\n}\n\n.dh-feishu-hint {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-feishu-link {\n color: var(--dsh-hooks-accent);\n font-size: 12px;\n}\n\n.dh-badge-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-error-retry {\n flex-shrink: 0;\n margin-left: 8px;\n padding: 2px 10px;\n font-size: 11px;\n}\n\n.dh-check {\n color: var(--dsh-hooks-muted);\n align-items: center;\n gap: 6px;\n font-size: 12px;\n display: flex;\n}\n\n.dh-button-small {\n padding: 2px 8px;\n font-size: 11px;\n}\n\n.dh-button-danger {\n color: var(--dsh-hooks-bad);\n border-color: #e5534b80;\n}\n\n.dh-button-danger:hover {\n background: #e5534b24;\n}\n\n.dh-hook-list, .dh-hook-editor {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-hook {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n flex-direction: column;\n gap: 4px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-hook-editing {\n border-color: #4d8df766;\n gap: 8px;\n padding: 9px;\n}\n\n.dh-hook-head {\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n display: flex;\n}\n\n.dh-hook-event {\n white-space: nowrap;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-hook-action {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n flex: 1;\n min-width: 0;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-hook-match {\n color: var(--dsh-hooks-accent);\n word-break: break-all;\n font-size: 11px;\n}\n\n.dh-hook-meta {\n align-items: center;\n gap: 8px;\n display: flex;\n}\n\n.dh-match-key {\n flex: 0 0 130px;\n}\n\n.dh-feishu-preview {\n color: var(--dsh-hooks-muted);\n border-left: 2px solid var(--dsh-hooks-border);\n word-break: break-all;\n max-height: 120px;\n padding: 4px 8px;\n font-size: 11px;\n overflow: hidden;\n}\n";
1340
1340
  //#endregion
1341
1341
  //#region src/client/index.ts
1342
1342
  const name = "dsh-hooks";
package/lib/config.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
2
- export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status'];
2
+ export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'tree/settled', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'approval/decided', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status'];
3
3
  export type HookEvent = (typeof HOOK_EVENTS)[number];
4
4
  /** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
5
5
  export declare const TURN_END_REASONS: readonly ['completed', 'error', 'aborted', 'blocked', 'max-tokens', 'interrupted'];
package/lib/config.js CHANGED
@@ -3,11 +3,13 @@ import Schema from '@deepseek-ai/schemastery';
3
3
  export const HOOK_EVENTS = [
4
4
  'turn/start',
5
5
  'turn/end',
6
+ 'tree/settled',
6
7
  'step/end',
7
8
  'tool/call',
8
9
  'tool/result',
9
10
  'user/message',
10
11
  'approval/asked',
12
+ 'approval/decided',
11
13
  'session/title',
12
14
  'session/created',
13
15
  'session/disposed',
@@ -31,7 +33,7 @@ export const TURN_END_REASONS = [
31
33
  // declaration self-contained.
32
34
  export const Config = Schema.object({
33
35
  hooks: Schema.array(Schema.object({
34
- on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | step/end | tool/call | tool/result | user/message | approval/asked | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status'),
36
+ on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | tree/settled | step/end | tool/call | tool/result | user/message | approval/asked | approval/decided | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status'),
35
37
  when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
36
38
  match: Schema.dict(Schema.regExp()).description('可选通用过滤:字段 → 正则,全部匹配才触发。字段为上下文键(tool/sessionName/sessionId/error/source/cwd/content/reason/…),上下文中不存在的字段视为不匹配'),
37
39
  run: Schema.string().description('触发时通过系统 shell 执行的命令(与 notify 二选一)'),
package/lib/context.d.ts CHANGED
@@ -42,6 +42,24 @@ export interface HookContext {
42
42
  * from the agents/subagents services when they are available.
43
43
  */
44
44
  runningSubagents?: number;
45
+ /** Subagent lineage: the parent session id (session header `parentSession`). */
46
+ parentSessionId?: string;
47
+ /** Whether the session was created as a subagent child (header `origin`). */
48
+ subagent?: boolean;
49
+ /** Delegation depth from the session header; 0 = top-level session. */
50
+ delegationDepth?: number;
51
+ /** Session creation time, epoch ms (session header `createdAt`). */
52
+ sessionCreatedAt?: number;
53
+ /** Agent preset id that composed the session's agent (header `agentPreset`). */
54
+ agentPreset?: string;
55
+ /** Approval audit id, pairing `approval/asked` with `approval/decided`. */
56
+ approvalId?: string;
57
+ /** Approval decision outcome (approval/decided). */
58
+ approvalOutcome?: string;
59
+ /** Total subagents in the settled tree (tree/settled). */
60
+ totalSubagents?: number;
61
+ /** Parent turn/end → tree settle duration, ms (tree/settled). */
62
+ treeDurationMs?: number;
45
63
  timestamp: string;
46
64
  }
47
65
  export declare function toEnv(ctx: HookContext): Record<string, string>;
package/lib/context.js CHANGED
@@ -47,6 +47,24 @@ export function toEnv(ctx) {
47
47
  env.DSH_HOOK_USAGE_REASONING_TOKENS = String(ctx.usageReasoningTokens);
48
48
  if (ctx.runningSubagents !== undefined)
49
49
  env.DSH_HOOK_RUNNING_SUBAGENTS = String(ctx.runningSubagents);
50
+ if (ctx.parentSessionId !== undefined)
51
+ env.DSH_HOOK_PARENT_SESSION_ID = ctx.parentSessionId;
52
+ if (ctx.subagent !== undefined)
53
+ env.DSH_HOOK_SUBAGENT = ctx.subagent ? '1' : '0';
54
+ if (ctx.delegationDepth !== undefined)
55
+ env.DSH_HOOK_DELEGATION_DEPTH = String(ctx.delegationDepth);
56
+ if (ctx.sessionCreatedAt !== undefined)
57
+ env.DSH_HOOK_SESSION_CREATED_AT = String(ctx.sessionCreatedAt);
58
+ if (ctx.agentPreset !== undefined)
59
+ env.DSH_HOOK_AGENT_PRESET = ctx.agentPreset;
60
+ if (ctx.approvalId !== undefined)
61
+ env.DSH_HOOK_APPROVAL_ID = ctx.approvalId;
62
+ if (ctx.approvalOutcome !== undefined)
63
+ env.DSH_HOOK_APPROVAL_OUTCOME = ctx.approvalOutcome;
64
+ if (ctx.totalSubagents !== undefined)
65
+ env.DSH_HOOK_TOTAL_SUBAGENTS = String(ctx.totalSubagents);
66
+ if (ctx.treeDurationMs !== undefined)
67
+ env.DSH_HOOK_TREE_DURATION_MS = String(ctx.treeDurationMs);
50
68
  return env;
51
69
  }
52
70
  /** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
package/lib/events.d.ts CHANGED
@@ -9,6 +9,11 @@ export interface ApprovalAskedData {
9
9
  callId?: string;
10
10
  reason?: string;
11
11
  }
12
+ /** `approval/decided` payload (merge-extensible, declared by dsh-user-approval). */
13
+ export interface ApprovalDecidedData {
14
+ id: string;
15
+ outcome: string;
16
+ }
12
17
  /** `session/title` payload (merge-extensible, declared by dsh-session-title). */
13
18
  export interface SessionTitleEventData {
14
19
  title: string;
@@ -25,6 +30,7 @@ export interface SessionTitleEventData {
25
30
  declare module '@deepseek-ai/dsh-session/types' {
26
31
  interface SessionEventMap {
27
32
  'approval/asked': ApprovalAskedData;
33
+ 'approval/decided': ApprovalDecidedData;
28
34
  'session/title': SessionTitleEventData;
29
35
  }
30
36
  }
@@ -106,6 +112,13 @@ export declare function titleContext(session: Session, title: unknown, source: u
106
112
  export declare function sessionCreatedContext(session: Session): HookContext;
107
113
  export declare function sessionDisposedContext(session: Session): HookContext;
108
114
  export declare function approvalContext(session: Session, data: ApprovalAskedData): HookContext;
115
+ export declare function approvalDecidedContext(session: Session, data: ApprovalDecidedData): HookContext;
116
+ /**
117
+ * Synthetic `tree/settled` context: the session's whole subagent tree has
118
+ * settled (no live child still running) after a turn ended with work handed
119
+ * off. Emitted by index.ts, not classified from a session log event.
120
+ */
121
+ export declare function treeSettledContext(session: Session, totalSubagents: number, treeDurationMs: number): HookContext;
109
122
  export declare function agentCreatedContext(agent: AgentLike): HookContext;
110
123
  export declare function agentDisposedContext(agent: AgentLike): HookContext;
111
124
  export declare function agentErrorContext(agent: AgentLike, turn: number | undefined, error: unknown): HookContext;
package/lib/events.js CHANGED
@@ -2,12 +2,17 @@
2
2
  const turnStarts = new Map();
3
3
  /** Tool name for an in-flight call, remembered at `tool/call` and consumed at `tool/result`. */
4
4
  const callTools = new Map();
5
+ /** Approval identity remembered at `approval/asked` and consumed at `approval/decided`. */
6
+ const approvalTools = new Map();
5
7
  function sessionKey(session) {
6
8
  return String(session.id);
7
9
  }
8
10
  function callKey(session, callId) {
9
11
  return `${sessionKey(session)}\u0000${String(callId)}`;
10
12
  }
13
+ function approvalKey(session, id) {
14
+ return `${sessionKey(session)}\u0000${String(id)}`;
15
+ }
11
16
  /** Best-effort access to a session's event log (test fakes may omit it). */
12
17
  function sessionEvents(session) {
13
18
  return Array.isArray(session.events) ? session.events : [];
@@ -155,12 +160,27 @@ export function matchFilters(match, ctx) {
155
160
  }
156
161
  return true;
157
162
  }
163
+ /**
164
+ * Session-header lineage/metadata shared by every session-backed context:
165
+ * subagent parentage, delegation depth, creation time, and agent preset.
166
+ */
167
+ function sessionMeta(session) {
168
+ const header = session.header;
169
+ return {
170
+ parentSessionId: header.parentSession,
171
+ subagent: header.origin === 'subagent',
172
+ delegationDepth: header.delegationDepth ?? 0,
173
+ sessionCreatedAt: header.createdAt,
174
+ agentPreset: header.agentPreset,
175
+ };
176
+ }
158
177
  function baseContext(session, event) {
159
178
  return {
160
179
  event,
161
180
  sessionId: sessionKey(session),
162
181
  sessionName: sessionTitle(session),
163
182
  cwd: session.header.cwd,
183
+ ...sessionMeta(session),
164
184
  timestamp: new Date().toISOString(),
165
185
  };
166
186
  }
@@ -259,6 +279,7 @@ export function sessionCreatedContext(session) {
259
279
  sessionId: sessionKey(session),
260
280
  sessionName: sessionTitle(session),
261
281
  cwd: session.header.cwd,
282
+ ...sessionMeta(session),
262
283
  timestamp: new Date().toISOString(),
263
284
  };
264
285
  }
@@ -268,17 +289,46 @@ export function sessionDisposedContext(session) {
268
289
  sessionId: sessionKey(session),
269
290
  sessionName: sessionTitle(session),
270
291
  cwd: session.header.cwd,
292
+ ...sessionMeta(session),
271
293
  timestamp: new Date().toISOString(),
272
294
  };
273
295
  }
274
296
  export function approvalContext(session, data) {
297
+ approvalTools.set(approvalKey(session, data.id), { toolName: data.toolName, callId: data.callId });
275
298
  return {
276
299
  ...baseContext(session, 'approval/asked'),
300
+ approvalId: data.id,
277
301
  tool: data.toolName,
278
302
  callId: data.callId,
279
303
  reason: data.reason,
280
304
  };
281
305
  }
306
+ export function approvalDecidedContext(session, data) {
307
+ const key = approvalKey(session, data.id);
308
+ const paired = approvalTools.get(key);
309
+ if (paired !== undefined)
310
+ approvalTools.delete(key);
311
+ return {
312
+ ...baseContext(session, 'approval/decided'),
313
+ approvalId: data.id,
314
+ approvalOutcome: data.outcome,
315
+ tool: paired?.toolName,
316
+ callId: paired?.callId,
317
+ };
318
+ }
319
+ /**
320
+ * Synthetic `tree/settled` context: the session's whole subagent tree has
321
+ * settled (no live child still running) after a turn ended with work handed
322
+ * off. Emitted by index.ts, not classified from a session log event.
323
+ */
324
+ export function treeSettledContext(session, totalSubagents, treeDurationMs) {
325
+ return {
326
+ ...baseContext(session, 'tree/settled'),
327
+ reason: 'settled',
328
+ totalSubagents,
329
+ treeDurationMs,
330
+ };
331
+ }
282
332
  export function agentCreatedContext(agent) {
283
333
  return {
284
334
  event: 'agent/created',
@@ -334,6 +384,8 @@ export function classifySessionEvent(session, event) {
334
384
  return userMessageContext(session, event.data.content, event.data.source);
335
385
  case 'approval/asked':
336
386
  return approvalContext(session, event.data);
387
+ case 'approval/decided':
388
+ return approvalDecidedContext(session, event.data);
337
389
  case 'session/title':
338
390
  return titleContext(session, event.data.title, event.data.source);
339
391
  default:
package/lib/index.d.ts CHANGED
@@ -23,21 +23,33 @@ interface SubagentsLike {
23
23
  id?: string;
24
24
  }>>;
25
25
  }
26
+ /** Snapshot of one session's subagent tree: live-running plus total descendants. */
27
+ export interface SubagentTreeStats {
28
+ /** Descendants whose live agent status is `running`. */
29
+ running: number;
30
+ /** Total descendants in the durable tree (running, idle, or settled). */
31
+ total: number;
32
+ }
26
33
  /**
27
- * Count live agents still running in one session's descendant subagent tree.
34
+ * Inspect one session's descendant subagent tree.
28
35
  *
29
36
  * Lineage comes from the durable session tree (`subagents.listDescendants`,
30
37
  * driven by the session header `parentSession`): a subagent's runtime owner
31
38
  * in the agents registry is the subagent manager's host-level scope, not the
32
39
  * parent agent, so ownership chains (`agents.isOwnedBy`) cannot find children.
33
- * Only agents whose live status is `running` count — a settled/idle
34
- * continuable child no longer suppresses the turn/end notification. Returns 0
35
- * when the session has no live agent or the services are unavailable.
40
+ * Only agents whose live status is `running` count as running — a settled/idle
41
+ * continuable child does not. Returns `{ running: 0, total: 0 }` when the
42
+ * session has no live agent or the services are unavailable.
36
43
  *
37
44
  * The live-registry scan is strictly a fallback for when listing is
38
45
  * unavailable (service absent or listing threw): a successful empty listing
39
46
  * stays empty, so ordinary subagent-free turns don't pay an O(registry) scan.
40
47
  */
48
+ export declare function inspectSubagentTree(agents: AgentsLike, subagents: SubagentsLike | undefined, sessionId: string | undefined): Promise<SubagentTreeStats>;
49
+ /**
50
+ * Count live agents still running in one session's descendant subagent tree
51
+ * (the `running` half of {@link inspectSubagentTree}).
52
+ */
41
53
  export declare function countRunningSubagents(agents: AgentsLike, subagents: SubagentsLike | undefined, sessionId: string | undefined): Promise<number>;
42
54
  export declare const inject: readonly ['sessions'];
43
55
  export { Config };
@@ -47,9 +59,10 @@ export { createHistorySink } from './history.js';
47
59
  * Model-facing announcement, installed only when the system-prompt service
48
60
  * exists (web profile). Tells agents the plugin exists and how to cooperate.
49
61
  */
50
- export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-hooks \u63D2\u4EF6\uFF08DeepSeek Harness \u914D\u7F6E\u9A71\u52A8\u751F\u547D\u5468\u671F hooks\uFF09\uFF1A\u53EF\u5728 profile \u7684 cordis.patch.yml \u58F0\u660E\u300C\u4E8B\u4EF6 \u2192 \u547D\u4EE4/\u901A\u77E5\u300D\u7684 hook\uFF08turn/start\u3001turn/end\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status \u5171 14 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219\u8FC7\u6EE4\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\uFF1B`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\u9A8C\u8BC1\u914D\u7F6E\u3002\u7528\u6237\u63D0\u5230\u300Chooks / \u94A9\u5B50 / \u751F\u547D\u5468\u671F / \u901A\u77E5\u914D\u7F6E\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
62
+ export declare const DSH_HOOKS_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-hooks \u63D2\u4EF6\uFF08DeepSeek Harness \u914D\u7F6E\u9A71\u52A8\u751F\u547D\u5468\u671F hooks\uFF09\uFF1A\u53EF\u5728 profile \u7684 cordis.patch.yml \u58F0\u660E\u300C\u4E8B\u4EF6 \u2192 \u547D\u4EE4/\u901A\u77E5\u300D\u7684 hook\uFF08turn/start\u3001turn/end\u3001tree/settled\u3001step/end\u3001tool/call\u3001tool/result\u3001user/message\u3001approval/asked\u3001approval/decided\u3001session/title\u3001session/created\u3001session/disposed\u3001agent/created\u3001agent/disposed\u3001agent/error\u3001agent/status \u5171 16 \u7C7B\u4E8B\u4EF6\uFF09\uFF0C\u652F\u6301 when \u539F\u56E0\u8FC7\u6EE4\u3001match \u5B57\u6BB5\u6B63\u5219\u8FC7\u6EE4\u3001stdin JSON \u8F93\u5165\u3001opt-in \u91CD\u8BD5\u3001\u5185\u7F6E webhook/desktop \u901A\u77E5\u6E20\u9053\uFF1B\u6267\u884C\u5386\u53F2\u8BB0\u5F55\u4E8E ~/.dsh/dsh-hooks/history.jsonl\uFF1B`dsh-hooks dry-run <event>` \u53EF\u6A21\u62DF\u4E8B\u4EF6\u9A8C\u8BC1\u914D\u7F6E\u3002\u7528\u6237\u63D0\u5230\u300Chooks / \u94A9\u5B50 / \u751F\u547D\u5468\u671F / \u901A\u77E5\u914D\u7F6E\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
51
63
  export declare function apply(ctx: Context, config?: Config): void;
52
64
  export declare const _internals: {
53
65
  clearTurnTracking: typeof clearTurnTracking;
54
66
  countRunningSubagents: typeof countRunningSubagents;
67
+ inspectSubagentTree: typeof inspectSubagentTree;
55
68
  };
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import './types.js';
2
2
  import { Config } from './config.js';
3
- import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, } from './events.js';
3
+ import { agentCreatedContext, agentDisposedContext, agentErrorContext, agentStatusContext, classifySessionEvent, clearTurnTracking, hookMatches, matchFilters, sessionCreatedContext, sessionDisposedContext, treeSettledContext, } from './events.js';
4
4
  import { eventLabel } from './context.js';
5
5
  import { createHookRunner } from './runner.js';
6
6
  import { fireNotify } from './notify.js';
@@ -9,23 +9,23 @@ import { createFeishuSetupManager } from './feishu-session.js';
9
9
  import { registerHookRoutes } from './server.js';
10
10
  export const name = 'dsh-hooks';
11
11
  /**
12
- * Count live agents still running in one session's descendant subagent tree.
12
+ * Inspect one session's descendant subagent tree.
13
13
  *
14
14
  * Lineage comes from the durable session tree (`subagents.listDescendants`,
15
15
  * driven by the session header `parentSession`): a subagent's runtime owner
16
16
  * in the agents registry is the subagent manager's host-level scope, not the
17
17
  * parent agent, so ownership chains (`agents.isOwnedBy`) cannot find children.
18
- * Only agents whose live status is `running` count — a settled/idle
19
- * continuable child no longer suppresses the turn/end notification. Returns 0
20
- * when the session has no live agent or the services are unavailable.
18
+ * Only agents whose live status is `running` count as running — a settled/idle
19
+ * continuable child does not. Returns `{ running: 0, total: 0 }` when the
20
+ * session has no live agent or the services are unavailable.
21
21
  *
22
22
  * The live-registry scan is strictly a fallback for when listing is
23
23
  * unavailable (service absent or listing threw): a successful empty listing
24
24
  * stays empty, so ordinary subagent-free turns don't pay an O(registry) scan.
25
25
  */
26
- export async function countRunningSubagents(agents, subagents, sessionId) {
26
+ export async function inspectSubagentTree(agents, subagents, sessionId) {
27
27
  if (sessionId === undefined || agents.get(sessionId) === undefined)
28
- return 0;
28
+ return { running: 0, total: 0 };
29
29
  let ids = [];
30
30
  let listed = false;
31
31
  if (subagents !== undefined) {
@@ -42,19 +42,26 @@ export async function countRunningSubagents(agents, subagents, sessionId) {
42
42
  if (!listed) {
43
43
  const owner = agents.get(sessionId);
44
44
  if (owner === undefined)
45
- return 0;
45
+ return { running: 0, total: 0 };
46
46
  ids = agents
47
47
  .list()
48
48
  .filter((candidate) => candidate !== owner && agents.isOwnedBy(candidate.id, owner))
49
49
  .map((candidate) => candidate.id);
50
50
  }
51
- let count = 0;
51
+ let running = 0;
52
52
  for (const id of ids) {
53
53
  const agent = agents.get(id);
54
54
  if (agent !== undefined && agent.status === 'running')
55
- count++;
55
+ running++;
56
56
  }
57
- return count;
57
+ return { running, total: ids.length };
58
+ }
59
+ /**
60
+ * Count live agents still running in one session's descendant subagent tree
61
+ * (the `running` half of {@link inspectSubagentTree}).
62
+ */
63
+ export async function countRunningSubagents(agents, subagents, sessionId) {
64
+ return (await inspectSubagentTree(agents, subagents, sessionId)).running;
58
65
  }
59
66
  // Dependency on the session service: `session/event` only exists once a
60
67
  // SessionStore is composed, and this plugin consumes the durable firehose.
@@ -66,7 +73,7 @@ export { createHistorySink } from './history.js';
66
73
  * Model-facing announcement, installed only when the system-prompt service
67
74
  * exists (web profile). Tells agents the plugin exists and how to cooperate.
68
75
  */
69
- export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(turn/start、turn/end、step/end、tool/call、tool/result、user/message、approval/asked、session/title、session/created、session/disposed、agent/created、agent/disposed、agent/error、agent/status 共 14 类事件),支持 when 原因过滤、match 字段正则过滤、stdin JSON 输入、opt-in 重试、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
76
+ export const DSH_HOOKS_GUIDANCE = '本机已安装 dsh-hooks 插件(DeepSeek Harness 配置驱动生命周期 hooks):可在 profile 的 cordis.patch.yml 声明「事件 → 命令/通知」的 hook(turn/start、turn/end、tree/settled、step/end、tool/call、tool/result、user/message、approval/asked、approval/decided、session/title、session/created、session/disposed、agent/created、agent/disposed、agent/error、agent/status 共 16 类事件),支持 when 原因过滤、match 字段正则过滤、stdin JSON 输入、opt-in 重试、内置 webhook/desktop 通知渠道;执行历史记录于 ~/.dsh/dsh-hooks/history.jsonl;`dsh-hooks dry-run <event>` 可模拟事件验证配置。用户提到「hooks / 钩子 / 生命周期 / 通知配置」时即指本插件,请据此协作。';
70
77
  export function apply(ctx, config = {}) {
71
78
  const hooks = config.hooks ?? [];
72
79
  const history = createHistorySink(config.history ?? undefined);
@@ -112,7 +119,40 @@ export function apply(ctx, config = {}) {
112
119
  // "the turn finished for real". The services are read lazily at event time —
113
120
  // at plugin apply time the agents/subagents rows may not be composed yet.
114
121
  let warnedAgentsUnavailable = false;
115
- const matchAfterSubagentCount = async (ctxValue, reasonKind) => {
122
+ const watchedTrees = new Map();
123
+ /**
124
+ * Re-check every watched tree on subagent-activity signals (any turn/end or
125
+ * agent/status). Each entry is claimed (deleted) before its await, so a
126
+ * concurrent refresh can never emit the same settle twice; entries whose
127
+ * tree is still running are re-armed. Best-effort: a failed re-check or a
128
+ * vanished service drops the watch silently instead of leaking it.
129
+ */
130
+ const refreshWatchedTrees = async () => {
131
+ if (watchedTrees.size === 0)
132
+ return;
133
+ const agents = ctx.get('agents', false);
134
+ if (agents === undefined) {
135
+ watchedTrees.clear();
136
+ return;
137
+ }
138
+ const subagents = ctx.get('subagents', false);
139
+ for (const [sessionId, entry] of [...watchedTrees]) {
140
+ watchedTrees.delete(sessionId);
141
+ try {
142
+ const { running, total } = await inspectSubagentTree(agents, subagents, sessionId);
143
+ if (running === 0) {
144
+ runMatching(treeSettledContext(entry.session, total, Date.now() - entry.startedAt));
145
+ }
146
+ else {
147
+ watchedTrees.set(sessionId, entry);
148
+ }
149
+ }
150
+ catch {
151
+ // Re-check failed: the claim above already dropped the entry.
152
+ }
153
+ }
154
+ };
155
+ const matchAfterSubagentCount = async (session, ctxValue, reasonKind) => {
116
156
  const agents = ctx.get('agents', false);
117
157
  if (agents === undefined) {
118
158
  // Warn once, not on every turn/end: profiles without the agents service
@@ -124,14 +164,27 @@ export function apply(ctx, config = {}) {
124
164
  }
125
165
  else {
126
166
  const subagents = ctx.get('subagents', false);
167
+ const sessionId = String(session.id);
127
168
  try {
128
- ctxValue.runningSubagents = await countRunningSubagents(agents, subagents, ctxValue.sessionId);
169
+ const { running } = await inspectSubagentTree(agents, subagents, ctxValue.sessionId);
170
+ ctxValue.runningSubagents = running;
171
+ if (running > 0) {
172
+ // Work was handed off: watch this tree until it settles. A re-handoff
173
+ // on a later turn restarts the settle clock from that turn/end.
174
+ watchedTrees.set(sessionId, { session, startedAt: Date.now() });
175
+ }
176
+ else {
177
+ watchedTrees.delete(sessionId);
178
+ }
129
179
  }
130
180
  catch (error) {
131
181
  ctx.logger?.warn?.('[dsh-hooks] failed to count running subagents: %s', String(error));
132
182
  }
133
183
  }
134
184
  runMatching(ctxValue, reasonKind);
185
+ void refreshWatchedTrees().catch((error) => {
186
+ ctx.logger?.warn?.('[dsh-hooks] tree settle refresh failed: %s', String(error));
187
+ });
135
188
  };
136
189
  // Durable session firehose: turn boundaries, steps, tool calls, messages,
137
190
  // titles, and approval requests.
@@ -147,7 +200,7 @@ export function apply(ctx, config = {}) {
147
200
  // Dispatch is deferred past the async count; guard the fire-and-forget
148
201
  // promise so a synchronous throw inside dispatch surfaces as a log line
149
202
  // instead of an unhandled rejection.
150
- void matchAfterSubagentCount(classified, reasonKind).catch((error) => {
203
+ void matchAfterSubagentCount(session, classified, reasonKind).catch((error) => {
151
204
  ctx.logger?.warn?.('[dsh-hooks] turn/end dispatch failed: %s', String(error));
152
205
  });
153
206
  });
@@ -156,7 +209,12 @@ export function apply(ctx, config = {}) {
156
209
  runMatching(sessionCreatedContext(session));
157
210
  });
158
211
  ctx.on('session/disposed', (session) => {
212
+ watchedTrees.delete(String(session.id));
159
213
  runMatching(sessionDisposedContext(session));
214
+ // A child session leaving the store is also settle-relevant activity.
215
+ void refreshWatchedTrees().catch((error) => {
216
+ ctx.logger?.warn?.('[dsh-hooks] tree settle refresh failed: %s', String(error));
217
+ });
160
218
  });
161
219
  // Agent lifecycle events.
162
220
  ctx.on('agent/created', (payload) => {
@@ -164,15 +222,25 @@ export function apply(ctx, config = {}) {
164
222
  });
165
223
  ctx.on('agent/disposed', (payload) => {
166
224
  runMatching(agentDisposedContext(payload.agent));
225
+ // A disposed (interrupted/killed) child agent can never settle on its
226
+ // own — re-check watched trees so its parent's settle still fires.
227
+ void refreshWatchedTrees().catch((error) => {
228
+ ctx.logger?.warn?.('[dsh-hooks] tree settle refresh failed: %s', String(error));
229
+ });
167
230
  });
168
231
  ctx.on('agent/error', (payload) => {
169
232
  runMatching(agentErrorContext(payload.agent, payload.turn, payload.error));
170
233
  });
171
234
  ctx.on('agent/status', (payload) => {
172
235
  runMatching(agentStatusContext(payload.agent, payload.status));
236
+ // A child agent going idle is the settle signal for watched trees.
237
+ void refreshWatchedTrees().catch((error) => {
238
+ ctx.logger?.warn?.('[dsh-hooks] tree settle refresh failed: %s', String(error));
239
+ });
173
240
  });
174
241
  ctx.effect(() => () => {
175
242
  runner.dispose();
243
+ watchedTrees.clear();
176
244
  });
177
245
  }
178
246
  /** Extract the `turn/end` reason kind from a session event, when present. */
@@ -186,4 +254,4 @@ function extractReasonKind(event) {
186
254
  }
187
255
  // Referenced only for tree-shaking clarity of the module contract; exported
188
256
  // for tests that need deterministic bookkeeping.
189
- export const _internals = { clearTurnTracking, countRunningSubagents };
257
+ export const _internals = { clearTurnTracking, countRunningSubagents, inspectSubagentTree };
package/lib/server.d.ts CHANGED
@@ -48,7 +48,7 @@ export interface HookRoutesOptions {
48
48
  /** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
49
49
  export declare function describeHooks(hooks: readonly HookSpec[]): {
50
50
  index: number;
51
- on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "turn/end" | "turn/start" | "user/message";
51
+ on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "approval/decided" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "tree/settled" | "turn/end" | "turn/start" | "user/message";
52
52
  when: "aborted" | "blocked" | "completed" | "error" | "interrupted" | "max-tokens" | undefined;
53
53
  match: {
54
54
  [k: string]: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hooks",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "packageManager": "pnpm@11.21.0",
5
5
  "description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required. Includes a Hooks section in the Web GUI settings (history timeline + manual tester + notify tests + hook editor + Feishu connect).",
6
6
  "author": "PeterBon",