dsh-plugin-desktop-notify 0.6.1 → 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
@@ -2,6 +2,14 @@
2
2
 
3
3
  Desktop notifications and an app-icon badge for DSH: a finished turn, a pending approval or question, and a failure, with click-through back to that session.
4
4
 
5
+ [![npm](https://img.shields.io/npm/v/dsh-plugin-desktop-notify)](https://www.npmjs.com/package/dsh-plugin-desktop-notify)
6
+ [![license](https://img.shields.io/npm/l/dsh-plugin-desktop-notify)](LICENSE)
7
+ [![DeepSeek Harness plugin](https://img.shields.io/badge/DSH-plugin-4D6BFE)](https://github.com/chendaleiQ/dsh-plugin-desktop-notify)
8
+
9
+ ![The notification banner that appears when a turn finishes (real screen recording)](https://raw.githubusercontent.com/chendaleiQ/dsh-plugin-desktop-notify/main/assets/demo.gif)
10
+
11
+ *(A real screen recording, cropped to the notification area.)*
12
+
5
13
  ## What it does
6
14
 
7
15
  - **Turn finished** — including while the window is backgrounded or another app is in front
@@ -36,6 +44,7 @@ Stored in localStorage under `dsh-plugin-desktop-notify.preferences`:
36
44
  | `notifyError` | `true` | Notify when a turn fails |
37
45
  | `notifyViewed` | `false` | Also notify for the session you are looking at |
38
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) |
39
48
  | `excludeSubagents` | `true` | Ignore subagent sessions |
40
49
  | `titleComplete` / `titlePending` / `titleError` | empty | Custom titles; empty keeps the built-in copy |
41
50
  | `maxBodyChars` | `140` | Body length limit |
@@ -64,6 +73,7 @@ jobs:
64
73
  | Method | Path | Purpose |
65
74
  | --- | --- | --- |
66
75
  | `POST` | `/desktop-notify/request` | Post one notification: `{"title": "...", "body": "...", "sessionId": "optional"}` |
76
+ | `GET` | `/desktop-notify/turn?sessionId=...` | the renderer asks how the last turn ended (callers do not need it) |
67
77
  | `GET` | `/desktop-notify/pending` | The renderer drains what is waiting (scripts do not call it) |
68
78
 
69
79
  The address is written to `~/.dsh/desktop-notify.endpoint`, so callers never guess
@@ -94,7 +104,12 @@ Notes:
94
104
  - The banner is posted by **DSH itself** (attributed to DeepSeek Harness), not by
95
105
  a script host
96
106
  - Clicking it raises the window; with `sessionId` it also switches to that session
97
- - The interface binds loopback only and needs no login; `enabled: false` switches
107
+ - The endpoint URL carries a random token; post to the whole URL as printed, and
108
+ requests without it (or cross-site ones) are refused. The renderer's `/pending`
109
+ uses the Host's own login instead
110
+ - Reopening or reloading the window restores the badge but does not re-post
111
+ banners for facts that were already there
112
+ - The interface binds loopback only; `enabled: false` switches
98
113
  off the osascript fallback, not the interface
99
114
 
100
115
  ## Known limits
package/README.md CHANGED
@@ -2,11 +2,21 @@
2
2
 
3
3
  给 DSH 加**桌面通知 + 应用图标小红点**:任务完成、等待你操作、任务失败时提醒你,点通知直接回到那个会话。
4
4
 
5
+ [![npm](https://img.shields.io/npm/v/dsh-plugin-desktop-notify)](https://www.npmjs.com/package/dsh-plugin-desktop-notify)
6
+ [![license](https://img.shields.io/npm/l/dsh-plugin-desktop-notify)](LICENSE)
7
+ [![DeepSeek Harness plugin](https://img.shields.io/badge/DSH-plugin-4D6BFE)](https://github.com/chendaleiQ/dsh-plugin-desktop-notify)
8
+
9
+ ![任务跑完时右上角弹出的通知横幅(真实录屏)](https://raw.githubusercontent.com/chendaleiQ/dsh-plugin-desktop-notify/main/assets/demo.gif)
10
+
11
+ *(真实录屏,只截取了通知区域)*
12
+
5
13
  ## 它做什么
6
14
 
7
15
  - **任务完成**——包括窗口切到后台、你在别的应用里的时候
8
16
  - **等待你操作**——工具审批、提问、执行计划确认
9
17
  - **任务失败**——这一轮出错时提示
18
+ - **说清楚怎么结束的**——正常完成、被拦截、输出到上限、被中止,各自一句话,不把中断说成「已完成」
19
+ - **需要你的排在前面**——运行中、待你审批/回答、出错、未读完成的会话自动置顶到所属分组第一条,处理完自动放开
10
20
  - **小红点**——应用图标上显示「需要你的会话数」,你看过之后才清零
11
21
  - **点击通知**——切到对应会话,而不是只把应用叫到前面
12
22
 
@@ -36,6 +46,7 @@ dsh-plugin-desktop-notify
36
46
  | `notifyViewed` | `false` | 正在看的会话也通知(默认跳过) |
37
47
  | `badge` | `true` | 显示小红点 |
38
48
  | `excludeSubagents` | `true` | 忽略子智能体会话 |
49
+ | `pinAttention` | `true` | 把需要你处理的会话自动置顶到所属分组第一条(用 DSH 自带置顶,处理完自动取消) |
39
50
  | `titleComplete` / `titlePending` / `titleError` | 空 | 自定义标题,空 = 用内置文案 |
40
51
  | `maxBodyChars` | `140` | 正文长度上限 |
41
52
 
@@ -51,6 +62,7 @@ dsh-plugin-desktop-notify
51
62
  __DSH_DESKTOP_NOTIFY__.capabilities() // 平台、通知权限、小红点、服务可达性
52
63
  __DSH_DESKTOP_NOTIFY__.state() // 小红点、待处理会话、最近一次通知/点击
53
64
  __DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于排查
65
+ __DSH_DESKTOP_NOTIFY__.pinState() // 插件置顶了哪些、你自己又置顶了哪些
54
66
  ```
55
67
 
56
68
 
@@ -62,6 +74,8 @@ __DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于
62
74
  | --- | --- | --- |
63
75
  | `POST` | `/desktop-notify/request` | 发一条通知,body:`{"title": "...", "body": "...", "sessionId": "可选"}` |
64
76
  | `GET` | `/desktop-notify/pending` | 渲染层取走待显示的通知(脚本不用调) |
77
+ | `GET` | `/desktop-notify/turn?sessionId=...` | 渲染层确认这一轮是怎么结束的(脚本不用调) |
78
+ | `GET`/`POST` | `/desktop-notify/pins` | 渲染层读写「插件自己置顶了哪些会话」(脚本不用调) |
65
79
 
66
80
  地址写在 `~/.dsh/desktop-notify.endpoint`,所以调用方不用猜端口:
67
81
 
@@ -89,10 +103,18 @@ urllib.request.urlopen(request, timeout=3).read()
89
103
 
90
104
  - 通知由 **DSH 自己弹出**(归属 DeepSeek Harness),不是 Script Editor
91
105
  - 点通知会把窗口叫到前面;带 `sessionId` 时会切到那个会话
92
- - 接口只监听回环地址,不需要登录;`enabled: false` 只关 osascript 兜底,不影响接口
106
+ - 接口只监听回环地址;地址里带一个随机 token,**照抄文件里的整条 URL** 就行。
107
+ 没有 token 的请求、以及其它网页发起的跨站请求都会被拒绝
108
+ - 渲染层那条 `/pending` 走的是 DSH 自己的登录态,不需要 token
109
+ - 窗口刷新或重开**不会**补发历史未读的横幅,但小红点还在;`enabled: false` 只关 osascript 兜底,不影响接口
93
110
 
94
111
  ## 已知限制
95
112
 
113
+ - 重启或刷新窗口时,已经存在的「未读 / 待审批」只恢复小红点,不再补弹横幅
114
+ - 自动置顶用的是 DSH 自己的置顶,所以会话行上会出现置顶标记,右键里也有「取消置顶」。
115
+ 插件只取消**自己**置顶的会话,你手动置顶的一律不动;想一次清干净可在控制台跑
116
+ `__DSH_DESKTOP_NOTIFY__.clearPins()`
117
+
96
118
  - **Windows**:点通知会切到对应会话,但窗口不一定到前台——`AppUserModelID` 和主进程 focus 通道属于桌面端,渲染层插件做不到
97
119
  - **通知归属**:系统通知中心里显示为 DeepSeek Harness(用的是渲染层原生通知)
98
120
  - **宿主半边**(`index.js`,osascript 兜底)默认关闭;只有渲染层拿不到 `Notification` 时才需要打开
package/cordis.patch.yml CHANGED
@@ -29,6 +29,7 @@
29
29
  titleApproval: 'DSH 需要你确认'
30
30
  notifyOnTurnEndKinds:
31
31
  - completed
32
+ - aborted
32
33
  - error
33
34
  - blocked
34
35
  - max-tokens
package/index.js CHANGED
@@ -15,6 +15,8 @@
15
15
  */
16
16
 
17
17
  import { execFile, spawn } from "node:child_process"
18
+ import { randomBytes } from "node:crypto"
19
+ import { readFileSync } from "node:fs"
18
20
  import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"
19
21
  import { homedir } from "node:os"
20
22
  import { basename, dirname, join } from "node:path"
@@ -36,7 +38,7 @@ const DEFAULTS = {
36
38
  soundFile: "/System/Library/Sounds/Glass.aiff",
37
39
  titleTurnEnd: "DSH 任务完成",
38
40
  titleApproval: "DSH 需要你确认",
39
- notifyOnTurnEndKinds: ["completed", "error", "blocked", "max-tokens"],
41
+ notifyOnTurnEndKinds: ["completed", "aborted", "error", "blocked", "max-tokens"],
40
42
  maxBodyChars: 140,
41
43
  logPath: "~/.dsh/desktop-notify.log",
42
44
  maxTrackedSessions: 64,
@@ -46,6 +48,8 @@ const DEFAULTS = {
46
48
  api: true,
47
49
  /** Where the endpoint URL is written for those tools. */
48
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",
49
53
  /** Longest accepted request body. */
50
54
  apiBodyLimit: 8192,
51
55
  /** How many unread requests the interface keeps. */
@@ -60,9 +64,11 @@ const TURN_END_BODIES = {
60
64
  "max-tokens": () => "输出达到上限,本轮结束",
61
65
  }
62
66
 
63
- /** The URL path tools post to, and the path the renderer drains. */
67
+ /** The URL path tools post to, and the paths the renderer drains and asks. */
64
68
  const API_REQUEST_PATH = "/desktop-notify/request"
65
69
  const API_PENDING_PATH = "/desktop-notify/pending"
70
+ const API_TURN_PATH = "/desktop-notify/turn"
71
+ const API_PINS_PATH = "/desktop-notify/pins"
66
72
 
67
73
  /** Read one JSON request body, bounded by `limit` bytes. */
68
74
  function readBody(request, limit) {
@@ -96,8 +102,6 @@ function answer(response, status, payload) {
96
102
  "content-type": "application/json; charset=utf-8",
97
103
  "content-length": Buffer.byteLength(body),
98
104
  "cache-control": "no-store",
99
- "access-control-allow-origin": "*",
100
- "access-control-allow-headers": "content-type",
101
105
  })
102
106
  response.end(body)
103
107
  }
@@ -106,118 +110,240 @@ function answer(response, status, payload) {
106
110
  * Mount the local notification interface other tools (a watch script, a
107
111
  * browser agent, a cron job) post to.
108
112
  *
109
- * The interface is two loopback HTTP routes on the Host's own web server: POST
110
- * a request, and the renderer drains the queue over the app protocol. The
111
- * renderer shows the banner, so it is attributed to DeepSeek Harness rather
112
- * than to a script host. Nothing here needs authentication: the server binds
113
- * loopback, and the endpoint URL is written to a file the caller can read.
113
+ * Three routes on the Host's own web server: POST a request, the renderer
114
+ * drains the queue over the app protocol, and the renderer asks how the turn
115
+ * that just ended actually ended so it can word the banner. `ctx.inject` waits
116
+ * for the `webServer` service instead of reading it once: the server finishes
117
+ * binding after this plugin applies, and a single `ctx.get` loses that race
118
+ * almost every launch.
119
+ *
120
+ * Requests are fenced: the external route demands the token the endpoint file
121
+ * carries (loopback plus token, no login), while the renderer routes rely on
122
+ * the Host's own `connection` admission, so a cross-site page cannot post
123
+ * banners or drain the queue.
114
124
  */
115
- function mountInterface(ctx, config) {
125
+ function mountInterface(ctx, config, state) {
116
126
  if (!config.api) return
117
- const server = (() => {
118
- try {
119
- return ctx.get?.("webServer")
120
- } catch {
121
- return undefined
127
+
128
+ const mount = (hostCtx) => {
129
+ const server = (() => {
130
+ try {
131
+ const injected = hostCtx.webServer
132
+ if (injected !== undefined) return injected
133
+ } catch {
134
+ /* a context without the service in `inject` throws on property access */
135
+ }
136
+ return hostCtx.get?.("webServer")
137
+ })()
138
+ if (server === undefined || typeof server.register !== "function") {
139
+ void noteToLog(config.logPath, "interface: the Host has no webServer service; the interface is unavailable")
140
+ return
122
141
  }
123
- })()
124
- if (server === undefined || typeof server.register !== "function") {
125
- void noteToLog(config.logPath, "interface: the Host has no webServer service; the interface is unavailable")
126
- return
127
- }
128
142
 
129
- const queue = []
130
- const disposers = []
143
+ const token = randomBytes(16).toString("hex")
144
+ const queue = []
131
145
 
132
- const post = async (request, response) => {
133
- if (request.method === "OPTIONS") {
134
- answer(response, 204, {})
135
- return
146
+ const urlOf = (request) => {
147
+ try {
148
+ return new URL(String(request?.url ?? "/"), "http://127.0.0.1")
149
+ } catch {
150
+ return undefined
151
+ }
136
152
  }
137
- if (request.method !== "POST") {
138
- answer(response, 405, { ok: false, error: "use POST" })
139
- return
153
+ const tokenOf = (request) => {
154
+ const fromQuery = urlOf(request)?.searchParams.get("token")
155
+ if (typeof fromQuery === "string" && fromQuery !== "") return fromQuery
156
+ const header = request?.headers?.["x-dsh-notify-token"]
157
+ return typeof header === "string" ? header : undefined
140
158
  }
141
- let payload
142
- try {
143
- payload = await readBody(request, Math.max(1024, config.apiBodyLimit))
144
- } catch (error) {
145
- answer(response, 400, { ok: false, error: String(error?.message ?? error) })
146
- return
159
+ const connectionOf = () => {
160
+ try {
161
+ return hostCtx.get?.("connection")
162
+ } catch {
163
+ return undefined
164
+ }
147
165
  }
148
- const title = typeof payload?.title === "string" ? payload.title.trim() : ""
149
- const body = typeof payload?.body === "string" ? payload.body.trim() : ""
150
- if (title === "" && body === "") {
151
- answer(response, 400, { ok: false, error: "title or body is required" })
152
- return
166
+
167
+ /**
168
+ * Answer a caller that may not use this interface; false when it may.
169
+ * `external` routes demand the endpoint token; renderer routes accept the
170
+ * Host's own admission (its login cookie) or the token.
171
+ */
172
+ const rejected = (request, response, external) => {
173
+ if (tokenOf(request) === token) return false
174
+ if (external) {
175
+ answer(response, 401, { ok: false, error: "a token is required; post to the URL in ~/.dsh/desktop-notify.endpoint" })
176
+ return true
177
+ }
178
+ const connection = connectionOf()
179
+ let status
180
+ try {
181
+ status = typeof connection?.requestRejection === "function" ? connection.requestRejection(request) : undefined
182
+ } catch {
183
+ status = undefined
184
+ }
185
+ if (status === undefined) return false
186
+ answer(response, status, { ok: false, error: "unauthorized" })
187
+ return true
153
188
  }
154
- queue.push({
155
- at: new Date().toISOString(),
156
- title: title === "" ? "DeepSeek Harness" : title,
157
- body: body.slice(0, 400),
158
- ...typeof payload?.sessionId === "string" && payload.sessionId !== "" ? { sessionId: payload.sessionId } : {},
159
- })
160
- while (queue.length > Math.max(1, config.apiQueueLimit)) queue.shift()
161
- answer(response, 202, { ok: true, queued: queue.length })
162
- }
163
189
 
164
- const pending = (request, response) => {
165
- if (request.method === "OPTIONS") {
166
- answer(response, 204, {})
167
- return
190
+ const post = async (request, response) => {
191
+ if (rejected(request, response, true)) return
192
+ if (request.method === "OPTIONS") {
193
+ answer(response, 204, {})
194
+ return
195
+ }
196
+ if (request.method !== "POST") {
197
+ answer(response, 405, { ok: false, error: "use POST" })
198
+ return
199
+ }
200
+ let payload
201
+ try {
202
+ payload = await readBody(request, Math.max(1024, config.apiBodyLimit))
203
+ } catch (error) {
204
+ answer(response, 400, { ok: false, error: String(error?.message ?? error) })
205
+ return
206
+ }
207
+ const title = typeof payload?.title === "string" ? payload.title.trim() : ""
208
+ const body = typeof payload?.body === "string" ? payload.body.trim() : ""
209
+ if (title === "" && body === "") {
210
+ answer(response, 400, { ok: false, error: "title or body is required" })
211
+ return
212
+ }
213
+ queue.push({
214
+ at: new Date().toISOString(),
215
+ title: title === "" ? "DeepSeek Harness" : title,
216
+ body: body.slice(0, 400),
217
+ ...typeof payload?.sessionId === "string" && payload.sessionId !== "" ? { sessionId: payload.sessionId } : {},
218
+ })
219
+ while (queue.length > Math.max(1, config.apiQueueLimit)) queue.shift()
220
+ answer(response, 202, { ok: true, queued: queue.length })
168
221
  }
169
- if (request.method !== "GET") {
170
- answer(response, 405, { ok: false, error: "use GET" })
171
- return
222
+
223
+ const pending = (request, response) => {
224
+ if (rejected(request, response, false)) return
225
+ if (request.method !== "GET") {
226
+ answer(response, 405, { ok: false, error: "use GET" })
227
+ return
228
+ }
229
+ const items = queue.splice(0, queue.length)
230
+ answer(response, 200, items)
172
231
  }
173
- const items = queue.splice(0, queue.length)
174
- answer(response, 200, items)
175
- }
176
232
 
177
- try {
178
- disposers.push(server.register({ kind: "exact", path: API_REQUEST_PATH, handler: (request, response) => {
179
- void post(request, response).catch(() => answer(response, 500, { ok: false }))
180
- } }))
181
- disposers.push(server.register({ kind: "exact", path: API_PENDING_PATH, handler: (request, response) => {
182
- try {
183
- pending(request, response)
184
- } catch {
185
- answer(response, 500, [])
233
+ const turn = (request, response) => {
234
+ if (rejected(request, response, false)) return
235
+ if (request.method !== "GET") {
236
+ answer(response, 405, { ok: false, error: "use GET" })
237
+ return
186
238
  }
187
- } }))
188
- } catch (error) {
189
- void noteToLog(config.logPath, `interface: cannot register routes: ${String(error)}`)
190
- return
191
- }
239
+ const sessionId = urlOf(request)?.searchParams.get("sessionId") ?? ""
240
+ const entry = state.lastTurnEnds.get(sessionId)
241
+ answer(response, 200, entry === undefined ? {} : { kind: entry.kind, at: entry.at })
242
+ }
192
243
 
193
- // Hand the caller an address to post to; the port is whatever the Host took.
194
- const endpointPath = expandHome(config.endpointPath)
195
- const origin = (() => {
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
+
275
+ let disposers
196
276
  try {
197
- return typeof server.getOrigin === "function" ? server.getOrigin() : undefined
198
- } catch {
199
- return undefined
277
+ disposers = [
278
+ server.register({ kind: "exact", path: API_REQUEST_PATH, handler: (request, response) => {
279
+ void post(request, response).catch(() => answer(response, 500, { ok: false }))
280
+ } }),
281
+ server.register({ kind: "exact", path: API_PENDING_PATH, handler: (request, response) => {
282
+ try {
283
+ pending(request, response)
284
+ } catch {
285
+ answer(response, 500, [])
286
+ }
287
+ } }),
288
+ server.register({ kind: "exact", path: API_TURN_PATH, handler: (request, response) => {
289
+ try {
290
+ turn(request, response)
291
+ } catch {
292
+ answer(response, 500, {})
293
+ }
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
+ } }),
302
+ ]
303
+ } catch (error) {
304
+ void noteToLog(config.logPath, `interface: cannot register routes: ${String(error)}`)
305
+ return
200
306
  }
201
- })() ?? (typeof server.port === "number" ? `http://127.0.0.1:${server.port}` : undefined)
202
- if (endpointPath !== "" && origin !== undefined) {
203
- void mkdir(dirname(endpointPath), { recursive: true })
204
- .then(() => writeFile(endpointPath, `${origin}${API_REQUEST_PATH}\n`, "utf8"))
205
- .then(() => noteToLog(config.logPath, `interface: ${origin}${API_REQUEST_PATH}`))
206
- .catch((error) => noteToLog(config.logPath, `interface: cannot write the endpoint file: ${String(error)}`))
207
- } else {
208
- void noteToLog(config.logPath, `interface: routes registered but the address is unknown (origin=${String(origin)})`)
209
- }
210
307
 
211
- ctx.effect(() => () => {
212
- for (const dispose of disposers) {
308
+ // Hand the caller an address to post to; the port is whatever the Host
309
+ // took, and the token keeps every other local page out.
310
+ const endpointPath = expandHome(config.endpointPath)
311
+ const origin = (() => {
213
312
  try {
214
- dispose()
313
+ return typeof server.getOrigin === "function" ? server.getOrigin() : undefined
215
314
  } catch {
216
- /* the route table is being torn down anyway */
315
+ return undefined
217
316
  }
317
+ })() ?? (typeof server.port === "number" ? `${typeof server.protocol === "string" ? server.protocol : "http:"}//127.0.0.1:${server.port}` : undefined)
318
+ if (endpointPath !== "" && origin !== undefined) {
319
+ void mkdir(dirname(endpointPath), { recursive: true })
320
+ .then(() => writeFile(endpointPath, `${origin}${API_REQUEST_PATH}?token=${token}\n`, "utf8"))
321
+ .then(() => noteToLog(config.logPath, `interface: ${origin}${API_REQUEST_PATH}`))
322
+ .catch((error) => noteToLog(config.logPath, `interface: cannot write the endpoint file: ${String(error)}`))
323
+ } else {
324
+ void noteToLog(config.logPath, "interface: routes registered but the address is unknown")
218
325
  }
219
- if (endpointPath !== "") void rm(endpointPath, { force: true }).catch(() => {})
220
- }, "desktop-notify: local interface")
326
+
327
+ hostCtx.effect(() => () => {
328
+ for (const dispose of disposers) {
329
+ try {
330
+ dispose()
331
+ } catch {
332
+ /* the route table is being torn down anyway */
333
+ }
334
+ }
335
+ if (endpointPath !== "") void rm(endpointPath, { force: true }).catch(() => {})
336
+ }, "desktop-notify: local interface")
337
+ }
338
+
339
+ // A Host without `inject` (older composition) still gets one best-effort try.
340
+ if (typeof ctx.inject !== "function") {
341
+ mount(ctx)
342
+ return
343
+ }
344
+ ctx.inject(["webServer"], (hostCtx) => {
345
+ mount(hostCtx)
346
+ })
221
347
  }
222
348
 
223
349
  /** Append one best-effort line to the log from outside the session machinery. */
@@ -322,8 +448,21 @@ function runDetached(command, args) {
322
448
  */
323
449
  export function apply(ctx, rawConfig) {
324
450
  const config = resolveConfig(rawConfig)
325
- mountInterface(ctx, config)
326
- if (!config.enabled) return
451
+ /** The newest turn-end kind per session, read by the renderer over the local interface. */
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
+ }
465
+ mountInterface(ctx, config, state)
327
466
 
328
467
  const logPath = expandHome(config.logPath)
329
468
  const pendingText = new Map()
@@ -339,6 +478,27 @@ export function apply(ctx, rawConfig) {
339
478
  }
340
479
  }
341
480
 
481
+ /**
482
+ * Keep the real reason of the last turn. The renderer's session status only
483
+ * carries `running` / `completionUnread`, so without this every ending —
484
+ * blocked, max-tokens, aborted — would be announced as "completed".
485
+ */
486
+ function rememberTurnEnd(session, event) {
487
+ if (event?.type !== "turn/end") return
488
+ const sessionId = session?.id
489
+ if (typeof sessionId !== "string" || sessionId === "") return
490
+ state.lastTurnEnds.delete(sessionId)
491
+ state.lastTurnEnds.set(sessionId, {
492
+ kind: event?.data?.reason?.kind ?? "completed",
493
+ at: new Date().toISOString(),
494
+ })
495
+ while (state.lastTurnEnds.size > Math.max(1, config.maxTrackedSessions)) {
496
+ const oldest = state.lastTurnEnds.keys().next()
497
+ if (oldest.done === true) break
498
+ state.lastTurnEnds.delete(oldest.value)
499
+ }
500
+ }
501
+
342
502
  /** Remember the newest assistant text per session, trimmed to a small cache. */
343
503
  function remember(sessionId, text) {
344
504
  if (text === "") return
@@ -406,8 +566,18 @@ export function apply(ctx, rawConfig) {
406
566
  }
407
567
  }
408
568
 
409
- /** One dispatch per committed event; work stays off the append path. */
569
+ /**
570
+ * One dispatch per committed event; work stays off the append path. The
571
+ * turn-end record is kept even with the osascript fallback switched off,
572
+ * because the renderer asks for it to word its own banner.
573
+ */
410
574
  ctx.on("session/event", (session, event) => {
575
+ try {
576
+ rememberTurnEnd(session, event)
577
+ } catch (error) {
578
+ void writeLog(`error session=${session?.id ?? "-"} ${String(error)}`)
579
+ }
580
+ if (!config.enabled) return
411
581
  try {
412
582
  handleEvent(session, event)
413
583
  } catch (error) {
@@ -415,11 +585,14 @@ export function apply(ctx, rawConfig) {
415
585
  }
416
586
  })
417
587
 
418
- /** A disposed session must not keep its remembered text alive. */
588
+ /** A disposed session must not keep its remembered text or turn kind alive. */
419
589
  ctx.on("session/disposed", (session) => {
420
590
  const sessionId = session?.id
421
- if (typeof sessionId === "string") pendingText.delete(sessionId)
591
+ if (typeof sessionId !== "string") return
592
+ pendingText.delete(sessionId)
593
+ state.lastTurnEnds.delete(sessionId)
422
594
  })
423
595
 
596
+ if (!config.enabled) return
424
597
  void writeLog(`plugin started pid=${process.pid} platform=${process.platform} onlyWhenUnfocused=${config.onlyWhenUnfocused}`)
425
598
  }
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.1"
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: "",
@@ -57,6 +61,12 @@ window.__ModuleLoader__.load({
57
61
  zh: {
58
62
  completeTitle: "任务已完成",
59
63
  complete: (name, duration) => withName(name, duration === "" ? "已完成" : `已完成,用时 ${duration}`),
64
+ blockedTitle: "任务被拦截",
65
+ blocked: (name) => withName(name, "本轮被拦截结束"),
66
+ maxTokensTitle: "输出达到上限",
67
+ maxTokens: (name) => withName(name, "本轮达到输出上限"),
68
+ abortedTitle: "任务已中止",
69
+ aborted: (name) => withName(name, "本轮已中止"),
60
70
  approvalTitle: "等待你的批准",
61
71
  approval: (name, tool) => withName(name, tool === "" ? "有一项操作等待你批准" : `请求使用 ${tool},等待你的批准`),
62
72
  questionTitle: "等待你的回答",
@@ -72,6 +82,12 @@ window.__ModuleLoader__.load({
72
82
  en: {
73
83
  completeTitle: "Task completed",
74
84
  complete: (name, duration) => withName(name, duration === "" ? "Completed" : `Completed in ${duration}`),
85
+ blockedTitle: "Task blocked",
86
+ blocked: (name) => withName(name, "the turn ended blocked"),
87
+ maxTokensTitle: "Output limit reached",
88
+ maxTokens: (name) => withName(name, "the turn hit the output limit"),
89
+ abortedTitle: "Turn stopped",
90
+ aborted: (name) => withName(name, "the turn was stopped"),
75
91
  approvalTitle: "Approval needed",
76
92
  approval: (name, tool) => withName(name, tool === "" ? "A tool is waiting for your approval" : `${tool} is waiting for your approval`),
77
93
  questionTitle: "Your reply is needed",
@@ -122,6 +138,31 @@ window.__ModuleLoader__.load({
122
138
  /** Pending-interaction kinds the plugin reports. */
123
139
  const PENDING_KINDS = ["approval", "question", "plan-review"]
124
140
 
141
+ /**
142
+ * The local route that answers how a session's newest turn ended, and how
143
+ * long the banner waits for it. Session status only carries `running` and
144
+ * `completionUnread`, so without this ask every ending reads as "completed".
145
+ */
146
+ const TURN_KIND_PATH = "/desktop-notify/turn"
147
+ const TURN_LOOKUP_TIMEOUT_MS = 800
148
+ /** Where the Host remembers the sessions this plugin pinned. */
149
+ const PINS_PATH = "/desktop-notify/pins"
150
+
151
+ /** Ask the Host how one turn ended; undefined when it cannot answer in time. */
152
+ function lookupTurnKind(sessionId) {
153
+ const lookup = fetch(`${TURN_KIND_PATH}?sessionId=${encodeURIComponent(sessionId)}`, {
154
+ headers: { accept: "application/json" },
155
+ cache: "no-store",
156
+ })
157
+ .then((response) => (response.ok ? response.json() : undefined))
158
+ .then((payload) => (typeof payload?.kind === "string" ? payload.kind : undefined))
159
+ .catch(() => undefined)
160
+ const timeout = new Promise((resolve) => {
161
+ setTimeout(() => resolve(undefined), TURN_LOOKUP_TIMEOUT_MS)
162
+ })
163
+ return Promise.race([lookup, timeout])
164
+ }
165
+
125
166
  /** Read the browser storage when this renderer has one. */
126
167
  function storage() {
127
168
  try {
@@ -157,6 +198,18 @@ window.__ModuleLoader__.load({
157
198
  return preferences
158
199
  }
159
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
+
160
213
  /** Collapse whitespace and cut to the body budget. */
161
214
  function bodyText(text, maxChars) {
162
215
  const collapsed = String(text).replace(/\s+/gu, " ").trim()
@@ -275,6 +328,128 @@ window.__ModuleLoader__.load({
275
328
  /** sessionId -> ms timestamp when the current turn was first seen running. */
276
329
  const turnStartedAt = new Map()
277
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
+
278
453
  /** The last banner attempt and its outcome — Windows has several ways to swallow it. */
279
454
  let lastNotify
280
455
  /** The last banner click and what it managed to do — Windows/macOS differ here. */
@@ -412,6 +587,27 @@ window.__ModuleLoader__.load({
412
587
  void pollExternal()
413
588
  }
414
589
 
590
+ /**
591
+ * Post the completion banner, worded by the turn's real ending when the
592
+ * Host can still answer for it. A blocked, max-tokens or aborted turn is
593
+ * not "finished", and calling it that is what makes the banner wrong.
594
+ */
595
+ function deliverCompletion(sessionId, table, name, duration) {
596
+ const completed = () => {
597
+ notify("complete", sessionId, preferences.titleComplete || table.completeTitle, bodyText(table.complete(name, duration), preferences.maxBodyChars))
598
+ }
599
+ if (typeof fetch !== "function") {
600
+ completed()
601
+ return
602
+ }
603
+ void lookupTurnKind(sessionId).then((kind) => {
604
+ if (kind === "blocked") return notify("complete:blocked", sessionId, table.blockedTitle, bodyText(table.blocked(name), preferences.maxBodyChars))
605
+ if (kind === "max-tokens") return notify("complete:max-tokens", sessionId, table.maxTokensTitle, bodyText(table.maxTokens(name), preferences.maxBodyChars))
606
+ if (kind === "aborted") return notify("complete:aborted", sessionId, table.abortedTitle, bodyText(table.aborted(name), preferences.maxBodyChars))
607
+ return completed()
608
+ })
609
+ }
610
+
415
611
  /** Post one banner; clicking it opens the session and raises the window. */
416
612
  function notify(kind, sessionId, title, body) {
417
613
  if (!preferences.notifications) {
@@ -495,6 +691,8 @@ window.__ModuleLoader__.load({
495
691
  const statuses = ctx.uiSession.sessionStatus.getSnapshot()
496
692
  const summaries = ctx.sessions.list.getSnapshot().byId ?? {}
497
693
  const seen = new Set()
694
+ /** Sessions that should sit at the top of their group. */
695
+ const wanted = new Set()
498
696
 
499
697
  for (const [sessionId, status] of statuses) {
500
698
  const summary = summaries[sessionId]
@@ -504,6 +702,11 @@ window.__ModuleLoader__.load({
504
702
  continue
505
703
  }
506
704
  seen.add(sessionId)
705
+ // The first sighting of a session only records the baseline: every
706
+ // unread completion and every waiting approval that already existed
707
+ // when the window (re)loaded would otherwise fire as a fresh edge,
708
+ // one burst of banners per launch.
709
+ const observed = previous.has(sessionId)
507
710
  const before = previous.get(sessionId) ?? {}
508
711
  const entry = attention.get(sessionId) ?? {}
509
712
  // A failure already recorded for this turn owns the banner and the
@@ -515,9 +718,9 @@ window.__ModuleLoader__.load({
515
718
  const pendingKind = pendingKindOf(status)
516
719
  const running = status?.running === true
517
720
  const justFinished = before.running === true && running === false
518
- const finishedNow = justFinished || (completed && before.completed !== true)
721
+ const finishedNow = justFinished || (observed && completed && before.completed !== true)
519
722
  const completionEdge = finishedNow && !failurePending
520
- const pendingEdge = pendingKind !== undefined && pendingKind !== before.pendingKind
723
+ const pendingEdge = observed && pendingKind !== undefined && pendingKind !== before.pendingKind
521
724
 
522
725
  if (running) {
523
726
  if (!turnStartedAt.has(sessionId)) turnStartedAt.set(sessionId, Date.now())
@@ -530,14 +733,9 @@ window.__ModuleLoader__.load({
530
733
  const started = turnStartedAt.get(sessionId)
531
734
  turnStartedAt.delete(sessionId)
532
735
  const duration = started === undefined ? "" : durationText(Date.now() - started, languageOf())
533
- const title = preferences.titleComplete || table.completeTitle
534
- const body = bodyText(table.complete(name, duration), preferences.maxBodyChars)
535
- if (looking) {
536
- // Watching it finish is only worth a banner when the user opted in.
537
- if (preferences.notifyViewed && preferences.notifyComplete) notify("complete", sessionId, title, body)
538
- } else if (preferences.notifyComplete) {
539
- notify("complete", sessionId, title, body)
540
- }
736
+ // Watching it finish is only worth a banner when the user opted in.
737
+ const shouldNotify = preferences.notifyComplete && (looking ? preferences.notifyViewed : true)
738
+ if (shouldNotify) deliverCompletion(sessionId, table, name, duration)
541
739
  }
542
740
 
543
741
  if (looking) {
@@ -564,6 +762,13 @@ window.__ModuleLoader__.load({
564
762
  })
565
763
  }
566
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
+
567
772
  previous.set(sessionId, { running, completed, pendingKind })
568
773
  }
569
774
 
@@ -572,6 +777,7 @@ window.__ModuleLoader__.load({
572
777
  previous.delete(sessionId)
573
778
  turnStartedAt.delete(sessionId)
574
779
  }
780
+ reconcilePins(wanted)
575
781
  applyBadge()
576
782
  }
577
783
 
@@ -706,10 +912,29 @@ window.__ModuleLoader__.load({
706
912
  badge: lastBadge ?? 0,
707
913
  attention: [...attention.entries()].map(([sessionId, entry]) => ({ sessionId, ...entry })),
708
914
  tracked: [...previous.keys()],
915
+ pins: [...selfPinned],
709
916
  lastNotify: lastNotify ?? null,
710
917
  lastClick: lastClick ?? null,
711
918
  }
712
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
+ },
713
938
  simulateComplete(sessionId = "desktop-notify-simulation") {
714
939
  const table = copy(languageOf())
715
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.1",
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
  }