dsh-plugin-desktop-notify 0.6.2 → 0.6.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/README.en.md +1 -0
- package/README.md +7 -0
- package/lib/client.js +122 -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
|
+
| `orderAttention` | `true` | seat what needs you below your pins (writes the view's order, never the pin state) |
|
|
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
|
+
| `orderAttention` | `true` | 把需要你处理的会话排到手动置顶下面第一条(不改置顶状态;关掉后完全不碰排序) |
|
|
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__.orderState() // 当前被排到前面的是哪几条、按什么顺序
|
|
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
|
+
- 这个排序是插件接管「手动排序」实现的:插件只写「需要你」那几条的顺序,其余会话仍然按最近更新往后排,
|
|
115
|
+
所以视觉上和「最近更新」一样,只是多了一条需要你的带状区域。代价是侧栏的排序方式会显示成「手动」,
|
|
116
|
+
而且你把会话拖动排序后,下一次有会话需要你时会被重排覆盖。不想要就 `orderAttention: false`
|
|
110
117
|
|
|
111
118
|
- **Windows**:点通知会切到对应会话,但窗口不一定到前台——`AppUserModelID` 和主进程 focus 通道属于桌面端,渲染层插件做不到
|
|
112
119
|
- **通知归属**:系统通知中心里显示为 DeepSeek Harness(用的是渲染层原生通知)
|
package/lib/client.js
CHANGED
|
@@ -30,7 +30,7 @@ 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.4"
|
|
34
34
|
const PREFERENCES_KEY = `${NS}:preferences`
|
|
35
35
|
|
|
36
36
|
/** Defaults; override per field with the localStorage JSON described in README. */
|
|
@@ -42,6 +42,8 @@ window.__ModuleLoader__.load({
|
|
|
42
42
|
notifyViewed: false,
|
|
43
43
|
badge: true,
|
|
44
44
|
excludeSubagents: true,
|
|
45
|
+
// Seat what needs you directly below the manual pins in its group.
|
|
46
|
+
orderAttention: true,
|
|
45
47
|
// Empty means "use the built-in copy for the active UI language".
|
|
46
48
|
titleComplete: "",
|
|
47
49
|
titlePending: "",
|
|
@@ -141,6 +143,9 @@ window.__ModuleLoader__.load({
|
|
|
141
143
|
*/
|
|
142
144
|
const TURN_KIND_PATH = "/desktop-notify/turn"
|
|
143
145
|
const TURN_LOOKUP_TIMEOUT_MS = 800
|
|
146
|
+
/** The sidebar's flat-list order key, and how long a tier change waits before it is written. */
|
|
147
|
+
const FLAT_ORDER_KEY = "__flat_session_order__"
|
|
148
|
+
const ORDER_SETTLE_MS = 600
|
|
144
149
|
|
|
145
150
|
/** Ask the Host how one turn ended; undefined when it cannot answer in time. */
|
|
146
151
|
function lookupTurnKind(sessionId) {
|
|
@@ -310,6 +315,95 @@ window.__ModuleLoader__.load({
|
|
|
310
315
|
/** sessionId -> ms timestamp when the current turn was first seen running. */
|
|
311
316
|
const turnStartedAt = new Map()
|
|
312
317
|
let lastBadge
|
|
318
|
+
/**
|
|
319
|
+
* The sessions that need you, in the order they started needing you.
|
|
320
|
+
* FIFO on purpose: a new arrival joins the bottom of the tier instead of
|
|
321
|
+
* displacing whoever was already in it, so a few running turns can never
|
|
322
|
+
* shuffle the sidebar under the cursor.
|
|
323
|
+
*/
|
|
324
|
+
let tier = []
|
|
325
|
+
/** One coalesced write per settled tier, never one per status tick. */
|
|
326
|
+
let tierTimer
|
|
327
|
+
/** Whether an order was ever written, so an empty tier can clear it once and then stay quiet. */
|
|
328
|
+
let hasWrittenOrder = false
|
|
329
|
+
/** The last order written per account key; an unchanged tier touches nothing. */
|
|
330
|
+
const lastOrderByKey = new Map()
|
|
331
|
+
|
|
332
|
+
/** The sidebar's workspace snapshot, or undefined on a build without the service. */
|
|
333
|
+
function workspaceSnapshot() {
|
|
334
|
+
try {
|
|
335
|
+
return ctx.uiWorkspace?.workspaces?.list?.getSnapshot?.()
|
|
336
|
+
} catch {
|
|
337
|
+
return undefined
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** True when two id arrays are identical, so a no-op sync never reorders anything. */
|
|
342
|
+
function sameOrder(left, right) {
|
|
343
|
+
if (left.length !== right.length) return false
|
|
344
|
+
for (let index = 0; index < left.length; index += 1) if (left[index] !== right[index]) return false
|
|
345
|
+
return true
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Recompute the tier; only a membership change schedules a write. */
|
|
349
|
+
function updateTier(wanted) {
|
|
350
|
+
const next = tier.filter((sessionId) => wanted.has(sessionId))
|
|
351
|
+
for (const sessionId of wanted) if (!next.includes(sessionId)) next.push(sessionId)
|
|
352
|
+
if (sameOrder(next, tier)) return
|
|
353
|
+
tier = next
|
|
354
|
+
scheduleOrderWrite()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Coalesce a burst of status ticks into one write. */
|
|
358
|
+
function scheduleOrderWrite() {
|
|
359
|
+
if (tierTimer !== undefined) return
|
|
360
|
+
tierTimer = setTimeout(() => {
|
|
361
|
+
tierTimer = undefined
|
|
362
|
+
writeOrder()
|
|
363
|
+
}, ORDER_SETTLE_MS)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Seat the tier directly below the manual pins in every group. Only the
|
|
368
|
+
* tier ids are written, so everything else keeps falling back to recency:
|
|
369
|
+
* the result reads like the "most recent" sort with a needs-you band.
|
|
370
|
+
*/
|
|
371
|
+
function writeOrder() {
|
|
372
|
+
const view = ctx.uiWorkspace?.view
|
|
373
|
+
if (typeof view?.setSessionOrder !== "function") return
|
|
374
|
+
const snapshot = workspaceSnapshot()
|
|
375
|
+
const list = ctx.sessions.list.getSnapshot()
|
|
376
|
+
if (snapshot === undefined || list?.phase !== "ready") return
|
|
377
|
+
const archived = new Set(Array.isArray(snapshot.archivedSessionIds) ? snapshot.archivedSessionIds : [])
|
|
378
|
+
const live = tier.filter((sessionId) => !archived.has(sessionId))
|
|
379
|
+
const active = preferences.orderAttention && live.length > 0
|
|
380
|
+
if (!active && !hasWrittenOrder) return
|
|
381
|
+
const accounted = new Set()
|
|
382
|
+
const groups = []
|
|
383
|
+
for (const item of Array.isArray(snapshot.items) ? snapshot.items : []) {
|
|
384
|
+
const memberIds = Array.isArray(item?.sessionIds) ? item.sessionIds : []
|
|
385
|
+
for (const sessionId of memberIds) accounted.add(sessionId)
|
|
386
|
+
groups.push([item?.workspaceId ?? "", memberIds])
|
|
387
|
+
}
|
|
388
|
+
const ids = Array.isArray(list.ids) ? list.ids : []
|
|
389
|
+
const known = (sessionId) => list.byId?.[sessionId] !== undefined
|
|
390
|
+
groups.push(["", ids.filter((sessionId) => known(sessionId) && !accounted.has(sessionId))])
|
|
391
|
+
groups.push([FLAT_ORDER_KEY, ids.filter(known)])
|
|
392
|
+
for (const [key, memberIds] of groups) {
|
|
393
|
+
const members = new Set(memberIds)
|
|
394
|
+
const order = active ? live.filter((sessionId) => members.has(sessionId)) : []
|
|
395
|
+
const signature = order.join("\n")
|
|
396
|
+
if (lastOrderByKey.get(key) === signature) continue
|
|
397
|
+
try {
|
|
398
|
+
view.setSessionOrder(key, order, {})
|
|
399
|
+
lastOrderByKey.set(key, signature)
|
|
400
|
+
} catch {
|
|
401
|
+
/* a view store that refuses the write keeps its own order */
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
hasWrittenOrder = active
|
|
405
|
+
}
|
|
406
|
+
|
|
313
407
|
/** The last banner attempt and its outcome — Windows has several ways to swallow it. */
|
|
314
408
|
let lastNotify
|
|
315
409
|
/** The last banner click and what it managed to do — Windows/macOS differ here. */
|
|
@@ -551,6 +645,8 @@ window.__ModuleLoader__.load({
|
|
|
551
645
|
const statuses = ctx.uiSession.sessionStatus.getSnapshot()
|
|
552
646
|
const summaries = ctx.sessions.list.getSnapshot().byId ?? {}
|
|
553
647
|
const seen = new Set()
|
|
648
|
+
/** Sessions that should sit at the top of their group. */
|
|
649
|
+
const wanted = new Set()
|
|
554
650
|
|
|
555
651
|
for (const [sessionId, status] of statuses) {
|
|
556
652
|
const summary = summaries[sessionId]
|
|
@@ -592,8 +688,8 @@ window.__ModuleLoader__.load({
|
|
|
592
688
|
turnStartedAt.delete(sessionId)
|
|
593
689
|
const duration = started === undefined ? "" : durationText(Date.now() - started, languageOf())
|
|
594
690
|
// Watching it finish is only worth a banner when the user opted in.
|
|
595
|
-
const
|
|
596
|
-
if (
|
|
691
|
+
const shouldNotify = preferences.notifyComplete && (looking ? preferences.notifyViewed : true)
|
|
692
|
+
if (shouldNotify) deliverCompletion(sessionId, table, name, duration)
|
|
597
693
|
}
|
|
598
694
|
|
|
599
695
|
if (looking) {
|
|
@@ -620,6 +716,13 @@ window.__ModuleLoader__.load({
|
|
|
620
716
|
})
|
|
621
717
|
}
|
|
622
718
|
|
|
719
|
+
// The tier follows the same facts the banner and badge use, plus a
|
|
720
|
+
// running turn: anything that would make you look belongs on top.
|
|
721
|
+
const current = attention.get(sessionId) ?? {}
|
|
722
|
+
if (!looking && (running || current.completed === true || current.pendingKind !== undefined || current.error !== undefined)) {
|
|
723
|
+
wanted.add(sessionId)
|
|
724
|
+
}
|
|
725
|
+
|
|
623
726
|
previous.set(sessionId, { running, completed, pendingKind })
|
|
624
727
|
}
|
|
625
728
|
|
|
@@ -628,6 +731,7 @@ window.__ModuleLoader__.load({
|
|
|
628
731
|
previous.delete(sessionId)
|
|
629
732
|
turnStartedAt.delete(sessionId)
|
|
630
733
|
}
|
|
734
|
+
updateTier(wanted)
|
|
631
735
|
applyBadge()
|
|
632
736
|
}
|
|
633
737
|
|
|
@@ -762,10 +866,25 @@ window.__ModuleLoader__.load({
|
|
|
762
866
|
badge: lastBadge ?? 0,
|
|
763
867
|
attention: [...attention.entries()].map(([sessionId, entry]) => ({ sessionId, ...entry })),
|
|
764
868
|
tracked: [...previous.keys()],
|
|
869
|
+
order: [...tier],
|
|
765
870
|
lastNotify: lastNotify ?? null,
|
|
766
871
|
lastClick: lastClick ?? null,
|
|
767
872
|
}
|
|
768
873
|
},
|
|
874
|
+
/** Which sessions the plugin seats under the pins, and in what order. */
|
|
875
|
+
orderState() {
|
|
876
|
+
return {
|
|
877
|
+
enabled: preferences.orderAttention,
|
|
878
|
+
tier: [...tier],
|
|
879
|
+
written: Object.fromEntries([...lastOrderByKey].map(([key, value]) => [key, value === "" ? 0 : value.split("\n").length])),
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
/** Drop the whole tier; the next settle clears it from the sidebar. */
|
|
883
|
+
clearOrder() {
|
|
884
|
+
tier = []
|
|
885
|
+
scheduleOrderWrite()
|
|
886
|
+
return this.orderState()
|
|
887
|
+
},
|
|
769
888
|
simulateComplete(sessionId = "desktop-notify-simulation") {
|
|
770
889
|
const table = copy(languageOf())
|
|
771
890
|
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.4",
|
|
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
|
}
|