dsh-plugin-desktop-notify 0.6.1 → 0.6.2

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
@@ -64,6 +72,7 @@ jobs:
64
72
  | Method | Path | Purpose |
65
73
  | --- | --- | --- |
66
74
  | `POST` | `/desktop-notify/request` | Post one notification: `{"title": "...", "body": "...", "sessionId": "optional"}` |
75
+ | `GET` | `/desktop-notify/turn?sessionId=...` | the renderer asks how the last turn ended (callers do not need it) |
67
76
  | `GET` | `/desktop-notify/pending` | The renderer drains what is waiting (scripts do not call it) |
68
77
 
69
78
  The address is written to `~/.dsh/desktop-notify.endpoint`, so callers never guess
@@ -94,7 +103,12 @@ Notes:
94
103
  - The banner is posted by **DSH itself** (attributed to DeepSeek Harness), not by
95
104
  a script host
96
105
  - 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
106
+ - The endpoint URL carries a random token; post to the whole URL as printed, and
107
+ requests without it (or cross-site ones) are refused. The renderer's `/pending`
108
+ uses the Host's own login instead
109
+ - Reopening or reloading the window restores the badge but does not re-post
110
+ banners for facts that were already there
111
+ - The interface binds loopback only; `enabled: false` switches
98
112
  off the osascript fallback, not the interface
99
113
 
100
114
  ## Known limits
package/README.md CHANGED
@@ -2,11 +2,20 @@
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
+ - **说清楚怎么结束的**——正常完成、被拦截、输出到上限、被中止,各自一句话,不把中断说成「已完成」
10
19
  - **小红点**——应用图标上显示「需要你的会话数」,你看过之后才清零
11
20
  - **点击通知**——切到对应会话,而不是只把应用叫到前面
12
21
 
@@ -62,6 +71,7 @@ __DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于
62
71
  | --- | --- | --- |
63
72
  | `POST` | `/desktop-notify/request` | 发一条通知,body:`{"title": "...", "body": "...", "sessionId": "可选"}` |
64
73
  | `GET` | `/desktop-notify/pending` | 渲染层取走待显示的通知(脚本不用调) |
74
+ | `GET` | `/desktop-notify/turn?sessionId=...` | 渲染层确认这一轮是怎么结束的(脚本不用调) |
65
75
 
66
76
  地址写在 `~/.dsh/desktop-notify.endpoint`,所以调用方不用猜端口:
67
77
 
@@ -89,10 +99,15 @@ urllib.request.urlopen(request, timeout=3).read()
89
99
 
90
100
  - 通知由 **DSH 自己弹出**(归属 DeepSeek Harness),不是 Script Editor
91
101
  - 点通知会把窗口叫到前面;带 `sessionId` 时会切到那个会话
92
- - 接口只监听回环地址,不需要登录;`enabled: false` 只关 osascript 兜底,不影响接口
102
+ - 接口只监听回环地址;地址里带一个随机 token,**照抄文件里的整条 URL** 就行。
103
+ 没有 token 的请求、以及其它网页发起的跨站请求都会被拒绝
104
+ - 渲染层那条 `/pending` 走的是 DSH 自己的登录态,不需要 token
105
+ - 窗口刷新或重开**不会**补发历史未读的横幅,但小红点还在;`enabled: false` 只关 osascript 兜底,不影响接口
93
106
 
94
107
  ## 已知限制
95
108
 
109
+ - 重启或刷新窗口时,已经存在的「未读 / 待审批」只恢复小红点,不再补弹横幅
110
+
96
111
  - **Windows**:点通知会切到对应会话,但窗口不一定到前台——`AppUserModelID` 和主进程 focus 通道属于桌面端,渲染层插件做不到
97
112
  - **通知归属**:系统通知中心里显示为 DeepSeek Harness(用的是渲染层原生通知)
