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 +15 -1
- package/README.md +16 -1
- package/cordis.patch.yml +1 -0
- package/index.js +218 -99
- package/lib/client.js +67 -11
- package/package.json +1 -1
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
|
+
[](https://www.npmjs.com/package/dsh-plugin-desktop-notify)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/chendaleiQ/dsh-plugin-desktop-notify)
|
|
8
|
+
|
|
9
|
+

|
|
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
|
|
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
|
+
[](https://www.npmjs.com/package/dsh-plugin-desktop-notify)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/chendaleiQ/dsh-plugin-desktop-notify)
|
|
8
|
+
|
|
9
|
+

|
|
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
|
-
-
|
|
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
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
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
130
|
-
|
|
139
|
+
const token = randomBytes(16).toString("hex")
|
|
140
|
+
const queue = []
|
|
131
141
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
const connectionOf = () => {
|
|
156
|
+
try {
|
|
157
|
+
return hostCtx.get?.("connection")
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
147
161
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
const endpointPath = expandHome(config.endpointPath)
|
|
195
|
-
const origin = (() => {
|
|
240
|
+
let disposers
|
|
196
241
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
271
|
+
return typeof server.getOrigin === "function" ? server.getOrigin() : undefined
|
|
215
272
|
} catch {
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
-
|
|
534
|
-
const
|
|
535
|
-
if (
|
|
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.
|
|
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",
|