@telosmaylx/dsh-session-notify 0.1.2 → 0.1.3

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/lib/index.js CHANGED
@@ -1,291 +1,312 @@
1
- /**
2
- * dsh-session-complete-notify:会话完成系统消息提醒插件(host 平面)。
3
- *
4
- * 原理:订阅 session/event 火线——
5
- * - turn/start 记下轮次开始时间;
6
- * - assistant/message 累加该轮 token 用量;
7
- * - turn/end 按 reason.kind(completed/aborted/blocked/error/max-tokens)
8
- * 组装一条系统消息,并以 plugin-source 的 user/message(form: 'notice')
9
- * 追加进会话日志:Web UI 把它渲染为可折叠的系统提示行(醒目提醒用户),
10
- * 并随 JSONL 持久化,恢复/回放后依然可见。
11
- *
12
- * 设计取舍:
13
- * - 只响应「实时」事件:resume/replay 不会重放旧通知,不会在加载会话时刷屏;
14
- * - 追加的事件类型是 user/message,与自身监听目标(turn/*)不相交,
15
- * 天然免疫自我循环;
16
- * - 零外部 import(@deepseek-ai/* 无法从本仓库目录解析),
17
- * UserMessage 对象按 dsh-llm 的 createUserMessage 契约手工构造
18
- * (id = crypto.randomUUID(),deep-freeze 由 session.append 的
19
- * adoptSessionEvent 快照阶段完成)。
20
- *
21
- * 装配(cordis.patch.yml dsh plugin add):
22
- * - id: session-complete-notify
23
- * name: '@telosmaylx/dsh-session-notify'
24
- * config:
25
- * reasons: [completed, aborted, blocked, error, max-tokens]
26
- * skipSubagents: true
27
- * includeDuration: true
28
- * includeUsage: true
29
- */
30
-
31
- import { appendFileSync } from 'node:fs'
32
- import { join } from 'node:path'
33
- import { homedir } from 'node:os'
34
- import { createRequire } from 'node:module'
35
- import { createTurnTracker, buildNotice, isSubagentSession, officialCacheRate, officialTps } from './core.js?v=1' // v=1: 缓存破坏——HMR 重载按 URL 键控
36
-
37
- /** Cordis loader 诊断用插件名。 */
38
- export const name = 'session-complete-notify'
39
-
40
- /** 系统消息 source.plugin 标识(UI 按插件名着色/标注来源)。 */
41
- const PLUGIN_ID = 'dsh-session-notify'
42
-
43
- /** 设置命名空间(官方「设置 → 插件」面板的键)。 */
44
- const SETTINGS_NS = 'session-complete-notify'
45
-
46
- /**
47
- * 本插件从仓库目录以 realpath 加载,裸导入(@deepseek-ai/*)无法解析;
48
- * createRequire 锚定 profile 共享依赖枢纽(.dsh/profiles/node_modules),
49
- * 取到与宿主同源(realpath 相同)的 schemastery 实例来构造设置 schema。
50
- */
51
- const HUB_REQUIRE = createRequire(join(homedir(), '.dsh', 'profiles', 'node_modules', '__scn_anchor__.js'))
52
-
53
- /** 默认配置。 */
54
- const DEFAULT_OPTIONS = {
55
- /**
56
- * 触发提醒的 turn/end reason 白名单。默认排除 interrupted
57
- * (崩溃恢复后由持久化后端补写的孤儿轮次关闭标记,用户视角的“完成”不含它)。
58
- */
59
- reasons: ['completed', 'aborted', 'blocked', 'error', 'max-tokens'],
60
- /** 跳过子代理会话:子孙会话由父会话编排,逐轮提醒是噪音。 */
61
- skipSubagents: true,
62
- /** 系统消息附带轮次用时。 */
63
- includeDuration: true,
64
- /** 系统消息附带 token 用量。 */
65
- includeUsage: true,
66
- }
67
-
68
- /** 设置面板可见字段的默认值(与 Config 同构;仓库里的 schema 默认值与此一致)。 */
69
- const DEFAULT_SETTINGS = {
70
- language: 'zh',
71
- templates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
72
- titleTemplate: '',
73
- includeDuration: true,
74
- includeUsage: true,
75
- skipSubagents: true,
76
- }
77
-
78
- /** 从任意输入规整为设置形状(容忍缺失/多余字段)。 */
79
- function sanitizeSettings(raw) {
80
- const src = raw && typeof raw === 'object' ? raw : {}
81
- const templatesRaw = src.templates && typeof src.templates === 'object' ? src.templates : {}
82
- const templates = { ...DEFAULT_SETTINGS.templates }
83
- for (const key of Object.keys(templates)) {
84
- if (typeof templatesRaw[key] === 'string') templates[key] = templatesRaw[key]
85
- }
86
- return {
87
- language: ['zh', 'zh-tw', 'en', 'ja', 'ko'].includes(src.language) ? src.language : 'zh',
88
- templates,
89
- titleTemplate: typeof src.titleTemplate === 'string' ? src.titleTemplate : '',
90
- includeDuration: typeof src.includeDuration === 'boolean' ? src.includeDuration : true,
91
- includeUsage: typeof src.includeUsage === 'boolean' ? src.includeUsage : true,
92
- skipSubagents: typeof src.skipSubagents === 'boolean' ? src.skipSubagents : true,
93
- }
94
- }
95
-
96
- /**
97
- * 插件入口。
98
- * @param {import('cordis').Context} ctx
99
- * @param {Partial<typeof DEFAULT_OPTIONS>} [config]
100
- */
101
- export const inject = ['settings']
102
-
103
- export function apply(ctx, config = {}) {
104
- const options = { ...DEFAULT_OPTIONS, ...config }
105
- const reasons = new Set(Array.isArray(options.reasons) ? options.reasons : DEFAULT_OPTIONS.reasons)
106
- const tracker = createTurnTracker()
107
- let settings = DEFAULT_SETTINGS
108
- let projRegistry = null // sessionProjections 服务(非注入可选依赖,经 ctx.inject 捕获)
109
-
110
- // 官方设置命名空间:设置面板(设置 插件)可编辑;user 层持久化在 settings 文档。
111
- // 重试兜底:热重载时旧 fiber 注销与新 fiber 注册存在竞态,register 可能因
112
- // duplicate 被拒——短暂重试直至成功(生产无重载时一次即中)。
113
- try {
114
- const Schema = HUB_REQUIRE('@deepseek-ai/schemastery')
115
- const schema = Schema.object({
116
- language: Schema.union(['zh', 'zh-tw', 'en', 'ja', 'ko']).default('zh'),
117
- templates: Schema.object({
118
- completed: Schema.string().default(''),
119
- error: Schema.string().default(''),
120
- aborted: Schema.string().default(''),
121
- blocked: Schema.string().default(''),
122
- 'max-tokens': Schema.string().default(''),
123
- }),
124
- titleTemplate: Schema.string().default(''),
125
- includeDuration: Schema.boolean().default(true),
126
- includeUsage: Schema.boolean().default(true),
127
- skipSubagents: Schema.boolean().default(true),
128
- })
129
- let attempts = 0
130
- const tryRegister = () => {
131
- try {
132
- const scope = ctx.settings.register(SETTINGS_NS, schema, { applies: 'live' })
133
- settings = sanitizeSettings(scope.get())
134
- scope.watch((next) => {
135
- settings = sanitizeSettings(next)
136
- fileLog(`settings updated: ${JSON.stringify({ language: settings.language, templates: Object.fromEntries(Object.entries(settings.templates).filter(([, v]) => v)) })}`)
137
- })
138
- fileLog('settings namespace registered')
139
- } catch (err) {
140
- if (attempts < 8) {
141
- attempts += 1
142
- setTimeout(tryRegister, 400 * attempts)
143
- fileLog(`settings register retry ${attempts}: ${err?.message ?? err}`)
144
- } else {
145
- warn(ctx, `设置命名空间注册失败(使用默认值): ${err?.message ?? err}`)
146
- fileLog(`settings register FAILED after ${attempts} attempts: ${err?.message ?? err}`)
147
- }
148
- }
149
- }
150
- tryRegister()
151
- } catch (err) {
152
- warn(ctx, `设置依赖加载失败: ${err?.message ?? err}`)
153
- fileLog(`settings deps load FAILED: ${err?.message ?? err}`)
154
- }
155
-
156
- // 会话投影:把每个会话「最近的系统消息全文」注册为一个投影单元(key =
157
- // session-complete-notify)。宿主会对**所有会话**(含后台/未打开窗口的)
158
- // 推送该值 → 客户端推送正文因此跨会话一致,不再依赖事件窗口是否打开。
159
- try {
160
- const z = HUB_REQUIRE('zod')
161
- if (typeof ctx.inject === 'function') {
162
- ctx.inject(['sessionProjections'], (scoped) => {
163
- // 说明:注入器(dsh-super-injector)为插件提供的是二级上下文的注册表
164
- // 实例,与 host 对客户端推送/列表快照所用的实例可能不同;优先取
165
- // ctx.root.get(最靠近宿主根的一份),拿不到时回退注入实例。
166
- // 只注册进注入实例时,客户端可能读不到本投影单元 → 后台会话推送正文
167
- // 走降级路径(详情见会话内系统消息),属尽力而为,不影响会话内系统消息。
168
- const rootRegistry = (typeof ctx.root?.get === 'function' && ctx.root.get('sessionProjections')) || scoped.sessionProjections
169
- projRegistry = rootRegistry
170
- try {
171
- rootRegistry.register({
172
- key: SETTINGS_NS,
173
- schema: z.string(),
174
- init: () => '',
175
- apply: (state, event) => {
176
- if (event.type !== 'user/message') return state
177
- const src = event.data?.source ?? {}
178
- if (src.kind !== 'plugin' || src.plugin !== PLUGIN_ID) return state
179
- const text = ((event.data?.content ?? []).map((b) => b?.text ?? '')).join('').trim()
180
- return text || state
181
- },
182
- view: (state) => state,
183
- stateVersion: 1,
184
- })
185
- fileLog('session-projections unit registered (key=session-complete-notify)')
186
- } catch (err) {
187
- warn(ctx, `投影单元注册失败: ${err?.message ?? err}`)
188
- }
189
- })
190
- }
191
- } catch (err) {
192
- warn(ctx, `投影依赖加载失败: ${err?.message ?? err}`)
193
- }
194
-
195
- ctx.on('session/event', (session, event) => {
196
- switch (event.type) {
197
- case 'turn/start':
198
- tracker.start(`${session.id}:${event.data.turn}`, event.time)
199
- return
200
- case 'assistant/message':
201
- tracker.addUsage(`${session.id}:${event.data.turn}`, event.data.usage)
202
- return
203
- case 'turn/end': {
204
- const key = `${session.id}:${event.data.turn}`
205
- const state = tracker.end(key, event.time)
206
- const kind = event.data.reason?.kind
207
- if (!reasons.has(kind)) return
208
- if (isSubagentSession(session)) return // 默认跳过子代理会话(子代理由父会话编排)
209
- // 官方投影口径:tokenUsage(缓存命中率)+ sessionStats(tok/s 生成速度)
210
- // 与 dsh-web-ui 状态栏同源;投影由宿主维护,无插件内存状态,重载也不丢。
211
- // 用户以「标签是否插入」控制显示(用量/用时无独立开关)。
212
- let cacheValue
213
- let tpsValue
214
- let titleValue = ''
215
- try {
216
- if (projRegistry) {
217
- const snap = projRegistry.snapshot(session)
218
- const usage = snap?.values?.tokenUsage
219
- const stats = snap?.values?.sessionStats
220
- cacheValue = officialCacheRate(usage)
221
- tpsValue = officialTps(stats)
222
- titleValue = typeof snap?.values?.title === 'string' ? snap.values.title : ''
223
- }
224
- } catch (projErr) {
225
- warn(ctx, `投影快照读取失败: ${projErr?.message ?? projErr}`)
226
- }
227
- const notice = buildNotice(kind, event.data.reason, {
228
- ...state,
229
- includeDuration: true, // 标签即开关:{duration} 插了才显示
230
- includeUsage: true, // 标签即开关:{usage}/{cache}/{tps} 插了才显示
231
- cacheValue,
232
- tpsValue,
233
- titleValue,
234
- }, { language: settings.language, templates: settings.templates })
235
- // 边界约束:session/event 观察者回调运行在 turn/end 那次 append 的
236
- // 发布边界之内(dsh-session 在 dispatch 前 set entry.appending,
237
- // finally 中复位),此时同步 append 会被拒绝:
238
- // "session append cannot reenter while another append is being published"
239
- // 推迟到微任务——微任务队列在本次同步栈(含 finally 复位)之后才跑。
240
- queueMicrotask(() => appendNotice(ctx, session, notice))
241
- return
242
- }
243
- default:
244
- return
245
- }
246
- })
247
- }
248
-
249
- /** 把系统消息追加进会话日志(失败只记日志,绝不抛出破坏 event 火线)。 */
250
- function appendNotice(ctx, session, notice) {
251
- try {
252
- session.append(
253
- 'user/message',
254
- {
255
- id: newId(),
256
- role: 'user',
257
- content: [{ type: 'text', text: notice.text }],
258
- source: { kind: 'plugin', plugin: PLUGIN_ID, form: 'notice', summary: notice.summary },
259
- },
260
- { surfaceOp: 'append' },
261
- )
262
- } catch (err) {
263
- warn(ctx, `追加系统消息失败: ${err?.message ?? String(err)}`)
264
- fileLog(`追加失败 ${session.id}: ${err?.stack ?? err}`)
265
- }
266
- }
267
-
268
- /** 追加失败时落一个调试文件(~/.dsh/session-complete-notify.log),便于排查。 */
269
- function fileLog(line) {
270
- try {
271
- appendFileSync(join(homedir(), '.dsh', 'session-complete-notify.log'), `${new Date().toISOString()} ${line}\n`, 'utf8')
272
- } catch {
273
- /* 尽力而为 */
274
- }
275
- }
276
-
277
- /** crypto.randomUUID(Node 18+ 全局存在;旧环境回退到时间戳+随机)。 */
278
- function newId() {
279
- return globalThis.crypto?.randomUUID?.() ?? `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
280
- }
281
-
282
- /** 日志上报(ctx.logger 缺失时落到 console.warn)。 */
283
- function warn(ctx, message) {
284
- try {
285
- const logger = ctx.logger
286
- if (logger && typeof logger.warn === 'function') logger.warn(`[${PLUGIN_ID}] ${message}`)
287
- else console.warn(`[${PLUGIN_ID}] ${message}`)
288
- } catch {
289
- /* 上报是尽力而为 */
290
- }
291
- }
1
+ /**
2
+ * dsh-session-complete-notify:会话完成系统消息提醒插件(host 平面)。
3
+ *
4
+ * 原理:订阅 session/event 火线——
5
+ * - turn/start 记下轮次开始时间;
6
+ * - assistant/message 累加该轮 token 用量;
7
+ * - turn/end 按 reason.kind(completed/aborted/blocked/error/max-tokens)
8
+ * 组装一条系统消息,并以 plugin-source 的 user/message(form: 'notice')
9
+ * 追加进会话日志:Web UI 把它渲染为可折叠的系统提示行(醒目提醒用户),
10
+ * 并随 JSONL 持久化,恢复/回放后依然可见。
11
+ *
12
+ * 设计取舍:
13
+ * - 只响应「实时」事件:resume/replay 不会重放旧通知,不会在加载会话时刷屏;
14
+ * - 追加的事件类型是 user/message,与自身监听目标(turn/*)不相交,
15
+ * 天然免疫自我循环;
16
+ * - 零外部 import(@deepseek-ai/* 无法从本仓库目录解析),
17
+ * UserMessage 对象按 dsh-llm 的 createUserMessage 契约手工构造
18
+ * (id = crypto.randomUUID(),deep-freeze 由 session.append 的
19
+ * adoptSessionEvent 快照阶段完成)。
20
+ *
21
+ * 装配:package.json 声明官方 dsh.bundle manifest(patch → 仓库根 cordis.patch.yml),
22
+ * 一条命令安装即自动挂载:
23
+ * dsh plugin --profile web add @telosmaylx/dsh-session-notify
24
+ * 也可手动在 ~/.dsh/profiles/web/cordis.patch.yml 追加(等价):
25
+ * - insert:
26
+ * - id: dsh-session-notify
27
+ * name: '@telosmaylx/dsh-session-notify'
28
+ * config:
29
+ * reasons: [completed, aborted, blocked, error, max-tokens]
30
+ * skipSubagents: true
31
+ * includeDuration: true
32
+ * includeUsage: true
33
+ */
34
+
35
+ import { appendFileSync } from 'node:fs'
36
+ import { join } from 'node:path'
37
+ import { homedir } from 'node:os'
38
+ import { createRequire } from 'node:module'
39
+ import { createTurnTracker, buildNotice, isSubagentSession, officialCacheRate, officialTps } from './core.js?v=1' // v=1: 缓存破坏——HMR 重载按 URL 键控
40
+
41
+ /** Cordis loader 诊断用插件名。 */
42
+ export const name = 'session-complete-notify'
43
+
44
+ /** 系统消息 source.plugin 标识(UI 按插件名着色/标注来源)。 */
45
+ const PLUGIN_ID = 'dsh-session-notify'
46
+
47
+ /** 设置命名空间(官方「设置 插件」面板的键)。 */
48
+ const SETTINGS_NS = 'session-complete-notify'
49
+
50
+ /**
51
+ * 本插件从仓库目录以 realpath 加载,裸导入(@deepseek-ai/*)无法解析;
52
+ * 用 createRequire 锚定 profile 共享依赖枢纽(.dsh/profiles/node_modules),
53
+ * 取到与宿主同源(realpath 相同)的 schemastery 实例来构造设置 schema。
54
+ */
55
+ const HUB_REQUIRE = createRequire(join(homedir(), '.dsh', 'profiles', 'node_modules', '__scn_anchor__.js'))
56
+
57
+ /** 默认配置。 */
58
+ const DEFAULT_OPTIONS = {
59
+ /**
60
+ * 触发提醒的 turn/end reason 白名单。默认排除 interrupted
61
+ * (崩溃恢复后由持久化后端补写的孤儿轮次关闭标记,用户视角的“完成”不含它)。
62
+ */
63
+ reasons: ['completed', 'aborted', 'blocked', 'error', 'max-tokens'],
64
+ /** 跳过子代理会话:子孙会话由父会话编排,逐轮提醒是噪音。 */
65
+ skipSubagents: true,
66
+ /** 系统消息附带轮次用时。 */
67
+ includeDuration: true,
68
+ /** 系统消息附带 token 用量。 */
69
+ includeUsage: true,
70
+ }
71
+
72
+ /** 设置面板可见字段的默认值(与 Config 同构;仓库里的 schema 默认值与此一致)。 */
73
+ const DEFAULT_SETTINGS = {
74
+ language: 'zh',
75
+ templates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
76
+ titleTemplate: '',
77
+ includeDuration: true,
78
+ includeUsage: true,
79
+ skipSubagents: true,
80
+ }
81
+
82
+ /** 从任意输入规整为设置形状(容忍缺失/多余字段)。 */
83
+ function sanitizeSettings(raw) {
84
+ const src = raw && typeof raw === 'object' ? raw : {}
85
+ const templatesRaw = src.templates && typeof src.templates === 'object' ? src.templates : {}
86
+ const templates = { ...DEFAULT_SETTINGS.templates }
87
+ for (const key of Object.keys(templates)) {
88
+ if (typeof templatesRaw[key] === 'string') templates[key] = templatesRaw[key]
89
+ }
90
+ return {
91
+ language: ['zh', 'zh-tw', 'en', 'ja', 'ko'].includes(src.language) ? src.language : 'zh',
92
+ templates,
93
+ titleTemplate: typeof src.titleTemplate === 'string' ? src.titleTemplate : '',
94
+ includeDuration: typeof src.includeDuration === 'boolean' ? src.includeDuration : true,
95
+ includeUsage: typeof src.includeUsage === 'boolean' ? src.includeUsage : true,
96
+ skipSubagents: typeof src.skipSubagents === 'boolean' ? src.skipSubagents : true,
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 插件入口。
102
+ * @param {import('cordis').Context} ctx
103
+ * @param {Partial<typeof DEFAULT_OPTIONS>} [config]
104
+ */
105
+ export const inject = ['settings']
106
+
107
+ export function apply(ctx, config = {}) {
108
+ const options = { ...DEFAULT_OPTIONS, ...config }
109
+ const reasons = new Set(Array.isArray(options.reasons) ? options.reasons : DEFAULT_OPTIONS.reasons)
110
+ const tracker = createTurnTracker()
111
+ let settings = DEFAULT_SETTINGS
112
+ let projRegistry = null // sessionProjections 服务(非注入可选依赖,经 ctx.inject 捕获)
113
+
114
+ // 官方设置命名空间:设置面板(设置 插件)可编辑;user 层持久化在 settings 文档。
115
+ // 重试兜底:热重载时旧 fiber 注销与新 fiber 注册存在竞态,register 可能因
116
+ // duplicate 被拒——短暂重试直至成功(生产无重载时一次即中)。
117
+ try {
118
+ const Schema = HUB_REQUIRE('@deepseek-ai/schemastery')
119
+ const schema = Schema.object({
120
+ language: Schema.union(['zh', 'zh-tw', 'en', 'ja', 'ko']).default('zh'),
121
+ templates: Schema.object({
122
+ completed: Schema.string().default(''),
123
+ error: Schema.string().default(''),
124
+ aborted: Schema.string().default(''),
125
+ blocked: Schema.string().default(''),
126
+ 'max-tokens': Schema.string().default(''),
127
+ }),
128
+ titleTemplate: Schema.string().default(''),
129
+ includeDuration: Schema.boolean().default(true),
130
+ includeUsage: Schema.boolean().default(true),
131
+ skipSubagents: Schema.boolean().default(true),
132
+ })
133
+ let attempts = 0
134
+ const tryRegister = () => {
135
+ try {
136
+ const scope = ctx.settings.register(SETTINGS_NS, schema, { applies: 'live' })
137
+ settings = sanitizeSettings(scope.get())
138
+ scope.watch((next) => {
139
+ settings = sanitizeSettings(next)
140
+ fileLog(`settings updated: ${JSON.stringify({ language: settings.language, templates: Object.fromEntries(Object.entries(settings.templates).filter(([, v]) => v)) })}`)
141
+ })
142
+ fileLog('settings namespace registered')
143
+ } catch (err) {
144
+ if (attempts < 8) {
145
+ attempts += 1
146
+ scheduleRetry(attempts)
147
+ fileLog(`settings register retry ${attempts}: ${err?.message ?? err}`)
148
+ } else {
149
+ warn(ctx, `设置命名空间注册失败(使用默认值): ${err?.message ?? err}`)
150
+ fileLog(`settings register FAILED after ${attempts} attempts: ${err?.message ?? err}`)
151
+ }
152
+ }
153
+ }
154
+ // Cordis 教程第 2 章 effect 纪律:重试定时器是 Cordis 之外的资源,必须包装为
155
+ // effect(返回 clearTimeout disposer)——插件在重试窗口内被卸载/热重载时,
156
+ // 定时器随 fiber 拆除而取消,不再对已释放的 ctx 触发注册。
157
+ const scheduleRetry = (attempt) => {
158
+ try {
159
+ if (typeof ctx.effect === 'function') {
160
+ ctx.effect(() => {
161
+ const timer = setTimeout(tryRegister, 400 * attempt)
162
+ return () => clearTimeout(timer)
163
+ })
164
+ } else {
165
+ setTimeout(tryRegister, 400 * attempt) // 极老环境无 effect API,退化为旧行为
166
+ }
167
+ } catch {
168
+ /* ctx 已拆除(卸载/HMR 替换):放弃重试,避免未捕获异常 */
169
+ }
170
+ }
171
+ tryRegister()
172
+ } catch (err) {
173
+ warn(ctx, `设置依赖加载失败: ${err?.message ?? err}`)
174
+ fileLog(`settings deps load FAILED: ${err?.message ?? err}`)
175
+ }
176
+
177
+ // 会话投影:把每个会话「最近的系统消息全文」注册为一个投影单元(key =
178
+ // session-complete-notify)。宿主会对**所有会话**(含后台/未打开窗口的)
179
+ // 推送该值 客户端推送正文因此跨会话一致,不再依赖事件窗口是否打开。
180
+ try {
181
+ const z = HUB_REQUIRE('zod')
182
+ if (typeof ctx.inject === 'function') {
183
+ ctx.inject(['sessionProjections'], (scoped) => {
184
+ // 说明:注入器(dsh-super-injector)为插件提供的是二级上下文的注册表
185
+ // 实例,与 host 对客户端推送/列表快照所用的实例可能不同;优先取
186
+ // ctx.root.get(最靠近宿主根的一份),拿不到时回退注入实例。
187
+ // 只注册进注入实例时,客户端可能读不到本投影单元 后台会话推送正文
188
+ // 走降级路径(详情见会话内系统消息),属尽力而为,不影响会话内系统消息。
189
+ const rootRegistry = (typeof ctx.root?.get === 'function' && ctx.root.get('sessionProjections')) || scoped.sessionProjections
190
+ projRegistry = rootRegistry
191
+ try {
192
+ rootRegistry.register({
193
+ key: SETTINGS_NS,
194
+ schema: z.string(),
195
+ init: () => '',
196
+ apply: (state, event) => {
197
+ if (event.type !== 'user/message') return state
198
+ const src = event.data?.source ?? {}
199
+ if (src.kind !== 'plugin' || src.plugin !== PLUGIN_ID) return state
200
+ const text = ((event.data?.content ?? []).map((b) => b?.text ?? '')).join('').trim()
201
+ return text || state
202
+ },
203
+ view: (state) => state,
204
+ stateVersion: 1,
205
+ })
206
+ fileLog('session-projections unit registered (key=session-complete-notify)')
207
+ } catch (err) {
208
+ warn(ctx, `投影单元注册失败: ${err?.message ?? err}`)
209
+ }
210
+ })
211
+ }
212
+ } catch (err) {
213
+ warn(ctx, `投影依赖加载失败: ${err?.message ?? err}`)
214
+ }
215
+
216
+ ctx.on('session/event', (session, event) => {
217
+ switch (event.type) {
218
+ case 'turn/start':
219
+ tracker.start(`${session.id}:${event.data.turn}`, event.time)
220
+ return
221
+ case 'assistant/message':
222
+ tracker.addUsage(`${session.id}:${event.data.turn}`, event.data.usage)
223
+ return
224
+ case 'turn/end': {
225
+ const key = `${session.id}:${event.data.turn}`
226
+ const state = tracker.end(key, event.time)
227
+ const kind = event.data.reason?.kind
228
+ if (!reasons.has(kind)) return
229
+ if (isSubagentSession(session)) return // 默认跳过子代理会话(子代理由父会话编排)
230
+ // 官方投影口径:tokenUsage(缓存命中率)+ sessionStats(tok/s 生成速度)
231
+ // 与 dsh-web-ui 状态栏同源;投影由宿主维护,无插件内存状态,重载也不丢。
232
+ // 用户以「标签是否插入」控制显示(用量/用时无独立开关)。
233
+ let cacheValue
234
+ let tpsValue
235
+ let titleValue = ''
236
+ try {
237
+ if (projRegistry) {
238
+ const snap = projRegistry.snapshot(session)
239
+ const usage = snap?.values?.tokenUsage
240
+ const stats = snap?.values?.sessionStats
241
+ cacheValue = officialCacheRate(usage)
242
+ tpsValue = officialTps(stats)
243
+ titleValue = typeof snap?.values?.title === 'string' ? snap.values.title : ''
244
+ }
245
+ } catch (projErr) {
246
+ warn(ctx, `投影快照读取失败: ${projErr?.message ?? projErr}`)
247
+ }
248
+ const notice = buildNotice(kind, event.data.reason, {
249
+ ...state,
250
+ includeDuration: true, // 标签即开关:{duration} 插了才显示
251
+ includeUsage: true, // 标签即开关:{usage}/{cache}/{tps} 插了才显示
252
+ cacheValue,
253
+ tpsValue,
254
+ titleValue,
255
+ }, { language: settings.language, templates: settings.templates })
256
+ // 边界约束:session/event 观察者回调运行在 turn/end 那次 append 的
257
+ // 发布边界之内(dsh-session dispatch set entry.appending,
258
+ // finally 中复位),此时同步 append 会被拒绝:
259
+ // "session append cannot reenter while another append is being published"
260
+ // 推迟到微任务——微任务队列在本次同步栈(含 finally 复位)之后才跑。
261
+ queueMicrotask(() => appendNotice(ctx, session, notice))
262
+ return
263
+ }
264
+ default:
265
+ return
266
+ }
267
+ })
268
+ }
269
+
270
+ /** 把系统消息追加进会话日志(失败只记日志,绝不抛出破坏 event 火线)。 */
271
+ function appendNotice(ctx, session, notice) {
272
+ try {
273
+ session.append(
274
+ 'user/message',
275
+ {
276
+ id: newId(),
277
+ role: 'user',
278
+ content: [{ type: 'text', text: notice.text }],
279
+ source: { kind: 'plugin', plugin: PLUGIN_ID, form: 'notice', summary: notice.summary },
280
+ },
281
+ { surfaceOp: 'append' },
282
+ )
283
+ } catch (err) {
284
+ warn(ctx, `追加系统消息失败: ${err?.message ?? String(err)}`)
285
+ fileLog(`追加失败 ${session.id}: ${err?.stack ?? err}`)
286
+ }
287
+ }
288
+
289
+ /** 追加失败时落一个调试文件(~/.dsh/session-complete-notify.log),便于排查。 */
290
+ function fileLog(line) {
291
+ try {
292
+ appendFileSync(join(homedir(), '.dsh', 'session-complete-notify.log'), `${new Date().toISOString()} ${line}\n`, 'utf8')
293
+ } catch {
294
+ /* 尽力而为 */
295
+ }
296
+ }
297
+
298
+ /** crypto.randomUUID(Node 18+ 全局存在;旧环境回退到时间戳+随机)。 */
299
+ function newId() {
300
+ return globalThis.crypto?.randomUUID?.() ?? `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
301
+ }
302
+
303
+ /** 日志上报(ctx.logger 缺失时落到 console.warn)。 */
304
+ function warn(ctx, message) {
305
+ try {
306
+ const logger = ctx.logger
307
+ if (logger && typeof logger.warn === 'function') logger.warn(`[${PLUGIN_ID}] ${message}`)
308
+ else console.warn(`[${PLUGIN_ID}] ${message}`)
309
+ } catch {
310
+ /* 上报是尽力而为 */
311
+ }
312
+ }