98
113
  - **宿主半边**(`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,7 @@
15
15
  */
16
16
 
17
17
  import { execFile, spawn } from "node:child_process"
18
+ import { randomBytes } from "node:crypto"
18
19
  import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"
19
20
  import { homedir } from "node:os"
20
21
  import { basename, dirname, join } from "node:path"
@@ -36,7 +37,7 @@ const DEFAULTS = {
36
37
  soundFile: "/System/Library/Sounds/Glass.aiff",
37
38
  titleTurnEnd: "DSH 任务完成",
38
39
  titleApproval: "DSH 需要你确认",
39
- notifyOnTurnEndKinds: ["completed", "error", "blocked", "max-tokens"],
40
+ notifyOnTurnEndKinds: ["completed", "aborted", "error", "blocked", "max-tokens"],
40
41
  maxBodyChars: 140,
41
42
  logPath: "~/.dsh/desktop-notify.log",
42
43
  maxTrackedSessions: 64,
@@ -60,9 +61,10 @@ const TURN_END_BODIES = {
60
61
  "max-tokens": () => "输出达到上限,本轮结束",
61
62
  }
62
63
 
63
- /** The URL path tools post to, and the path the renderer drains. */
64
+ /** The URL path tools post to, and the paths the renderer drains and asks. */
64
65
  const API_REQUEST_PATH = "/desktop-notify/request"
65
66
  const API_PENDING_PATH = "/desktop-notify/pending"
67
+ const API_TURN_PATH = "/desktop-notify/turn"
66
68
 
67
69
  /** Read one JSON request body, bounded by `limit` bytes. */
68
70
  function readBody(request, limit) {
@@ -96,8 +98,6 @@ function answer(response, status, payload) {
96
98
  "content-type": "application/json; charset=utf-8",
97
99
  "content-length": Buffer.byteLength(body),
98
100
  "cache-control": "no-store",
99
- "access-control-allow-origin": "*",
100
- "access-control-allow-headers": "content-type",
101
101
  })
102
102
  response.end(body)
103
103
  }
@@ -106,118 +106,202 @@ function answer(response, status, payload) {
106
106
  * Mount the local notification interface other tools (a watch script, a
107
107
  * browser agent, a cron job) post to.
108
108
  *
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.
109
+ * Three routes on the Host's own web server: POST a request, the renderer
110
+ * drains the queue over the app protocol, and the renderer asks how the turn
111
+ * that just ended actually ended so it can word the banner. `ctx.inject` waits
112
+ * for the `webServer` service instead of reading it once: the server finishes
113
+ * binding after this plugin applies, and a single `ctx.get` loses that race
114
+ * almost every launch.
115
+ *
116
+ * Requests are fenced: the external route demands the token the endpoint file
117
+ * carries (loopback plus token, no login), while the renderer routes rely on
118
+ * the Host's own `connection` admission, so a cross-site page cannot post
119
+ * banners or drain the queue.
114
120
  */
115
- function mountInterface(ctx, config) {
121
+ function mountInterface(ctx, config, state) {
116
122
  if (!config.api) return
117
- const server = (() => {
118
- try {
119
- return ctx.get?.("webServer")
120
- } catch {
121
- return undefined
123
+
124
+ const mount = (hostCtx) => {
125
+ const server = (() => {
126
+ try {
127
+ const injected = hostCtx.webServer
128
+ if (injected !== undefined) return injected
129
+ } catch {
130
+ /* a context without the service in `inject` throws on property access */
131
+ }
132
+ return hostCtx.get?.("webServer")
133
+ })()
134
+ if (server === undefined || typeof server.register !== "function") {
135
+ void noteToLog(config.logPath, "interface: the Host has no webServer service; the interface is unavailable")
136
+ return
122
137
  }
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
138
 
129
- const queue = []
130
- const disposers = []
139
+ const token = randomBytes(16).toString("hex")
140
+ const queue = []
131
141
 
132
- const post = async (request, response) => {
133
- if (request.method === "OPTIONS") {
134
- answer(response, 204, {})
135
- return
142
+ const urlOf = (request) => {
143
+ try {
144
+ return new URL(String(request?.url ?? "/"), "http://127.0.0.1")
145
+ } catch {
146
+ return undefined
147
+ }
136
148
  }
137
- if (request.method !== "POST") {
138
- answer(response, 405, { ok: false, error: "use POST" })
139
- return
149
+ const tokenOf = (request) => {
150
+ const fromQuery = urlOf(request)?.searchParams.get("token")
151
+ if (typeof fromQuery === "string" && fromQuery !== "") return fromQuery
152
+ const header = request?.headers?.["x-dsh-notify-token"]
153
+ return typeof header === "string" ? header : undefined
140
154
  }
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
155
+ const connectionOf = () => {
156
+ try {
157
+ return hostCtx.get?.("connection")
158
+ } catch {
159
+ return undefined
160
+ }
147
161
  }
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
162
+
163
+ /**
164
+ * Answer a caller that may not use this interface; false when it may.
165
+ * `external` routes demand the endpoint token; renderer routes accept the
166
+ * Host's own admission (its login cookie) or the token.
167
+ */
168
+ const rejected = (request, response, external) => {
169
+ if (tokenOf(request) === token) return false
170
+ if (external) {
171
+ answer(response, 401, { ok: false, error: "a token is required; post to the URL in ~/.dsh/desktop-notify.endpoint" })
172
+ return true
173
+ }
174
+ const connection = connectionOf()
175
+ let status
176
+ try {
177
+ status = typeof connection?.requestRejection === "function" ? connection.requestRejection(request) : undefined
178
+ } catch {
179
+ status = undefined
180
+ }
181
+ if (status === undefined) return false
182
+ answer(response, status, { ok: false, error: "unauthorized" })
183
+ return true
153
184
  }
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
185
 
164
- const pending = (request, response) => {
165
- if (request.method === "OPTIONS") {
166
- answer(response, 204, {})
167
- return
186
+ const post = async (request, response) => {
187
+ if (rejected(request, response, true)) return
188
+ if (request.method === "OPTIONS") {
189
+ answer(response, 204, {})
190
+ return
191
+ }
192
+ if (request.method !== "POST") {
193
+ answer(response, 405, { ok: false, error: "use POST" })
194
+ return
195
+ }
196
+ let payload
197
+ try {
198
+ payload = await readBody(request, Math.max(1024, config.apiBodyLimit))
199
+ } catch (error) {
200
+ answer(response, 400, { ok: false, error: String(error?.message ?? error) })
201
+ return
202
+ }
203
+ const title = typeof payload?.title === "string" ? payload.title.trim() : ""
204
+ const body = typeof payload?.body === "string" ? payload.body.trim() : ""
205
+ if (title === "" && body === "") {
206
+ answer(response, 400, { ok: false, error: "title or body is required" })
207
+ return
208
+ }
209
+ queue.push({
210
+ at: new Date().toISOString(),
211
+ title: title === "" ? "DeepSeek Harness" : title,
212
+ body: body.slice(0, 400),
213
+ ...typeof payload?.sessionId === "string" && payload.sessionId !== "" ? { sessionId: payload.sessionId } : {},
214
+ })
215
+ while (queue.length > Math.max(1, config.apiQueueLimit)) queue.shift()
216
+ answer(response, 202, { ok: true, queued: queue.length })
168
217
  }
169
- if (request.method !== "GET") {
170
- answer(response, 405, { ok: false, error: "use GET" })
171
- return
218
+
219
+ const pending = (request, response) => {
220
+ if (rejected(request, response, false)) return
221
+ if (request.method !== "GET") {
222
+ answer(response, 405, { ok: false, error: "use GET" })
223
+ return
224
+ }
225
+ const items = queue.splice(0, queue.length)
226
+ answer(response, 200, items)
172
227
  }
173
- const items = queue.splice(0, queue.length)
174
- answer(response, 200, items)
175
- }
176
228
 
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, [])
229
+ const turn = (request, response) => {
230
+ if (rejected(request, response, false)) return
231
+ if (request.method !== "GET") {
232
+ answer(response, 405, { ok: false, error: "use GET" })
233
+ return
186
234
  }
187
- } }))
188
- } catch (error) {
189
- void noteToLog(config.logPath, `interface: cannot register routes: ${String(error)}`)
190
- return
191
- }
235
+ const sessionId = urlOf(request)?.searchParams.get("sessionId") ?? ""
236
+ const entry = state.lastTurnEnds.get(sessionId)
237
+ answer(response, 200, entry === undefined ? {} : { kind: entry.kind, at: entry.at })
238
+ }
192
239
 
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 = (() => {
240
+ let disposers
196
241
  try {
197
- return typeof server.getOrigin === "function" ? server.getOrigin() : undefined
198
- } catch {
199
- return undefined
242
+ disposers = [
243
+ server.register({ kind: "exact", path: API_REQUEST_PATH, handler: (request, response) => {
244
+ void post(request, response).catch(() => answer(response, 500, { ok: false }))
245
+ } }),
246
+ server.register({ kind: "exact", path: API_PENDING_PATH, handler: (request, response) => {
247
+ try {
248
+ pending(request, response)
249
+ } catch {
250
+ answer(response, 500, [])
251
+ }
252
+ } }),
253
+ server.register({ kind: "exact", path: API_TURN_PATH, handler: (request, response) => {
254
+ try {
255
+ turn(request, response)
256
+ } catch {
257
+ answer(response, 500, {})
258
+ }
259
+ } }),
260
+ ]
261
+ } catch (error) {
262
+ void noteToLog(config.logPath, `interface: cannot register routes: ${String(error)}`)
263
+ return
200
264
  }
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
265
 
211
- ctx.effect(() => () => {
212
- for (const dispose of disposers) {
266
+ // Hand the caller an address to post to; the port is whatever the Host
267
+ // took, and the token keeps every other local page out.
268
+ const endpointPath = expandHome(config.endpointPath)
269
+ const origin = (() => {
213
270
  try {
214
- dispose()
271
+ return typeof server.getOrigin === "function" ? server.getOrigin() : undefined
215
272
  } catch {
216
- /* the route table is being torn down anyway */
273
+ return undefined
217
274
  }
275
+ })() ?? (typeof server.port === "number" ? `${typeof server.protocol === "string" ? server.protocol : "http:"}//127.0.0.1:${server.port}` : undefined)
276
+ if (endpointPath !== "" && origin !== undefined) {
277
+ void mkdir(dirname(endpointPath), { recursive: true })
278
+ .then(() => writeFile(endpointPath, `${origin}${API_REQUEST_PATH}?token=${token}\n`, "utf8"))
279
+ .then(() => noteToLog(config.logPath, `interface: ${origin}${API_REQUEST_PATH}`))
280
+ .catch((error) => noteToLog(config.logPath, `interface: cannot write the endpoint file: ${String(error)}`))
281
+ } else {
282
+ void noteToLog(config.logPath, "interface: routes registered but the address is unknown")
218
283
  }
219
- if (endpointPath !== "") void rm(endpointPath, { force: true }).catch(() => {})
220
- }, "desktop-notify: local interface")
284
+
285
+ hostCtx.effect(() => () => {
286
+ for (const dispose of disposers) {
287
+ try {
288
+ dispose()
289
+ } catch {
290
+ /* the route table is being torn down anyway */
291
+ }
292
+ }
293
+ if (endpointPath !== "") void rm(endpointPath, { force: true }).catch(() => {})
294
+ }, "desktop-notify: local interface")
295
+ }
296
+
297
+ // A Host without `inject` (older composition) still gets one best-effort try.
298
+ if (typeof ctx.inject !== "function") {
299
+ mount(ctx)
300
+ return
301
+ }
302
+ ctx.inject(["webServer"], (hostCtx) => {
303
+ mount(hostCtx)
304
+ })
221
305
  }
