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 +16 -1
- package/README.md +23 -1
- package/cordis.patch.yml +1 -0
- package/index.js +272 -99
- package/lib/client.js +236 -11
- package/package.json +4 -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
|
|
@@ -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
|
|
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
|
+
[](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
|
+
- **说清楚怎么结束的**——正常完成、被拦截、输出到上限、被中止,各自一句话,不把中断说成「已完成」
|
|
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
|
-
-
|
|
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
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
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
130
|
-
|
|
143
|
+
const token = randomBytes(16).toString("hex")
|
|
144
|
+
const queue = []
|
|
131
145
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
159
|
+
const connectionOf = () => {
|
|
160
|
+
try {
|
|
161
|
+
return hostCtx.get?.("connection")
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
147
165
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
313
|
+
return typeof server.getOrigin === "function" ? server.getOrigin() : undefined
|
|
215
314
|
} catch {
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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.
|
|
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
|
}
|