dsh-plugin-desktop-notify 0.0.0-stage → 0.5.6
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/LICENSE +21 -0
- package/README.en.md +214 -0
- package/README.md +237 -2
- package/cordis.patch.yml +36 -0
- package/icon.svg +6 -0
- package/index.js +244 -0
- package/lib/client.js +709 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/index.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-desktop-notify
|
|
3
|
+
*
|
|
4
|
+
* Host plugin for a DeepSeek Harness profile: post a macOS notification when a
|
|
5
|
+
* turn ends (completed / error / blocked / max-tokens) or when a tool approval
|
|
6
|
+
* is waiting for the user. Banners go through `osascript`, so they are
|
|
7
|
+
* attributed to the AppleScript host app rather than to Harness itself; the
|
|
8
|
+
* optional `sound` keeps an audible cue even when that app's notification
|
|
9
|
+
* permission is off.
|
|
10
|
+
*
|
|
11
|
+
* The plugin observes the session log through the same `session/event` seam the
|
|
12
|
+
* persistence plugins use, so it never participates in a decision: it only
|
|
13
|
+
* reads committed events, and every side effect is deferred off the synchronous
|
|
14
|
+
* append path.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFile, spawn } from "node:child_process"
|
|
18
|
+
import { appendFile, mkdir } from "node:fs/promises"
|
|
19
|
+
import { homedir } from "node:os"
|
|
20
|
+
import { basename, dirname } from "node:path"
|
|
21
|
+
|
|
22
|
+
/** Read the notification in argv order, so no AppleScript string escaping is needed. */
|
|
23
|
+
const APPLESCRIPT = [
|
|
24
|
+
"on run argv",
|
|
25
|
+
"display notification (item 1 of argv) with title (item 2 of argv) subtitle (item 3 of argv)",
|
|
26
|
+
"end run",
|
|
27
|
+
].join("\n")
|
|
28
|
+
|
|
29
|
+
const DEFAULTS = {
|
|
30
|
+
enabled: true,
|
|
31
|
+
onTurnEnd: true,
|
|
32
|
+
onApproval: true,
|
|
33
|
+
onlyWhenUnfocused: true,
|
|
34
|
+
frontmostAppNames: ["DeepSeek Harness"],
|
|
35
|
+
sound: true,
|
|
36
|
+
soundFile: "/System/Library/Sounds/Glass.aiff",
|
|
37
|
+
titleTurnEnd: "DSH 任务完成",
|
|
38
|
+
titleApproval: "DSH 需要你确认",
|
|
39
|
+
notifyOnTurnEndKinds: ["completed", "error", "blocked", "max-tokens"],
|
|
40
|
+
maxBodyChars: 140,
|
|
41
|
+
logPath: "~/.dsh/desktop-notify.log",
|
|
42
|
+
maxTrackedSessions: 64,
|
|
43
|
+
/** Log the decision without spawning osascript/afplay; the tests use it. */
|
|
44
|
+
dryRun: false,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Turn-end bodies for the kinds that carry no model text. */
|
|
48
|
+
const TURN_END_BODIES = {
|
|
49
|
+
error: (reason) => `本轮出错:${reason?.error?.message ?? "未知错误"}`,
|
|
50
|
+
aborted: () => "本轮已中止",
|
|
51
|
+
blocked: () => "本轮被拦截",
|
|
52
|
+
"max-tokens": () => "输出达到上限,本轮结束",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Merge the row's config over the defaults, keeping field types honest. */
|
|
56
|
+
function resolveConfig(raw) {
|
|
57
|
+
const config = { ...DEFAULTS }
|
|
58
|
+
if (raw === null || typeof raw !== "object") return config
|
|
59
|
+
for (const [key, fallback] of Object.entries(DEFAULTS)) {
|
|
60
|
+
const value = raw[key]
|
|
61
|
+
if (value === undefined) continue
|
|
62
|
+
if (Array.isArray(fallback)) {
|
|
63
|
+
if (!Array.isArray(value)) continue
|
|
64
|
+
} else if (typeof value !== typeof fallback) continue
|
|
65
|
+
config[key] = value
|
|
66
|
+
}
|
|
67
|
+
return config
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Expand one leading `~` so the log path can be written in config. */
|
|
71
|
+
function expandHome(path) {
|
|
72
|
+
return path === "~" || path.startsWith("~/") ? homedir() + path.slice(1) : path
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Collapse whitespace and cut to the configured body budget. */
|
|
76
|
+
function bodyText(text, maxChars) {
|
|
77
|
+
const collapsed = String(text).replace(/\s+/gu, " ").trim()
|
|
78
|
+
if (collapsed.length <= maxChars) return collapsed
|
|
79
|
+
return `${collapsed.slice(0, Math.max(0, maxChars - 1))}…`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The first visible line of an assistant message's text blocks. */
|
|
83
|
+
function firstTextLine(message) {
|
|
84
|
+
if (message === null || typeof message !== "object" || !Array.isArray(message.content)) return ""
|
|
85
|
+
for (const block of message.content) {
|
|
86
|
+
if (block === null || typeof block !== "object" || block.type !== "text") continue
|
|
87
|
+
const text = typeof block.text === "string" ? block.text.trim() : ""
|
|
88
|
+
if (text !== "") return text
|
|
89
|
+
}
|
|
90
|
+
return ""
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Direct children of a session belong to subagents; they get no banner. */
|
|
94
|
+
function isNotifiableSession(session) {
|
|
95
|
+
const header = session?.header
|
|
96
|
+
if (header === null || typeof header !== "object") return false
|
|
97
|
+
if (header.origin === "subagent") return false
|
|
98
|
+
return header.parentSession === undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The workspace folder is the closest thing to a session name without a title lookup. */
|
|
102
|
+
function subtitleOf(session) {
|
|
103
|
+
const cwd = session?.header?.cwd
|
|
104
|
+
if (typeof cwd !== "string" || cwd === "") return "DSH"
|
|
105
|
+
return basename(cwd) || "DSH"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Parse the app name out of `lsappinfo info -only name`. */
|
|
109
|
+
function parseFrontmostApp(stdout) {
|
|
110
|
+
const match = /^"([^"]+)"/u.exec(String(stdout).trim())
|
|
111
|
+
return match === null ? undefined : match[1]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The foreground application's display name, or undefined when it cannot be read. */
|
|
115
|
+
function frontmostApp() {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
execFile("/usr/bin/lsappinfo", ["front"], { timeout: 2000 }, (frontError, frontOut) => {
|
|
118
|
+
if (frontError !== null) return resolve(undefined)
|
|
119
|
+
const asn = String(frontOut).trim()
|
|
120
|
+
if (asn === "") return resolve(undefined)
|
|
121
|
+
execFile("/usr/bin/lsappinfo", ["info", "-only", "name", asn], { timeout: 2000 }, (infoError, infoOut) => {
|
|
122
|
+
resolve(infoError !== null ? undefined : parseFrontmostApp(infoOut))
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Start a detached child: the Host never waits on banners or audio. */
|
|
129
|
+
function runDetached(command, args) {
|
|
130
|
+
try {
|
|
131
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
|
132
|
+
child.on("error", () => {})
|
|
133
|
+
child.unref()
|
|
134
|
+
} catch {
|
|
135
|
+
/* notification support is best effort; a missing helper must not break a turn */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {object} ctx - the plugin context; the row is mounted on the profile root.
|
|
141
|
+
* @param {object} rawConfig - the row's `config` from `cordis.patch.yml`.
|
|
142
|
+
*/
|
|
143
|
+
export function apply(ctx, rawConfig) {
|
|
144
|
+
const config = resolveConfig(rawConfig)
|
|
145
|
+
if (!config.enabled) return
|
|
146
|
+
|
|
147
|
+
const logPath = expandHome(config.logPath)
|
|
148
|
+
const pendingText = new Map()
|
|
149
|
+
|
|
150
|
+
/** Append one diagnostic line; the log is the only durable evidence of a fired banner. */
|
|
151
|
+
async function writeLog(line) {
|
|
152
|
+
if (config.logPath === "") return
|
|
153
|
+
try {
|
|
154
|
+
await mkdir(dirname(logPath), { recursive: true })
|
|
155
|
+
await appendFile(logPath, `${new Date().toISOString()} ${line}\n`, "utf8")
|
|
156
|
+
} catch {
|
|
157
|
+
/* a log that cannot be written must not break notification delivery */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Remember the newest assistant text per session, trimmed to a small cache. */
|
|
162
|
+
function remember(sessionId, text) {
|
|
163
|
+
if (text === "") return
|
|
164
|
+
pendingText.delete(sessionId)
|
|
165
|
+
pendingText.set(sessionId, text)
|
|
166
|
+
while (pendingText.size > config.maxTrackedSessions) {
|
|
167
|
+
const oldest = pendingText.keys().next()
|
|
168
|
+
if (oldest.done === true) break
|
|
169
|
+
pendingText.delete(oldest.value)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Deliver one banner and the optional sound, honoring the focus rule. */
|
|
174
|
+
async function deliver(kind, session, body) {
|
|
175
|
+
try {
|
|
176
|
+
const app = await frontmostApp()
|
|
177
|
+
const sessionId = session?.id ?? "-"
|
|
178
|
+
if (config.onlyWhenUnfocused && app !== undefined && config.frontmostAppNames.includes(app)) {
|
|
179
|
+
await writeLog(`skip kind=${kind} reason=focused app=${app} session=${sessionId} body=${body}`)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
const title = kind === "approval" ? config.titleApproval : config.titleTurnEnd
|
|
183
|
+
const subtitle = subtitleOf(session)
|
|
184
|
+
if (process.platform === "darwin" && !config.dryRun) {
|
|
185
|
+
runDetached("/usr/bin/osascript", ["-e", APPLESCRIPT, body, title, subtitle])
|
|
186
|
+
if (config.sound && config.soundFile !== "") runDetached("/usr/bin/afplay", [config.soundFile])
|
|
187
|
+
}
|
|
188
|
+
await writeLog(`notify kind=${kind} app=${app ?? "-"} session=${sessionId} title=${title} subtitle=${subtitle} body=${body}`)
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await writeLog(`error kind=${kind} ${String(error)}`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Decide what, if anything, one committed session event deserves. */
|
|
195
|
+
function handleEvent(session, event) {
|
|
196
|
+
const type = event?.type
|
|
197
|
+
const data = event?.data
|
|
198
|
+
if (!isNotifiableSession(session)) return
|
|
199
|
+
const sessionId = session.id
|
|
200
|
+
|
|
201
|
+
if (type === "assistant/message") {
|
|
202
|
+
if (data?.interrupted === true) return
|
|
203
|
+
remember(sessionId, firstTextLine(data?.message))
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (type === "turn/end") {
|
|
208
|
+
const kind = data?.reason?.kind ?? "completed"
|
|
209
|
+
const remembered = pendingText.get(sessionId)
|
|
210
|
+
pendingText.delete(sessionId)
|
|
211
|
+
if (!config.onTurnEnd) return
|
|
212
|
+
if (!config.notifyOnTurnEndKinds.includes(kind)) return
|
|
213
|
+
const body = kind === "completed"
|
|
214
|
+
? bodyText(remembered ?? "本轮已完成", config.maxBodyChars)
|
|
215
|
+
: bodyText(TURN_END_BODIES[kind]?.(data?.reason) ?? `本轮结束(${kind})`, config.maxBodyChars)
|
|
216
|
+
void deliver(`turn-end:${kind}`, session, body)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (type === "approval/asked") {
|
|
221
|
+
if (!config.onApproval) return
|
|
222
|
+
const toolName = typeof data?.toolName === "string" && data.toolName !== "" ? data.toolName : "未知工具"
|
|
223
|
+
const reason = typeof data?.reason === "string" && data.reason !== "" ? `:${data.reason}` : ""
|
|
224
|
+
void deliver("approval", session, bodyText(`工具 ${toolName} 等待你批准${reason}`, config.maxBodyChars))
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** One dispatch per committed event; work stays off the append path. */
|
|
229
|
+
ctx.on("session/event", (session, event) => {
|
|
230
|
+
try {
|
|
231
|
+
handleEvent(session, event)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
void writeLog(`error session=${session?.id ?? "-"} ${String(error)}`)
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
/** A disposed session must not keep its remembered text alive. */
|
|
238
|
+
ctx.on("session/disposed", (session) => {
|
|
239
|
+
const sessionId = session?.id
|
|
240
|
+
if (typeof sessionId === "string") pendingText.delete(sessionId)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
void writeLog(`plugin started pid=${process.pid} platform=${process.platform} onlyWhenUnfocused=${config.onlyWhenUnfocused}`)
|
|
244
|
+
}
|