dsh-notify-yimit 0.1.2 → 0.1.4

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,716 +1,1098 @@
1
- /**
2
- * dsh-notify-yimit-yimit 宿主插件。
3
- *
4
- * 单一 Loader 行(见 cordis.patch.yml)挂载本模块,职责:
5
- * 1. 持久化插件配置($DSH_HOME/storages/dsh-notify-yimit/config.json):总开关、通知方式
6
- * (system 系统通知 / custom 自定义样式 / off 关闭,默认 off)、自定义通知的最大
7
- * 同时显示数、显示时长、背景色与文字色;
8
- * 2. 监听主机事件流,为每个会话维护轻量状态机并生成"通知事件":
9
- * - session/event:turn/start、assistant/chunk、tool/call(含 ask_user_question
10
- * 的提问内容)、turn/end(completed/error)、session/title(对话标题);
11
- * - agent/status:运行位边沿(运行中/空闲的兜底信号);
12
- * - approval/request:等待审批时的具体审批内容;
13
- * 3. 维护未确认事件队列(客户端轮询 getState + ackEvents 消费,运行中事件按会话
14
- * 节流合并,完成/出错事件总是新增);
15
- * 4. 提供 notify 服务(手写 typertRemote 绑定,配合 ./typert 清单走 Typert 网关),
16
- * 客户端经 remote.notify.* 读取配置/事件并回执。
17
- *
18
- * 零构建、零 React 依赖;仅用 ctx API 与 Node 内建能力(配置路径经
19
- * @deepseek-ai/dsh-home-paths 解析,契约校验用 zod,与 dsh-cost-meter 同款姿势)。
20
- */
21
-
22
- import { z } from 'zod'
23
- import fs from 'node:fs'
24
- import path from 'node:path'
25
- import { spawn } from 'node:child_process'
26
- import { fileURLToPath } from 'node:url'
27
- import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
28
-
29
- export const name = 'dsh-notify-yimit'
30
-
31
- // ── 配置 ───────────────────────────────────────────────────────────────────
32
-
33
- /** 通知类型清单(与事件 kind 一一对应)。 */
34
- const KIND_KEYS = Object.freeze(['completed', 'error', 'running', 'approval', 'question'])
35
-
36
- /** 默认配置(首次启动;之后持久化副本优先)。 */
37
- const DEFAULT_CONFIG = Object.freeze({
38
- enabled: false, // 插件总开关
39
- mode: 'off', // 通知方式:system(Windows 系统通知)| custom(自定义样式)| off(关闭)
40
- maxVisible: 3, // 自定义通知最大同时显示数量
41
- durationMs: 8000, // 自定义通知显示时长(毫秒)
42
- browser: '', // 跳转会话使用的浏览器可执行文件路径;空 = 系统默认浏览器
43
- bgColor: '#20272f', // 自定义通知全局默认背景色(旧配置兼容/顶层默认)
44
- textColor: '#e6edf3', // 自定义通知全局默认文字色(旧配置兼容/顶层默认)
45
- // 每种通知类型的背景/文字色;未显式配置时:旧配置(顶层 bgColor/textColor)迁移使用顶层值,
46
- // 全新默认则使用下方的类型区分色。
47
- colors: {
48
- completed: { bg: '#1f5138', fg: '#e6f4ec' }, // 绿:完成
49
- error: { bg: '#5c1f24', fg: '#fbe9ea' }, // 红:出错
50
- running: { bg: '#203a5c', fg: '#e8f0fb' }, // 蓝:运行中
51
- approval: { bg: '#4a3a12', fg: '#f7efd8' }, // 黄:待审批
52
- question: { bg: '#3d2a55', fg: '#f0e8fa' }, // 紫:待回答
53
- },
54
- })
55
-
56
- const HEX_RE = /^#[0-9a-fA-F]{3,8}$/
57
- const isHex = (v) => typeof v === 'string' && HEX_RE.test(v)
58
-
59
- /**
60
- * 配置合并校验:未知键忽略,非法值回退默认(设置页只发合法 patch)。
61
- * colors 采用"候选即结果"策略:输入中的 colors[kind].bg/fg 合法即采用;
62
- * 缺失时回退每类型默认色(旧配置的顶层色迁移在 openConfig 加载时一次性完成)。
63
- */
64
- function normalizeConfig(input) {
65
- const src = input !== null && typeof input === 'object' && !Array.isArray(input) ? input : {}
66
- const out = { ...DEFAULT_CONFIG }
67
- if (typeof src.enabled === 'boolean') out.enabled = src.enabled
68
- if (src.mode === 'system' || src.mode === 'custom' || src.mode === 'off') out.mode = src.mode
69
- if (typeof src.maxVisible === 'number' && Number.isFinite(src.maxVisible)) out.maxVisible = Math.max(1, Math.min(20, Math.floor(src.maxVisible)))
70
- if (typeof src.durationMs === 'number' && Number.isFinite(src.durationMs)) out.durationMs = Math.max(1000, Math.min(300000, Math.floor(src.durationMs)))
71
- if (typeof src.browser === 'string') out.browser = src.browser
72
- if (isHex(src.bgColor)) out.bgColor = src.bgColor
73
- if (isHex(src.textColor)) out.textColor = src.textColor
74
- const srcColors = src.colors !== null && typeof src.colors === 'object' && !Array.isArray(src.colors) ? src.colors : {}
75
- out.colors = {}
76
- for (const kind of KIND_KEYS) {
77
- const entry = srcColors[kind]
78
- const def = DEFAULT_CONFIG.colors[kind]
79
- out.colors[kind] = {
80
- bg: isHex(entry?.bg) ? entry.bg : def.bg,
81
- fg: isHex(entry?.fg) ? entry.fg : def.fg,
82
- }
83
- }
84
- return out
85
- }
86
-
87
- /** 旧配置(仅有顶层 bgColor/textColor、无 colors)一次性迁移:colors 全用顶层色。 */
88
- function migrateLegacyColors(input) {
89
- if (input === null || typeof input !== 'object' || Array.isArray(input)) return input
90
- if (input.colors !== null && typeof input.colors === 'object') return input
91
- if (!isHex(input.bgColor) && !isHex(input.textColor)) return input
92
- const colors = {}
93
- for (const kind of KIND_KEYS) colors[kind] = { bg: input.bgColor, fg: input.textColor }
94
- return { ...input, colors }
95
- }
96
-
97
- /** 深合并配置补丁:colors 按类型浅合并(只 patch 变更的 bg/fg,保留其余类型与字段)。 */
98
- function mergeConfigPatch(base, patch) {
99
- const out = { ...base, ...patch }
100
- if (patch !== null && typeof patch === 'object' && patch.colors !== null && typeof patch.colors === 'object' && !Array.isArray(patch.colors)) {
101
- const baseColors = base.colors !== null && typeof base.colors === 'object' ? base.colors : {}
102
- const next = {}
103
- for (const kind of KIND_KEYS) next[kind] = { ...(baseColors[kind] ?? {}), ...(patch.colors[kind] ?? {}) }
104
- out.colors = next
105
- }
106
- return out
107
- }
108
-
109
- /** 配置存储:$DSH_HOME/storages/dsh-notify-yimit/config.json(临时文件 + 原子重命名,写防抖)。 */
110
- function openConfig() {
111
- const home = resolveDshHome()
112
- const dir = path.join(home, 'storages', 'dsh-notify-yimit')
113
- const file = path.join(dir, 'config.json')
114
- let loaded = null
115
- try {
116
- loaded = JSON.parse(fs.readFileSync(file, 'utf8'))
117
- } catch {
118
- loaded = null
119
- }
120
- let config = normalizeConfig(migrateLegacyColors(loaded))
121
- let timer = null
122
- const persist = () => {
123
- if (timer !== null) return
124
- timer = setTimeout(() => {
125
- timer = null
126
- try {
127
- fs.mkdirSync(dir, { recursive: true })
128
- const tmp = `${file}.${process.pid}.tmp`
129
- fs.writeFileSync(tmp, JSON.stringify(config, null, 2), 'utf8')
130
- fs.renameSync(tmp, file)
131
- } catch (error) {
132
- // 配置写盘失败不应影响插件主流程,仅记录。
133
- console.warn(`[dsh-notify-yimit] 配置写盘失败: ${String(error)}`)
134
- }
135
- }, 200)
136
- }
137
- return {
138
- get: () => config,
139
- set(patch) {
140
- config = normalizeConfig(mergeConfigPatch(config, patch))
141
- persist()
142
- return config
143
- },
144
- close() {
145
- if (timer !== null) { clearTimeout(timer); timer = null }
146
- try {
147
- fs.mkdirSync(dir, { recursive: true })
148
- const tmp = `${file}.${process.pid}.tmp`
149
- fs.writeFileSync(tmp, JSON.stringify(config, null, 2), 'utf8')
150
- fs.renameSync(tmp, file)
151
- } catch {
152
- // 同上:关闭时尽力落盘。
153
- }
154
- },
155
- }
156
- }
157
-
158
- // ── 通知事件队列 ───────────────────────────────────────────────────────────
159
-
160
- const MAX_QUEUE = 500 // 未确认事件上限(防堆积)
161
- const RUNNING_THROTTLE_MS = 400 // 运行中事件按会话节流合并(更快响应,实时性优先)
162
- const KIND_DEBOUNCE_MS = 2000 // 非运行中事件同会话同类型防抖窗口
163
- const TOAST_GAP = 12 // 浮窗间距(像素);堆叠按各窗口回传的真实高度累加,自适应
164
- const TEXT_MAX = 300 // 事件文本上限
165
-
166
- function clip(text) {
167
- const s = String(text ?? '')
168
- return s.length > TEXT_MAX ? `${s.slice(0, TEXT_MAX)}…` : s
169
- }
170
-
171
- /** 会话标题:优先会话日志里的 session/title,其次 cwd 目录名,最后会话 id。 */
172
- function titleOf(session) {
173
- const events = session?.events
174
- if (Array.isArray(events)) {
175
- for (let i = events.length - 1; i >= 0; i -= 1) {
176
- const event = events[i]
177
- if (event?.type === 'session/title' && typeof event.data?.title === 'string' && event.data.title.length > 0) {
178
- return event.data.title
179
- }
180
- }
181
- }
182
- const cwd = session?.header?.cwd
183
- if (typeof cwd === 'string' && cwd.length > 0) {
184
- const base = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop()
185
- if (typeof base === 'string' && base.length > 0) return base
186
- }
187
- return session?.id ?? ''
188
- }
189
-
190
- // ── 浏览器探测(跳转会话时选择用哪个浏览器打开) ─────────────────────────────
191
-
192
- /** 常见 Windows 浏览器安装候选路径(按 64/32 位与用户级安装展开)。 */
193
- function browserCandidates() {
194
- const pf = process.env.ProgramFiles ?? 'C:\\Program Files'
195
- const pfx86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
196
- const local = process.env.LOCALAPPDATA ?? ''
197
- return [
198
- { id: 'edge', name: 'Microsoft Edge', paths: [`${pfx86}\\Microsoft\\Edge\\Application\\msedge.exe`, `${pf}\\Microsoft\\Edge\\Application\\msedge.exe`] },
199
- { id: 'chrome', name: 'Google Chrome', paths: [`${pf}\\Google\\Chrome\\Application\\chrome.exe`, `${pfx86}\\Google\\Chrome\\Application\\chrome.exe`, local ? `${local}\\Google\\Chrome\\Application\\chrome.exe` : ''] },
200
- { id: 'firefox', name: 'Mozilla Firefox', paths: [`${pf}\\Mozilla Firefox\\firefox.exe`, `${pfx86}\\Mozilla Firefox\\firefox.exe`] },
201
- { id: 'brave', name: 'Brave', paths: [`${pf}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`, local ? `${local}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe` : ''] },
202
- { id: 'opera', name: 'Opera', paths: [local ? `${local}\\Programs\\Opera\\opera.exe` : '', `${pf}\\Opera\\launcher.exe`] },
203
- { id: 'vivaldi', name: 'Vivaldi', paths: [local ? `${local}\\Vivaldi\\Application\\vivaldi.exe` : '', `${pf}\\Vivaldi\\Application\\vivaldi.exe`] },
204
- { id: '360se', name: '360 安全浏览器', paths: [`${pfx86}\\360\\360se6\\Application\\360se.exe`] },
205
- { id: '360chrome', name: '360 极速浏览器', paths: [`${pfx86}\\360\\360Chrome\\Chrome\\Application\\360chrome.exe`] },
206
- { id: 'qqbrowser', name: 'QQ 浏览器', paths: [`${pfx86}\\Tencent\\QQBrowser\\QQBrowser.exe`, `${pf}\\Tencent\\QQBrowser\\QQBrowser.exe`] },
207
- { id: 'sogou', name: '搜狗浏览器', paths: [`${pfx86}\\SogouExplorer\\SogouExplorer.exe`] },
208
- { id: 'liebao', name: '猎豹浏览器', paths: [`${pfx86}\\liebao\\liebao.exe`] },
209
- ]
210
- }
211
-
212
- /** 探测本机已安装浏览器:返回 [{ id, name, path }](按候选顺序,路径存在即收录)。 */
213
- function detectBrowsers() {
214
- const found = []
215
- for (const candidate of browserCandidates()) {
216
- const path = (candidate.paths ?? []).find((p) => typeof p === 'string' && p.length > 0 && fs.existsSync(p))
217
- if (path !== undefined) found.push({ id: candidate.id, name: candidate.name, path })
218
- }
219
- return found
220
- }
221
-
222
- // ── 插件主体 ───────────────────────────────────────────────────────────────
223
-
224
- export function apply(ctx) {
225
- const store = openConfig()
226
- ctx.effect(() => () => store.close(), 'dsh-notify-yimit: config close')
227
-
228
- /** 每会话状态:标题、状态位、最近一次运行中事件(用于节流合并)、等待类型、防抖时间戳。 */
229
- const states = new Map()
230
- const stateOf = (sessionId) => {
231
- let st = states.get(sessionId)
232
- if (st === undefined) {
233
- st = { title: '', status: 'idle', lastRunningId: null, lastRunningAt: 0, lastRunningText: null, waitingKind: null, lastFired: {} }
234
- states.set(sessionId, st)
235
- }
236
- return st
237
- }
238
-
239
- /** 未确认事件队列(升序 id)。 */
240
- const queue = []
241
- let nextId = 1
242
-
243
- // ── 桌面自定义通知浮窗(常驻 PowerShell 宿主 + WPF,不依赖浏览器) ──────────
244
- // custom 模式下通知直接弹桌面上浮窗(显示即消费,不入队);system 模式走浏览器
245
- // Web Notification(事件入队由客户端消费)。
246
- // 架构:插件加载时启动一个常驻 powershell 宿主进程(toast-host.ps1),所有浮窗都在
247
- // 该进程内创建(WPF 只加载一次)。宿主经 stdin 收一行一个 JSON 命令、经 stdout 回传
248
- // pos/exit 报告——通知创建从 ~1s 冷启动降到 ~10ms,并消灭 ctl/pos 文件轮询。
249
-
250
- const TOAST_HOST_SCRIPT = fileURLToPath(new URL('./toast-host.ps1', import.meta.url))
251
- const activeToasts = new Map() // 键 → { key, instance, meta: {left,top,height,hwnd,workBottom} | null, offsetY }
252
- const toastOrder = [] // 浮窗键序(弹出顺序 = 从底到顶堆叠)
253
- const pendingClose = new Set() // 已发 close 命令、等待淡出退出的浮窗键
254
- /** 屏幕底部基准线(工作区底边):由浮窗回传的准确 workBottom 更新,供自适应堆叠。 */
255
- let globalWorkBottom = null
256
- /** 浮窗实例序号:同 key 的新旧窗口用 instance 区分(旧窗 exit 回传不会误删新窗)。 */
257
- let toastSeq = 1
258
-
259
- // ── 常驻宿主进程 ─────────────────────────────────────────────────────────
260
- let host = null
261
- let hostAlive = false
262
- function spawnHost() {
263
- try {
264
- // 测试钩子:单测通过 globalThis.__DSH_NOTIFY_TEST_SPAWN__ 注入假的 spawn,避免真实进程。
265
- const spawnFn = typeof globalThis.__DSH_NOTIFY_TEST_SPAWN__ === 'function'
266
- ? globalThis.__DSH_NOTIFY_TEST_SPAWN__
267
- : spawn
268
- host = spawnFn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', TOAST_HOST_SCRIPT], {
269
- windowsHide: true,
270
- stdio: ['pipe', 'pipe', 'pipe'],
271
- })
272
- } catch (error) {
273
- console.warn(`[dsh-notify-yimit] 浮窗宿主启动失败: ${String(error)}`)
274
- host = null
275
- hostAlive = false
276
- return
277
- }
278
- hostAlive = true
279
- // 宿主死亡/管道破裂时静默,避免未捕获的 error 事件击穿插件。
280
- host.stdin.on('error', () => {})
281
- host.stdout.on('error', () => {})
282
- host.on('error', () => { hostAlive = false })
283
- host.stdout.setEncoding('utf8')
284
- let buf = ''
285
- host.stdout.on('data', (d) => {
286
- buf += String(d)
287
- let nl
288
- while ((nl = buf.indexOf('\n')) !== -1) {
289
- const line = buf.slice(0, nl).trim()
290
- buf = buf.slice(nl + 1)
291
- if (line.length === 0) continue
292
- let msg
293
- try { msg = JSON.parse(line) } catch { console.warn(`[dsh-notify-yimit] 浮窗回传解析失败: ${line}`); continue }
294
- if (msg === null || typeof msg !== 'object') continue
295
- if (msg.type === 'pos' && typeof msg.key === 'string') {
296
- const rec = activeToasts.get(msg.key)
297
- if (rec !== undefined && rec.instance === msg.instance) {
298
- setToastMeta(rec, { left: msg.left, top: msg.top, height: msg.height, hwnd: msg.hwnd, workBottom: msg.workBottom })
299
- reflowToasts()
300
- }
301
- } else if (msg.type === 'exit' && typeof msg.key === 'string') {
302
- const rec = activeToasts.get(msg.key)
303
- if (rec !== undefined && rec.instance === msg.instance) {
304
- pendingClose.delete(msg.key)
305
- activeToasts.delete(msg.key)
306
- const idx = toastOrder.indexOf(msg.key)
307
- if (idx !== -1) toastOrder.splice(idx, 1)
308
- reflowToasts() // 窗口真正退出后才重排,上方通知收到 move 命令曲线下落
309
- }
310
- }
311
- }
312
- })
313
- host.on('exit', () => {
314
- hostAlive = false
315
- // 宿主退出:全部浮窗随之消失,清理状态(下次 show 时按需重新拉起)。
316
- activeToasts.clear()
317
- toastOrder.length = 0
318
- pendingClose.clear()
319
- })
320
- }
321
-
322
- /** 下发一条命令到常驻宿主(stdin,一行 JSON)。 */
323
- function sendCmd(payload) {
324
- if (host === null || !hostAlive) return false
325
- try {
326
- host.stdin.write(`${JSON.stringify(payload)}\n`)
327
- return true
328
- } catch { return false }
329
- }
330
-
331
- /** 宿主不在时重新拉起(插件加载时已预热一次;意外退出后按需恢复)。 */
332
- function ensureHost() {
333
- if (host !== null && hostAlive) return true
334
- spawnHost()
335
- return host !== null && hostAlive
336
- }
337
-
338
- /** 优雅关闭:发 close 命令让窗口淡出;窗口退出后由 exit 回传触发带动画的重排。
339
- * 附带 1.5s 兜底(重发 close + 强制清理),确保堆叠序列绝不泄漏。 */
340
- function dismissToast(key) {
341
- if (pendingClose.has(key)) return
342
- const rec = activeToasts.get(key)
343
- if (rec === undefined) return
344
- pendingClose.add(key)
345
- sendCmd({ cmd: 'close', key })
346
- setTimeout(() => {
347
- if (activeToasts.get(key) === rec) {
348
- sendCmd({ cmd: 'close', key })
349
- pendingClose.delete(key)
350
- activeToasts.delete(key)
351
- const idx = toastOrder.indexOf(key)
352
- if (idx !== -1) toastOrder.splice(idx, 1)
353
- reflowToasts()
354
- } else {
355
- pendingClose.delete(key)
356
- }
357
- }, 1500).unref?.()
358
- }
359
-
360
- /** 重排浮窗:从底部按各窗口回传的真实高度逐个向上累加(12px 间距),
361
- * 下发 move 命令让窗口自带曲线移动。 */
362
- function reflowToasts() {
363
- if (globalWorkBottom === null) return
364
- let anchor = globalWorkBottom - 20 // 底部留边
365
- for (let i = 0; i < toastOrder.length; i += 1) {
366
- const rec = activeToasts.get(toastOrder[i])
367
- if (rec === undefined) continue
368
- // meta 未回传的窗口用默认高度 130 占位推进(不下发命令,避免基于估算高度的错误移动;
369
- // 其初始位置由 offsetY 估算保证);meta 到达后 reflow 精修。
370
- const height = rec.meta?.height ?? 130
371
- const newTop = Math.round(anchor - height)
372
- if (rec.meta !== null && newTop !== rec.meta.top) {
373
- rec.meta.top = newTop
374
- sendCmd({ cmd: 'move', key: rec.key, top: newTop }) // 窗口以曲线自行移动
375
- }
376
- anchor = newTop - TOAST_GAP
377
- }
378
- }
379
-
380
- /** 设置一个浮窗的 meta;用浮窗回传的准确 workBottom 更新全局基准线。 */
381
- function setToastMeta(rec, meta) {
382
- rec.meta = meta
383
- if (Number.isFinite(meta.workBottom)) globalWorkBottom = meta.workBottom
384
- }
385
-
386
- const baseUrlOf = () => {
387
- const ws = ctx.get('webServer')
388
- return `http://127.0.0.1:${ws !== null && ws !== undefined && typeof ws.port === 'number' ? ws.port : 3080}`
389
- }
390
-
391
- function showDesktopToast(cfg, ev) {
392
- const maxVisible = Math.max(1, Math.min(20, Math.floor(Number(cfg.maxVisible) || 3)))
393
- // 状态性浮窗键(运行中/待审批/待回答):同会话同类型只有一个,刷新内容实现实时更新。
394
- const stickyKind = ev.kind === 'running' || ev.kind === 'approval' || ev.kind === 'question'
395
- const key = stickyKind ? `${ev.kind}:${ev.sessionId}` : `${ev.kind}:${ev.sessionId}:${ev.at}`
396
- // 状态性浮窗已在屏且未在淡出:原地更新文本(text 命令)——不 kill、不重弹,
397
- // 消灭运行中通知的闪烁与二次延迟;正在淡出的旧窗除外(直接走重弹路径替换)。
398
- let replaceIndex = -1
399
- if (stickyKind) {
400
- const prev = activeToasts.get(key)
401
- if (prev !== undefined && !pendingClose.has(key)) {
402
- sendCmd({ cmd: 'text', key, text: clip(ev.text) })
403
- return
404
- }
405
- if (prev !== undefined) {
406
- // 旧窗正在淡出:从堆叠序列移除;exit 回传时 instance 不匹配,不会误删新窗。
407
- replaceIndex = toastOrder.indexOf(key)
408
- if (replaceIndex !== -1) toastOrder.splice(replaceIndex, 1)
409
- activeToasts.delete(key)
410
- pendingClose.delete(key)
411
- }
412
- }
413
- // 并发上限:超出时对最先弹出的窗口发 close 优雅淡出(不立即删除,由 exit 回传收尾;
414
- // 计算活跃数时排除已在淡出中的窗口)。
415
- let guard = 0
416
- while (activeToasts.size - pendingClose.size >= maxVisible && guard++ < 64) {
417
- let first = null
418
- for (const k of activeToasts.keys()) {
419
- if (pendingClose.has(k)) continue
420
- if (stickyKind && !/^(running|approval|question):/.test(k)) { first = k; break }
421
- if (first === null) first = k
422
- }
423
- if (first === null) break
424
- dismissToast(first)
425
- }
426
- const colors = cfg.colors !== null && typeof cfg.colors === 'object' ? cfg.colors[ev.kind] : undefined
427
- // 堆叠位置:全新通知插到底部(最新在底);替换场景插回原槽位(保持原生成时间顺序)。
428
- // 弹窗偏移按 insertIndex 之前窗口的真实高度估算(meta 回传后由 reflowToasts 精确校正)。
429
- if (replaceIndex === -1) {
430
- toastOrder.unshift(key)
431
- } else {
432
- toastOrder.splice(replaceIndex, 0, key)
433
- }
434
- const insertIndex = replaceIndex !== -1 ? replaceIndex : 0
435
- let offsetY = 0
436
- for (let i = 0; i < insertIndex; i += 1) {
437
- const r = activeToasts.get(toastOrder[i])
438
- offsetY += (r?.meta?.height ?? 130) + TOAST_GAP
439
- }
440
- const payload = {
441
- cmd: 'show',
442
- key,
443
- instance: toastSeq++,
444
- title: String(ev.title || ev.sessionId),
445
- text: clip(ev.text),
446
- bg: colors?.bg ?? cfg.bgColor ?? '#20272f',
447
- fg: colors?.fg ?? cfg.textColor ?? '#e6edf3',
448
- durationSec: Math.max(1, Math.round((Number(cfg.durationMs) || 8000) / 1000)),
449
- sticky: stickyKind,
450
- offsetY,
451
- sessionId: String(ev.sessionId),
452
- baseUrl: baseUrlOf(),
453
- winTitle: `dsh-notify-yimit-${key}`,
454
- }
455
- if (typeof cfg.browser === 'string' && cfg.browser.length > 0) payload.browserPath = cfg.browser
456
- if (!ensureHost()) return
457
- if (!sendCmd(payload)) return
458
- activeToasts.set(key, { key, instance: payload.instance, meta: null, offsetY })
459
- }
460
-
461
- /** 关闭一个状态性浮窗(状态结束时优雅淡出;不存在的键为 no-op)。 */
462
- function closeToast(kind, sessionId) {
463
- dismissToast(`${kind}:${sessionId}`)
464
- }
465
-
466
- // 卸载/退出时关闭常驻宿主与全部浮窗。
467
- ctx.effect(() => () => {
468
- if (host !== null) {
469
- try { sendCmd({ cmd: 'shutdown' }) } catch { /* 忽略 */ }
470
- try { host.kill() } catch { /* 已退出 */ }
471
- }
472
- activeToasts.clear()
473
- toastOrder.length = 0
474
- pendingClose.clear()
475
- }, 'dsh-notify-yimit: toast cleanup')
476
-
477
- // 插件加载时预热一次宿主:把 ~1s 的 WPF 冷启动变成一次性成本,之后每个通知近实时。
478
- spawnHost()
479
-
480
- const push = (sessionId, kind, text) => {
481
- const st = states.get(sessionId)
482
- if (st === undefined) return
483
- const at = Date.now()
484
- const cfg = store.get()
485
- const desktop = cfg.enabled === true && cfg.mode === 'custom'
486
- if (kind === 'running') {
487
- // 运行中事件节流合并:同会话 RUNNING_THROTTLE_MS 内已有运行中事件。
488
- if (st.lastRunningAt !== 0 && at - st.lastRunningAt < RUNNING_THROTTLE_MS && st.lastRunningId !== null) {
489
- if (desktop) {
490
- // 桌面模式:仅当活动文本变化时替换浮窗(实时内容更新);相同文本忽略防刷屏。
491
- if (st.lastRunningText !== clip(text)) {
492
- st.lastRunningText = clip(text)
493
- showDesktopToast(cfg, { sessionId, title: st.title, kind, text: clip(text), at })
494
- }
495
- return
496
- }
497
- const entry = queue.find((item) => item.id === st.lastRunningId)
498
- if (entry !== undefined) {
499
- entry.text = clip(text)
500
- entry.at = at
501
- }
502
- return
503
- }
504
- st.lastRunningAt = at
505
- st.lastRunningText = clip(text)
506
- if (desktop) {
507
- // 桌面模式:替换同会话运行中浮窗,实现实时内容更新。
508
- showDesktopToast(cfg, { sessionId, title: st.title, kind, text: clip(text), at })
509
- st.lastRunningId = `desktop:${at}`
510
- return
511
- }
512
- const entry = { id: String(nextId++), sessionId, title: st.title, kind, text: clip(text), activity: clip(text), at }
513
- st.lastRunningId = entry.id
514
- queue.push(entry)
515
- trim()
516
- return
517
- }
518
- // 完成 / 出错 / 审批 / 问答:同会话同类型防抖(KIND_DEBOUNCE_MS 内不重复)。
519
- // 审批/问答是状态性通知(sticky,在屏即原地更新文本):不做防抖,防止 2s 内多条
520
- // 审批/提问事件被静默吞掉——等待状态必须让用户看得到(替换即更新)。
521
- if (st.lastFired === undefined) st.lastFired = {}
522
- const isSticky = kind === 'approval' || kind === 'question'
523
- if (!isSticky && at - (st.lastFired[kind] ?? 0) < KIND_DEBOUNCE_MS) return
524
- st.lastFired[kind] = at
525
- if (desktop) {
526
- showDesktopToast(cfg, { sessionId, title: st.title, kind, text: clip(text), at })
527
- return
528
- }
529
- const stale = queue.findIndex((item) => item.sessionId === sessionId && item.kind === kind)
530
- if (stale !== -1) queue.splice(stale, 1)
531
- const entry = { id: String(nextId++), sessionId, title: st.title, kind, text: clip(text), at }
532
- queue.push(entry)
533
- trim()
534
- }
535
-
536
- function trim() {
537
- while (queue.length > MAX_QUEUE) queue.shift()
538
- }
539
-
540
- // ── 会话事件流:状态机与通知事件 ─────────────────────────────────────────
541
-
542
- ctx.on('session/event', (session, event) => {
543
- if (session === undefined || session === null || event === undefined || event === null) return
544
- const id = session.id
545
- const st = stateOf(id)
546
- // 首次接触该会话时回退标题(会话日志中可能尚无 session/title 事件)。
547
- if (st.title.length === 0) st.title = titleOf(session)
548
- switch (event.type) {
549
- case 'session/title': {
550
- if (typeof event.data?.title === 'string' && event.data.title.length > 0) {
551
- st.title = event.data.title
552
- // 已入队事件补标题(通常未入队,防御)。
553
- for (const item of queue) if (item.sessionId === id && item.title.length === 0) item.title = st.title
554
- }
555
- return
556
- }
557
- case 'turn/start':
558
- st.status = 'running'
559
- st.activity = '开始处理'
560
- push(id, 'running', st.activity)
561
- return
562
- case 'step/start':
563
- st.status = 'running'
564
- st.activity = '正在思考…'
565
- push(id, 'running', st.activity)
566
- return
567
- case 'assistant/chunk': {
568
- const chunk = event.data?.chunk
569
- if (chunk === null || typeof chunk !== 'object') return
570
- if (chunk.type === 'reasoning' && typeof chunk.text === 'string' && chunk.text.length > 0) {
571
- st.activity = '正在思考…'
572
- push(id, 'running', st.activity)
573
- } else if (chunk.type === 'text' && typeof chunk.text === 'string' && chunk.text.length > 0) {
574
- st.activity = '正在生成回复…'
575
- push(id, 'running', st.activity)
576
- }
577
- return
578
- }
579
- case 'tool/call': {
580
- const data = event.data
581
- if (data === null || typeof data !== 'object') return
582
- const toolName = typeof data.name === 'string' ? data.name : ''
583
- st.activity = `正在执行 ${toolName}`
584
- push(id, 'running', st.activity)
585
- // ask_user_question 工具:提问内容即"等待回答"通知文本(状态性浮窗,回答前不消失)。
586
- if (toolName === 'ask_user_question' && typeof data.arguments === 'string') {
587
- try {
588
- const args = JSON.parse(data.arguments)
589
- const questions = Array.isArray(args?.questions) ? args.questions : []
590
- const first = questions.find((q) => q !== null && typeof q === 'object' && typeof q.question === 'string')
591
- if (first !== undefined) {
592
- st.status = 'waiting'
593
- st.waitingKind = 'question'
594
- push(id, 'question', first.question)
595
- }
596
- } catch {
597
- // arguments 非 JSON:跳过问答解析。
598
- }
599
- }
600
- return
601
- }
602
- case 'tool/result':
603
- // 工具结果返回:问答/审批结束,关闭对应状态性浮窗并恢复运行中。
604
- if (st.waitingKind === 'question') {
605
- closeToast('question', id)
606
- st.waitingKind = null
607
- st.status = 'running'
608
- st.activity = '继续处理…'
609
- push(id, 'running', st.activity)
610
- } else if (st.status === 'waiting') {
611
- st.status = 'running'
612
- st.activity = '继续处理…'
613
- push(id, 'running', st.activity)
614
- }
615
- return
616
- case 'approval/decided':
617
- // 审批已决定:关闭待审批状态性浮窗。
618
- closeToast('approval', id)
619
- if (st.waitingKind === 'approval') st.waitingKind = null
620
- return
621
- case 'turn/end': {
622
- const reason = event.data?.reason
623
- const kind = reason !== null && typeof reason === 'object' ? reason.kind : undefined
624
- st.status = 'idle'
625
- st.activity = ''
626
- st.lastRunningAt = 0
627
- st.lastRunningId = null
628
- st.lastRunningText = null
629
- st.waitingKind = null
630
- // 任务结束:关闭运行中状态性浮窗。
631
- closeToast('running', id)
632
- if (kind === 'completed') {
633
- push(id, 'completed', '任务已完成')
634
- } else if (kind === 'error') {
635
- const message = reason !== null && typeof reason === 'object' && reason.error !== null && typeof reason.error === 'object'
636
- ? (typeof reason.error.message === 'string' ? reason.error.message : '')
637
- : ''
638
- push(id, 'error', message.length > 0 ? `任务出错:${message}` : '任务出错')
639
- }
640
- // aborted / interrupted / max-tokens:不打扰用户,不通知。
641
- return
642
- }
643
- default:
644
- return
645
- }
646
- })
647
-
648
- // ── 运行位兜底:turn/end 事件可能缺失时的运行中信号 ──────────────────────
649
-
650
- ctx.on('agent/status', ({ agent, status }) => {
651
- if (agent === undefined || agent === null || typeof agent.id !== 'string') return
652
- const st = stateOf(agent.id)
653
- if (status === 'running' && st.status !== 'running') {
654
- st.status = 'running'
655
- st.activity = '正在处理…'
656
- push(agent.id, 'running', st.activity)
657
- } else if (status === 'idle' && st.status === 'running') {
658
- st.status = 'idle'
659
- st.lastRunningAt = 0
660
- st.lastRunningId = null
661
- }
662
- })
663
-
664
- // ── 等待审批:具体审批内容(approval/request 为 waterfall,观察后放行) ─────
665
-
666
- ctx.on('approval/request', (req, next) => {
667
- try {
668
- const sessionId = req?.agent?.session?.id ?? req?.agent?.id
669
- if (typeof sessionId === 'string' && req !== null && typeof req === 'object') {
670
- const st = stateOf(sessionId)
671
- st.status = 'waiting'
672
- st.waitingKind = 'approval'
673
- const toolName = typeof req.toolName === 'string' ? req.toolName : ''
674
- const reason = typeof req.reason === 'string' && req.reason.length > 0 ? req.reason : ''
675
- push(sessionId, 'approval', reason.length > 0 ? reason : (toolName.length > 0 ? `等待批准执行 ${toolName}` : '等待批准'))
676
- }
677
- } catch {
678
- // 观察失败不影响审批链路。
679
- }
680
- return next()
681
- })
682
-
683
- // ── typert 服务 ──────────────────────────────────────────────────────────
684
-
685
- const service = {
686
- /** 读取当前配置与全部未确认事件(客户端轮询消费)。 */
687
- getState() {
688
- return { config: store.get(), events: queue.map((item) => ({ ...item })) }
689
- },
690
- /** 深合并一份配置补丁并持久化(返回值与 ./typert 清单的纯 config codec 一致)。 */
691
- updateConfig(patch) {
692
- return store.set(patch)
693
- },
694
- /** 回执已消费的事件 id(从队列移除,防止重复提醒)。 */
695
- ackEvents(ids) {
696
- if (Array.isArray(ids)) {
697
- const acked = new Set(ids.map((x) => String(x)))
698
- for (let i = queue.length - 1; i >= 0; i -= 1) if (acked.has(queue[i].id)) queue.splice(i, 1)
699
- }
700
- return { ok: true }
701
- },
702
- /** 探测本机已安装浏览器(设置页"跳转会话浏览器"选择器数据源)。 */
703
- listBrowsers() {
704
- return { browsers: detectBrowsers() }
705
- },
706
- }
707
- Object.defineProperty(service, 'typertRemote', {
708
- configurable: false,
709
- enumerable: false,
710
- writable: false,
711
- value: { service, serviceKey: 'notify', namespace: 'notify' },
712
- })
713
- ctx.provide('notify', service)
714
-
715
- console.log('[dsh-notify-yimit] 已加载(默认关闭;请在 设置 → 通知 中开启)')
716
- }
1
+ /**
2
+ * dsh-notify-yimit-yimit 宿主插件。
3
+ * 优化点:内存LRU控制、队列头指针优化、浏览器探测缓存、宿主崩溃退避、全面多语言支持。
4
+ */
5
+ import {
6
+ z
7
+ } from 'zod'
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import {
11
+ spawn
12
+ } from 'node:child_process'
13
+ import {
14
+ fileURLToPath
15
+ } from 'node:url'
16
+ import {
17
+ resolveDshHome
18
+ } from '@deepseek-ai/dsh-home-paths'
19
+
20
+ export const name = 'dsh-notify-yimit'
21
+
22
+ const KIND_KEYS = Object.freeze(['completed', 'error', 'running', 'approval', 'question'])
23
+
24
+ const DEFAULT_CONFIG = Object.freeze({
25
+ enabled: false,
26
+ mode: 'off',
27
+ maxVisible: 3,
28
+ durationMs: 8000,
29
+ browser: '',
30
+ bgColor: '#20272f',
31
+ textColor: '#e6edf3',
32
+ locale: 'en',
33
+ // 通知窗口样式:width 窗口宽度(编辑模式拖拽边缘调整);gap 通知间距(px);
34
+ // position = null 表示默认右下贴边,{left,top} 为任意屏幕位置(工作区相对坐标)。
35
+ toast: {
36
+ width: 340,
37
+ gap: 12,
38
+ position: null,
39
+ },
40
+ colors: {
41
+ completed: {
42
+ bg: '#1f5138',
43
+ fg: '#e6f4ec',
44
+ enabled: true
45
+ },
46
+ error: {
47
+ bg: '#5c1f24',
48
+ fg: '#fbe9ea',
49
+ enabled: true
50
+ },
51
+ running: {
52
+ bg: '#203a5c',
53
+ fg: '#e8f0fb',
54
+ enabled: true
55
+ },
56
+ approval: {
57
+ bg: '#4a3a12',
58
+ fg: '#f7efd8',
59
+ enabled: true
60
+ },
61
+ question: {
62
+ bg: '#3d2a55',
63
+ fg: '#f0e8fa',
64
+ enabled: true
65
+ },
66
+ },
67
+ })
68
+
69
+ const ACTIVITY_LABELS = {
70
+ zh: {
71
+ start: '开始处理',
72
+ thinking: '正在思考…',
73
+ generating: '正在生成回复…',
74
+ executing: '正在执行',
75
+ continuing: '继续处理…',
76
+ processing: '正在处理…',
77
+ completed: '任务已完成',
78
+ errorPrefix: '任务出错:',
79
+ errorFallback: '任务出错',
80
+ approvalPrefix: '等待批准执行',
81
+ approvalFallback: '等待批准',
82
+ },
83
+ en: {
84
+ start: 'Started processing',
85
+ thinking: 'Thinking…',
86
+ generating: 'Generating response…',
87
+ executing: 'Executing',
88
+ continuing: 'Continuing…',
89
+ processing: 'Processing...',
90
+ completed: 'Task completed',
91
+ errorPrefix: 'Task failed: ',
92
+ errorFallback: 'Task failed',
93
+ approvalPrefix: 'Awaiting approval to execute',
94
+ approvalFallback: 'Awaiting approval',
95
+ }
96
+ }
97
+
98
+ const TOAST_LABELS = {
99
+ zh: {
100
+ ignore: '忽略',
101
+ jump: '跳转会话',
102
+ unnamed: '(未命名会话)'
103
+ },
104
+ en: {
105
+ ignore: 'Ignore',
106
+ jump: 'Open',
107
+ unnamed: '(Unnamed session)'
108
+ }
109
+ }
110
+
111
+ /** 标题未生成时的占位文本(中/英):session/title 到达后原地替换。 */
112
+ const UNTITLED_NAMES = new Set([TOAST_LABELS.zh.unnamed, TOAST_LABELS.en.unnamed])
113
+
114
+ /** 编辑模式样板窗标题(中/英)。 */
115
+ const EDIT_TITLES = {
116
+ zh: '通知样式预览',
117
+ en: 'Notification preview'
118
+ }
119
+
120
+ const HEX_RE = /^#[0-9a-fA-F]{3,8}$/
121
+ const isHex = (v) => typeof v === 'string' && HEX_RE.test(v)
122
+
123
+ function normalizeConfig(input) {
124
+ const src = input !== null && typeof input === 'object' && !Array.isArray(input) ? input : {}
125
+ const out = {
126
+ ...DEFAULT_CONFIG
127
+ }
128
+ if (typeof src.enabled === 'boolean') out.enabled = src.enabled
129
+ if (src.mode === 'system' || src.mode === 'custom' || src.mode === 'off') out.mode = src.mode
130
+ if (typeof src.maxVisible === 'number' && Number.isFinite(src.maxVisible)) out.maxVisible = Math.max(1, Math.min(20,
131
+ Math.floor(src.maxVisible)))
132
+ if (typeof src.durationMs === 'number' && Number.isFinite(src.durationMs)) out.durationMs = Math.max(1000, Math.min(
133
+ 300000, Math.floor(src.durationMs)))
134
+ if (typeof src.browser === 'string') out.browser = src.browser
135
+ if (isHex(src.bgColor)) out.bgColor = src.bgColor
136
+ if (isHex(src.textColor)) out.textColor = src.textColor
137
+ if (src.locale === 'zh' || src.locale === 'en') out.locale = src.locale
138
+
139
+ // 通知窗口样式:width 260–520;gap 0–60;position 缺省 null(默认右下贴边),合法 {left,top} 才采纳。
140
+ out.toast = { width: 340, gap: 12, position: null }
141
+ const srcToast = src.toast !== null && typeof src.toast === 'object' && !Array.isArray(src.toast) ? src.toast : {}
142
+ if (typeof srcToast.width === 'number' && Number.isFinite(srcToast.width)) {
143
+ out.toast.width = Math.max(260, Math.min(520, Math.round(srcToast.width)))
144
+ }
145
+ if (typeof srcToast.gap === 'number' && Number.isFinite(srcToast.gap)) {
146
+ out.toast.gap = Math.max(0, Math.min(60, Math.round(srcToast.gap)))
147
+ }
148
+ const pos = srcToast.position
149
+ if (pos !== null && typeof pos === 'object' && !Array.isArray(pos) &&
150
+ typeof pos.left === 'number' && Number.isFinite(pos.left) &&
151
+ typeof pos.top === 'number' && Number.isFinite(pos.top)) {
152
+ out.toast.position = { left: Math.round(pos.left), top: Math.round(pos.top) }
153
+ }
154
+
155
+ const srcColors = src.colors !== null && typeof src.colors === 'object' && !Array.isArray(src.colors) ? src.colors :
156
+ {}
157
+ out.colors = {}
158
+ for (const kind of KIND_KEYS) {
159
+ const entry = srcColors[kind]
160
+ const def = DEFAULT_CONFIG.colors[kind]
161
+ out.colors[kind] = {
162
+ bg: isHex(entry?.bg) ? entry.bg : def.bg,
163
+ fg: isHex(entry?.fg) ? entry.fg : def.fg,
164
+ enabled: entry?.enabled !== false,
165
+ }
166
+ }
167
+ return out
168
+ }
169
+
170
+ function migrateLegacyColors(input) {
171
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) return input
172
+ if (input.colors !== null && typeof input.colors === 'object' && !Array.isArray(input.colors)) return input
173
+ if (!isHex(input.bgColor) && !isHex(input.textColor)) return input
174
+ const colors = {}
175
+ for (const kind of KIND_KEYS) colors[kind] = {
176
+ bg: input.bgColor,
177
+ fg: input.textColor,
178
+ enabled: true
179
+ }
180
+ return {
181
+ ...input,
182
+ colors
183
+ }
184
+ }
185
+
186
+ function mergeConfigPatch(base, patch) {
187
+ const out = {
188
+ ...base,
189
+ ...patch
190
+ }
191
+ if (patch !== null && typeof patch === 'object' && patch.colors !== null && typeof patch.colors === 'object' && !
192
+ Array.isArray(patch.colors)) {
193
+ const baseColors = base.colors !== null && typeof base.colors === 'object' ? base.colors : {}
194
+ const next = {}
195
+ for (const kind of KIND_KEYS) next[kind] = {
196
+ ...(baseColors[kind] ?? {}),
197
+ ...(patch.colors[kind] ?? {})
198
+ }
199
+ out.colors = next
200
+ }
201
+ if (patch !== null && typeof patch === 'object' && patch.toast !== null && typeof patch.toast === 'object' && !
202
+ Array.isArray(patch.toast)) {
203
+ out.toast = {
204
+ ...(base.toast !== null && typeof base.toast === 'object' && !Array.isArray(base.toast) ? base.toast : {}),
205
+ ...patch.toast
206
+ }
207
+ }
208
+ return out
209
+ }
210
+
211
+ function openConfig() {
212
+ const home = resolveDshHome()
213
+ const dir = path.join(home, 'storages', 'dsh-notify-yimit')
214
+ const file = path.join(dir, 'config.json')
215
+ let loaded = null
216
+ try {
217
+ loaded = JSON.parse(fs.readFileSync(file, 'utf8'))
218
+ } catch {
219
+ loaded = null
220
+ }
221
+ let config = normalizeConfig(migrateLegacyColors(loaded))
222
+ let timer = null
223
+
224
+ const persist = () => {
225
+ if (timer !== null) return
226
+ timer = setTimeout(() => {
227
+ timer = null
228
+ try {
229
+ fs.mkdirSync(dir, {
230
+ recursive: true
231
+ })
232
+ const tmp = `${file}.${process.pid}.tmp`
233
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2), 'utf8')
234
+ fs.renameSync(tmp, file)
235
+ } catch (error) {
236
+ console.warn(`[dsh-notify-yimit] Failed to persist config: ${String(error)}`)
237
+ }
238
+ }, 200)
239
+ }
240
+
241
+ return {
242
+ get: () => config,
243
+ set(patch) {
244
+ config = normalizeConfig(mergeConfigPatch(config, patch))
245
+ persist()
246
+ return config
247
+ },
248
+ close() {
249
+ if (timer !== null) {
250
+ clearTimeout(timer);
251
+ timer = null
252
+ }
253
+ try {
254
+ fs.mkdirSync(dir, {
255
+ recursive: true
256
+ })
257
+ const tmp = `${file}.${process.pid}.tmp`
258
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2), 'utf8')
259
+ fs.renameSync(tmp, file)
260
+ } catch {
261
+ /* 静默 */ }
262
+ },
263
+ }
264
+ }
265
+
266
+ const MAX_QUEUE = 500
267
+ const RUNNING_THROTTLE_MS = 400
268
+ const KIND_DEBOUNCE_MS = 2000
269
+ /** 通知间距默认值(px);实际使用 config.toast.gap(0–60,可调)。 */
270
+ const TOAST_GAP = 12
271
+ const TEXT_MAX = 300
272
+
273
+ function clip(text) {
274
+ const s = String(text ?? '')
275
+ return s.length > TEXT_MAX ? `${s.slice(0, TEXT_MAX)}…` : s
276
+ }
277
+
278
+ function titleOf(session) {
279
+ const events = session?.events
280
+ if (Array.isArray(events)) {
281
+ // 全量扫描:标题事件通常在会话早期(seq 小)生成,长会话/恢复会话里它可能在
282
+ // 最后 50 条之外——只扫尾部会漏掉,导致回退到占位/目录名(恢复会话不会重放
283
+ // 种子事件,event 流也补不上)。
284
+ for (let i = events.length - 1; i >= 0; i -= 1) {
285
+ const event = events[i]
286
+ if (event?.type === 'session/title' && typeof event.data?.title === 'string' && event.data.title.length > 0)
287
+ return event.data.title
288
+ }
289
+ }
290
+ // 标题尚未生成(异步 LLM/摘录,首轮事件都先于 session/title 到达):
291
+ // 不回退到 cwd 目录名(工作区名),返回空由调用方按语言显示占位;
292
+ // session/title 事件到达后,已弹浮窗标题经 title: 命令原地更新。
293
+ return ''
294
+ }
295
+
296
+ let cachedBrowsers = null
297
+
298
+ function browserCandidates() {
299
+ const pf = process.env.ProgramFiles ?? 'C:\\Program Files'
300
+ const pfx86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
301
+ const local = process.env.LOCALAPPDATA ?? ''
302
+ return [{
303
+ id: 'edge',
304
+ name: 'Microsoft Edge',
305
+ paths: [`${pfx86}\\Microsoft\\Edge\\Application\\msedge.exe`,
306
+ `${pf}\\Microsoft\\Edge\\Application\\msedge.exe`
307
+ ]
308
+ },
309
+ {
310
+ id: 'chrome',
311
+ name: 'Google Chrome',
312
+ paths: [`${pf}\\Google\\Chrome\\Application\\chrome.exe`,
313
+ `${pfx86}\\Google\\Chrome\\Application\\chrome.exe`, local ?
314
+ `${local}\\Google\\Chrome\\Application\\chrome.exe` : ''
315
+ ]
316
+ },
317
+ {
318
+ id: 'firefox',
319
+ name: 'Mozilla Firefox',
320
+ paths: [`${pf}\\Mozilla Firefox\\firefox.exe`, `${pfx86}\\Mozilla Firefox\\firefox.exe`]
321
+ },
322
+ {
323
+ id: 'brave',
324
+ name: 'Brave',
325
+ paths: [`${pf}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`, local ?
326
+ `${local}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe` : ''
327
+ ]
328
+ },
329
+ {
330
+ id: 'opera',
331
+ name: 'Opera',
332
+ paths: [local ? `${local}\\Programs\\Opera\\opera.exe` : '', `${pf}\\Opera\\launcher.exe`]
333
+ },
334
+ {
335
+ id: 'vivaldi',
336
+ name: 'Vivaldi',
337
+ paths: [local ? `${local}\\Vivaldi\\Application\\vivaldi.exe` : '',
338
+ `${pf}\\Vivaldi\\Application\\vivaldi.exe`
339
+ ]
340
+ },
341
+ {
342
+ id: '360se',
343
+ name: '360 Safe Browser',
344
+ paths: [`${pfx86}\\360\\360se6\\Application\\360se.exe`]
345
+ },
346
+ {
347
+ id: '360chrome',
348
+ name: 'Chrome Browser',
349
+ paths: [`${pfx86}\\360\\360Chrome\\Chrome\\Application\\360chrome.exe`]
350
+ },
351
+ {
352
+ id: 'qqbrowser',
353
+ name: 'QQ Browser',
354
+ paths: [`${pfx86}\\Tencent\\QQBrowser\\QQBrowser.exe`, `${pf}\\Tencent\\QQBrowser\\QQBrowser.exe`]
355
+ },
356
+ {
357
+ id: 'sogou',
358
+ name: 'Sogou Browser',
359
+ paths: [`${pfx86}\\SogouExplorer\\SogouExplorer.exe`]
360
+ },
361
+ {
362
+ id: 'liebao',
363
+ name: 'Cheetah Browser',
364
+ paths: [`${pfx86}\\liebao\\liebao.exe`]
365
+ },
366
+ ]
367
+ }
368
+
369
+ function detectBrowsers() {
370
+ if (cachedBrowsers !== null) return cachedBrowsers
371
+ const found = []
372
+ for (const candidate of browserCandidates()) {
373
+ const path = (candidate.paths ?? []).find((p) => typeof p === 'string' && p.length > 0 && fs.existsSync(p))
374
+ if (path !== undefined) found.push({
375
+ id: candidate.id,
376
+ name: candidate.name,
377
+ path
378
+ })
379
+ }
380
+ cachedBrowsers = found
381
+ return found
382
+ }
383
+
384
+ export function apply(ctx) {
385
+ const store = openConfig()
386
+ ctx.effect(() => () => store.close(), 'dsh-notify-yimit: config close')
387
+
388
+ const MAX_SESSIONS = 200
389
+ const states = new Map()
390
+ const stateOf = (sessionId) => {
391
+ let st = states.get(sessionId)
392
+ if (st === undefined) {
393
+ st = {
394
+ title: '',
395
+ status: 'idle',
396
+ lastRunningId: null,
397
+ lastRunningAt: 0,
398
+ lastRunningText: null,
399
+ waitingKind: null,
400
+ lastFired: {}
401
+ }
402
+ states.set(sessionId, st)
403
+ if (states.size > MAX_SESSIONS) states.delete(states.keys().next().value)
404
+ }
405
+ return st
406
+ }
407
+
408
+ let queue = []
409
+ let queueHead = 0
410
+
411
+ function trim() {
412
+ while (queue.length - queueHead > MAX_QUEUE) queueHead++
413
+ if (queueHead > MAX_QUEUE) {
414
+ queue = queue.slice(queueHead);
415
+ queueHead = 0
416
+ }
417
+ }
418
+
419
+ const TOAST_HOST_SCRIPT = fileURLToPath(new URL('./toast-host.ps1',
420
+ import.meta.url))
421
+ const activeToasts = new Map()
422
+ const toastOrder = []
423
+ const pendingClose = new Set()
424
+
425
+ let globalWorkBottom = null
426
+ let toastSeq = 1
427
+ let host = null
428
+ let hostAlive = false
429
+ let hostRestartAttempts = 0
430
+ let hostRestartUntil = 0
431
+
432
+ function spawnHost() {
433
+ try {
434
+ const spawnFn = typeof globalThis.__DSH_NOTIFY_TEST_SPAWN__ === 'function' ? globalThis
435
+ .__DSH_NOTIFY_TEST_SPAWN__ : spawn
436
+ host = spawnFn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', TOAST_HOST_SCRIPT], {
437
+ windowsHide: true,
438
+ stdio: ['pipe', 'pipe', 'pipe'],
439
+ })
440
+ } catch (error) {
441
+ console.warn(`[dsh-notify-yimit] Toast host spawn failed: ${String(error)}`)
442
+ host = null;
443
+ hostAlive = false;
444
+ return
445
+ }
446
+ hostAlive = true
447
+ host.stdin.on('error', (err) => {
448
+ if (process.env.DSH_DEBUG) console.warn(`[dsh-notify-yimit] stdin error: ${err}`)
449
+ })
450
+ host.stdout.on('error', () => {})
451
+ host.on('error', () => {
452
+ hostAlive = false
453
+ })
454
+ host.stdout.setEncoding('utf8')
455
+
456
+ let buf = ''
457
+ host.stdout.on('data', (d) => {
458
+ buf += String(d)
459
+ let nl
460
+ while ((nl = buf.indexOf('\n')) !== -1) {
461
+ const line = buf.slice(0, nl).trim()
462
+ buf = buf.slice(nl + 1)
463
+ if (line.length === 0) continue
464
+ let msg
465
+ try {
466
+ msg = JSON.parse(line)
467
+ } catch {
468
+ continue
469
+ }
470
+ if (msg === null || typeof msg !== 'object') continue
471
+ if (msg.type === 'pos' && typeof msg.key === 'string') {
472
+ const rec = activeToasts.get(msg.key)
473
+ if (rec !== undefined && rec.instance === msg.instance) {
474
+ setToastMeta(rec, {
475
+ left: msg.left,
476
+ top: msg.top,
477
+ height: msg.height,
478
+ hwnd: msg.hwnd,
479
+ workBottom: msg.workBottom
480
+ })
481
+ reflowToasts()
482
+ }
483
+ } else if (msg.type === 'exit' && typeof msg.key === 'string') {
484
+ const rec = activeToasts.get(msg.key)
485
+ if (rec !== undefined && rec.instance === msg.instance) {
486
+ pendingClose.delete(msg.key)
487
+ activeToasts.delete(msg.key)
488
+ const idx = toastOrder.indexOf(msg.key)
489
+ if (idx !== -1) toastOrder.splice(idx, 1)
490
+ reflowToasts()
491
+ }
492
+ } else if (msg.type === 'geometry' && typeof msg.left === 'number' && typeof msg.top === 'number') {
493
+ // 编辑模式:样板窗拖拽/缩放结束 → 写回配置,并应用到在屏浮窗。
494
+ const width = Math.max(260, Math.min(520, Math.round(Number(msg.width) || 340)))
495
+ const left = Math.round(Number(msg.left))
496
+ const top = Math.round(Number(msg.top))
497
+ store.set({
498
+ toast: {
499
+ width,
500
+ position: { left, top }
501
+ }
502
+ })
503
+ for (const rec of activeToasts.values()) {
504
+ sendCmd({ cmd: 'size', key: rec.key, width })
505
+ }
506
+ reflowToasts()
507
+ }
508
+ }
509
+ })
510
+
511
+ host.on('exit', () => {
512
+ hostAlive = false
513
+ activeToasts.clear()
514
+ toastOrder.length = 0
515
+ pendingClose.clear()
516
+ })
517
+ }
518
+
519
+ function sendCmd(payload) {
520
+ if (host === null || !hostAlive) return false
521
+ try {
522
+ host.stdin.write(`${JSON.stringify(payload)}\n`);
523
+ return true
524
+ } catch {
525
+ return false
526
+ }
527
+ }
528
+
529
+ function ensureHost() {
530
+ if (host !== null && hostAlive) return true
531
+ if (Date.now() < hostRestartUntil) return false
532
+ spawnHost()
533
+ if (!hostAlive) {
534
+ hostRestartAttempts++
535
+ hostRestartUntil = Date.now() + Math.min(30000, 1000 * (2 ** hostRestartAttempts))
536
+ } else {
537
+ hostRestartAttempts = 0
538
+ }
539
+ return host !== null && hostAlive
540
+ }
541
+
542
+ function dismissToast(key) {
543
+ if (pendingClose.has(key)) return
544
+ const rec = activeToasts.get(key)
545
+ if (rec === undefined) return
546
+ pendingClose.add(key)
547
+ sendCmd({
548
+ cmd: 'close',
549
+ key
550
+ })
551
+ setTimeout(() => {
552
+ if (activeToasts.get(key) === rec) {
553
+ sendCmd({
554
+ cmd: 'close',
555
+ key
556
+ })
557
+ pendingClose.delete(key)
558
+ activeToasts.delete(key)
559
+ const idx = toastOrder.indexOf(key)
560
+ if (idx !== -1) toastOrder.splice(idx, 1)
561
+ reflowToasts()
562
+ } else {
563
+ pendingClose.delete(key)
564
+ }
565
+ }, 1500).unref?.()
566
+ }
567
+
568
+ function reflowToasts() {
569
+ const cfg = store.get()
570
+ const pos = cfg.toast?.position ?? null
571
+ const gap = typeof cfg.toast?.gap === 'number' ? cfg.toast.gap : TOAST_GAP
572
+ if (pos !== null) {
573
+ // 自定义位置:position.top 是"第一个(最新,最底部)浮窗的顶边",向上堆叠。
574
+ // 与宿主 Loaded 的 targetTop = pos.top - offsetY 语义一致,否则重排会把
575
+ // 浮窗再上移一个自身高度(先正确、随即上移的 bug)。横向同步 position.left,
576
+ // 编辑调整位置后,在屏浮窗立即左右移动到位。
577
+ let prevTop = pos.top
578
+ let first = true
579
+ for (let i = 0; i < toastOrder.length; i += 1) {
580
+ const rec = activeToasts.get(toastOrder[i])
581
+ if (rec === undefined) continue
582
+ const height = rec.meta?.height ?? 130
583
+ const newTop = first ? prevTop : Math.round(prevTop - gap - height)
584
+ first = false
585
+ const needsTop = rec.meta !== null && newTop !== rec.meta.top
586
+ const needsLeft = rec.meta !== null && rec.meta.left !== pos.left
587
+ if (needsTop || needsLeft) {
588
+ const move = { cmd: 'move', key: rec.key, top: newTop }
589
+ if (needsTop) rec.meta.top = newTop
590
+ if (needsLeft) { move.left = pos.left; rec.meta.left = pos.left }
591
+ sendCmd(move)
592
+ }
593
+ prevTop = newTop
594
+ }
595
+ return
596
+ }
597
+ // 默认右下:底边对齐(工作区底边 - 20),向上堆叠;横向回默认贴边(left 'default'),
598
+ // 覆盖"恢复默认位置/改宽度后需回到右下角"的场景。
599
+ if (globalWorkBottom === null) return
600
+ let cursor = globalWorkBottom - 20
601
+ for (let i = 0; i < toastOrder.length; i += 1) {
602
+ const rec = activeToasts.get(toastOrder[i])
603
+ if (rec === undefined) continue
604
+ const height = rec.meta?.height ?? 130
605
+ const newTop = Math.round(cursor - height)
606
+ if (rec.meta !== null && newTop !== rec.meta.top) {
607
+ rec.meta.top = newTop
608
+ sendCmd({
609
+ cmd: 'move',
610
+ key: rec.key,
611
+ top: newTop,
612
+ left: 'default'
613
+ })
614
+ }
615
+ cursor = newTop - gap
616
+ }
617
+ }
618
+
619
+ function setToastMeta(rec, meta) {
620
+ rec.meta = meta
621
+ if (Number.isFinite(meta.workBottom)) globalWorkBottom = meta.workBottom
622
+ }
623
+
624
+ const baseUrlOf = () => {
625
+ const ws = ctx.get('webServer')
626
+ return `http://127.0.0.1:${ws !== null && ws !== undefined && typeof ws.port === 'number' ? ws.port : 3080}`
627
+ }
628
+
629
+ function showDesktopToast(cfg, ev) {
630
+ const maxVisible = Math.max(1, Math.min(20, Math.floor(Number(cfg.maxVisible) || 3)))
631
+ const stickyKind = ev.kind === 'running' || ev.kind === 'approval' || ev.kind === 'question'
632
+ const key = stickyKind ? `${ev.kind}:${ev.sessionId}` : `${ev.kind}:${ev.sessionId}:${ev.at}`
633
+
634
+ let replaceIndex = -1
635
+ if (stickyKind) {
636
+ const prev = activeToasts.get(key)
637
+ if (prev !== undefined && !pendingClose.has(key)) {
638
+ sendCmd({
639
+ cmd: 'text',
640
+ key,
641
+ text: clip(ev.text)
642
+ })
643
+ return
644
+ }
645
+ if (prev !== undefined) {
646
+ replaceIndex = toastOrder.indexOf(key)
647
+ if (replaceIndex !== -1) toastOrder.splice(replaceIndex, 1)
648
+ activeToasts.delete(key)
649
+ pendingClose.delete(key)
650
+ }
651
+ }
652
+
653
+ let guard = 0
654
+ while (activeToasts.size - pendingClose.size >= maxVisible && guard++ < 64) {
655
+ let first = null
656
+ for (const k of activeToasts.keys()) {
657
+ if (pendingClose.has(k)) continue
658
+ if (stickyKind && !/^(running|approval|question):/.test(k)) {
659
+ first = k;
660
+ break
661
+ }
662
+ if (first === null) first = k
663
+ }
664
+ if (first === null) break
665
+ dismissToast(first)
666
+ }
667
+
668
+ const colors = cfg.colors !== null && typeof cfg.colors === 'object' ? cfg.colors[ev.kind] : undefined
669
+ if (replaceIndex === -1) {
670
+ toastOrder.unshift(key)
671
+ } else {
672
+ toastOrder.splice(replaceIndex, 0, key)
673
+ }
674
+ const insertIndex = replaceIndex !== -1 ? replaceIndex : 0
675
+ const gap = typeof cfg.toast?.gap === 'number' ? cfg.toast.gap : TOAST_GAP
676
+ let offsetY = 0
677
+ for (let i = 0; i < insertIndex; i += 1) {
678
+ const r = activeToasts.get(toastOrder[i])
679
+ offsetY += (r?.meta?.height ?? 130) + gap
680
+ }
681
+
682
+ const lang = cfg.locale === 'zh' ? 'zh' : 'en'
683
+ const l = TOAST_LABELS[lang]
684
+
685
+ const payload = {
686
+ cmd: 'show',
687
+ key,
688
+ instance: toastSeq++,
689
+ title: typeof ev.title === 'string' && ev.title.length > 0 ? ev.title : l.unnamed,
690
+ text: clip(ev.text),
691
+ bg: colors?.bg ?? cfg.bgColor ?? '#20272f',
692
+ fg: colors?.fg ?? cfg.textColor ?? '#e6edf3',
693
+ durationSec: Math.max(1, Math.round((Number(cfg.durationMs) || 8000) / 1000)),
694
+ sticky: stickyKind,
695
+ offsetY,
696
+ sessionId: String(ev.sessionId),
697
+ baseUrl: baseUrlOf(),
698
+ winTitle: `dsh-notify-yimit-${key}`,
699
+ ignoreLabel: l.ignore,
700
+ jumpLabel: l.jump,
701
+ unnamedLabel: l.unnamed,
702
+ // 通知窗口样式:宽度 + 自定义位置(null = 默认右下贴边)
703
+ width: cfg.toast?.width ?? 340,
704
+ position: cfg.toast?.position ?? null,
705
+ }
706
+ if (typeof cfg.browser === 'string' && cfg.browser.length > 0) payload.browserPath = cfg.browser
707
+
708
+ if (!ensureHost() || !sendCmd(payload)) {
709
+ const entry = {
710
+ id: String(nextId++),
711
+ sessionId: String(ev.sessionId),
712
+ title: typeof ev.title === 'string' && ev.title.length > 0 ? ev.title : l.unnamed,
713
+ kind: ev.kind,
714
+ text: clip(ev.text),
715
+ at: ev.at
716
+ }
717
+ queue.push(entry)
718
+ trim()
719
+ return
720
+ }
721
+ activeToasts.set(key, {
722
+ key,
723
+ instance: payload.instance,
724
+ meta: null,
725
+ offsetY,
726
+ sessionId: String(ev.sessionId)
727
+ })
728
+ }
729
+
730
+ function closeToast(kind, sessionId) {
731
+ dismissToast(`${kind}:${sessionId}`)
732
+ }
733
+
734
+ /** 会话标题到达后原地更新已弹浮窗的标题(宿主 title: 命令,不重弹、不闪烁)。 */
735
+ function updateToastTitle(sessionId, title) {
736
+ if (typeof title !== 'string' || title.length === 0) return
737
+ for (const rec of activeToasts.values()) {
738
+ if (rec.sessionId === sessionId) {
739
+ sendCmd({ cmd: 'title', key: rec.key, title })
740
+ }
741
+ }
742
+ }
743
+
744
+ ctx.effect(() => () => {
745
+ if (host !== null) {
746
+ try {
747
+ sendCmd({
748
+ cmd: 'shutdown'
749
+ })
750
+ } catch {
751
+ /* 忽略 */ }
752
+ try {
753
+ host.kill()
754
+ } catch {
755
+ /* 已退出 */ }
756
+ }
757
+ activeToasts.clear()
758
+ toastOrder.length = 0
759
+ pendingClose.clear()
760
+ }, 'dsh-notify-yimit: toast cleanup')
761
+
762
+ spawnHost()
763
+ let nextId = 1
764
+
765
+ const push = (sessionId, kind, text) => {
766
+ const st = states.get(sessionId)
767
+ if (st === undefined) return
768
+ const at = Date.now()
769
+ const cfg = store.get()
770
+ // 每类型开关:该类型被关闭时完全不产生通知(桌面浮窗与系统通知都不发)。
771
+ if (cfg.colors?.[kind]?.enabled === false) return
772
+ const desktop = cfg.enabled === true && cfg.mode === 'custom'
773
+ const lang = cfg.locale === 'zh' ? 'zh' : 'en'
774
+ const l = TOAST_LABELS[lang]
775
+ const entryTitle = typeof st.title === 'string' && st.title.length > 0 ? st.title : l.unnamed
776
+
777
+ if (kind === 'running') {
778
+ if (st.lastRunningAt !== 0 && at - st.lastRunningAt < RUNNING_THROTTLE_MS && st.lastRunningId !==
779
+ null) {
780
+ if (desktop) {
781
+ if (st.lastRunningText !== clip(text)) {
782
+ st.lastRunningText = clip(text)
783
+ showDesktopToast(cfg, {
784
+ sessionId,
785
+ title: st.title,
786
+ kind,
787
+ text: clip(text),
788
+ at
789
+ })
790
+ }
791
+ return
792
+ }
793
+ for (let i = queueHead; i < queue.length; i++) {
794
+ if (queue[i].id === st.lastRunningId) {
795
+ queue[i].text = clip(text);
796
+ queue[i].at = at;
797
+ break
798
+ }
799
+ }
800
+ return
801
+ }
802
+ st.lastRunningAt = at
803
+ st.lastRunningText = clip(text)
804
+ if (desktop) {
805
+ showDesktopToast(cfg, {
806
+ sessionId,
807
+ title: st.title,
808
+ kind,
809
+ text: clip(text),
810
+ at
811
+ })
812
+ st.lastRunningId = `desktop:${at}`
813
+ return
814
+ }
815
+ const entry = {
816
+ id: String(nextId++),
817
+ sessionId,
818
+ title: entryTitle,
819
+ kind,
820
+ text: clip(text),
821
+ activity: clip(text),
822
+ at
823
+ }
824
+ st.lastRunningId = entry.id
825
+ queue.push(entry)
826
+ trim()
827
+ return
828
+ }
829
+
830
+ if (st.lastFired === undefined) st.lastFired = {}
831
+ const isSticky = kind === 'approval' || kind === 'question'
832
+ if (!isSticky && at - (st.lastFired[kind] ?? 0) < KIND_DEBOUNCE_MS) return
833
+ st.lastFired[kind] = at
834
+
835
+ if (desktop) {
836
+ showDesktopToast(cfg, {
837
+ sessionId,
838
+ title: st.title,
839
+ kind,
840
+ text: clip(text),
841
+ at
842
+ })
843
+ return
844
+ }
845
+
846
+ let stale = -1
847
+ for (let i = queueHead; i < queue.length; i++) {
848
+ if (queue[i].sessionId === sessionId && queue[i].kind === kind) {
849
+ stale = i;
850
+ break
851
+ }
852
+ }
853
+ if (stale !== -1) queue.splice(stale, 1)
854
+
855
+ const entry = {
856
+ id: String(nextId++),
857
+ sessionId,
858
+ title: entryTitle,
859
+ kind,
860
+ text: clip(text),
861
+ at
862
+ }
863
+ queue.push(entry)
864
+ trim()
865
+ }
866
+
867
+ ctx.on('session/event', (session, event) => {
868
+ if (session === undefined || session === null || event === undefined || event === null) return
869
+ const id = session.id
870
+ const st = stateOf(id)
871
+ if (st.title.length === 0) st.title = titleOf(session)
872
+
873
+ const lang = store.get().locale === 'zh' ? 'zh' : 'en'
874
+ const L = ACTIVITY_LABELS[lang]
875
+
876
+ switch (event.type) {
877
+ case 'session/title': {
878
+ if (typeof event.data?.title === 'string' && event.data.title.length > 0) {
879
+ st.title = event.data.title
880
+ for (let i = queueHead; i < queue.length; i++) {
881
+ if (queue[i].sessionId === id && (queue[i].title.length === 0 || UNTITLED_NAMES.has(queue[i].title)))
882
+ queue[i].title = st.title
883
+ }
884
+ // 已弹浮窗(运行中/待审批/待回答等)标题原地更新,不再一直顶着占位/旧标题。
885
+ updateToastTitle(id, st.title)
886
+ }
887
+ return
888
+ }
889
+ case 'turn/start': {
890
+ st.status = 'running'
891
+ st.activity = L.start
892
+ push(id, 'running', st.activity)
893
+ return
894
+ }
895
+ case 'step/start': {
896
+ st.status = 'running'
897
+ st.activity = L.thinking
898
+ push(id, 'running', st.activity)
899
+ return
900
+ }
901
+ case 'assistant/chunk': {
902
+ const chunk = event.data?.chunk
903
+ if (chunk === null || typeof chunk !== 'object') return
904
+ if (chunk.type === 'reasoning' && typeof chunk.text === 'string' && chunk.text.length > 0) {
905
+ st.activity = L.thinking
906
+ push(id, 'running', st.activity)
907
+ } else if (chunk.type === 'text' && typeof chunk.text === 'string' && chunk.text.length > 0) {
908
+ st.activity = L.generating
909
+ push(id, 'running', st.activity)
910
+ }
911
+ return
912
+ }
913
+ case 'tool/call': {
914
+ const data = event.data
915
+ if (data === null || typeof data !== 'object') return
916
+ const toolName = typeof data.name === 'string' ? data.name : ''
917
+ st.activity = `${L.executing} ${toolName}`
918
+ push(id, 'running', st.activity)
919
+ if (toolName === 'ask_user_question' && typeof data.arguments === 'string') {
920
+ try {
921
+ const args = JSON.parse(data.arguments)
922
+ const questions = Array.isArray(args?.questions) ? args.questions : []
923
+ const first = questions.find((q) => q !== null && typeof q === 'object' && typeof q
924
+ .question === 'string')
925
+ if (first !== undefined) {
926
+ st.status = 'waiting'
927
+ st.waitingKind = 'question'
928
+ push(id, 'question', first.question)
929
+ }
930
+ } catch {
931
+ /* arguments 非 JSON */ }
932
+ }
933
+ return
934
+ }
935
+ case 'tool/result': {
936
+ if (st.waitingKind === 'question') {
937
+ closeToast('question', id)
938
+ st.waitingKind = null
939
+ st.status = 'running'
940
+ st.activity = L.continuing
941
+ push(id, 'running', st.activity)
942
+ } else if (st.status === 'waiting') {
943
+ st.status = 'running'
944
+ st.activity = L.continuing
945
+ push(id, 'running', st.activity)
946
+ }
947
+ return
948
+ }
949
+ case 'approval/decided': {
950
+ closeToast('approval', id)
951
+ if (st.waitingKind === 'approval') st.waitingKind = null
952
+ return
953
+ }
954
+ case 'turn/end': {
955
+ const reason = event.data?.reason
956
+ const kind = reason !== null && typeof reason === 'object' ? reason.kind : undefined
957
+ st.status = 'idle'
958
+ st.activity = ''
959
+ st.lastRunningAt = 0
960
+ st.lastRunningId = null
961
+ st.lastRunningText = null
962
+ st.waitingKind = null
963
+ closeToast('running', id)
964
+ if (kind === 'completed') {
965
+ push(id, 'completed', L.completed)
966
+ } else if (kind === 'error') {
967
+ const message = reason !== null && typeof reason === 'object' && reason.error !== null &&
968
+ typeof reason.error === 'object' ? (typeof reason.error.message === 'string' ? reason
969
+ .error.message : '') : ''
970
+ push(id, 'error', message.length > 0 ? `${L.errorPrefix}${message}` : L.errorFallback)
971
+ }
972
+ return
973
+ }
974
+ default:
975
+ return
976
+ }
977
+ })
978
+
979
+ ctx.on('agent/status', ({
980
+ agent,
981
+ status
982
+ }) => {
983
+ if (agent === undefined || agent === null) return
984
+ const sessionId = agent.session?.id ?? agent.id
985
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return
986
+ const st = stateOf(sessionId)
987
+ const lang = store.get().locale === 'zh' ? 'zh' : 'en'
988
+ const L = ACTIVITY_LABELS[lang]
989
+ if (status === 'running' && st.status !== 'running') {
990
+ st.status = 'running'
991
+ st.activity = L.processing
992
+ push(sessionId, 'running', st.activity)
993
+ } else if (status === 'idle' && st.status === 'running') {
994
+ st.status = 'idle'
995
+ st.lastRunningAt = 0
996
+ st.lastRunningId = null
997
+ }
998
+ })
999
+
1000
+ ctx.on('approval/request', (req, next) => {
1001
+ try {
1002
+ const sid = req?.agent?.session?.id
1003
+ const sessionId = (typeof sid === 'string' && sid.length > 0) ? sid : req?.agent?.id
1004
+ if (typeof sessionId === 'string' && sessionId.length > 0 && req !== null && typeof req ===
1005
+ 'object') {
1006
+ const st = stateOf(sessionId)
1007
+ st.status = 'waiting'
1008
+ st.waitingKind = 'approval'
1009
+ const toolName = typeof req.toolName === 'string' ? req.toolName : ''
1010
+ const reason = typeof req.reason === 'string' && req.reason.length > 0 ? req.reason : ''
1011
+
1012
+ const lang = store.get().locale === 'zh' ? 'zh' : 'en'
1013
+ const L = ACTIVITY_LABELS[lang]
1014
+ push(sessionId, 'approval', reason.length > 0 ? reason : (toolName.length > 0 ?
1015
+ `${L.approvalPrefix} ${toolName}` : L.approvalFallback))
1016
+ }
1017
+ } catch {
1018
+ /* 观察失败不影响审批链路 */ }
1019
+ return next()
1020
+ })
1021
+
1022
+ const service = {
1023
+ getState() {
1024
+ return {
1025
+ config: store.get(),
1026
+ events: queue.slice(queueHead).map((item) => ({
1027
+ ...item
1028
+ }))
1029
+ }
1030
+ },
1031
+ updateConfig(patch) {
1032
+ const prev = store.get()
1033
+ const next = store.set(patch)
1034
+ // toast 几何(位置/宽度/间距)变更:立即应用到在屏浮窗,无需等待下一个浮窗。
1035
+ const pToast = patch !== null && typeof patch === 'object' && !Array.isArray(patch) ? patch.toast : undefined
1036
+ if (pToast !== null && typeof pToast === 'object' && !Array.isArray(pToast)) {
1037
+ if (typeof pToast.width === 'number' && pToast.width !== prev.toast?.width) {
1038
+ for (const rec of activeToasts.values()) {
1039
+ sendCmd({ cmd: 'size', key: rec.key, width: next.toast.width })
1040
+ }
1041
+ }
1042
+ reflowToasts()
1043
+ }
1044
+ return next
1045
+ },
1046
+ ackEvents(ids) {
1047
+ if (Array.isArray(ids)) {
1048
+ const acked = new Set(ids.map((x) => String(x)))
1049
+ for (let i = queue.length - 1; i >= queueHead; i -= 1) {
1050
+ if (acked.has(queue[i].id)) queue.splice(i, 1)
1051
+ }
1052
+ if (queueHead > MAX_QUEUE / 2) {
1053
+ queue = queue.slice(queueHead);
1054
+ queueHead = 0
1055
+ }
1056
+ }
1057
+ return {
1058
+ ok: true
1059
+ }
1060
+ },
1061
+ listBrowsers() {
1062
+ return {
1063
+ browsers: detectBrowsers()
1064
+ }
1065
+ },
1066
+ /** 进入通知窗口编辑模式:宿主弹样板窗(可拖拽/缩放),几何回传后写回配置。 */
1067
+ startEditMode() {
1068
+ const cfg = store.get()
1069
+ const lang = cfg.locale === 'zh' ? 'zh' : 'en'
1070
+ sendCmd({
1071
+ cmd: 'edit',
1072
+ title: EDIT_TITLES[lang],
1073
+ width: cfg.toast?.width ?? 340,
1074
+ position: cfg.toast?.position ?? null,
1075
+ })
1076
+ return { ok: true }
1077
+ },
1078
+ /** 退出编辑模式:样板窗淡出消失。 */
1079
+ endEditMode() {
1080
+ sendCmd({ cmd: 'edit-end' })
1081
+ return { ok: true }
1082
+ },
1083
+ }
1084
+
1085
+ Object.defineProperty(service, 'typertRemote', {
1086
+ configurable: false,
1087
+ enumerable: false,
1088
+ writable: false,
1089
+ value: {
1090
+ service,
1091
+ serviceKey: 'notify',
1092
+ namespace: 'notify'
1093
+ },
1094
+ })
1095
+
1096
+ ctx.provide('notify', service)
1097
+ console.log('[dsh-notify-yimit] Loaded (disabled by default; please enable in Settings -> Notifications)')
1098
+ }