dsh-plugin-desktop-notify 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -0
- package/README.md +7 -0
- package/index.js +55 -1
- package/lib/client.js +172 -3
- package/package.json +4 -1
package/README.en.md
CHANGED
|
@@ -44,6 +44,7 @@ Stored in localStorage under `dsh-plugin-desktop-notify.preferences`:
|
|
|
44
44
|
| `notifyError` | `true` | Notify when a turn fails |
|
|
45
45
|
| `notifyViewed` | `false` | Also notify for the session you are looking at |
|
|
46
46
|
| `badge` | `true` | Show the badge |
|
|
47
|
+
| `pinAttention` | `true` | pin what needs you to the top of its group (uses the app's own pin) |
|
|
47
48
|
| `excludeSubagents` | `true` | Ignore subagent sessions |
|
|
48
49
|
| `titleComplete` / `titlePending` / `titleError` | empty | Custom titles; empty keeps the built-in copy |
|
|
49
50
|
| `maxBodyChars` | `140` | Body length limit |
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
- **等待你操作**——工具审批、提问、执行计划确认
|
|
17
17
|
- **任务失败**——这一轮出错时提示
|
|
18
18
|
- **说清楚怎么结束的**——正常完成、被拦截、输出到上限、被中止,各自一句话,不把中断说成「已完成」
|
|
19
|
+
- **需要你的排在前面**——运行中、待你审批/回答、出错、未读完成的会话自动置顶到所属分组第一条,处理完自动放开
|
|
19
20
|
- **小红点**——应用图标上显示「需要你的会话数」,你看过之后才清零
|
|
20
21
|
- **点击通知**——切到对应会话,而不是只把应用叫到前面
|
|
21
22
|
|
|
@@ -45,6 +46,7 @@ dsh-plugin-desktop-notify
|
|
|
45
46
|
| `notifyViewed` | `false` | 正在看的会话也通知(默认跳过) |
|
|
46
47
|
| `badge` | `true` | 显示小红点 |
|
|
47
48
|
| `excludeSubagents` | `true` | 忽略子智能体会话 |
|
|
49
|
+
| `pinAttention` | `true` | 把需要你处理的会话自动置顶到所属分组第一条(用 DSH 自带置顶,处理完自动取消) |
|
|
48
50
|
| `titleComplete` / `titlePending` / `titleError` | 空 | 自定义标题,空 = 用内置文案 |
|
|
49
51
|
| `maxBodyChars` | `140` | 正文长度上限 |
|
|
50
52
|
|
|
@@ -60,6 +62,7 @@ dsh-plugin-desktop-notify
|
|
|
60
62
|
__DSH_DESKTOP_NOTIFY__.capabilities() // 平台、通知权限、小红点、服务可达性
|
|
61
63
|
__DSH_DESKTOP_NOTIFY__.state() // 小红点、待处理会话、最近一次通知/点击
|
|
62
64
|
__DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于排查
|
|
65
|
+
__DSH_DESKTOP_NOTIFY__.pinState() // 插件置顶了哪些、你自己又置顶了哪些
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
|
|
@@ -72,6 +75,7 @@ __DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于
|
|
|
72
75
|
| `POST` | `/desktop-notify/request` | 发一条通知,body:`{"title": "...", "body": "...", "sessionId": "可选"}` |
|
|
73
76
|
| `GET` | `/desktop-notify/pending` | 渲染层取走待显示的通知(脚本不用调) |
|
|
74
77
|
| `GET` | `/desktop-notify/turn?sessionId=...` | 渲染层确认这一轮是怎么结束的(脚本不用调) |
|
|
78
|
+
| `GET`/`POST` | `/desktop-notify/pins` | 渲染层读写「插件自己置顶了哪些会话」(脚本不用调) |
|
|
75
79
|
|
|
76
80
|
地址写在 `~/.dsh/desktop-notify.endpoint`,所以调用方不用猜端口:
|
|
77
81
|
|
|
@@ -107,6 +111,9 @@ urllib.request.urlopen(request, timeout=3).read()
|
|
|
107
111
|
## 已知限制
|
|
108
112
|
|
|
109
113
|
- 重启或刷新窗口时,已经存在的「未读 / 待审批」只恢复小红点,不再补弹横幅
|
|
114
|
+
- 自动置顶用的是 DSH 自己的置顶,所以会话行上会出现置顶标记,右键里也有「取消置顶」。
|
|
115
|
+
插件只取消**自己**置顶的会话,你手动置顶的一律不动;想一次清干净可在控制台跑
|
|
116
|
+
`__DSH_DESKTOP_NOTIFY__.clearPins()`
|
|
110
117
|
|
|
111
118
|
- **Windows**:点通知会切到对应会话,但窗口不一定到前台——`AppUserModelID` 和主进程 focus 通道属于桌面端,渲染层插件做不到
|
|
112
119
|
- **通知归属**:系统通知中心里显示为 DeepSeek Harness(用的是渲染层原生通知)
|
package/index.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { execFile, spawn } from "node:child_process"
|
|
18
18
|
import { randomBytes } from "node:crypto"
|
|
19
|
+
import { readFileSync } from "node:fs"
|
|
19
20
|
import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"
|
|
20
21
|
import { homedir } from "node:os"
|
|
21
22
|
import { basename, dirname, join } from "node:path"
|
|
@@ -47,6 +48,8 @@ const DEFAULTS = {
|
|
|
47
48
|
api: true,
|
|
48
49
|
/** Where the endpoint URL is written for those tools. */
|
|
49
50
|
endpointPath: "~/.dsh/desktop-notify.endpoint",
|
|
51
|
+
/** Where the sessions this plugin pinned are remembered across window lives. */
|
|
52
|
+
pinsPath: "~/.dsh/desktop-notify-pins.json",
|
|
50
53
|
/** Longest accepted request body. */
|
|
51
54
|
apiBodyLimit: 8192,
|
|
52
55
|
/** How many unread requests the interface keeps. */
|
|
@@ -65,6 +68,7 @@ const TURN_END_BODIES = {
|
|
|
65
68
|
const API_REQUEST_PATH = "/desktop-notify/request"
|
|
66
69
|
const API_PENDING_PATH = "/desktop-notify/pending"
|
|
67
70
|
const API_TURN_PATH = "/desktop-notify/turn"
|
|
71
|
+
const API_PINS_PATH = "/desktop-notify/pins"
|
|
68
72
|
|
|
69
73
|
/** Read one JSON request body, bounded by `limit` bytes. */
|
|
70
74
|
function readBody(request, limit) {
|
|
@@ -237,6 +241,37 @@ function mountInterface(ctx, config, state) {
|
|
|
237
241
|
answer(response, 200, entry === undefined ? {} : { kind: entry.kind, at: entry.at })
|
|
238
242
|
}
|
|
239
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Remember which sessions this plugin pinned. The renderer's origin (and so
|
|
246
|
+
* its localStorage) changes with every launch, while the sidebar pins live
|
|
247
|
+
* on the Host; this file is what lets a restart release the pins it owns
|
|
248
|
+
* instead of mistaking them for the user's own.
|
|
249
|
+
*/
|
|
250
|
+
const pins = (request, response) => {
|
|
251
|
+
if (rejected(request, response, false)) return
|
|
252
|
+
if (request.method === "GET") {
|
|
253
|
+
answer(response, 200, { ids: [...state.pinnedByPlugin] })
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
if (request.method !== "POST") {
|
|
257
|
+
answer(response, 405, { ok: false, error: "use GET or POST" })
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
void readBody(request, Math.max(1024, config.apiBodyLimit))
|
|
261
|
+
.then((payload) => {
|
|
262
|
+
const ids = Array.isArray(payload?.ids) ? payload.ids.filter((id) => typeof id === "string" && id !== "") : []
|
|
263
|
+
state.pinnedByPlugin = new Set(ids.slice(0, Math.max(1, config.maxTrackedSessions)))
|
|
264
|
+
const path = expandHome(config.pinsPath)
|
|
265
|
+
if (path !== "") {
|
|
266
|
+
void mkdir(dirname(path), { recursive: true })
|
|
267
|
+
.then(() => writeFile(path, `${JSON.stringify({ ids: [...state.pinnedByPlugin] })}\n`, "utf8"))
|
|
268
|
+
.catch(() => {})
|
|
269
|
+
}
|
|
270
|
+
answer(response, 200, { ok: true, tracked: state.pinnedByPlugin.size })
|
|
271
|
+
})
|
|
272
|
+
.catch((error) => answer(response, 400, { ok: false, error: String(error?.message ?? error) }))
|
|
273
|
+
}
|
|
274
|
+
|
|
240
275
|
let disposers
|
|
241
276
|
try {
|
|
242
277
|
disposers = [
|
|
@@ -257,6 +292,13 @@ function mountInterface(ctx, config, state) {
|
|
|
257
292
|
answer(response, 500, {})
|
|
258
293
|
}
|
|
259
294
|
} }),
|
|
295
|
+
server.register({ kind: "exact", path: API_PINS_PATH, handler: (request, response) => {
|
|
296
|
+
try {
|
|
297
|
+
pins(request, response)
|
|
298
|
+
} catch {
|
|
299
|
+
answer(response, 500, { ok: false })
|
|
300
|
+
}
|
|
301
|
+
} }),
|
|
260
302
|
]
|
|
261
303
|
} catch (error) {
|
|
262
304
|
void noteToLog(config.logPath, `interface: cannot register routes: ${String(error)}`)
|
|
@@ -407,7 +449,19 @@ function runDetached(command, args) {
|
|
|
407
449
|
export function apply(ctx, rawConfig) {
|
|
408
450
|
const config = resolveConfig(rawConfig)
|
|
409
451
|
/** The newest turn-end kind per session, read by the renderer over the local interface. */
|
|
410
|
-
const state = { lastTurnEnds: new Map() }
|
|
452
|
+
const state = { lastTurnEnds: new Map(), pinnedByPlugin: new Set() }
|
|
453
|
+
// Seeded synchronously: the renderer asks for it as soon as it applies.
|
|
454
|
+
try {
|
|
455
|
+
const pinsPath = expandHome(config.pinsPath)
|
|
456
|
+
if (pinsPath !== "") {
|
|
457
|
+
const remembered = JSON.parse(readFileSync(pinsPath, "utf8"))
|
|
458
|
+
if (Array.isArray(remembered?.ids)) {
|
|
459
|
+
for (const id of remembered.ids) if (typeof id === "string" && id !== "") state.pinnedByPlugin.add(id)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
} catch {
|
|
463
|
+
/* no remembered pins yet */
|
|
464
|
+
}
|
|
411
465
|
mountInterface(ctx, config, state)
|
|
412
466
|
|
|
413
467
|
const logPath = expandHome(config.logPath)
|
package/lib/client.js
CHANGED
|
@@ -30,8 +30,10 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
factory: (require) => {
|
|
31
31
|
const NS = "dsh-plugin-desktop-notify"
|
|
32
32
|
/** Reported by capabilities(); keep in step with package.json (cross-machine support). */
|
|
33
|
-
const VERSION = "0.6.
|
|
33
|
+
const VERSION = "0.6.3"
|
|
34
34
|
const PREFERENCES_KEY = `${NS}:preferences`
|
|
35
|
+
/** Sessions this plugin pinned itself; a reload reads it back so it never drops a user's own pin. */
|
|
36
|
+
const PINNED_KEY = `${NS}:pinned`
|
|
35
37
|
|
|
36
38
|
/** Defaults; override per field with the localStorage JSON described in README. */
|
|
37
39
|
const DEFAULTS = {
|
|
@@ -42,6 +44,8 @@ window.__ModuleLoader__.load({
|
|
|
42
44
|
notifyViewed: false,
|
|
43
45
|
badge: true,
|
|
44
46
|
excludeSubagents: true,
|
|
47
|
+
// Keep the sessions that need you at the top of their sidebar group.
|
|
48
|
+
pinAttention: true,
|
|
45
49
|
// Empty means "use the built-in copy for the active UI language".
|
|
46
50
|
titleComplete: "",
|
|
47
51
|
titlePending: "",
|
|
@@ -141,6 +145,8 @@ window.__ModuleLoader__.load({
|
|
|
141
145
|
*/
|
|
142
146
|
const TURN_KIND_PATH = "/desktop-notify/turn"
|
|
143
147
|
const TURN_LOOKUP_TIMEOUT_MS = 800
|
|
148
|
+
/** Where the Host remembers the sessions this plugin pinned. */
|
|
149
|
+
const PINS_PATH = "/desktop-notify/pins"
|
|
144
150
|
|
|
145
151
|
/** Ask the Host how one turn ended; undefined when it cannot answer in time. */
|
|
146
152
|
function lookupTurnKind(sessionId) {
|
|
@@ -192,6 +198,18 @@ window.__ModuleLoader__.load({
|
|
|
192
198
|
return preferences
|
|
193
199
|
}
|
|
194
200
|
|
|
201
|
+
/** The session ids this plugin pinned in an earlier window life. */
|
|
202
|
+
function readPinnedIds() {
|
|
203
|
+
try {
|
|
204
|
+
const raw = storage()?.getItem(PINNED_KEY)
|
|
205
|
+
if (typeof raw !== "string" || raw === "") return []
|
|
206
|
+
const parsed = JSON.parse(raw)
|
|
207
|
+
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : []
|
|
208
|
+
} catch {
|
|
209
|
+
return []
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
195
213
|
/** Collapse whitespace and cut to the body budget. */
|
|
196
214
|
function bodyText(text, maxChars) {
|
|
197
215
|
const collapsed = String(text).replace(/\s+/gu, " ").trim()
|
|
@@ -310,6 +328,128 @@ window.__ModuleLoader__.load({
|
|
|
310
328
|
/** sessionId -> ms timestamp when the current turn was first seen running. */
|
|
311
329
|
const turnStartedAt = new Map()
|
|
312
330
|
let lastBadge
|
|
331
|
+
/** Sessions this plugin pinned itself, so unpinning never touches the user's own pins. */
|
|
332
|
+
const selfPinned = new Set(readPinnedIds())
|
|
333
|
+
/** Whether the Host has answered with its remembered pins; nothing is pinned before that. */
|
|
334
|
+
let pinsReady = false
|
|
335
|
+
/** The most recent wanted set, so hydration can reconcile the moment it lands. */
|
|
336
|
+
let lastWanted = new Set()
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Write the pinned-by-this-plugin set back. localStorage covers a reload of
|
|
340
|
+
* the same origin; the Host file covers a new launch, whose URL (and so
|
|
341
|
+
* this origin) has changed and taken localStorage with it.
|
|
342
|
+
*/
|
|
343
|
+
function persistPins() {
|
|
344
|
+
try {
|
|
345
|
+
storage()?.setItem(PINNED_KEY, JSON.stringify([...selfPinned]))
|
|
346
|
+
} catch {
|
|
347
|
+
/* a storage-less renderer keeps the set for this life only */
|
|
348
|
+
}
|
|
349
|
+
if (typeof fetch !== "function") return
|
|
350
|
+
try {
|
|
351
|
+
void fetch(PINS_PATH, {
|
|
352
|
+
method: "POST",
|
|
353
|
+
headers: { "content-type": "application/json" },
|
|
354
|
+
body: JSON.stringify({ ids: [...selfPinned] }),
|
|
355
|
+
}).catch(() => {})
|
|
356
|
+
} catch {
|
|
357
|
+
/* the local interface may not be mounted */
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Read back the pins an earlier launch left behind. Until this answers the
|
|
363
|
+
* plugin pins nothing: acting first would make its own pins look like the
|
|
364
|
+
* user's and leave them in the sidebar forever.
|
|
365
|
+
*/
|
|
366
|
+
async function hydratePins() {
|
|
367
|
+
if (typeof fetch !== "function") {
|
|
368
|
+
pinsReady = true
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const response = await fetch(PINS_PATH, { headers: { accept: "application/json" }, cache: "no-store" })
|
|
373
|
+
if (response.ok) {
|
|
374
|
+
const payload = await response.json()
|
|
375
|
+
if (Array.isArray(payload?.ids)) {
|
|
376
|
+
for (const sessionId of payload.ids) if (typeof sessionId === "string" && sessionId !== "") selfPinned.add(sessionId)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
/* no Host half: this life reconciles from localStorage alone */
|
|
381
|
+
}
|
|
382
|
+
pinsReady = true
|
|
383
|
+
reconcilePins(lastWanted)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The sidebar's pinned snapshot, or undefined on a build without the workspace service. */
|
|
387
|
+
function pinnedSnapshot() {
|
|
388
|
+
try {
|
|
389
|
+
return ctx.uiWorkspace?.workspaces?.list?.getSnapshot?.()
|
|
390
|
+
} catch {
|
|
391
|
+
return undefined
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Pin or unpin one session, best effort: a refused call must not disturb notifications. */
|
|
396
|
+
function callPin(sessionId, pin) {
|
|
397
|
+
const service = ctx.uiWorkspace
|
|
398
|
+
const method = pin ? "pinSession" : "unpinSession"
|
|
399
|
+
if (typeof service?.[method] !== "function") return
|
|
400
|
+
try {
|
|
401
|
+
const pending = service[method](sessionId)
|
|
402
|
+
if (pending !== null && typeof pending === "object" && typeof pending.catch === "function") pending.catch(() => {})
|
|
403
|
+
} catch {
|
|
404
|
+
/* the view keeps its order */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Keep what needs you at the top of its group. The sidebar's own pin seats
|
|
410
|
+
* a session ahead of the rest of the group, so this pins exactly the ones
|
|
411
|
+
* the plugin would notify about and unpins each once that reason is gone —
|
|
412
|
+
* never a session the user pinned by hand, and never one this plugin did
|
|
413
|
+
* not pin in the first place.
|
|
414
|
+
*/
|
|
415
|
+
function reconcilePins(wanted) {
|
|
416
|
+
lastWanted = wanted
|
|
417
|
+
if (!pinsReady) return
|
|
418
|
+
let changed = false
|
|
419
|
+
if (!preferences.pinAttention) {
|
|
420
|
+
for (const sessionId of [...selfPinned]) {
|
|
421
|
+
selfPinned.delete(sessionId)
|
|
422
|
+
callPin(sessionId, false)
|
|
423
|
+
changed = true
|
|
424
|
+
}
|
|
425
|
+
if (changed) persistPins()
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
const snapshot = pinnedSnapshot()
|
|
429
|
+
if (snapshot === undefined) return
|
|
430
|
+
const pinned = new Set(Array.isArray(snapshot.pinnedSessionIds) ? snapshot.pinnedSessionIds : [])
|
|
431
|
+
// An archived session is out of the tree anyway, and pinning it is not
|
|
432
|
+
// this plugin's business.
|
|
433
|
+
const archived = new Set(Array.isArray(snapshot.archivedSessionIds) ? snapshot.archivedSessionIds : [])
|
|
434
|
+
for (const sessionId of wanted) {
|
|
435
|
+
if (archived.has(sessionId)) continue
|
|
436
|
+
if (selfPinned.has(sessionId) || pinned.has(sessionId)) continue
|
|
437
|
+
selfPinned.add(sessionId)
|
|
438
|
+
changed = true
|
|
439
|
+
callPin(sessionId, true)
|
|
440
|
+
}
|
|
441
|
+
for (const sessionId of [...selfPinned]) {
|
|
442
|
+
if (wanted.has(sessionId) && !archived.has(sessionId)) continue
|
|
443
|
+
selfPinned.delete(sessionId)
|
|
444
|
+
changed = true
|
|
445
|
+
callPin(sessionId, false)
|
|
446
|
+
}
|
|
447
|
+
if (changed) persistPins()
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Ask the Host which pins an earlier launch left; nothing is pinned until it answers.
|
|
451
|
+
void hydratePins()
|
|
452
|
+
|
|
313
453
|
/** The last banner attempt and its outcome — Windows has several ways to swallow it. */
|
|
314
454
|
let lastNotify
|
|
315
455
|
/** The last banner click and what it managed to do — Windows/macOS differ here. */
|
|
@@ -551,6 +691,8 @@ window.__ModuleLoader__.load({
|
|
|
551
691
|
const statuses = ctx.uiSession.sessionStatus.getSnapshot()
|
|
552
692
|
const summaries = ctx.sessions.list.getSnapshot().byId ?? {}
|
|
553
693
|
const seen = new Set()
|
|
694
|
+
/** Sessions that should sit at the top of their group. */
|
|
695
|
+
const wanted = new Set()
|
|
554
696
|
|
|
555
697
|
for (const [sessionId, status] of statuses) {
|
|
556
698
|
const summary = summaries[sessionId]
|
|
@@ -592,8 +734,8 @@ window.__ModuleLoader__.load({
|
|
|
592
734
|
turnStartedAt.delete(sessionId)
|
|
593
735
|
const duration = started === undefined ? "" : durationText(Date.now() - started, languageOf())
|
|
594
736
|
// Watching it finish is only worth a banner when the user opted in.
|
|
595
|
-
const
|
|
596
|
-
if (
|
|
737
|
+
const shouldNotify = preferences.notifyComplete && (looking ? preferences.notifyViewed : true)
|
|
738
|
+
if (shouldNotify) deliverCompletion(sessionId, table, name, duration)
|
|
597
739
|
}
|
|
598
740
|
|
|
599
741
|
if (looking) {
|
|
@@ -620,6 +762,13 @@ window.__ModuleLoader__.load({
|
|
|
620
762
|
})
|
|
621
763
|
}
|
|
622
764
|
|
|
765
|
+
// The pin follows the same facts the banner and badge use, plus a
|
|
766
|
+
// running turn: anything that would make you look belongs on top.
|
|
767
|
+
const current = attention.get(sessionId) ?? {}
|
|
768
|
+
if (!looking && (running || current.completed === true || current.pendingKind !== undefined || current.error !== undefined)) {
|
|
769
|
+
wanted.add(sessionId)
|
|
770
|
+
}
|
|
771
|
+
|
|
623
772
|
previous.set(sessionId, { running, completed, pendingKind })
|
|
624
773
|
}
|
|
625
774
|
|
|
@@ -628,6 +777,7 @@ window.__ModuleLoader__.load({
|
|
|
628
777
|
previous.delete(sessionId)
|
|
629
778
|
turnStartedAt.delete(sessionId)
|
|
630
779
|
}
|
|
780
|
+
reconcilePins(wanted)
|
|
631
781
|
applyBadge()
|
|
632
782
|
}
|
|
633
783
|
|
|
@@ -762,10 +912,29 @@ window.__ModuleLoader__.load({
|
|
|
762
912
|
badge: lastBadge ?? 0,
|
|
763
913
|
attention: [...attention.entries()].map(([sessionId, entry]) => ({ sessionId, ...entry })),
|
|
764
914
|
tracked: [...previous.keys()],
|
|
915
|
+
pins: [...selfPinned],
|
|
765
916
|
lastNotify: lastNotify ?? null,
|
|
766
917
|
lastClick: lastClick ?? null,
|
|
767
918
|
}
|
|
768
919
|
},
|
|
920
|
+
/** What this plugin pinned, next to what the sidebar currently shows. */
|
|
921
|
+
pinState() {
|
|
922
|
+
const snapshot = pinnedSnapshot()
|
|
923
|
+
return {
|
|
924
|
+
enabled: preferences.pinAttention,
|
|
925
|
+
pinnedByPlugin: [...selfPinned],
|
|
926
|
+
pinnedByUser: Array.isArray(snapshot?.pinnedSessionIds) ? snapshot.pinnedSessionIds.filter((id) => !selfPinned.has(id)) : null,
|
|
927
|
+
}
|
|
928
|
+
},
|
|
929
|
+
/** Release every pin this plugin owns — leaves manual pins alone. */
|
|
930
|
+
clearPins() {
|
|
931
|
+
for (const sessionId of [...selfPinned]) {
|
|
932
|
+
selfPinned.delete(sessionId)
|
|
933
|
+
callPin(sessionId, false)
|
|
934
|
+
}
|
|
935
|
+
persistPins()
|
|
936
|
+
return this.pinState()
|
|
937
|
+
},
|
|
769
938
|
simulateComplete(sessionId = "desktop-notify-simulation") {
|
|
770
939
|
const table = copy(languageOf())
|
|
771
940
|
setAttention(sessionId, { completed: true })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-desktop-notify",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Desktop notifications and a Dock/taskbar badge for DeepSeek Harness: a finished turn, a pending approval or question, and a failure, with click-through back to the session",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,5 +58,8 @@
|
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
60
|
"test": "node test/client.test.mjs && node test/host.test.mjs /tmp/dsh-notify-host-test.log"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
61
64
|
}
|
|
62
65
|
}
|