222
306
 
223
307
  /** Append one best-effort line to the log from outside the session machinery. */
@@ -322,8 +406,9 @@ function runDetached(command, args) {
322
406
  */
323
407
  export function apply(ctx, rawConfig) {
324
408
  const config = resolveConfig(rawConfig)
325
- mountInterface(ctx, config)
326
- if (!config.enabled) return
409
+ /** The newest turn-end kind per session, read by the renderer over the local interface. */
410
+ const state = { lastTurnEnds: new Map() }
411
+ mountInterface(ctx, config, state)
327
412
 
328
413
  const logPath = expandHome(config.logPath)
329
414
  const pendingText = new Map()
@@ -339,6 +424,27 @@ export function apply(ctx, rawConfig) {
339
424
  }
340
425
  }
341
426
 
427
+ /**
428
+ * Keep the real reason of the last turn. The renderer's session status only
429
+ * carries `running` / `completionUnread`, so without this every ending —
430
+ * blocked, max-tokens, aborted — would be announced as "completed".
431
+ */
432
+ function rememberTurnEnd(session, event) {
433
+ if (event?.type !== "turn/end") return
434
+ const sessionId = session?.id
435
+ if (typeof sessionId !== "string" || sessionId === "") return
436
+ state.lastTurnEnds.delete(sessionId)
437
+ state.lastTurnEnds.set(sessionId, {
438
+ kind: event?.data?.reason?.kind ?? "completed",
439
+ at: new Date().toISOString(),
440
+ })
441
+ while (state.lastTurnEnds.size > Math.max(1, config.maxTrackedSessions)) {
442
+ const oldest = state.lastTurnEnds.keys().next()
443
+ if (oldest.done === true) break
444
+ state.lastTurnEnds.delete(oldest.value)
445
+ }
446
+ }
447
+
342
448
  /** Remember the newest assistant text per session, trimmed to a small cache. */
343
449
  function remember(sessionId, text) {
344
450
  if (text === "") return
@@ -406,8 +512,18 @@ export function apply(ctx, rawConfig) {
406
512
  }
407
513
  }
408
514
 
409
- /** One dispatch per committed event; work stays off the append path. */
515
+ /**
516
+ * One dispatch per committed event; work stays off the append path. The
517
+ * turn-end record is kept even with the osascript fallback switched off,
518
+ * because the renderer asks for it to word its own banner.
519
+ */
410
520
  ctx.on("session/event", (session, event) => {
521
+ try {
522
+ rememberTurnEnd(session, event)
523
+ } catch (error) {
524
+ void writeLog(`error session=${session?.id ?? "-"} ${String(error)}`)
525
+ }
526
+ if (!config.enabled) return
411
527
  try {
412
528
  handleEvent(session, event)
413
529
  } catch (error) {
@@ -415,11 +531,14 @@ export function apply(ctx, rawConfig) {
415
531
  }
416
532
  })
417
533
 
418
- /** A disposed session must not keep its remembered text alive. */
534
+ /** A disposed session must not keep its remembered text or turn kind alive. */
419
535
  ctx.on("session/disposed", (session) => {
420
536
  const sessionId = session?.id
421
- if (typeof sessionId === "string") pendingText.delete(sessionId)
537
+ if (typeof sessionId !== "string") return
538
+ pendingText.delete(sessionId)
539
+ state.lastTurnEnds.delete(sessionId)
422
540
  })
423
541
 
542
+ if (!config.enabled) return
424
543
  void writeLog(`plugin started pid=${process.pid} platform=${process.platform} onlyWhenUnfocused=${config.onlyWhenUnfocused}`)
425
544
  }
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.1"
33
+ const VERSION = "0.6.2"
34
34
  const PREFERENCES_KEY = `${NS}:preferences`
35
35
 
36
36
  /** Defaults; override per field with the localStorage JSON described in README. */
@@ -57,6 +57,12 @@ window.__ModuleLoader__.load({
57
57
  zh: {
58
58
  completeTitle: "任务已完成",
59
59
  complete: (name, duration) => withName(name, duration === "" ? "已完成" : `已完成,用时 ${duration}`),
60
+ blockedTitle: "任务被拦截",
61
+ blocked: (name) => withName(name, "本轮被拦截结束"),
62
+ maxTokensTitle: "输出达到上限",
63
+ maxTokens: (name) => withName(name, "本轮达到输出上限"),
64
+ abortedTitle: "任务已中止",
65
+ aborted: (name) => withName(name, "本轮已中止"),
60
66
  approvalTitle: "等待你的批准",
61
67
  approval: (name, tool) => withName(name, tool === "" ? "有一项操作等待你批准" : `请求使用 ${tool},等待你的批准`),
62
68
  questionTitle: "等待你的回答",
@@ -72,6 +78,12 @@ window.__ModuleLoader__.load({
72
78
  en: {
73
79
  completeTitle: "Task completed",
74
80
  complete: (name, duration) => withName(name, duration === "" ? "Completed" : `Completed in ${duration}`),
81
+ blockedTitle: "Task blocked",
82
+ blocked: (name) => withName(name, "the turn ended blocked"),
83
+ maxTokensTitle: "Output limit reached",
84
+ maxTokens: (name) => withName(name, "the turn hit the output limit"),
85
+ abortedTitle: "Turn stopped",
86
+ aborted: (name) => withName(name, "the turn was stopped"),
75
87
  approvalTitle: "Approval needed",
76
88
  approval: (name, tool) => withName(name, tool === "" ? "A tool is waiting for your approval" : `${tool} is waiting for your approval`),
77
89
  questionTitle: "Your reply is needed",
@@ -122,6 +134,29 @@ window.__ModuleLoader__.load({
122
134
  /** Pending-interaction kinds the plugin reports. */
123
135
  const PENDING_KINDS = ["approval", "question", "plan-review"]
124
136
 
137
+ /**
138
+ * The local route that answers how a session's newest turn ended, and how
139
+ * long the banner waits for it. Session status only carries `running` and
140
+ * `completionUnread`, so without this ask every ending reads as "completed".
141
+ */
142
+ const TURN_KIND_PATH = "/desktop-notify/turn"
143
+ const TURN_LOOKUP_TIMEOUT_MS = 800
144
+
145
+ /** Ask the Host how one turn ended; undefined when it cannot answer in time. */
146
+ function lookupTurnKind(sessionId) {
147
+ const lookup = fetch(`${TURN_KIND_PATH}?sessionId=${encodeURIComponent(sessionId)}`, {
148
+ headers: { accept: "application/json" },
149
+ cache: "no-store",
150
+ })
151
+ .then((response) => (response.ok ? response.json() : undefined))
152
+ .then((payload) => (typeof payload?.kind === "string" ? payload.kind : undefined))
153
+ .catch(() => undefined)
154
+ const timeout = new Promise((resolve) => {
155
+ setTimeout(() => resolve(undefined), TURN_LOOKUP_TIMEOUT_MS)
156
+ })
157
+ return Promise.race([lookup, timeout])
158
+ }
159
+
125
160
  /** Read the browser storage when this renderer has one. */
126
161
  function storage() {
127
162
  try {
@@ -412,6 +447,27 @@ window.__ModuleLoader__.load({
412
447
  void pollExternal()
413
448
  }
414
449
 
450
+ /**
451
+ * Post the completion banner, worded by the turn's real ending when the
452
+ * Host can still answer for it. A blocked, max-tokens or aborted turn is
453
+ * not "finished", and calling it that is what makes the banner wrong.
454
+ */
455
+ function deliverCompletion(sessionId, table, name, duration) {
456
+ const completed = () => {
457
+ notify("complete", sessionId, preferences.titleComplete || table.completeTitle, bodyText(table.complete(name, duration), preferences.maxBodyChars))
458
+ }
459
+ if (typeof fetch !== "function") {
460
+ completed()
461
+ return
462
+ }
463
+ void lookupTurnKind(sessionId).then((kind) => {
464
+ if (kind === "blocked") return notify("complete:blocked", sessionId, table.blockedTitle, bodyText(table.blocked(name), preferences.maxBodyChars))
465
+ if (kind === "max-tokens") return notify("complete:max-tokens", sessionId, table.maxTokensTitle, bodyText(table.maxTokens(name), preferences.maxBodyChars))
466
+ if (kind === "aborted") return notify("complete:aborted", sessionId, table.abortedTitle, bodyText(table.aborted(name), preferences.maxBodyChars))
467
+ return completed()
468
+ })
469
+ }
470
+
415
471
  /** Post one banner; clicking it opens the session and raises the window. */
416
472
  function notify(kind, sessionId, title, body) {
417
473
  if (!preferences.notifications) {
@@ -504,6 +560,11 @@ window.__ModuleLoader__.load({
504
560
  continue
505
561
  }
506
562
  seen.add(sessionId)
563
+ // The first sighting of a session only records the baseline: every
564
+ // unread completion and every waiting approval that already existed
565
+ // when the window (re)loaded would otherwise fire as a fresh edge,
566
+ // one burst of banners per launch.
567
+ const observed = previous.has(sessionId)
507
568
  const before = previous.get(sessionId) ?? {}
508
569
  const entry = attention.get(sessionId) ?? {}
509
570
  // A failure already recorded for this turn owns the banner and the
@@ -515,9 +576,9 @@ window.__ModuleLoader__.load({
515
576
  const pendingKind = pendingKindOf(status)
516
577
  const running = status?.running === true
517
578
  const justFinished = before.running === true && running === false
518
- const finishedNow = justFinished || (completed && before.completed !== true)
579
+ const finishedNow = justFinished || (observed && completed && before.completed !== true)
519
580
  const completionEdge = finishedNow && !failurePending
520
- const pendingEdge = pendingKind !== undefined && pendingKind !== before.pendingKind
581
+ const pendingEdge = observed && pendingKind !== undefined && pendingKind !== before.pendingKind
521
582
 
522
583
  if (running) {
523
584
  if (!turnStartedAt.has(sessionId)) turnStartedAt.set(sessionId, Date.now())
@@ -530,14 +591,9 @@ window.__ModuleLoader__.load({
530
591
  const started = turnStartedAt.get(sessionId)
531
592
  turnStartedAt.delete(sessionId)
532
593
  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
- }
594
+ // 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)
541
597
  }
542
598
 
543
599
  if (looking) {
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.2",
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",