dsh-plugin-desktop-notify 0.0.0-stage → 0.5.6
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/LICENSE +21 -0
- package/README.en.md +214 -0
- package/README.md +237 -2
- package/cordis.patch.yml +36 -0
- package/icon.svg +6 -0
- package/index.js +244 -0
- package/lib/client.js +709 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-desktop-notify — renderer half.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors what the Codex desktop app does for "something needs you":
|
|
5
|
+
*
|
|
6
|
+
* - a native macOS banner for a finished turn, a pending approval / question /
|
|
7
|
+
* plan review, or a turn that failed;
|
|
8
|
+
* - a Dock badge whose number is the count of sessions currently needing you,
|
|
9
|
+
* i.e. every banner also leaves a persistent red dot until you open that
|
|
10
|
+
* session (the user's rule: a notification always implies the badge);
|
|
11
|
+
* - clicking the banner opens the session and raises the window.
|
|
12
|
+
*
|
|
13
|
+
* Both surfaces run in the renderer: `new Notification(...)` is forwarded by
|
|
14
|
+
* Electron to the macOS notification centre **as DeepSeek Harness**, and
|
|
15
|
+
* `navigator.setAppBadge()` reaches Electron's BadgeManager and ends up at
|
|
16
|
+
* `NSDockTile.badgeLabel`. The host process cannot touch either.
|
|
17
|
+
*
|
|
18
|
+
* Facts, not bookkeeping: the completion and waiting facts come from the
|
|
19
|
+
* harness's own `uiSession.sessionStatus` (the same ones the sidebar dots read),
|
|
20
|
+
* so "read" means "opened that session in the main view", exactly like the
|
|
21
|
+
* official UI. Failures are event-sourced from `api-session/error`.
|
|
22
|
+
*
|
|
23
|
+
* Diagnostics: `window.__DSH_DESKTOP_NOTIFY__` in DevTools (⌘⌥I) reports the
|
|
24
|
+
* capabilities and the live attention state, and can drive each surface by
|
|
25
|
+
* hand.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
window.__ModuleLoader__.load({
|
|
29
|
+
id: "dsh-plugin-desktop-notify",
|
|
30
|
+
factory: (require) => {
|
|
31
|
+
const NS = "dsh-plugin-desktop-notify"
|
|
32
|
+
/** Reported by capabilities(); keep in step with package.json (cross-machine support). */
|
|
33
|
+
const VERSION = "0.5.6"
|
|
34
|
+
const PREFERENCES_KEY = `${NS}:preferences`
|
|
35
|
+
|
|
36
|
+
/** Defaults; override per field with the localStorage JSON described in README. */
|
|
37
|
+
const DEFAULTS = {
|
|
38
|
+
notifications: true,
|
|
39
|
+
notifyComplete: true,
|
|
40
|
+
notifyPending: true,
|
|
41
|
+
notifyError: true,
|
|
42
|
+
notifyViewed: false,
|
|
43
|
+
badge: true,
|
|
44
|
+
excludeSubagents: true,
|
|
45
|
+
// Empty means "use the built-in copy for the active UI language".
|
|
46
|
+
titleComplete: "",
|
|
47
|
+
titlePending: "",
|
|
48
|
+
titleError: "",
|
|
49
|
+
maxBodyChars: 140,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Notification copy. Terse product language, mirroring how the app itself
|
|
54
|
+
* words things; the language follows the UI (`<html lang>`), not the OS.
|
|
55
|
+
*/
|
|
56
|
+
const COPY = {
|
|
57
|
+
zh: {
|
|
58
|
+
completeTitle: "任务已完成",
|
|
59
|
+
complete: (name, duration) => withName(name, duration === "" ? "已完成" : `已完成,用时 ${duration}`),
|
|
60
|
+
approvalTitle: "等待你的批准",
|
|
61
|
+
approval: (name, tool) => withName(name, tool === "" ? "有一项操作等待你批准" : `请求使用 ${tool},等待你的批准`),
|
|
62
|
+
questionTitle: "等待你的回答",
|
|
63
|
+
question: (name) => withName(name, "有提问等待你回复"),
|
|
64
|
+
planTitle: "等待你的确认",
|
|
65
|
+
plan: (name) => withName(name, "执行计划等待你确认"),
|
|
66
|
+
errorTitle: "任务执行失败",
|
|
67
|
+
error: (name, message) => withName(name, message === "" ? "执行中断" : message),
|
|
68
|
+
seconds: (value) => `${value} 秒`,
|
|
69
|
+
minutes: (minutes, seconds) => (seconds === 0 ? `${minutes} 分` : `${minutes} 分 ${seconds} 秒`),
|
|
70
|
+
hours: (hours, minutes) => (minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes} 分`),
|
|
71
|
+
},
|
|
72
|
+
en: {
|
|
73
|
+
completeTitle: "Task completed",
|
|
74
|
+
complete: (name, duration) => withName(name, duration === "" ? "Completed" : `Completed in ${duration}`),
|
|
75
|
+
approvalTitle: "Approval needed",
|
|
76
|
+
approval: (name, tool) => withName(name, tool === "" ? "A tool is waiting for your approval" : `${tool} is waiting for your approval`),
|
|
77
|
+
questionTitle: "Your reply is needed",
|
|
78
|
+
question: (name) => withName(name, "A question is waiting for your reply"),
|
|
79
|
+
planTitle: "Plan review needed",
|
|
80
|
+
plan: (name) => withName(name, "The execution plan is waiting for your review"),
|
|
81
|
+
errorTitle: "Task failed",
|
|
82
|
+
error: (name, message) => withName(name, message === "" ? "The run was interrupted" : message),
|
|
83
|
+
seconds: (value) => `${value}s`,
|
|
84
|
+
minutes: (minutes, seconds) => (seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`),
|
|
85
|
+
hours: (hours, minutes) => (minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`),
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Prefix the state sentence with the session name, when the session has one. */
|
|
90
|
+
function withName(name, text) {
|
|
91
|
+
return name === "" ? text : `「${name}」${text}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The UI language drives the copy; the app always sets `<html lang>`. */
|
|
95
|
+
function activeLanguage() {
|
|
96
|
+
try {
|
|
97
|
+
const html = typeof document === "undefined" ? "" : document.documentElement?.lang ?? ""
|
|
98
|
+
const declared = String(html !== "" ? html : (typeof navigator === "undefined" ? "" : navigator.language) ?? "").toLowerCase()
|
|
99
|
+
if (declared.startsWith("zh")) return "zh"
|
|
100
|
+
if (declared !== "") return "en"
|
|
101
|
+
} catch {
|
|
102
|
+
/* fall through to the default */
|
|
103
|
+
}
|
|
104
|
+
return "zh"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The copy table for the current UI language. */
|
|
108
|
+
function copy() {
|
|
109
|
+
return COPY[activeLanguage()] ?? COPY.zh
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Compact elapsed time: "42 秒" / "3 分 12 秒" / "1 小时 5 分". */
|
|
113
|
+
function durationText(milliseconds) {
|
|
114
|
+
const table = copy()
|
|
115
|
+
const total = Math.max(1, Math.round(milliseconds / 1000))
|
|
116
|
+
if (total < 60) return table.seconds(total)
|
|
117
|
+
const minutes = Math.floor(total / 60)
|
|
118
|
+
if (minutes < 60) return table.minutes(minutes, total % 60)
|
|
119
|
+
return table.hours(Math.floor(minutes / 60), minutes % 60)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Pending-interaction kinds the plugin reports. */
|
|
123
|
+
const PENDING_KINDS = ["approval", "question", "plan-review"]
|
|
124
|
+
|
|
125
|
+
/** Read the browser storage when this renderer has one. */
|
|
126
|
+
function storage() {
|
|
127
|
+
try {
|
|
128
|
+
return typeof localStorage === "undefined" ? undefined : localStorage
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Normalize one stored section; malformed or unknown fields fall back to defaults. */
|
|
135
|
+
function readPreferences() {
|
|
136
|
+
const preferences = { ...DEFAULTS }
|
|
137
|
+
let raw
|
|
138
|
+
try {
|
|
139
|
+
raw = storage()?.getItem(PREFERENCES_KEY)
|
|
140
|
+
} catch {
|
|
141
|
+
raw = undefined
|
|
142
|
+
}
|
|
143
|
+
if (typeof raw !== "string" || raw === "") return preferences
|
|
144
|
+
let parsed
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(raw)
|
|
147
|
+
} catch {
|
|
148
|
+
return preferences
|
|
149
|
+
}
|
|
150
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return preferences
|
|
151
|
+
for (const [key, fallback] of Object.entries(DEFAULTS)) {
|
|
152
|
+
const value = parsed[key]
|
|
153
|
+
if (value === undefined) continue
|
|
154
|
+
if (typeof value !== typeof fallback) continue
|
|
155
|
+
preferences[key] = value
|
|
156
|
+
}
|
|
157
|
+
return preferences
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Collapse whitespace and cut to the body budget. */
|
|
161
|
+
function bodyText(text, maxChars) {
|
|
162
|
+
const collapsed = String(text).replace(/\s+/gu, " ").trim()
|
|
163
|
+
if (collapsed.length <= maxChars) return collapsed
|
|
164
|
+
return `${collapsed.slice(0, Math.max(0, maxChars - 1))}…`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether one status is waiting for a human answer or approval. */
|
|
168
|
+
function pendingKindOf(status) {
|
|
169
|
+
const kind = status?.pendingInteraction?.kind
|
|
170
|
+
return typeof kind === "string" && kind !== "" ? kind : undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The tool a pending approval names, when the interaction carries one. */
|
|
174
|
+
function pendingToolName(status) {
|
|
175
|
+
const tool = status?.pendingInteraction?.toolName
|
|
176
|
+
return typeof tool === "string" ? tool.trim() : ""
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Whether the main view is currently showing this session. */
|
|
180
|
+
function isViewed(summary) {
|
|
181
|
+
const retained = summary?.retainedBy?.mainView
|
|
182
|
+
return typeof retained === "number" && retained > 0
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A session row that belongs to a subagent never gets a banner or a badge. */
|
|
186
|
+
function isSubagent(summary) {
|
|
187
|
+
return summary?.origin === "subagent"
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The session title as the body prefix, or a neutral fallback. */
|
|
191
|
+
function sessionName(summary, maxChars) {
|
|
192
|
+
const title = typeof summary?.title === "string" ? summary.title.trim() : ""
|
|
193
|
+
return title === "" ? "" : bodyText(title, maxChars)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether this renderer can post a native notification. */
|
|
197
|
+
function canNotify() {
|
|
198
|
+
return typeof Notification !== "undefined" && Notification.permission !== "denied"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Whether this renderer can write the Dock badge. */
|
|
202
|
+
function canBadge() {
|
|
203
|
+
return typeof navigator !== "undefined" && typeof navigator.setAppBadge === "function"
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Whether the application window currently has keyboard focus. */
|
|
207
|
+
function windowFocused() {
|
|
208
|
+
try {
|
|
209
|
+
return typeof document === "undefined" || typeof document.hasFocus !== "function" ? true : document.hasFocus()
|
|
210
|
+
} catch {
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Whether the application window is visible (not hidden or minimized). */
|
|
216
|
+
function windowVisible() {
|
|
217
|
+
try {
|
|
218
|
+
return typeof document === "undefined" || document.visibilityState === undefined
|
|
219
|
+
? true
|
|
220
|
+
: document.visibilityState === "visible"
|
|
221
|
+
} catch {
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* "Watching this session" = it is the main view AND the window has focus and
|
|
228
|
+
* is visible. The harness's own `completionUnread` only covers "another
|
|
229
|
+
* session is in the main view"; a backgrounded or hidden window is invisible
|
|
230
|
+
* to the host, so this plugin adds the focus/visibility half itself.
|
|
231
|
+
*/
|
|
232
|
+
function isLooking(summary) {
|
|
233
|
+
return isViewed(summary) && windowFocused() && windowVisible()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Ask once for notification permission; Electron normally already granted it. */
|
|
237
|
+
function askPermission() {
|
|
238
|
+
if (typeof Notification === "undefined") return
|
|
239
|
+
try {
|
|
240
|
+
if (Notification.permission === "default") void Notification.requestPermission()
|
|
241
|
+
} catch {
|
|
242
|
+
/* a denied or unavailable permission surface simply leaves banners off */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const inject = ["sessions", "uiSession", "uiWorkspace"]
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param ctx - Client root context.
|
|
250
|
+
*/
|
|
251
|
+
function apply(ctx) {
|
|
252
|
+
const preferences = readPreferences()
|
|
253
|
+
askPermission()
|
|
254
|
+
|
|
255
|
+
/** sessionId -> { completed?: true, pendingKind?: string, error?: string } */
|
|
256
|
+
const attention = new Map()
|
|
257
|
+
/** sessionId -> last observed { running, completed, pendingKind }, for edge detection. */
|
|
258
|
+
const previous = new Map()
|
|
259
|
+
/** sessionId -> ms timestamp when the current turn was first seen running. */
|
|
260
|
+
const turnStartedAt = new Map()
|
|
261
|
+
let lastBadge
|
|
262
|
+
/** The last banner attempt and its outcome — Windows has several ways to swallow it. */
|
|
263
|
+
let lastNotify
|
|
264
|
+
/** The last banner click and what it managed to do — Windows/macOS differ here. */
|
|
265
|
+
let lastClick
|
|
266
|
+
|
|
267
|
+
/** Record why a banner did or did not reach the OS. */
|
|
268
|
+
function recordNotify(entry) {
|
|
269
|
+
lastNotify = {
|
|
270
|
+
at: new Date().toISOString(),
|
|
271
|
+
permission: typeof Notification === "undefined" ? "unavailable" : Notification.permission,
|
|
272
|
+
...entry,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Write the Dock badge only when the number actually changes. */
|
|
277
|
+
function applyBadge() {
|
|
278
|
+
if (!canBadge()) return
|
|
279
|
+
const value = preferences.badge && attention.size > 0 ? attention.size : 0
|
|
280
|
+
if (value === lastBadge) return
|
|
281
|
+
lastBadge = value
|
|
282
|
+
try {
|
|
283
|
+
if (value > 0) void navigator.setAppBadge(value)
|
|
284
|
+
else void navigator.clearAppBadge()
|
|
285
|
+
} catch {
|
|
286
|
+
/* a renderer without badge support keeps running without one */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Open one session in the main view. `uiWorkspace` is the service the sidebar
|
|
292
|
+
* uses; older builds may only expose it through `ctx.get`, so try both and
|
|
293
|
+
* report what happened instead of swallowing a failure.
|
|
294
|
+
*/
|
|
295
|
+
function openSessionTarget(sessionId) {
|
|
296
|
+
if (typeof sessionId !== "string" || sessionId === "") return { ok: false, attempts: ["empty session id"] }
|
|
297
|
+
const candidates = []
|
|
298
|
+
try {
|
|
299
|
+
if (typeof ctx.uiWorkspace?.openSession === "function") candidates.push(["ctx.uiWorkspace (injected)", ctx.uiWorkspace])
|
|
300
|
+
} catch (error) {
|
|
301
|
+
candidates.push(["ctx.uiWorkspace", `unavailable: ${String(error)}`])
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
if (ctx.uiWorkspace !== undefined) candidates.push(["ctx.uiWorkspace", ctx.uiWorkspace])
|
|
305
|
+
} catch {
|
|
306
|
+
candidates.push(["ctx.uiWorkspace", "unavailable"])
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const resolved = typeof ctx.get === "function" ? ctx.get("uiWorkspace") : undefined
|
|
310
|
+
if (resolved !== undefined) candidates.push(["ctx.get('uiWorkspace')", resolved])
|
|
311
|
+
} catch {
|
|
312
|
+
candidates.push(["ctx.get('uiWorkspace')", "unavailable"])
|
|
313
|
+
}
|
|
314
|
+
const attempts = []
|
|
315
|
+
for (const [name, service] of candidates) {
|
|
316
|
+
if (typeof service?.openSession !== "function") {
|
|
317
|
+
attempts.push(`${name}: no openSession`)
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
service.openSession(sessionId)
|
|
322
|
+
return { ok: true, via: name, attempts }
|
|
323
|
+
} catch (error) {
|
|
324
|
+
attempts.push(`${name}: ${String(error)}`)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (candidates.length === 0) attempts.push("uiWorkspace service is not reachable")
|
|
328
|
+
return { ok: false, attempts }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Post one banner; clicking it opens the session and raises the window. */
|
|
332
|
+
function notify(kind, sessionId, title, body) {
|
|
333
|
+
if (!preferences.notifications) {
|
|
334
|
+
recordNotify({ kind, sessionId, result: "skipped: preferences.notifications=false" })
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
if (!canNotify()) {
|
|
338
|
+
const permission = typeof Notification === "undefined" ? "unavailable" : Notification.permission
|
|
339
|
+
recordNotify({ kind, sessionId, result: `skipped: Notification permission=${permission}` })
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
let notification
|
|
343
|
+
try {
|
|
344
|
+
// No `tag` and no `silent`: Windows 11 silently drops web notifications
|
|
345
|
+
// whose tag is longer than ~34 characters (electron/electron#40433), and
|
|
346
|
+
// a long per-session tag was exactly what this used to pass. Edge-based
|
|
347
|
+
// dedupe below already prevents the same banner firing twice.
|
|
348
|
+
notification = new Notification(title, { body })
|
|
349
|
+
} catch (error) {
|
|
350
|
+
recordNotify({ kind, sessionId, result: `threw: ${String(error)}` })
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
recordNotify({ kind, sessionId, title, body, result: "posted" })
|
|
354
|
+
notification.onclick = () => {
|
|
355
|
+
// Raise the window first: the navigation below is a layout operation and
|
|
356
|
+
// behaves best on an active window.
|
|
357
|
+
let focused = false
|
|
358
|
+
try {
|
|
359
|
+
window.focus()
|
|
360
|
+
focused = true
|
|
361
|
+
} catch {
|
|
362
|
+
/* focus is best effort */
|
|
363
|
+
}
|
|
364
|
+
const opened = openSessionTarget(sessionId)
|
|
365
|
+
lastClick = {
|
|
366
|
+
at: new Date().toISOString(),
|
|
367
|
+
kind,
|
|
368
|
+
sessionId,
|
|
369
|
+
opened,
|
|
370
|
+
focused,
|
|
371
|
+
}
|
|
372
|
+
// A window that was closed to the tray can swallow the first attempt;
|
|
373
|
+
// retry once after the OS finishes activating the application.
|
|
374
|
+
if (!opened.ok) {
|
|
375
|
+
setTimeout(() => {
|
|
376
|
+
const retry = openSessionTarget(sessionId)
|
|
377
|
+
lastClick = { ...lastClick, retried: retry }
|
|
378
|
+
}, 150)
|
|
379
|
+
}
|
|
380
|
+
// Windows applies foreground changes asynchronously and may swallow the
|
|
381
|
+
// first attempt; retry the raise without touching the selected session.
|
|
382
|
+
for (const delay of [60, 300]) {
|
|
383
|
+
setTimeout(() => {
|
|
384
|
+
try {
|
|
385
|
+
window.focus()
|
|
386
|
+
} catch {
|
|
387
|
+
/* focus is best effort */
|
|
388
|
+
}
|
|
389
|
+
}, delay)
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
notification.close()
|
|
393
|
+
} catch {
|
|
394
|
+
/* already gone */
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Record one attention fact and drop the session's badge entry when it empties. */
|
|
400
|
+
function setAttention(sessionId, patch) {
|
|
401
|
+
const entry = { ...(attention.get(sessionId) ?? {}), ...patch }
|
|
402
|
+
for (const key of Object.keys(entry)) {
|
|
403
|
+
if (entry[key] === undefined) delete entry[key]
|
|
404
|
+
}
|
|
405
|
+
if (Object.keys(entry).length === 0) attention.delete(sessionId)
|
|
406
|
+
else attention.set(sessionId, entry)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Recompute notifications, attention entries, and the badge from live status. */
|
|
410
|
+
function sync() {
|
|
411
|
+
const statuses = ctx.uiSession.sessionStatus.getSnapshot()
|
|
412
|
+
const summaries = ctx.sessions.list.getSnapshot().byId ?? {}
|
|
413
|
+
const seen = new Set()
|
|
414
|
+
|
|
415
|
+
for (const [sessionId, status] of statuses) {
|
|
416
|
+
const summary = summaries[sessionId]
|
|
417
|
+
if (preferences.excludeSubagents && isSubagent(summary)) {
|
|
418
|
+
attention.delete(sessionId)
|
|
419
|
+
previous.delete(sessionId)
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
seen.add(sessionId)
|
|
423
|
+
const before = previous.get(sessionId) ?? {}
|
|
424
|
+
const entry = attention.get(sessionId) ?? {}
|
|
425
|
+
// A failure already recorded for this turn owns the banner and the
|
|
426
|
+
// badge; the `running: true -> false` edge that follows it is the same
|
|
427
|
+
// event, not a second one.
|
|
428
|
+
const failurePending = entry.error !== undefined
|
|
429
|
+
const looking = isLooking(summary)
|
|
430
|
+
const completed = status?.completionUnread === true
|
|
431
|
+
const pendingKind = pendingKindOf(status)
|
|
432
|
+
const running = status?.running === true
|
|
433
|
+
const justFinished = before.running === true && running === false
|
|
434
|
+
const finishedNow = justFinished || (completed && before.completed !== true)
|
|
435
|
+
const completionEdge = finishedNow && !failurePending
|
|
436
|
+
const pendingEdge = pendingKind !== undefined && pendingKind !== before.pendingKind
|
|
437
|
+
|
|
438
|
+
if (running) {
|
|
439
|
+
if (!turnStartedAt.has(sessionId)) turnStartedAt.set(sessionId, Date.now())
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const table = copy()
|
|
443
|
+
const name = sessionName(summary, preferences.maxBodyChars)
|
|
444
|
+
|
|
445
|
+
if (completionEdge) {
|
|
446
|
+
const started = turnStartedAt.get(sessionId)
|
|
447
|
+
turnStartedAt.delete(sessionId)
|
|
448
|
+
const duration = started === undefined ? "" : durationText(Date.now() - started)
|
|
449
|
+
const title = preferences.titleComplete || table.completeTitle
|
|
450
|
+
const body = bodyText(table.complete(name, duration), preferences.maxBodyChars)
|
|
451
|
+
if (looking) {
|
|
452
|
+
// Watching it finish is only worth a banner when the user opted in.
|
|
453
|
+
if (preferences.notifyViewed && preferences.notifyComplete) notify("complete", sessionId, title, body)
|
|
454
|
+
} else if (preferences.notifyComplete) {
|
|
455
|
+
notify("complete", sessionId, title, body)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (looking) {
|
|
460
|
+
// Reading the session drops every fact for it, so the badge falls back.
|
|
461
|
+
attention.delete(sessionId)
|
|
462
|
+
} else {
|
|
463
|
+
if (preferences.notifyPending && pendingEdge) {
|
|
464
|
+
const tool = pendingKind === "approval" ? pendingToolName(status) : ""
|
|
465
|
+
const body = pendingKind === "approval"
|
|
466
|
+
? table.approval(name, tool)
|
|
467
|
+
: pendingKind === "plan-review"
|
|
468
|
+
? table.plan(name)
|
|
469
|
+
: table.question(name)
|
|
470
|
+
const title = preferences.titlePending || (pendingKind === "approval" ? table.approvalTitle : pendingKind === "plan-review" ? table.planTitle : table.questionTitle)
|
|
471
|
+
notify("pending", sessionId, title, bodyText(body, preferences.maxBodyChars))
|
|
472
|
+
}
|
|
473
|
+
setAttention(sessionId, {
|
|
474
|
+
// Sticky until you look at the session: the edge is the trigger,
|
|
475
|
+
// the unread fact is what keeps the red dot alive.
|
|
476
|
+
completed: completionEdge || completed || entry.completed === true ? true : undefined,
|
|
477
|
+
pendingKind,
|
|
478
|
+
// A new turn clears the failure recorded for the previous one.
|
|
479
|
+
error: running ? undefined : entry.error,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
previous.set(sessionId, { running, completed, pendingKind })
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
for (const sessionId of [...attention.keys()]) if (!seen.has(sessionId)) attention.delete(sessionId)
|
|
487
|
+
for (const sessionId of [...previous.keys()]) if (!seen.has(sessionId)) {
|
|
488
|
+
previous.delete(sessionId)
|
|
489
|
+
turnStartedAt.delete(sessionId)
|
|
490
|
+
}
|
|
491
|
+
applyBadge()
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** A live agent failure: same banner + badge treatment as the three status facts. */
|
|
495
|
+
function onAgentError(sessionId, message) {
|
|
496
|
+
if (typeof sessionId !== "string" || sessionId === "") return
|
|
497
|
+
const summaries = ctx.sessions.list.getSnapshot().byId ?? {}
|
|
498
|
+
const summary = summaries[sessionId]
|
|
499
|
+
if (preferences.excludeSubagents && isSubagent(summary)) return
|
|
500
|
+
if (isLooking(summary)) return
|
|
501
|
+
const table = copy()
|
|
502
|
+
const raw = typeof message === "string" ? message.trim() : ""
|
|
503
|
+
const text = bodyText(raw, preferences.maxBodyChars)
|
|
504
|
+
const before = attention.get(sessionId) ?? {}
|
|
505
|
+
turnStartedAt.delete(sessionId)
|
|
506
|
+
setAttention(sessionId, { error: text === "" ? "中断" : text })
|
|
507
|
+
if (preferences.notifyError && before.error !== text) {
|
|
508
|
+
const title = preferences.titleError || table.errorTitle
|
|
509
|
+
const body = bodyText(table.error(sessionName(summary, preferences.maxBodyChars), text), preferences.maxBodyChars)
|
|
510
|
+
notify("error", sessionId, title, body)
|
|
511
|
+
}
|
|
512
|
+
applyBadge()
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
ctx.effect(() => {
|
|
516
|
+
const offStatus = ctx.uiSession.sessionStatus.subscribe(sync)
|
|
517
|
+
const offList = ctx.sessions.list.subscribe(sync)
|
|
518
|
+
sync()
|
|
519
|
+
return () => {
|
|
520
|
+
offStatus()
|
|
521
|
+
offList()
|
|
522
|
+
}
|
|
523
|
+
}, "dsh-plugin-desktop-notify: status watch")
|
|
524
|
+
|
|
525
|
+
/*
|
|
526
|
+
* Focus and visibility are the half the host cannot see: a window in the
|
|
527
|
+
* background still shows a session in its main view, so `completionUnread`
|
|
528
|
+
* stays false. Re-evaluate on every focus edge so a banner appears when the
|
|
529
|
+
* app is in the background and the badge clears as soon as you look.
|
|
530
|
+
*/
|
|
531
|
+
ctx.effect(() => {
|
|
532
|
+
const reevaluate = () => sync()
|
|
533
|
+
const bindings = []
|
|
534
|
+
const bind = (target, names) => {
|
|
535
|
+
try {
|
|
536
|
+
for (const name of names) target.addEventListener(name, reevaluate)
|
|
537
|
+
bindings.push([target, names])
|
|
538
|
+
} catch {
|
|
539
|
+
/* a target without listeners simply keeps the last known state */
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
bind(window, ["focus", "blur"])
|
|
543
|
+
bind(document, ["visibilitychange"])
|
|
544
|
+
return () => {
|
|
545
|
+
for (const [target, names] of bindings) {
|
|
546
|
+
try {
|
|
547
|
+
for (const name of names) target.removeEventListener(name, reevaluate)
|
|
548
|
+
} catch {
|
|
549
|
+
/* already detached */
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}, "dsh-plugin-desktop-notify: focus watch")
|
|
554
|
+
|
|
555
|
+
ctx.effect(() => {
|
|
556
|
+
let remote
|
|
557
|
+
try {
|
|
558
|
+
remote = ctx.get === undefined ? ctx.remote : ctx.get("remote")
|
|
559
|
+
} catch {
|
|
560
|
+
remote = undefined
|
|
561
|
+
}
|
|
562
|
+
if (remote === undefined || typeof remote.$on !== "function") return
|
|
563
|
+
const off = remote.$on("api-session/error", onAgentError)
|
|
564
|
+
return () => {
|
|
565
|
+
if (typeof off === "function") off()
|
|
566
|
+
}
|
|
567
|
+
}, "dsh-plugin-desktop-notify: failure watch")
|
|
568
|
+
|
|
569
|
+
ctx.effect(() => () => {
|
|
570
|
+
try {
|
|
571
|
+
if (canBadge()) void navigator.clearAppBadge()
|
|
572
|
+
} catch {
|
|
573
|
+
/* nothing to clear */
|
|
574
|
+
}
|
|
575
|
+
}, "dsh-plugin-desktop-notify: badge release")
|
|
576
|
+
|
|
577
|
+
/*
|
|
578
|
+
* Diagnostic surface for DevTools (⌘⌥I):
|
|
579
|
+
* __DSH_DESKTOP_NOTIFY__.capabilities() — what this renderer supports
|
|
580
|
+
* __DSH_DESKTOP_NOTIFY__.state() — live attention + badge
|
|
581
|
+
* __DSH_DESKTOP_NOTIFY__.simulateComplete() — banner + badge, end to end
|
|
582
|
+
* __DSH_DESKTOP_NOTIFY__.reset() — undo the simulation
|
|
583
|
+
*/
|
|
584
|
+
const diagnostics = {
|
|
585
|
+
version: VERSION,
|
|
586
|
+
capabilities() {
|
|
587
|
+
return {
|
|
588
|
+
platform: (() => {
|
|
589
|
+
try {
|
|
590
|
+
return navigator.userAgentData?.platform ?? navigator.platform ?? "unknown"
|
|
591
|
+
} catch {
|
|
592
|
+
return "unknown"
|
|
593
|
+
}
|
|
594
|
+
})(),
|
|
595
|
+
notification: typeof Notification === "undefined" ? "unavailable" : Notification.permission,
|
|
596
|
+
badge: canBadge(),
|
|
597
|
+
remote: (() => {
|
|
598
|
+
try {
|
|
599
|
+
return ctx.get === undefined ? typeof ctx.remote : typeof ctx.get("remote")
|
|
600
|
+
} catch {
|
|
601
|
+
return "unavailable"
|
|
602
|
+
}
|
|
603
|
+
})(),
|
|
604
|
+
workspace: (() => {
|
|
605
|
+
try {
|
|
606
|
+
if (typeof ctx.uiWorkspace?.openSession === "function") return "available"
|
|
607
|
+
return "missing"
|
|
608
|
+
} catch (error) {
|
|
609
|
+
return `unavailable: ${String(error)}`
|
|
610
|
+
}
|
|
611
|
+
})(),
|
|
612
|
+
preferences,
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
state() {
|
|
616
|
+
return {
|
|
617
|
+
badge: lastBadge ?? 0,
|
|
618
|
+
attention: [...attention.entries()].map(([sessionId, entry]) => ({ sessionId, ...entry })),
|
|
619
|
+
tracked: [...previous.keys()],
|
|
620
|
+
lastNotify: lastNotify ?? null,
|
|
621
|
+
lastClick: lastClick ?? null,
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
simulateComplete(sessionId = "desktop-notify-simulation") {
|
|
625
|
+
const table = copy()
|
|
626
|
+
setAttention(sessionId, { completed: true })
|
|
627
|
+
notify("complete", sessionId, preferences.titleComplete || table.completeTitle, table.complete("", durationText(12000)))
|
|
628
|
+
applyBadge()
|
|
629
|
+
return this.state()
|
|
630
|
+
},
|
|
631
|
+
reset() {
|
|
632
|
+
attention.clear()
|
|
633
|
+
lastBadge = undefined
|
|
634
|
+
applyBadge()
|
|
635
|
+
return this.state()
|
|
636
|
+
},
|
|
637
|
+
testBadge(count = 3) {
|
|
638
|
+
if (!canBadge()) return "navigator.setAppBadge 不可用"
|
|
639
|
+
try {
|
|
640
|
+
void navigator.setAppBadge(count)
|
|
641
|
+
return `ok: ${count}`
|
|
642
|
+
} catch (error) {
|
|
643
|
+
return String(error)
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
testNotification() {
|
|
647
|
+
if (!canNotify()) {
|
|
648
|
+
return `Notification 不可用(${typeof Notification === "undefined" ? "undefined" : Notification.permission})`
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
const probe = new Notification("DSH 通知自检", { body: "这条来自插件自检,不是任务通知" })
|
|
652
|
+
probe.onclick = () => window.focus()
|
|
653
|
+
return "ok"
|
|
654
|
+
} catch (error) {
|
|
655
|
+
return String(error)
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
/**
|
|
659
|
+
* One-line support report: version, capabilities, live state, and what the
|
|
660
|
+
* last banner click actually did. Copied to the clipboard when possible.
|
|
661
|
+
*/
|
|
662
|
+
report() {
|
|
663
|
+
const payload = {
|
|
664
|
+
version: VERSION,
|
|
665
|
+
capabilities: this.capabilities(),
|
|
666
|
+
state: this.state(),
|
|
667
|
+
window: { focus: windowFocused(), visible: windowVisible(), visibility: (() => {
|
|
668
|
+
try {
|
|
669
|
+
return typeof document === "undefined" ? "unknown" : document.visibilityState ?? "unknown"
|
|
670
|
+
} catch {
|
|
671
|
+
return "unknown"
|
|
672
|
+
}
|
|
673
|
+
})() },
|
|
674
|
+
}
|
|
675
|
+
const text = JSON.stringify(payload, null, 2)
|
|
676
|
+
try {
|
|
677
|
+
navigator.clipboard?.writeText?.(text)?.catch?.(() => {})
|
|
678
|
+
} catch {
|
|
679
|
+
/* clipboard is optional */
|
|
680
|
+
}
|
|
681
|
+
return text
|
|
682
|
+
},
|
|
683
|
+
/** Try to open a session directly, the same way a banner click does. */
|
|
684
|
+
openSession(sessionId) {
|
|
685
|
+
const result = openSessionTarget(String(sessionId ?? ""))
|
|
686
|
+
try {
|
|
687
|
+
window.focus()
|
|
688
|
+
} catch {
|
|
689
|
+
/* focus is best effort */
|
|
690
|
+
}
|
|
691
|
+
return result
|
|
692
|
+
},
|
|
693
|
+
/** Fire the plugin's real delivery path (tag + silent), so its options can be compared. */
|
|
694
|
+
testNotifyPath() {
|
|
695
|
+
notify("probe", "desktop-notify-probe", "DSH 通知自检(插件路径)", "这条走插件真实的 notify(),带 tag/silent 参数")
|
|
696
|
+
return lastNotify ?? null
|
|
697
|
+
},
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
window.__DSH_DESKTOP_NOTIFY__ = diagnostics
|
|
701
|
+
console.info(`[desktop-notify] loaded — ${JSON.stringify(diagnostics.capabilities())}`)
|
|
702
|
+
} catch {
|
|
703
|
+
/* a locked-down window simply leaves the diagnostics unset */
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return { inject, apply }
|
|
708
|
+
},
|
|
709
|
+
})
|