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 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.2"
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 wanted = preferences.notifyComplete && (looking ? preferences.notifyViewed : true)
596
- if (wanted) deliverCompletion(sessionId, table, name, duration)
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.2",
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
  }