dsh-plugin-desktop-notify 0.5.7 → 0.6.1

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
@@ -55,6 +55,48 @@ __DSH_DESKTOP_NOTIFY__.state()
55
55
  __DSH_DESKTOP_NOTIFY__.report()
56
56
  ```
57
57
 
58
+
59
+ ## Notification interface for other tools
60
+
61
+ The Host half exposes two loopback routes for scripts, browser agents and cron
62
+ jobs:
63
+
64
+ | Method | Path | Purpose |
65
+ | --- | --- | --- |
66
+ | `POST` | `/desktop-notify/request` | Post one notification: `{"title": "...", "body": "...", "sessionId": "optional"}` |
67
+ | `GET` | `/desktop-notify/pending` | The renderer drains what is waiting (scripts do not call it) |
68
+
69
+ The address is written to `~/.dsh/desktop-notify.endpoint`, so callers never guess
70
+ the port:
71
+
72
+ ```bash
73
+ curl -s "$(cat ~/.dsh/desktop-notify.endpoint)" \
74
+ -H 'content-type: application/json' \
75
+ -d '{"title":"BrowserOS needs you","body":"A takeover request is waiting"}'
76
+ ```
77
+
78
+ Python:
79
+
80
+ ```python
81
+ import json, pathlib, urllib.request
82
+
83
+ url = pathlib.Path.home().joinpath(".dsh/desktop-notify.endpoint").read_text().strip()
84
+ request = urllib.request.Request(
85
+ url,
86
+ data=json.dumps({"title": "BrowserOS needs you", "body": "A takeover request is waiting"}).encode(),
87
+ headers={"content-type": "application/json"},
88
+ )
89
+ urllib.request.urlopen(request, timeout=3).read()
90
+ ```
91
+
92
+ Notes:
93
+
94
+ - The banner is posted by **DSH itself** (attributed to DeepSeek Harness), not by
95
+ a script host
96
+ - 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
98
+ off the osascript fallback, not the interface
99
+
58
100
  ## Known limits
59
101
 
60
102
  - **Windows**: clicking a notification switches the session, but the window does
package/README.md CHANGED
@@ -53,6 +53,44 @@ __DSH_DESKTOP_NOTIFY__.state() // 小红点、待处理会话、最近
53
53
  __DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于排查
54
54
  ```
55
55
 
56
+
57
+ ## 给其它工具用的通知接口
58
+
59
+ 宿主半边在**回环地址**上开了两个路由,供脚本、浏览器代理、定时任务调用:
60
+
61
+ | 方法 | 路径 | 作用 |
62
+ | --- | --- | --- |
63
+ | `POST` | `/desktop-notify/request` | 发一条通知,body:`{"title": "...", "body": "...", "sessionId": "可选"}` |
64
+ | `GET` | `/desktop-notify/pending` | 渲染层取走待显示的通知(脚本不用调) |
65
+
66
+ 地址写在 `~/.dsh/desktop-notify.endpoint`,所以调用方不用猜端口:
67
+
68
+ ```bash
69
+ curl -s "$(cat ~/.dsh/desktop-notify.endpoint)" \
70
+ -H 'content-type: application/json' \
71
+ -d '{"title":"BrowserOS 需要你","body":"有一个接管请求"}'
72
+ ```
73
+
74
+ Python(比如监听 neo 的守护脚本):
75
+
76
+ ```python
77
+ import json, pathlib, urllib.request
78
+
79
+ url = pathlib.Path.home().joinpath(".dsh/desktop-notify.endpoint").read_text().strip()
80
+ request = urllib.request.Request(
81
+ url,
82
+ data=json.dumps({"title": "BrowserOS 需要你", "body": "有一个接管请求"}).encode(),
83
+ headers={"content-type": "application/json"},
84
+ )
85
+ urllib.request.urlopen(request, timeout=3).read()
86
+ ```
87
+
88
+ 要点:
89
+
90
+ - 通知由 **DSH 自己弹出**(归属 DeepSeek Harness),不是 Script Editor
91
+ - 点通知会把窗口叫到前面;带 `sessionId` 时会切到那个会话
92
+ - 接口只监听回环地址,不需要登录;`enabled: false` 只关 osascript 兜底,不影响接口
93
+
56
94
  ## 已知限制
57
95
 
58
96
  - **Windows**:点通知会切到对应会话,但窗口不一定到前台——`AppUserModelID` 和主进程 focus 通道属于桌面端,渲染层插件做不到
package/index.js CHANGED
@@ -15,9 +15,9 @@
15
15
  */
16
16
 
17
17
  import { execFile, spawn } from "node:child_process"
18
- import { appendFile, mkdir } from "node:fs/promises"
18
+ import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"
19
19
  import { homedir } from "node:os"
