/** * @dsh-external/dsh-desktop-notify — DSH 桌面通知(host 半侧)。 * * 触发条件:所有 DSH Web 前端都失焦(window/document 无焦点)时,才向 Windows * 通知中心发 toast。四类事件: * ① user-questions/request → 需要你选择决策 * ② approval/request → 需要你授权 * ③ turn/end error/interrupted/max-tokens、api-session/error → 意外中断,请重试 * ④ turn/end completed → 任务已完成 * * 通知标题 = 会话标题(sessionTitle 折叠;没有时回退首条用户消息), * 内容 = 对应提示。 * * 前端失焦状态由 client.js 通过 /dsh-desktop-notify/focus 心跳上报;host 汇总 * 所有标签页/窗口,任意一个“可见且拥有焦点”就静默。 */ import { execFileSync, spawn } from 'node:child_process' import { randomBytes } from 'node:crypto' import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { homedir, tmpdir } from 'node:os' import { fileURLToPath, pathToFileURL } from 'node:url' export const name = '@dsh-external/dsh-desktop-notify' export const inject = ['webServer', 'sessions', 'sessionTitle', 'connection'] const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') const FOCUS_TTL_MS = 180000 const HEARTBEAT_MS = 3000 const DEDUPE_MS = 2500 const DEFAULTS = { notifyOnDecision: true, notifyOnApproval: true, notifyOnInterruption: true, notifyOnComplete: true, onlyWhenUnfocused: true, ignoreSubagent: true, minTurnDurationMs: 0, clickToOpen: true, enableCard: false, toastAppId: 'DeepSeek.DSH.Notify', toastAppName: 'Deepseek Harness Notification', toastIconPath: '', toastAppIconPath: '', logFile: '', } function truncate(value, max) { const text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim() return text.length > max ? text.slice(0, max - 1) + '…' : text } function formatDuration(ms) { if (!ms || ms < 0) return '' const s = Math.round(ms / 1000) if (s < 60) return s + ' 秒' return Math.floor(s / 60) + ' 分 ' + (s % 60) + ' 秒' } function blocksToText(content) { if (!Array.isArray(content)) return '' const parts = [] for (const block of content) { if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text) } return parts.join('\n') } /** Never persist or return the launch token in logs / HTTP responses. */ function redactLaunch(value) { return String(value || '') .replace(/([?&]token=)[^&#]*/gi, '$1***') .replace(/([?&]key=)[^&#]*/gi, '$1***') } function escapeXml(value) { return String(value == null ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } /** * ToastGeneric XML: title + body + official whale logo. `launch` uses protocol * activation so a click opens the DSH web URL (which the client half turns into * the target conversation). The fragment carries the session id and survives * the launch-token 303 redirect; query parameters would be dropped. */ export function buildToastXml(title, body, iconPng, launch) { const launchAttr = launch ? ' activationType="protocol" launch="' + escapeXml(launch) + '"' : '' const image = iconPng ? '' : '' return '' + '' + escapeXml(title) + '' + '' + escapeXml(body) + '' + image + '