20
- import { basename, dirname } from "node:path"
20
+ import { basename, dirname, join } from "node:path"
21
21
 
22
22
  /** Read the notification in argv order, so no AppleScript string escaping is needed. */
23
23
  const APPLESCRIPT = [
@@ -42,6 +42,14 @@ const DEFAULTS = {
42
42
  maxTrackedSessions: 64,
43
43
  /** Log the decision without spawning osascript/afplay; the tests use it. */
44
44
  dryRun: false,
45
+ /** Local interface other tools post notifications to (see README). */
46
+ api: true,
47
+ /** Where the endpoint URL is written for those tools. */
48
+ endpointPath: "~/.dsh/desktop-notify.endpoint",
49
+ /** Longest accepted request body. */
50
+ apiBodyLimit: 8192,
51
+ /** How many unread requests the interface keeps. */
52
+ apiQueueLimit: 50,
45
53
  }
46
54
 
47
55
  /** Turn-end bodies for the kinds that carry no model text. */
@@ -52,6 +60,178 @@ const TURN_END_BODIES = {
52
60
  "max-tokens": () => "输出达到上限,本轮结束",
53
61
  }
54
62
 
63
+ /** The URL path tools post to, and the path the renderer drains. */
64
+ const API_REQUEST_PATH = "/desktop-notify/request"
65
+ const API_PENDING_PATH = "/desktop-notify/pending"
66
+
67
+ /** Read one JSON request body, bounded by `limit` bytes. */
68
+ function readBody(request, limit) {
69
+ return new Promise((resolve, reject) => {
70
+ const chunks = []
71
+ let size = 0
72
+ request.on("data", (chunk) => {
73
+ size += chunk.length
74
+ if (size > limit) {
75
+ reject(new Error("body too large"))
76
+ request.destroy()
77
+ return
78
+ }
79
+ chunks.push(chunk)
80
+ })
81
+ request.on("end", () => {
82
+ try {
83
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")))
84
+ } catch (error) {
85
+ reject(error)
86
+ }
87
+ })
88
+ request.on("error", reject)
89
+ })
90
+ }
91
+
92
+ /** Write one JSON answer with the headers a local tool needs. */
93
+ function answer(response, status, payload) {
94
+ const body = JSON.stringify(payload)
95
+ response.writeHead(status, {
96
+ "content-type": "application/json; charset=utf-8",
97
+ "content-length": Buffer.byteLength(body),
98
+ "cache-control": "no-store",
99
+ "access-control-allow-origin": "*",
100
+ "access-control-allow-headers": "content-type",
101
+ })
102
+ response.end(body)
103
+ }
104
+
105
+ /**
106
+ * Mount the local notification interface other tools (a watch script, a
107
+ * browser agent, a cron job) post to.
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.
114
+ */
115
+ function mountInterface(ctx, config) {
116
+ if (!config.api) return
117
+ const server = (() => {
118
+ try {
119
+ return ctx.get?.("webServer")
120
+ } catch {
121
+ return undefined
122
+ }
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
+
129
+ const queue = []
130
+ const disposers = []
131
+
132
+ const post = async (request, response) => {
133
+ if (request.method === "OPTIONS") {
134
+ answer(response, 204, {})
135
+ return
136
+ }
137
+ if (request.method !== "POST") {
138
+ answer(response, 405, { ok: false, error: "use POST" })
139
+ return
140
+ }
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
147
+ }
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
153
+ }
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
+
164
+ const pending = (request, response) => {
165
+ if (request.method === "OPTIONS") {
166
+ answer(response, 204, {})
167
+ return
168
+ }
169
+ if (request.method !== "GET") {
170
+ answer(response, 405, { ok: false, error: "use GET" })
171
+ return
172
+ }
173
+ const items = queue.splice(0, queue.length)
174
+ answer(response, 200, items)
175
+ }
176
+
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, [])
186
+ }
187
+ } }))
188
+ } catch (error) {
189
+ void noteToLog(config.logPath, `interface: cannot register routes: ${String(error)}`)
190
+ return
191
+ }
192
+
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 = (() => {
196
+ try {
197
+ return typeof server.getOrigin === "function" ? server.getOrigin() : undefined
198
+ } catch {
199
+ return undefined
200
+ }
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
+
211
+ ctx.effect(() => () => {
212
+ for (const dispose of disposers) {
213
+ try {
214
+ dispose()
215
+ } catch {
216
+ /* the route table is being torn down anyway */
217
+ }
218
+ }
219
+ if (endpointPath !== "") void rm(endpointPath, { force: true }).catch(() => {})
220
+ }, "desktop-notify: local interface")
221
+ }
222
+
223
+ /** Append one best-effort line to the log from outside the session machinery. */
224
+ async function noteToLog(logPathConfig, line) {
225
+ try {
226
+ const path = expandHome(logPathConfig)
227
+ if (path === "") return
228
+ await mkdir(dirname(path), { recursive: true })
229
+ await appendFile(path, `${new Date().toISOString()} ${line}\n`, "utf8")
230
+ } catch {
231
+ /* logging is best effort */
232
+ }
233
+ }
234
+
55
235
  /** Merge the row's config over the defaults, keeping field types honest. */
56
236
  function resolveConfig(raw) {
57
237
  const config = { ...DEFAULTS }
@@ -142,6 +322,7 @@ function runDetached(command, args) {
142
322
  */
143
323
  export function apply(ctx, rawConfig) {
144
324
  const config = resolveConfig(rawConfig)
325
+ mountInterface(ctx, config)
145
326
  if (!config.enabled) return
146
327
 
147
328
  const logPath = expandHome(config.logPath)
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.5.6"
33
+ const VERSION = "0.6.1"
34
34
  const PREFERENCES_KEY = `${NS}:preferences`
35
35
 
36
36
  /** Defaults; override per field with the localStorage JSON described in README. */
@@ -344,6 +344,74 @@ window.__ModuleLoader__.load({
344
344
  return { ok: false, attempts }
345
345
  }
346
346
 
347
+ /**
348
+ * Banners requested by other tools: the Host half accepts POSTs on a
349
+ * loopback route and this half drains them over the app protocol, so the
350
+ * banner is a real DeepSeek Harness notification rather than a script
351
+ * host's.
352
+ */
353
+ const EXTERNAL_PENDING_PATH = "/desktop-notify/pending"
354
+ let externalTimer
355
+ let externalStopped = false
356
+ const showExternal = (item) => {
357
+ const title = typeof item?.title === "string" && item.title !== "" ? item.title : "DeepSeek Harness"
358
+ const body = typeof item?.body === "string" ? item.body.slice(0, 200) : ""
359
+ if (!preferences.notifications) {
360
+ recordNotify({ kind: "external", title, body, result: "skipped: preferences.notifications=false" })
361
+ return
362
+ }
363
+ if (!canNotify()) {
364
+ recordNotify({ kind: "external", title, body, result: "skipped: Notification unavailable" })
365
+ return
366
+ }
367
+ let notification
368
+ try {
369
+ notification = new Notification(title, { body })
370
+ } catch (error) {
371
+ recordNotify({ kind: "external", title, body, result: `threw: ${String(error)}` })
372
+ return
373
+ }
374
+ recordNotify({ kind: "external", title, body, result: "posted" })
375
+ notification.onclick = () => {
376
+ let focused = false
377
+ try {
378
+ window.focus()
379
+ focused = true
380
+ } catch {
381
+ /* focus is best effort */
382
+ }
383
+ const sessionId = typeof item?.sessionId === "string" && item.sessionId !== "" ? item.sessionId : null
384
+ const opened = sessionId === null ? undefined : openSessionTarget(sessionId)
385
+ lastClick = {
386
+ at: new Date().toISOString(),
387
+ kind: "external",
388
+ sessionId,
389
+ ...opened === undefined ? {} : { opened },
390
+ focused,
391
+ }
392
+ }
393
+ }
394
+ const pollExternal = async () => {
395
+ if (externalStopped || typeof fetch !== "function") return
396
+ try {
397
+ const response = await fetch(EXTERNAL_PENDING_PATH, {
398
+ headers: { accept: "application/json" },
399
+ cache: "no-store",
400
+ })
401
+ if (!response.ok) return
402
+ const items = await response.json()
403
+ for (const item of Array.isArray(items) ? items : []) showExternal(item)
404
+ } catch {
405
+ /* no Host half, or this build does not proxy the path */
406
+ }
407
+ }
408
+ if (preferences.notifications) {
409
+ externalTimer = setInterval(() => { void pollExternal() }, 2500)
410
+ // Node-style timers can hold a process open; the renderer's cannot.
411
+ externalTimer?.unref?.()
412
+ void pollExternal()
413
+ }
414
+
347
415
  /** Post one banner; clicking it opens the session and raises the window. */
348
416
  function notify(kind, sessionId, title, body) {
349
417
  if (!preferences.notifications) {
@@ -582,6 +650,11 @@ window.__ModuleLoader__.load({
582
650
  }
583
651
  }, "dsh-plugin-desktop-notify: failure watch")
584
652
 
653
+ ctx.effect(() => () => {
654
+ externalStopped = true
655
+ clearInterval(externalTimer)
656
+ }, "dsh-plugin-desktop-notify: external interface release")
657
+
585
658
  ctx.effect(() => () => {
586
659
  try {
587
660
  if (canBadge()) void navigator.clearAppBadge()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-desktop-notify",
3
- "version": "0.5.7",
3
+ "version": "0.6.1",
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",