dsh-macos-notify 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CrombastiC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # dsh-macos-notify
2
+
3
+ Native macOS notifications for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), with event-specific sounds, notification filtering, multi-task coalescing, and a settings card in the DSH Web UI.
4
+
5
+ ## Features
6
+
7
+ - Notification Center alerts when a turn completes, fails, is blocked, or waits for approval.
8
+ - Separate sounds for completed, error, aborted, and approval events; any event can be muted.
9
+ - System sound picker plus managed custom sound import and deletion from the Web settings card.
10
+ - Custom imports are converted to AIFF, limited to 5 MB and 10 seconds, capped at 20 managed files / 50 MB total, and stored in `~/Library/Sounds`.
11
+ - A 1.5-second coalescing window and optional digest mode prevent parallel tasks from flooding Notification Center.
12
+ - Recent notification diagnostics explain whether an event was sent, queued, suppressed, or failed.
13
+ - A six-event test matrix validates completed, error, approval, aborted, coalesced, and digest notifications.
14
+ - Daily quiet hours and temporary 30-minute, 1-hour, or 24-hour pauses.
15
+ - Duplicate error suppression with a configurable cooldown window.
16
+ - Project path rules for muting, error-only alerts, or important-project bypasses.
17
+ - Minimum turn-duration filtering avoids notifications for near-instant replies.
18
+ - Optional Web-tab focus suppression and macOS HID idle-time gating.
19
+ - Dedicated wording for API rate limits, including provider retry timing when available.
20
+ - OSC 9 notifications for supported terminals, including tmux DCS passthrough.
21
+ - Live settings updates without restarting DSH.
22
+
23
+ ## Requirements
24
+
25
+ - macOS
26
+ - Node.js 22 or newer
27
+ - DeepSeek Harness with the `web` profile
28
+
29
+ ## Install
30
+
31
+ Install the published npm package:
32
+
33
+ ```bash
34
+ npx -y @deepseek-ai/dsh plugin --profile web add dsh-macos-notify
35
+ npx -y @deepseek-ai/dsh web
36
+ ```
37
+
38
+ Alternatively, install the latest source directly from GitHub:
39
+
40
+ ```bash
41
+ npx -y @deepseek-ai/dsh plugin --profile web add github:CrombastiC/dsh-macos-notify
42
+ ```
43
+
44
+ Then open **Settings → Plugins → Plugin configuration → macOS notifications**.
45
+
46
+ To remove the plugin:
47
+
48
+ ```bash
49
+ npx -y @deepseek-ai/dsh plugin --profile web remove dsh-macos-notify
50
+ ```
51
+
52
+ ### Local development install
53
+
54
+ From this repository checkout:
55
+
56
+ ```bash
57
+ npx -y @deepseek-ai/dsh plugin --profile web add .
58
+ npx -y @deepseek-ai/dsh web
59
+ ```
60
+
61
+ `dsh plugin` anchors relative paths to the invoking directory and forwards the install to pnpm in the selected profile. Avoid absolute-path bundle overlays in `cordis.dev.yml`; install the local package through `plugin add` instead.
62
+
63
+ ## Configuration
64
+
65
+ The settings namespace is `macos-notify`. Values changed from the Web card apply live.
66
+
67
+ | Option | Default | Description |
68
+ | --- | --- | --- |
69
+ | `onCompleted` | `true` | Notify when a turn completes normally. |
70
+ | `onError` | `true` | Notify for errors and blocked turns. These alerts bypass completion gates and digesting. |
71
+ | `onAborted` | `false` | Notify when a user aborts a turn. |
72
+ | `onApproval` | `true` | Notify immediately when a tool waits for approval. |
73
+ | `minDurationSec` | `30` | Suppress completed-turn notifications shorter than this many seconds; `0` disables the threshold. |
74
+ | `onlyWhenIdleSec` | `0` | Require this many seconds of keyboard/mouse idle time for completed notifications; `0` disables idle gating. |
75
+ | `onlyWhenUnfocused` | `true` | Suppress completed notifications while any DSH Web tab is focused. |
76
+ | `digestMinutes` | `0` | Collect completed notifications into a periodic digest; `0` sends them immediately. |
77
+ | `includeSubagents` | `false` | Include subagent sessions instead of notifying only for top-level sessions. |
78
+ | `channel` | `auto` | `auto`, `osascript`, or `osc9`. |
79
+ | `sounds.completed` | `Glass` | Sound for completed turns. |
80
+ | `sounds.error` | `Basso` | Sound for errors, blocked turns, and rate limits. |
81
+ | `sounds.aborted` | empty | Sound for aborted turns; empty means silent. |
82
+ | `sounds.approval` | `Ping` | Sound for approval requests. |
83
+ | `coalesceMs` | `1500` | Window for merging simultaneous turn results; `0` disables merging. |
84
+ | `quietHoursEnabled` | `false` | Enable the daily local-time quiet period. |
85
+ | `quietStart` | `23:00` | Quiet-period start in local `HH:mm` time. |
86
+ | `quietEnd` | `08:00` | Quiet-period end in local `HH:mm` time. Overnight ranges are supported. |
87
+ | `quietAllowCritical` | `true` | Continue sending error, blocked, and approval alerts during quiet hours. |
88
+ | `pauseUntil` | `0` | Temporary pause deadline as Unix milliseconds; managed by quick actions in the Web card. |
89
+ | `duplicateWindowSec` | `300` | Suppress identical errors from the same session within this window; `0` disables it. |
90
+ | `projectRulesJson` | `[]` | Project rules managed by the Web card. More-specific descendant paths win. |
91
+ | `notifyOnLoad` | `true` | Send a test notification when the plugin loads. |
92
+
93
+ ### Project rule modes
94
+
95
+ - `mute` — suppress every notification under the configured project path.
96
+ - `errors` — allow only errors, blocked events, and approval requests.
97
+ - `important` — bypass minimum-duration, focus, idle, quiet-hour, and temporary-pause filters.
98
+
99
+ The settings card keeps the last 50 decisions in process memory. Each row records whether an event was sent, queued, suppressed, or failed and includes the reason. This history resets when DSH restarts and does not contain message content.
100
+
101
+ ## Notification channels
102
+
103
+ `auto` uses OSC 9 when the terminal is recognized as supporting it, and falls back to `osascript` otherwise.
104
+
105
+ Recognized OSC 9 terminals include iTerm2, WezTerm, Kitty, Ghostty, and Warp. tmux sessions are wrapped in DCS passthrough automatically.
106
+
107
+ Sound selection applies to the `osascript` channel. With OSC 9, the terminal controls whether and how a notification sound is played.
108
+
109
+ ## Custom sounds
110
+
111
+ In the settings card, select **Edit → Import sound**. Supported inputs include AAC, AIFF, CAF, FLAC, M4A, MP3, OGG, Opus, and WAV.
112
+
113
+ The host validates the extension, decoded size, converted duration, managed-file count, and total managed size before writing anything to the user sound directory. Imports are converted to 44.1 kHz mono AIFF using macOS `afconvert`, with `ffmpeg` as a fallback for formats that `afconvert` cannot decode. Existing files are never overwritten; a numeric suffix is added instead.
114
+
115
+ New imports are recorded in `~/Library/Application Support/dsh-macos-notify/sounds.json` and can be deleted from the settings card. If a sound is currently selected, deletion asks for confirmation and changes affected events to silent. Files remain in `~/Library/Sounds` if the plugin is removed without deleting them first.
116
+
117
+ ## Known limitations
118
+
119
+ - The plugin is macOS-only. Native notifications use `osascript`, and custom import uses macOS audio tooling.
120
+ - OSC 9 sound behavior belongs to the terminal and ignores the per-event sound selection.
121
+ - The Web settings card uses the trusted `/macos-notify` RPC channel because the current DSH Web settings proxy has a namespace allowlist for built-in settings.
122
+ - Only sounds imported by v0.2.0 or later are tracked as managed sounds. Earlier manually copied/imported files can still be selected, but must be removed from `~/Library/Sounds` manually.
123
+
124
+ ## Development
125
+
126
+ The package is intentionally build-free:
127
+
128
+ - `index.js` — host plugin, event handling, notification delivery, settings RPC, and sound import.
129
+ - `client.js` — hand-written DSH client module for focus reporting and the Web settings card.
130
+ - `cordis.patch.yml` — profile bundle patch.
131
+
132
+ Run the release checks:
133
+
134
+ ```bash
135
+ node --check index.js
136
+ node --check client.js
137
+ npm test
138
+ npm pack --dry-run
139
+ ```
140
+
141
+ ## 中文说明
142
+
143
+ 这是一个仅支持 macOS 的 DeepSeek Harness 通知插件。它可以在任务完成、出错、等待审批时发送系统通知,并支持通知诊断、测试矩阵、每日勿扰、临时暂停、自定义声音管理、重复错误抑制、项目规则、焦点抑制、合并通知和定时汇总。推荐从 npm 安装,也可以直接从 GitHub 安装最新版源码。
144
+
145
+ ## License
146
+
147
+ [MIT](LICENSE) © 2026 CrombastiC
package/client.js ADDED
@@ -0,0 +1,204 @@
1
+ // dsh-macos-notify Web UI: focus reporting, settings, sound management and diagnostics.
2
+ window.__ModuleLoader__.load({
3
+ id: 'dsh-macos-notify',
4
+ factory: (require) => {
5
+ var module = { exports: {} }
6
+ var exports = module.exports
7
+ var React = require('react')
8
+ var h = React.createElement
9
+
10
+ var SOUND_ROWS = [
11
+ ['completed', '任务完成'], ['error', '出错 / 被阻止'],
12
+ ['aborted', '任务中断'], ['approval', '等待审批'],
13
+ ]
14
+ var FALLBACK_SOUNDS = ['Basso', 'Blow', 'Bottle', 'Frog', 'Funk', 'Glass', 'Hero', 'Morse', 'Ping', 'Pop', 'Purr', 'Sosumi', 'Submarine', 'Tink']
15
+ var RULE_LABELS = { mute: '全部静音', errors: '仅错误与审批', important: '重要项目(忽略过滤)' }
16
+ var KIND_LABELS = { completed: '完成', error: '错误', aborted: '中断', approval: '审批' }
17
+ var inputStyle = { width: '150px', padding: '5px 8px', fontSize: '12px', boxSizing: 'border-box', border: '1px solid var(--dsw-alias-border-l2,#444)', borderRadius: '6px', background: 'transparent', color: 'inherit' }
18
+ var buttonStyle = { padding: '5px 10px', minHeight: '29px', fontSize: '12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2,#444)', borderRadius: '6px', background: 'transparent', color: 'inherit' }
19
+ var sectionStyle = { borderTop: '1px solid var(--dsw-alias-border-l2,#444)', paddingTop: '12px', display: 'flex', flexDirection: 'column', gap: '8px' }
20
+ var rowStyle = { display: 'flex', alignItems: 'center', gap: '9px', minHeight: '30px' }
21
+ var css = [
22
+ '@keyframes dshSaved{0%{box-shadow:0 0 0 0 rgba(46,157,98,.3);border-color:rgba(46,157,98,.7)}100%{box-shadow:0 0 0 5px rgba(46,157,98,0)}}',
23
+ '@keyframes dshShake{0%,100%{transform:translateX(0)}30%{transform:translateX(-3px)}70%{transform:translateX(3px)}}',
24
+ '.dsh-notify-saved{animation:dshSaved 650ms ease-out both}.dsh-notify-error{animation:dshShake 240ms ease-out both}',
25
+ '@media(prefers-reduced-motion:reduce){.dsh-notify-saved,.dsh-notify-error{animation:none!important}}',
26
+ ].join('\n')
27
+
28
+ function parseRules(raw) {
29
+ try {
30
+ var value = JSON.parse(raw || '[]')
31
+ return Array.isArray(value) ? value.map(function (rule) { return { path: String(rule.path || ''), mode: String(rule.mode || 'mute') } }) : []
32
+ } catch { return [] }
33
+ }
34
+ function toDraft(settings) {
35
+ return Object.assign({}, settings, { sounds: Object.assign({}, settings.sounds || {}), projectRules: parseRules(settings.projectRulesJson) })
36
+ }
37
+ function toPatch(draft) {
38
+ return {
39
+ onCompleted: !!draft.onCompleted, onError: !!draft.onError, onAborted: !!draft.onAborted, onApproval: !!draft.onApproval,
40
+ minDurationSec: Math.max(0, Number(draft.minDurationSec) || 0), onlyWhenIdleSec: Math.max(0, Number(draft.onlyWhenIdleSec) || 0),
41
+ onlyWhenUnfocused: !!draft.onlyWhenUnfocused, digestMinutes: Math.max(0, Number(draft.digestMinutes) || 0),
42
+ includeSubagents: !!draft.includeSubagents, channel: draft.channel || 'auto', sounds: Object.assign({}, draft.sounds),
43
+ coalesceMs: Math.max(0, Number(draft.coalesceMs) || 0), quietHoursEnabled: !!draft.quietHoursEnabled,
44
+ quietStart: draft.quietStart || '23:00', quietEnd: draft.quietEnd || '08:00', quietAllowCritical: !!draft.quietAllowCritical,
45
+ pauseUntil: Number(draft.pauseUntil) || 0, duplicateWindowSec: Math.max(0, Number(draft.duplicateWindowSec) || 0),
46
+ projectRulesJson: JSON.stringify((draft.projectRules || []).filter(function (rule) { return rule.path.trim() }).map(function (rule) { return { path: rule.path.trim(), mode: rule.mode } })),
47
+ }
48
+ }
49
+ function SectionTitle(props) {
50
+ return h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: '8px' } },
51
+ h('strong', null, props.title), props.note ? h('span', { style: { opacity: .55, fontSize: '11px', textAlign: 'right' } }, props.note) : null)
52
+ }
53
+ function Toggle(props) {
54
+ return h('label', { style: rowStyle }, h('input', { type: 'checkbox', checked: !!props.value, disabled: props.disabled, onChange: function (e) { props.onChange(e.target.checked) } }), h('span', { style: { flex: 1 } }, props.label))
55
+ }
56
+ function SettingRow(props) {
57
+ return h('label', { style: rowStyle }, h('span', { style: { flex: 1 } }, props.label, props.note ? h('small', { style: { display: 'block', opacity: .5 } }, props.note) : null), props.children)
58
+ }
59
+
60
+ function Card(props) {
61
+ var settingsState = React.useState(null), settings = settingsState[0], setSettings = settingsState[1]
62
+ var draftState = React.useState(null), draft = draftState[0], setDraft = draftState[1]
63
+ var catalogState = React.useState({ names: [], managed: [], limits: {} }), catalog = catalogState[0], setCatalog = catalogState[1]
64
+ var diagState = React.useState({ entries: [], status: {} }), diagnostics = diagState[0], setDiagnostics = diagState[1]
65
+ var editingState = React.useState(false), editing = editingState[0], setEditing = editingState[1]
66
+ var savingState = React.useState(false), saving = savingState[0], setSaving = savingState[1]
67
+ var savedState = React.useState(false), saved = savedState[0], setSaved = savedState[1]
68
+ var importingState = React.useState(false), importing = importingState[0], setImporting = importingState[1]
69
+ var testingState = React.useState(''), testing = testingState[0], setTesting = testingState[1]
70
+ var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
71
+ var fileInput = React.useRef(null), savedTimer = React.useRef(null), testTimer = React.useRef(null)
72
+
73
+ var loadSettings = function () { return props.call('settings', { op: 'get' }).then(function (value) { setSettings(value); setDraft(toDraft(value)); return value }) }
74
+ var loadCatalog = function () { return props.call('sounds', {}).then(function (value) { if (value && Array.isArray(value.names)) setCatalog(value); return value }) }
75
+ var loadDiagnostics = function () { return props.call('diagnostics', { op: 'get' }).then(setDiagnostics).catch(function () {}) }
76
+ React.useEffect(function () {
77
+ Promise.all([loadSettings(), loadCatalog(), loadDiagnostics()]).catch(function (err) { setError(String(err && err.message || '设置读取失败')) })
78
+ var timer = setInterval(loadDiagnostics, 5000)
79
+ return function () { clearInterval(timer); if (savedTimer.current) clearTimeout(savedTimer.current); if (testTimer.current) clearTimeout(testTimer.current) }
80
+ }, [])
81
+ if (!settings || !draft) return h('div', { style: { padding: '12px', opacity: .6 } }, '加载中…')
82
+
83
+ var patch = toPatch(draft), dirty = JSON.stringify(patch) !== JSON.stringify(toPatch(toDraft(settings)))
84
+ var status = diagnostics.status || {}, pauseActive = Number(status.pauseUntil) > Date.now()
85
+ var availableSounds = catalog.names.length ? catalog.names : FALLBACK_SOUNDS
86
+ var change = function (field, value) { setDraft(Object.assign({}, draft, { [field]: value })) }
87
+ var changeSound = function (kind, value) { setDraft(Object.assign({}, draft, { sounds: Object.assign({}, draft.sounds, { [kind]: value }) })) }
88
+ var beginEdit = function () { setDraft(toDraft(settings)); setEditing(true); setSaved(false); setError('') }
89
+ var cancel = function () { setDraft(toDraft(settings)); setEditing(false); setError('') }
90
+ var save = function () {
91
+ if (!dirty || saving) return
92
+ setSaving(true); setError('')
93
+ props.call('settings', { op: 'patch', value: patch }).then(function (value) {
94
+ setSettings(value); setDraft(toDraft(value)); setEditing(false); setSaved(true); loadDiagnostics()
95
+ if (savedTimer.current) clearTimeout(savedTimer.current)
96
+ savedTimer.current = setTimeout(function () { setSaved(false) }, 1800)
97
+ }).catch(function (err) { setError(String(err && err.message || '保存失败')) }).finally(function () { setSaving(false) })
98
+ }
99
+ var test = function (kind, sound) {
100
+ setTesting(kind); setError('')
101
+ props.call('test', { kind: kind, sound: sound }).then(loadDiagnostics).catch(function (err) { setError(String(err && err.message || '测试失败')) })
102
+ if (testTimer.current) clearTimeout(testTimer.current)
103
+ testTimer.current = setTimeout(function () { setTesting('') }, 1200)
104
+ }
105
+ var pause = function (duration) {
106
+ var until = duration ? Date.now() + duration : 0
107
+ props.call('settings', { op: 'set', field: 'pauseUntil', value: until }).then(function () {
108
+ setSettings(Object.assign({}, settings, { pauseUntil: until }))
109
+ setDraft(Object.assign({}, draft, { pauseUntil: until }))
110
+ return loadDiagnostics()
111
+ }).catch(function (err) { setError(String(err && err.message || '暂停设置失败')) })
112
+ }
113
+ var importFile = function (event) {
114
+ var file = event.target.files && event.target.files[0]; event.target.value = ''
115
+ if (!file) return
116
+ if (file.size > 5 * 1024 * 1024) { setError('声音文件不能超过 5MB'); return }
117
+ setImporting(true); setError('')
118
+ var reader = new FileReader()
119
+ reader.onload = function () {
120
+ props.call('sound/import', { filename: file.name, data: String(reader.result || '').split(',')[1] || '' }).then(function (result) {
121
+ if (!result || !result.name) throw new Error('导入结果无效')
122
+ changeSound('completed', result.name); return loadCatalog()
123
+ }).catch(function (err) { setError(String(err && err.message || '导入失败')) }).finally(function () { setImporting(false) })
124
+ }
125
+ reader.onerror = function () { setImporting(false); setError('声音文件读取失败') }
126
+ reader.readAsDataURL(file)
127
+ }
128
+ var deleteSound = function (name, force) {
129
+ props.call('sound/delete', { name: name, force: !!force }).then(function (result) {
130
+ if (result && result.requiresConfirmation) {
131
+ var used = result.inUse.map(function (kind) { return KIND_LABELS[kind] || kind }).join('、')
132
+ if (window.confirm('“' + name + '”正在用于' + used + '通知。删除后会改为静音,继续吗?')) return deleteSound(name, true)
133
+ return
134
+ }
135
+ var nextSavedSounds = Object.assign({}, settings.sounds)
136
+ var nextDraftSounds = Object.assign({}, draft.sounds)
137
+ Object.keys(nextSavedSounds).forEach(function (kind) { if (nextSavedSounds[kind] === name) nextSavedSounds[kind] = '' })
138
+ Object.keys(nextDraftSounds).forEach(function (kind) { if (nextDraftSounds[kind] === name) nextDraftSounds[kind] = '' })
139
+ setSettings(Object.assign({}, settings, { sounds: nextSavedSounds }))
140
+ setDraft(Object.assign({}, draft, { sounds: nextDraftSounds }))
141
+ return loadCatalog()
142
+ }).catch(function (err) { setError(String(err && err.message || '删除失败')) })
143
+ }
144
+ var updateRule = function (index, field, value) { var rules = draft.projectRules.slice(); rules[index] = Object.assign({}, rules[index], { [field]: value }); change('projectRules', rules) }
145
+ var removeRule = function (index) { change('projectRules', draft.projectRules.filter(function (_, candidate) { return candidate !== index })) }
146
+ var addRule = function () { change('projectRules', draft.projectRules.concat({ path: props.getCurrentCwd() || '', mode: 'mute' })) }
147
+
148
+ return h('div', { className: saved ? 'dsh-notify-saved' : '', style: { border: '1px solid var(--dsw-alias-border-l2,#444)', borderRadius: '10px', padding: '14px 16px', fontSize: '13px', display: 'flex', flexDirection: 'column', gap: '12px' } },
149
+ h('style', null, css),
150
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' } },
151
+ h('div', null, h('strong', null, 'macOS 通知'), h('div', { style: { opacity: .55, fontSize: '11px', marginTop: '2px' } }, '通道:' + (status.channel || settings.channel) + ' · 诊断记录 ' + diagnostics.entries.length + ' 条')),
152
+ editing ? h('span', { style: { padding: '3px 7px', borderRadius: '99px', fontSize: '11px', background: 'rgba(47,111,237,.1)' } }, '整体编辑中') : h('button', { style: buttonStyle, disabled: saved, onClick: beginEdit }, saved ? '已保存 ✓' : '编辑全部')),
153
+ error ? h('div', { className: 'dsh-notify-error', role: 'alert', style: { padding: '7px 9px', borderRadius: '6px', color: '#d94b43', background: 'rgba(217,75,67,.1)' } }, error) : null,
154
+
155
+ h('div', { style: sectionStyle }, h(SectionTitle, { title: '运行状态与测试', note: status.quietActive ? '当前处于勿扰时段' : pauseActive ? '通知已暂停' : '运行正常' }),
156
+ h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } }, ['completed', 'error', 'approval', 'aborted', 'coalesced', 'digest'].map(function (kind) {
157
+ var names = { completed: '完成', error: '错误', approval: '审批', aborted: '中断', coalesced: '合并', digest: '摘要' }
158
+ return h('button', { key: kind, style: buttonStyle, onClick: function () { test(kind, draft.sounds[kind]) } }, testing === kind ? '发送中…' : '测试' + names[kind])
159
+ })),
160
+ h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' } }, h('small', { style: { opacity: .55 } }, pauseActive ? '暂停至 ' + new Date(status.pauseUntil).toLocaleTimeString() : '临时暂停:'),
161
+ h('button', { style: buttonStyle, onClick: function () { pause(30 * 60 * 1000) } }, '30 分钟'), h('button', { style: buttonStyle, onClick: function () { pause(60 * 60 * 1000) } }, '1 小时'), h('button', { style: buttonStyle, onClick: function () { pause(24 * 60 * 60 * 1000) } }, '24 小时'), pauseActive ? h('button', { style: buttonStyle, onClick: function () { pause(0) } }, '立即恢复') : null)),
162
+
163
+ h('div', { style: sectionStyle }, h(SectionTitle, { title: '通知事件与过滤' }),
164
+ [['onCompleted', '任务完成'], ['onError', '错误与阻止'], ['onApproval', '等待审批'], ['onAborted', '用户中断']].map(function (row) { return h(Toggle, { key: row[0], disabled: !editing, value: draft[row[0]], label: row[1], onChange: function (value) { change(row[0], value) } }) }),
165
+ h(Toggle, { disabled: !editing, value: draft.onlyWhenUnfocused, label: '仅在 DSH 页面未聚焦时发送完成通知', onChange: function (value) { change('onlyWhenUnfocused', value) } }),
166
+ h(Toggle, { disabled: !editing, value: draft.includeSubagents, label: '包含子 Agent 会话', onChange: function (value) { change('includeSubagents', value) } }),
167
+ [['minDurationSec', '完成通知最短耗时(秒)'], ['onlyWhenIdleSec', '键鼠空闲门槛(秒)'], ['digestMinutes', '摘要间隔(分钟)'], ['coalesceMs', '合并窗口(毫秒)'], ['duplicateWindowSec', '重复错误抑制(秒)']].map(function (row) { return h(SettingRow, { key: row[0], label: row[1] }, h('input', { style: inputStyle, type: 'number', min: 0, disabled: !editing, value: draft[row[0]], onChange: function (e) { change(row[0], e.target.value) } })) }),
168
+ h(SettingRow, { label: '通知通道' }, h('select', { style: inputStyle, disabled: !editing, value: draft.channel, onChange: function (e) { change('channel', e.target.value) } }, h('option', { value: 'auto' }, '自动'), h('option', { value: 'osascript' }, 'osascript'), h('option', { value: 'osc9' }, 'OSC 9')))),
169
+
170
+ h('div', { style: sectionStyle }, h(SectionTitle, { title: '每日勿扰', note: '使用本机时间' }),
171
+ h(Toggle, { disabled: !editing, value: draft.quietHoursEnabled, label: '启用每日勿扰时段', onChange: function (value) { change('quietHoursEnabled', value) } }),
172
+ h(SettingRow, { label: '时段' }, h('span', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h('input', { 'aria-label': '勿扰开始时间', style: Object.assign({}, inputStyle, { width: '104px' }), type: 'time', disabled: !editing, value: draft.quietStart, onChange: function (e) { change('quietStart', e.target.value) } }), h('span', null, '至'), h('input', { 'aria-label': '勿扰结束时间', style: Object.assign({}, inputStyle, { width: '104px' }), type: 'time', disabled: !editing, value: draft.quietEnd, onChange: function (e) { change('quietEnd', e.target.value) } }))),
173
+ h(Toggle, { disabled: !editing, value: draft.quietAllowCritical, label: '勿扰时仍允许错误、阻止和审批', onChange: function (value) { change('quietAllowCritical', value) } })),
174
+
175
+ h('div', { style: sectionStyle }, h(SectionTitle, { title: '提示音', note: '单个≤5MB/10秒;最多20个/50MB' }),
176
+ editing ? h('div', { style: rowStyle }, h('input', { ref: fileInput, type: 'file', accept: '.aac,.aif,.aiff,.caf,.flac,.m4a,.mp3,.oga,.ogg,.opus,.wav,audio/*', style: { display: 'none' }, onChange: importFile }), h('button', { style: buttonStyle, disabled: importing, onClick: function () { if (fileInput.current) fileInput.current.click() } }, importing ? '导入中…' : '导入声音'), h('small', { style: { opacity: .55 } }, '已管理 ' + catalog.managed.length + ' 个')) : null,
177
+ SOUND_ROWS.map(function (row) { var kind = row[0], value = draft.sounds[kind] || '', choices = availableSounds.includes(value) || !value ? availableSounds : [value].concat(availableSounds); return h('div', { key: kind, style: rowStyle }, h('span', { style: { width: '110px' } }, row[1]), h('select', { style: Object.assign({}, inputStyle, { flex: 1, width: 'auto' }), disabled: !editing, value: value, onChange: function (e) { changeSound(kind, e.target.value) } }, h('option', { value: '' }, '静音'), choices.map(function (name) { return h('option', { key: name, value: name }, name) })), h('button', { style: buttonStyle, onClick: function () { test(kind, value) } }, testing === kind ? '播放中…' : '试听')) }),
178
+ catalog.managed.length ? h('div', { style: { padding: '8px', borderRadius: '7px', background: 'rgba(127,127,127,.06)' } }, catalog.managed.map(function (item) { return h('div', { key: item.name, style: rowStyle }, h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, item.name), h('small', { style: { opacity: .5 } }, (item.bytes / 1024).toFixed(0) + 'KB'), editing ? h('button', { style: Object.assign({}, buttonStyle, { color: '#d94b43' }), onClick: function () { deleteSound(item.name, false) } }, '删除') : null) })) : null),
179
+
180
+ h('div', { style: sectionStyle }, h(SectionTitle, { title: '项目规则', note: '更具体的路径优先' }),
181
+ draft.projectRules.length ? draft.projectRules.map(function (rule, index) { return h('div', { key: index, style: { display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 150px auto', gap: '6px' } }, h('input', { style: Object.assign({}, inputStyle, { width: '100%' }), disabled: !editing, value: rule.path, placeholder: '/Users/name/project', onChange: function (e) { updateRule(index, 'path', e.target.value) } }), h('select', { style: Object.assign({}, inputStyle, { width: '100%' }), disabled: !editing, value: rule.mode, onChange: function (e) { updateRule(index, 'mode', e.target.value) } }, Object.keys(RULE_LABELS).map(function (mode) { return h('option', { key: mode, value: mode }, RULE_LABELS[mode]) })), editing ? h('button', { style: Object.assign({}, buttonStyle, { color: '#d94b43' }), onClick: function () { removeRule(index) } }, '移除') : h('span')) }) : h('small', { style: { opacity: .55 } }, '尚未配置项目规则'),
182
+ editing ? h('button', { style: Object.assign({}, buttonStyle, { alignSelf: 'flex-start' }), onClick: addRule }, props.getCurrentCwd() ? '添加当前项目' : '添加规则') : null),
183
+
184
+ h('div', { style: sectionStyle }, h(SectionTitle, { title: '最近通知诊断', note: '当前进程最近50条' }),
185
+ diagnostics.entries.length ? diagnostics.entries.slice(0, 12).map(function (entry) { var colors = { sent: '#238452', suppressed: '#b36b00', queued: '#3867c8', error: '#d94b43' }, labels = { sent: '已发送', suppressed: '已抑制', queued: '排队中', error: '失败' }; return h('div', { key: entry.id, style: { display: 'grid', gridTemplateColumns: '58px minmax(0,1fr) auto', gap: '7px', padding: '6px 0', borderBottom: '1px solid rgba(127,127,127,.12)' } }, h('span', { style: { color: colors[entry.status], fontSize: '11px', fontWeight: 600 } }, labels[entry.status] || entry.status), h('span', null, h('b', null, entry.label || entry.title || entry.kind), h('small', { style: { display: 'block', opacity: .55 } }, entry.detail)), h('small', { style: { opacity: .45, whiteSpace: 'nowrap' } }, new Date(entry.time).toLocaleTimeString())) }) : h('small', { style: { opacity: .55 } }, '暂无记录,可以点击上方测试按钮。'),
186
+ diagnostics.entries.length ? h('button', { style: Object.assign({}, buttonStyle, { alignSelf: 'flex-start' }), onClick: function () { props.call('diagnostics', { op: 'clear' }).then(setDiagnostics) } }, '清空记录') : null),
187
+
188
+ editing ? h('div', { style: { position: 'sticky', bottom: '8px', display: 'flex', gap: '8px', padding: '9px', borderRadius: '8px', background: 'var(--dsw-alias-bg-primary,#1d1d1d)', border: '1px solid var(--dsw-alias-border-l2,#444)', boxShadow: '0 5px 18px rgba(0,0,0,.18)' } }, h('button', { style: Object.assign({}, buttonStyle, { fontWeight: 650 }), disabled: !dirty || saving || importing, onClick: save }, saving ? '保存中…' : dirty ? '保存全部' : '没有改动'), h('button', { style: buttonStyle, disabled: saving || importing, onClick: cancel }, '取消')) : null)
189
+ }
190
+
191
+ exports.inject = ['connection', 'slots', 'sessions']
192
+ exports.apply = function apply(ctx) {
193
+ var clientId = crypto.randomUUID()
194
+ var report = function () { ctx.connection.rpc.call('/macos-notify', 'visibility', { id: clientId, focused: document.visibilityState === 'visible' && document.hasFocus() }).catch(function () {}) }
195
+ report(); document.addEventListener('visibilitychange', report); window.addEventListener('focus', report); window.addEventListener('blur', report)
196
+ var timer = setInterval(report, 30000)
197
+ var call = function (endpoint, payload) { return ctx.connection.rpc.call('/macos-notify', endpoint, payload).then(function (result) { if (!result || result.ok !== true) throw new Error(result && result.error && result.error.message || 'RPC 调用失败'); return result.value }) }
198
+ var getCurrentCwd = function () { try { var state = ctx.sessions.list.getSnapshot(); return state.current && state.byId[state.current] && state.byId[state.current].cwd || '' } catch { return '' } }
199
+ ctx.slots.inject('settings.plugin.item', function* () { yield ctx.slots.register({ name: 'settings.plugin.item', id: 'macos-notify', order: 100, inject: function () { return { call: call, getCurrentCwd: getCurrentCwd } } }, Card) })
200
+ ctx.effect(function () { return function () { clearInterval(timer); document.removeEventListener('visibilitychange', report); window.removeEventListener('focus', report); window.removeEventListener('blur', report) } })
201
+ }
202
+ return module.exports
203
+ },
204
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: macos-notify
3
+ name: dsh-macos-notify
package/index.js ADDED
@@ -0,0 +1,858 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { constants as fsConstants } from 'node:fs'
3
+ import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
4
+ import { homedir, tmpdir } from 'node:os'
5
+ import { basename, extname, join, resolve } from 'node:path'
6
+ import { promisify } from 'node:util'
7
+ import Schema from '@deepseek-ai/schemastery'
8
+
9
+ export const name = 'dsh-macos-notify'
10
+ export const inject = ['sessions', 'settings']
11
+
12
+ export const Config = Schema.object({
13
+ /** 轮次正常完成时通知 */
14
+ onCompleted: Schema.boolean().default(true),
15
+ /** 轮次出错或被阻止时通知(不受 minDurationSec / onlyWhenIdleSec / onlyWhenUnfocused / digestMinutes 限制) */
16
+ onError: Schema.boolean().default(true),
17
+ /** 用户取消(中断)时通知 */
18
+ onAborted: Schema.boolean().default(false),
19
+ /** 工具等待审批时通知(立即发送,不参与合并) */
20
+ onApproval: Schema.boolean().default(true),
21
+ /** 「完成」通知的最短轮次时长(秒):短于此值的轮次不弹完成通知,0 表示都弹 */
22
+ minDurationSec: Schema.number().default(30),
23
+ /** 「完成」类通知仅在键鼠空闲超过此值(秒)时发送,0 关闭。注意:测的是输入空闲,切走应用但还在打字就不算空闲 */
24
+ onlyWhenIdleSec: Schema.number().default(0),
25
+ /** 「完成」类通知仅在没有任何浏览器 tab 聚焦 dsh 时发送(切走 tab 或切走应用都会弹)。无客户端上报时(如 headless)视为未聚焦,照弹 */
26
+ onlyWhenUnfocused: Schema.boolean().default(true),
27
+ /** 「完成」类通知的汇总间隔(分钟):攒一批定时合并发送,0 表示实时。审批和出错不受影响 */
28
+ digestMinutes: Schema.number().default(0),
29
+ /** 子 agent 会话也通知(默认只通知顶层会话,避免刷屏) */
30
+ includeSubagents: Schema.boolean().default(false),
31
+ /** 通知通道:auto = 支持的终端走 OSC 9,否则 osascript;osc9/osascript 强制指定 */
32
+ channel: Schema.union(['auto', 'osascript', 'osc9']).default('auto'),
33
+ /** 各事件类型的通知声音(macOS 声音名,空串为静音;OSC 9 通道下声音由终端决定,此配置无效) */
34
+ sounds: Schema.object({
35
+ completed: Schema.string().default('Glass'),
36
+ error: Schema.string().default('Basso'),
37
+ aborted: Schema.string().default(''),
38
+ approval: Schema.string().default('Ping'),
39
+ }).default({ completed: 'Glass', error: 'Basso', aborted: '', approval: 'Ping' }),
40
+ /** 轮次结束通知的合并窗口(毫秒):窗口内多个会话的结束合并成一条,0 关闭合并 */
41
+ coalesceMs: Schema.number().default(1500),
42
+ /** 是否启用每日勿扰时段 */
43
+ quietHoursEnabled: Schema.boolean().default(false),
44
+ /** 每日勿扰开始时间(本机时间,HH:mm) */
45
+ quietStart: Schema.string().default('23:00'),
46
+ /** 每日勿扰结束时间(本机时间,HH:mm) */
47
+ quietEnd: Schema.string().default('08:00'),
48
+ /** 勿扰时段仍允许错误、阻止和审批通知 */
49
+ quietAllowCritical: Schema.boolean().default(true),
50
+ /** 临时暂停截止时间(Unix 毫秒);0 表示未暂停 */
51
+ pauseUntil: Schema.number().default(0),
52
+ /** 相同会话相同错误的重复抑制窗口(秒);0 表示关闭 */
53
+ duplicateWindowSec: Schema.number().default(300),
54
+ /** 项目规则 JSON:[{ path, mode }],mode 为 mute / errors / important */
55
+ projectRulesJson: Schema.string().default('[]'),
56
+ /** 插件加载时发一条测试通知 */
57
+ notifyOnLoad: Schema.boolean().default(true),
58
+ })
59
+
60
+ /** 设置页可编辑的提示音字段 */
61
+ const SOUND_KINDS = ['completed', 'error', 'aborted', 'approval']
62
+ const SOUND_EXTENSIONS = new Set(['.aif', '.aiff', '.caf', '.m4a', '.wav'])
63
+ const IMPORT_EXTENSIONS = new Set([
64
+ '.aac', '.aif', '.aiff', '.caf', '.flac', '.m4a', '.mp3', '.oga', '.ogg', '.opus', '.wav',
65
+ ])
66
+ const MAX_SOUND_BYTES = 5 * 1024 * 1024
67
+ const MAX_SOUND_DURATION_SEC = 10
68
+ const MAX_MANAGED_SOUND_COUNT = 20
69
+ const MAX_MANAGED_SOUND_BYTES = 50 * 1024 * 1024
70
+ const MAX_DIAGNOSTICS = 50
71
+ const execFileAsync = promisify(execFile)
72
+
73
+ function userSoundsDir() {
74
+ return join(homedir(), 'Library/Sounds')
75
+ }
76
+
77
+ function soundRegistryPath() {
78
+ return join(homedir(), 'Library/Application Support/dsh-macos-notify/sounds.json')
79
+ }
80
+
81
+ async function readSoundRegistry() {
82
+ try {
83
+ const parsed = JSON.parse(await readFile(soundRegistryPath(), 'utf8'))
84
+ return Array.isArray(parsed) ? parsed.filter((item) =>
85
+ item && typeof item.name === 'string' && typeof item.filename === 'string') : []
86
+ } catch {
87
+ return []
88
+ }
89
+ }
90
+
91
+ async function writeSoundRegistry(entries) {
92
+ const path = soundRegistryPath()
93
+ await mkdir(resolve(path, '..'), { recursive: true })
94
+ await writeFile(path, `${JSON.stringify(entries, null, 2)}\n`, { mode: 0o600 })
95
+ }
96
+
97
+ async function managedSoundCatalog() {
98
+ const dir = userSoundsDir()
99
+ const registry = await readSoundRegistry()
100
+ const catalog = []
101
+ const kept = []
102
+ for (const item of registry) {
103
+ const path = join(dir, item.filename)
104
+ try {
105
+ const info = await stat(path)
106
+ if (!info.isFile()) continue
107
+ kept.push(item)
108
+ catalog.push({
109
+ name: item.name,
110
+ filename: item.filename,
111
+ bytes: info.size,
112
+ importedAt: Number(item.importedAt) || info.birthtimeMs || info.mtimeMs,
113
+ })
114
+ } catch {
115
+ // 用户在 Finder 中删除了文件;下次写回时清理失效注册项。
116
+ }
117
+ }
118
+ if (kept.length !== registry.length) await writeSoundRegistry(kept)
119
+ return catalog.sort((a, b) => b.importedAt - a.importedAt)
120
+ }
121
+
122
+ function safeSoundName(filename) {
123
+ const extension = extname(filename).toLowerCase()
124
+ if (!IMPORT_EXTENSIONS.has(extension)) {
125
+ throw new Error('不支持该音频格式')
126
+ }
127
+ const name = basename(filename, extension)
128
+ .normalize('NFKC')
129
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, ' ')
130
+ .replace(/\s+/g, ' ')
131
+ .replace(/^\.+|\.+$/g, '')
132
+ .trim()
133
+ .slice(0, 80)
134
+ if (!name) throw new Error('声音文件名无效')
135
+ return { extension, name }
136
+ }
137
+
138
+ async function availableSoundPath(name) {
139
+ const dir = userSoundsDir()
140
+ await mkdir(dir, { recursive: true })
141
+ const existing = new Set((await readdir(dir)).map((item) => item.toLowerCase()))
142
+ for (let suffix = 0; suffix < 1000; suffix += 1) {
143
+ const candidateName = suffix === 0 ? name : `${name} (${suffix + 1})`
144
+ const filename = `${candidateName}.aiff`
145
+ if (!existing.has(filename.toLowerCase())) {
146
+ return { candidateName, path: join(dir, filename) }
147
+ }
148
+ }
149
+ throw new Error('同名声音文件过多')
150
+ }
151
+
152
+ async function convertToAiff(input, output) {
153
+ try {
154
+ await execFileAsync('/usr/bin/afconvert', ['-f', 'AIFF', '-d', 'BEI16@44100', input, output])
155
+ } catch (afconvertError) {
156
+ try {
157
+ await execFileAsync('ffmpeg', [
158
+ '-y', '-loglevel', 'error', '-i', input,
159
+ '-ar', '44100', '-ac', '1', '-c:a', 'pcm_s16be', output,
160
+ ])
161
+ } catch {
162
+ throw new Error(`音频转换失败:${afconvertError?.message ?? '格式无法识别'}`)
163
+ }
164
+ }
165
+ }
166
+
167
+ async function validateSoundDuration(path) {
168
+ const { stdout } = await execFileAsync('/usr/bin/afinfo', ['-r', path])
169
+ const match = stdout.match(/estimated duration:\s*([\d.]+)\s*sec/i)
170
+ const duration = match ? Number(match[1]) : NaN
171
+ if (!Number.isFinite(duration)) throw new Error('无法读取音频时长')
172
+ if (duration > MAX_SOUND_DURATION_SEC) {
173
+ throw new Error(`提示音时长不能超过 ${MAX_SOUND_DURATION_SEC} 秒`)
174
+ }
175
+ }
176
+
177
+ async function importSound(payload) {
178
+ if (typeof payload?.filename !== 'string' || typeof payload?.data !== 'string') {
179
+ throw new Error('缺少声音文件')
180
+ }
181
+ if (payload.data.length > Math.ceil(MAX_SOUND_BYTES * 4 / 3) + 8) {
182
+ throw new Error('声音文件不能超过 5MB')
183
+ }
184
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(payload.data)) {
185
+ throw new Error('声音文件内容无效')
186
+ }
187
+ const bytes = Buffer.from(payload.data, 'base64')
188
+ if (bytes.length === 0 || bytes.length > MAX_SOUND_BYTES) {
189
+ throw new Error('声音文件不能超过 5MB')
190
+ }
191
+
192
+ const { extension, name } = safeSoundName(payload.filename)
193
+ const tempDir = await mkdtemp(join(tmpdir(), 'dsh-macos-notify-'))
194
+ try {
195
+ const input = join(tempDir, `input${extension}`)
196
+ const output = join(tempDir, 'output.aiff')
197
+ await writeFile(input, bytes)
198
+ await convertToAiff(input, output)
199
+ await validateSoundDuration(output)
200
+ const existing = await managedSoundCatalog()
201
+ const outputInfo = await stat(output)
202
+ const usedBytes = existing.reduce((total, item) => total + item.bytes, 0)
203
+ if (existing.length >= MAX_MANAGED_SOUND_COUNT) {
204
+ throw new Error(`最多管理 ${MAX_MANAGED_SOUND_COUNT} 个自定义提示音,请先删除不用的声音`)
205
+ }
206
+ if (usedBytes + outputInfo.size > MAX_MANAGED_SOUND_BYTES) {
207
+ throw new Error('自定义提示音总容量不能超过 50MB,请先删除不用的声音')
208
+ }
209
+ const destination = await availableSoundPath(name)
210
+ await copyFile(output, destination.path, fsConstants.COPYFILE_EXCL)
211
+ await chmod(destination.path, 0o644)
212
+ try {
213
+ const registry = await readSoundRegistry()
214
+ registry.push({
215
+ name: destination.candidateName,
216
+ filename: basename(destination.path),
217
+ importedAt: Date.now(),
218
+ })
219
+ await writeSoundRegistry(registry)
220
+ } catch (err) {
221
+ await rm(destination.path, { force: true })
222
+ throw err
223
+ }
224
+ return destination.candidateName
225
+ } finally {
226
+ await rm(tempDir, { recursive: true, force: true })
227
+ }
228
+ }
229
+
230
+ async function deleteManagedSound(name) {
231
+ const registry = await readSoundRegistry()
232
+ const index = registry.findIndex((item) => item.name === name)
233
+ if (index < 0) throw new Error('只能删除由本插件导入并管理的声音')
234
+ const [entry] = registry.splice(index, 1)
235
+ const target = resolve(userSoundsDir(), entry.filename)
236
+ const root = `${resolve(userSoundsDir())}/`
237
+ if (!target.startsWith(root) || basename(target) !== entry.filename) {
238
+ throw new Error('声音文件路径无效')
239
+ }
240
+ try {
241
+ await unlink(target)
242
+ } catch (err) {
243
+ if (err?.code !== 'ENOENT') throw err
244
+ }
245
+ await writeSoundRegistry(registry)
246
+ }
247
+
248
+ /** macOS 系统与用户声音目录;读取失败的目录直接忽略 */
249
+ async function systemSoundNames() {
250
+ const dirs = [
251
+ '/System/Library/Sounds',
252
+ '/Library/Sounds',
253
+ userSoundsDir(),
254
+ ]
255
+ const names = new Set()
256
+ await Promise.all(dirs.map(async (dir) => {
257
+ try {
258
+ const entries = await readdir(dir, { withFileTypes: true })
259
+ for (const entry of entries) {
260
+ if (!entry.isFile()) continue
261
+ const extension = extname(entry.name).toLowerCase()
262
+ if (SOUND_EXTENSIONS.has(extension)) names.add(basename(entry.name, extension))
263
+ }
264
+ } catch {
265
+ // 目录可能不存在或无读取权限;其余目录仍可用
266
+ }
267
+ }))
268
+ return [...names].sort((a, b) => a.localeCompare(b, 'en'))
269
+ }
270
+
271
+ async function soundCatalog() {
272
+ const [names, managed] = await Promise.all([systemSoundNames(), managedSoundCatalog()])
273
+ return {
274
+ names,
275
+ managed,
276
+ limits: { count: MAX_MANAGED_SOUND_COUNT, bytes: MAX_MANAGED_SOUND_BYTES },
277
+ }
278
+ }
279
+
280
+ function esc(s) {
281
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
282
+ }
283
+
284
+ function notify(title, body, sound, channel, onResult = () => {}) {
285
+ if (channel === 'osc9') {
286
+ emitOsc9(title, body)
287
+ onResult(null)
288
+ return
289
+ }
290
+ const soundPart = sound ? ` sound name "${esc(sound)}"` : ''
291
+ const script = `display notification "${esc(body)}" with title "${esc(title)}"${soundPart}`
292
+ execFile('osascript', ['-e', script], (err) => {
293
+ if (err) console.warn('[dsh-macos-notify] osascript failed:', err.message)
294
+ onResult(err ?? null)
295
+ })
296
+ }
297
+
298
+ // —— OSC 9 通道(思路参考 kimi-code 的 terminal-notification.ts)——
299
+
300
+ /** 认识 OSC 9 桌面通知的终端白名单;不认识 OSC 9 的终端收到转义序列会打印乱码,所以必须保守 */
301
+ function supportsOsc9(env = process.env) {
302
+ const termProgram = env.TERM_PROGRAM ?? ''
303
+ if (['iTerm.app', 'WezTerm', 'ghostty', 'WarpTerminal'].includes(termProgram)) return true
304
+ const term = env.TERM ?? ''
305
+ return term === 'xterm-kitty' || term === 'xterm-ghostty'
306
+ }
307
+
308
+ /** 剥掉控制字符,避免污染终端 */
309
+ function sanitizeOsc9(s) {
310
+ return String(s).replace(/[\x00-\x1f\x7f]/g, ' ').trim()
311
+ }
312
+
313
+ function emitOsc9(title, body) {
314
+ const message = [title, body].map(sanitizeOsc9).filter(Boolean).join(': ').slice(0, 256)
315
+ if (!message) return
316
+ let seq = `\x1b]9;${message}\x07`
317
+ // tmux 会吞掉 OSC,需要 DCS passthrough 包裹并把载荷里的 ESC 双写
318
+ if (process.env.TMUX) {
319
+ seq = `\x1bPtmux;${seq.replaceAll('\x1b', '\x1b\x1b')}\x1b\\`
320
+ }
321
+ process.stdout.write(seq)
322
+ }
323
+
324
+ /** macOS 键鼠空闲秒数;读不到时返回 0(视为"人在电脑前",即不抑制通知) */
325
+ function idleSeconds() {
326
+ return new Promise((resolve) => {
327
+ execFile('ioreg', ['-c', 'IOHIDSystem', '-d', '1'], (err, stdout) => {
328
+ const m = stdout?.match(/"HIDIdleTime" = (\d+)/)
329
+ resolve(m ? Number(m[1]) / 1e9 : 0)
330
+ })
331
+ })
332
+ }
333
+
334
+ function minuteOfDay(value) {
335
+ const match = /^(\d{2}):(\d{2})$/.exec(String(value))
336
+ if (!match) return null
337
+ const hour = Number(match[1])
338
+ const minute = Number(match[2])
339
+ if (hour > 23 || minute > 59) return null
340
+ return hour * 60 + minute
341
+ }
342
+
343
+ function quietHoursActive(config, now = new Date()) {
344
+ if (!config.quietHoursEnabled) return false
345
+ const start = minuteOfDay(config.quietStart)
346
+ const end = minuteOfDay(config.quietEnd)
347
+ if (start === null || end === null || start === end) return false
348
+ const currentMinute = now.getHours() * 60 + now.getMinutes()
349
+ return start < end
350
+ ? currentMinute >= start && currentMinute < end
351
+ : currentMinute >= start || currentMinute < end
352
+ }
353
+
354
+ function parseProjectRules(raw) {
355
+ try {
356
+ const value = JSON.parse(raw)
357
+ if (!Array.isArray(value)) return []
358
+ return value.slice(0, 50).flatMap((item) => {
359
+ const path = typeof item?.path === 'string' ? item.path.trim() : ''
360
+ const mode = item?.mode
361
+ if (!path || !['mute', 'errors', 'important'].includes(mode)) return []
362
+ return [{ path: resolve(path), mode }]
363
+ })
364
+ } catch {
365
+ return []
366
+ }
367
+ }
368
+
369
+ function matchingProjectRule(rules, cwd) {
370
+ if (!cwd) return null
371
+ const target = resolve(cwd)
372
+ return rules
373
+ .filter((rule) => target === rule.path || target.startsWith(`${rule.path}/`))
374
+ .sort((a, b) => b.path.length - a.path.length)[0] ?? null
375
+ }
376
+
377
+ function isCriticalKind(kind) {
378
+ return kind === '出错' || kind === '被阻止' || kind === '审批'
379
+ }
380
+
381
+ export function apply(ctx, config) {
382
+ // 配置三层叠加:schema 默认值 < cordis 组合层(base = 插件 config)< 用户层(设置页)。
383
+ // 设置页写入后经 watch 实时生效,不需要重启。
384
+ const scope = ctx.settings.register('macos-notify', Config, { base: config, applies: 'live' })
385
+ let current = scope.get()
386
+
387
+ // sessionId -> 最新标题(session/title 事件是 latest-wins 快照)
388
+ const titles = new Map()
389
+ // sessionId -> 本轮 turn/start 的时间戳,用于算轮次时长
390
+ const turnStartedAt = new Map()
391
+ // 有未关闭轮次的顶层会话,用于「还有 N 个任务进行中」
392
+ const running = new Set()
393
+ // 合并窗口内待发的轮次结束通知(实时通道)
394
+ let pending = []
395
+ let flushTimer = null
396
+ // digest 通道:只攒「完成」
397
+ let digestPending = []
398
+ let digestTimer = null
399
+ // 最近的通知决策,仅保存在当前进程内;设置页用于解释“为什么没弹”。
400
+ let diagnostics = []
401
+ let diagnosticSeq = 0
402
+ // 重复错误键 -> 首次发送时间、被抑制次数
403
+ const duplicates = new Map()
404
+ let projectRules = parseProjectRules(current.projectRulesJson)
405
+
406
+ const record = (status, item, detail, extra = {}) => {
407
+ diagnostics.unshift({
408
+ id: ++diagnosticSeq,
409
+ time: Date.now(),
410
+ status,
411
+ kind: item?.kind ?? '系统',
412
+ title: item?.title ?? '',
413
+ label: item?.label ?? '',
414
+ sessionId: item?.sessionId ?? null,
415
+ cwd: item?.cwd ?? null,
416
+ detail,
417
+ channel: resolvedChannel,
418
+ ...extra,
419
+ })
420
+ if (diagnostics.length > MAX_DIAGNOSTICS) diagnostics.length = MAX_DIAGNOSTICS
421
+ }
422
+
423
+ // 通知通道:auto 在支持的终端里走 OSC 9(终端自己转系统通知),否则 osascript
424
+ let resolvedChannel = resolveChannel()
425
+ function resolveChannel() {
426
+ return current.channel === 'auto' ? (supportsOsc9() ? 'osc9' : 'osascript') : current.channel
427
+ }
428
+ const send = (title, body, sound, items = [], options = {}) => {
429
+ const representative = items[0] ?? { kind: options.kind ?? '系统', title, label: body }
430
+ notify(title, body, sound, resolvedChannel, (err) => {
431
+ if (err) {
432
+ for (const item of items.length ? items : [representative]) {
433
+ record('error', item, `发送失败:${err.message}`)
434
+ }
435
+ return
436
+ }
437
+ for (const item of items.length ? items : [representative]) {
438
+ record('sent', item, options.detail ?? `已通过 ${resolvedChannel} 发送`)
439
+ }
440
+ })
441
+ }
442
+
443
+ // 上报「正在看 dsh tab」的浏览器客户端:id -> 最近一次聚焦上报的时间戳
444
+ const focusedClients = new Map()
445
+
446
+ /** 是否有客户端正聚焦在 dsh tab 上(90 秒未上报视为已关闭) */
447
+ const anyFocused = () => {
448
+ const cutoff = Date.now() - 90_000
449
+ for (const [id, at] of focusedClients) {
450
+ if (at < cutoff) focusedClients.delete(id)
451
+ }
452
+ return focusedClients.size > 0
453
+ }
454
+
455
+ // 接收浏览器半的焦点上报、试听与设置读写。connection 服务只在 web profile 存在,
456
+ // 用 ctx.inject() 延迟挂载:headless 下回调永不触发(视为无人盯着,通知照发)
457
+ //
458
+ // 注意一:设置读写走自有 RPC 而不是 settings.describe/mutate 线面——
459
+ // dsh-host-apiproxy 对暴露给 Web 的设置命名空间有硬编码白名单
460
+ // (WEB_SETTINGS_NAMESPACES),第三方命名空间目前上不了那条线。
461
+ // 注意二:共享的 /api 通道只允许一个拦截器(已被 api-gateway 占用),
462
+ // 所以用 rpc.handle 注册独立通道 /macos-notify。
463
+ ctx.inject(['connection'], (connCtx) => {
464
+ try {
465
+ connCtx.connection.rpc.handle(
466
+ '/macos-notify',
467
+ async (endpoint, payload) => {
468
+ if (endpoint === 'visibility') {
469
+ const { id, focused } = payload ?? {}
470
+ if (typeof id === 'string') {
471
+ if (focused) focusedClients.set(id, Date.now())
472
+ else focusedClients.delete(id)
473
+ }
474
+ return { ok: true, value: null }
475
+ }
476
+ if (endpoint === 'test') {
477
+ // 测试矩阵绕过勿扰和过滤规则,确保能直接验证系统投递通道。
478
+ const kind = payload?.kind
479
+ if (SOUND_KINDS.includes(kind)) {
480
+ const sound = typeof payload?.sound === 'string' ? payload.sound : current.sounds[kind]
481
+ const labels = {
482
+ completed: ['完成测试', '模拟任务已完成'],
483
+ error: ['错误测试', '模拟任务发生错误'],
484
+ aborted: ['中断测试', '模拟任务已中断'],
485
+ approval: ['审批测试', '模拟工具正在等待审批'],
486
+ }
487
+ const item = { kind: kind === 'approval' ? '审批' : `测试/${kind}`, title: labels[kind][0], label: labels[kind][1] }
488
+ send(labels[kind][0], labels[kind][1], sound, [item], { detail: '设置页测试通知' })
489
+ } else if (kind === 'coalesced') {
490
+ const items = [
491
+ { kind: '完成', title: '合并测试', label: '示例任务 A' },
492
+ { kind: '完成', title: '合并测试', label: '示例任务 B' },
493
+ ]
494
+ send('2 个任务有结果', '2 个完成:示例任务 A、示例任务 B', current.sounds.completed, items, { detail: '设置页合并通知测试' })
495
+ } else if (kind === 'digest') {
496
+ const item = { kind: '完成', title: '摘要测试', label: '3 个示例任务' }
497
+ send('3 个任务完成', '示例任务 A、示例任务 B、示例任务 C', current.sounds.completed, [item], { detail: '设置页摘要通知测试' })
498
+ }
499
+ return { ok: true, value: null }
500
+ }
501
+ if (endpoint === 'sounds') {
502
+ return { ok: true, value: await soundCatalog() }
503
+ }
504
+ if (endpoint === 'sound/import') {
505
+ try {
506
+ const imported = await importSound(payload)
507
+ return { ok: true, value: { name: imported } }
508
+ } catch (err) {
509
+ console.warn('[dsh-macos-notify] sound import failed:', err?.message ?? err)
510
+ return {
511
+ ok: false,
512
+ error: { code: 'internal', message: String(err?.message ?? err), details: {} },
513
+ }
514
+ }
515
+ }
516
+ if (endpoint === 'sound/delete') {
517
+ try {
518
+ const soundName = typeof payload?.name === 'string' ? payload.name : ''
519
+ const inUse = SOUND_KINDS.filter((kind) => current.sounds[kind] === soundName)
520
+ if (inUse.length && payload?.force !== true) {
521
+ return { ok: true, value: { requiresConfirmation: true, inUse } }
522
+ }
523
+ await deleteManagedSound(soundName)
524
+ if (inUse.length) {
525
+ const sounds = { ...current.sounds }
526
+ for (const kind of inUse) sounds[kind] = ''
527
+ await scope.update({ sounds })
528
+ }
529
+ return { ok: true, value: { deleted: true, catalog: await soundCatalog() } }
530
+ } catch (err) {
531
+ return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
532
+ }
533
+ }
534
+ if (endpoint === 'diagnostics') {
535
+ const op = payload?.op ?? 'get'
536
+ if (op === 'clear') diagnostics = []
537
+ anyFocused()
538
+ return {
539
+ ok: true,
540
+ value: {
541
+ entries: diagnostics,
542
+ status: {
543
+ channel: resolvedChannel,
544
+ configuredChannel: current.channel,
545
+ focusedTabs: focusedClients.size,
546
+ quietActive: quietHoursActive(current),
547
+ pauseUntil: current.pauseUntil,
548
+ pending: pending.length,
549
+ digestPending: digestPending.length,
550
+ },
551
+ },
552
+ }
553
+ }
554
+ if (endpoint === 'settings') {
555
+ // 设置卡片读写用户层:get 返回解析后的生效值,set 写入一个字段
556
+ const op = payload?.op
557
+ if (op === 'get') return { ok: true, value: scope.get() }
558
+ if (op === 'set' && typeof payload.field === 'string') {
559
+ try {
560
+ await scope.update({ [payload.field]: payload.value })
561
+ return { ok: true, value: null }
562
+ } catch (err) {
563
+ return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
564
+ }
565
+ }
566
+ if (op === 'patch' && payload.value && typeof payload.value === 'object' && !Array.isArray(payload.value)) {
567
+ try {
568
+ const editable = new Set([
569
+ 'onCompleted', 'onError', 'onAborted', 'onApproval', 'minDurationSec',
570
+ 'onlyWhenIdleSec', 'onlyWhenUnfocused', 'digestMinutes', 'includeSubagents',
571
+ 'channel', 'sounds', 'coalesceMs', 'quietHoursEnabled', 'quietStart', 'quietEnd',
572
+ 'quietAllowCritical', 'pauseUntil', 'duplicateWindowSec', 'projectRulesJson',
573
+ ])
574
+ if (Object.keys(payload.value).some((key) => !editable.has(key))) throw new Error('包含不可编辑的设置字段')
575
+ for (const field of ['quietStart', 'quietEnd']) {
576
+ if (field in payload.value && minuteOfDay(payload.value[field]) === null) throw new Error('勿扰时间格式无效')
577
+ }
578
+ if (typeof payload.value.projectRulesJson === 'string') {
579
+ const parsed = JSON.parse(payload.value.projectRulesJson)
580
+ if (!Array.isArray(parsed) || parsed.length > 50) throw new Error('项目规则格式无效')
581
+ if (parsed.some((rule) => typeof rule?.path !== 'string' || !['mute', 'errors', 'important'].includes(rule?.mode))) {
582
+ throw new Error('项目规则格式无效')
583
+ }
584
+ }
585
+ await scope.update(payload.value)
586
+ return { ok: true, value: scope.get() }
587
+ } catch (err) {
588
+ return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
589
+ }
590
+ }
591
+ return { ok: false, error: { code: 'internal', message: 'unknown settings op', details: {} } }
592
+ }
593
+ return { ok: false, error: { code: 'internal', message: 'unknown endpoint', details: {} } }
594
+ },
595
+ { authority: 'trusted-host' },
596
+ )
597
+ console.log('[dsh-macos-notify] RPC intercept mounted')
598
+ } catch (err) {
599
+ console.error('[dsh-macos-notify] RPC intercept failed:', err)
600
+ }
601
+ })
602
+
603
+ const label = (session) =>
604
+ titles.get(session.id) ??
605
+ (session.header?.cwd ? basename(session.header.cwd) : String(session.id).slice(0, 8))
606
+
607
+ const runningSuffix = () => {
608
+ const n = running.size
609
+ return n > 0 ? `(还有 ${n} 个任务进行中)` : ''
610
+ }
611
+
612
+ const makeItem = (kind, title, body, sound, session) => ({
613
+ kind,
614
+ title,
615
+ body,
616
+ sound,
617
+ label: label(session),
618
+ sessionId: session?.id ?? null,
619
+ cwd: session?.header?.cwd ?? null,
620
+ important: false,
621
+ })
622
+
623
+ const applyProjectRule = (item) => {
624
+ const rule = matchingProjectRule(projectRules, item.cwd)
625
+ if (!rule) return true
626
+ if (rule.mode === 'mute') {
627
+ record('suppressed', item, `项目规则已静音:${rule.path}`)
628
+ return false
629
+ }
630
+ if (rule.mode === 'errors' && !isCriticalKind(item.kind)) {
631
+ record('suppressed', item, `项目规则仅允许错误和审批:${rule.path}`)
632
+ return false
633
+ }
634
+ if (rule.mode === 'important') item.important = true
635
+ return true
636
+ }
637
+
638
+ const applyTimePolicy = (item) => {
639
+ if (item.important) return true
640
+ if (current.pauseUntil > Date.now()) {
641
+ record('suppressed', item, `通知已暂停至 ${new Date(current.pauseUntil).toLocaleString()}`)
642
+ return false
643
+ }
644
+ if (quietHoursActive(current) && !(current.quietAllowCritical && isCriticalKind(item.kind))) {
645
+ record('suppressed', item, `当前处于勿扰时段 ${current.quietStart}–${current.quietEnd}`)
646
+ return false
647
+ }
648
+ return true
649
+ }
650
+
651
+ const applyDuplicatePolicy = (item) => {
652
+ if (!['出错', '被阻止'].includes(item.kind) || current.duplicateWindowSec <= 0) return true
653
+ const now = Date.now()
654
+ const key = `${item.sessionId ?? ''}\u0000${item.kind}\u0000${item.body}`
655
+ const previous = duplicates.get(key)
656
+ const windowMs = current.duplicateWindowSec * 1000
657
+ if (previous && now - previous.at < windowMs) {
658
+ previous.count += 1
659
+ record('suppressed', item, `重复通知已合并(本窗口第 ${previous.count} 次)`)
660
+ return false
661
+ }
662
+ if (previous?.count) item.body += `(此前重复 ${previous.count} 次)`
663
+ duplicates.set(key, { at: now, count: 0 })
664
+ for (const [candidate, value] of duplicates) {
665
+ if (now - value.at > Math.max(windowMs * 2, 60_000)) duplicates.delete(candidate)
666
+ }
667
+ return true
668
+ }
669
+
670
+ // 发送前再检查实时策略;摘要可能在进入队列后才跨入勿扰时段。
671
+ const gateAndNotify = async (items, sendBatch) => {
672
+ let allowed = items.filter(applyTimePolicy)
673
+ if (allowed.length === 0) return
674
+ if (current.onlyWhenUnfocused && anyFocused()) {
675
+ allowed = allowed.filter((item) => {
676
+ if (item.kind !== '完成' || item.important) return true
677
+ record('suppressed', item, 'DSH Web 页面当前处于聚焦状态')
678
+ return false
679
+ })
680
+ }
681
+ const idleCandidates = allowed.filter((item) => item.kind === '完成' && !item.important)
682
+ if (current.onlyWhenIdleSec > 0 && idleCandidates.length) {
683
+ const idle = await idleSeconds()
684
+ if (idle < current.onlyWhenIdleSec) {
685
+ allowed = allowed.filter((item) => {
686
+ if (item.kind !== '完成' || item.important) return true
687
+ record('suppressed', item, `键鼠仅空闲 ${Math.floor(idle)} 秒,要求 ${current.onlyWhenIdleSec} 秒`)
688
+ return false
689
+ })
690
+ }
691
+ }
692
+ if (allowed.length) sendBatch(allowed)
693
+ }
694
+
695
+ const render = (items) => {
696
+ if (items.length === 1) {
697
+ return { title: items[0].title, body: items[0].body, sound: items[0].sound }
698
+ }
699
+ // 同类合并:N 个完成 / N 个出错;列表取前 3 个标签
700
+ const byKind = Map.groupBy(items, (i) => i.kind)
701
+ const parts = []
702
+ let sound = ''
703
+ for (const [kind, group] of byKind) {
704
+ const names = group.slice(0, 3).map((g) => g.label).join('、')
705
+ const more = group.length > 3 ? ` 等 ${group.length} 个` : ''
706
+ parts.push(`${group.length} 个${kind}:${names}${more}`)
707
+ // 合并通知的声音:有出错优先用出错声,否则用第一条的
708
+ if (!sound || kind === '出错' || kind === '被阻止') sound = group[0].sound
709
+ }
710
+ return { title: `${items.length} 个任务有结果`, body: parts.join(';'), sound }
711
+ }
712
+
713
+ const flush = () => {
714
+ flushTimer = null
715
+ const items = pending
716
+ pending = []
717
+ if (items.length === 0) return
718
+ void gateAndNotify(items, (allowed) => {
719
+ const { title, body, sound } = render(allowed)
720
+ send(title, body + runningSuffix(), sound, allowed)
721
+ })
722
+ }
723
+
724
+ const flushDigest = () => {
725
+ const items = digestPending
726
+ digestPending = []
727
+ if (items.length === 0) return
728
+ void gateAndNotify(items, (allowed) => {
729
+ const names = allowed.slice(0, 5).map((i) => i.label).join('、')
730
+ const more = allowed.length > 5 ? ` 等 ${allowed.length} 个` : ''
731
+ send(
732
+ `${allowed.length} 个任务完成`,
733
+ `${names}${more}${runningSuffix()}`,
734
+ current.sounds.completed,
735
+ allowed,
736
+ )
737
+ })
738
+ }
739
+
740
+ const setupDigest = () => {
741
+ if (digestTimer) {
742
+ clearInterval(digestTimer)
743
+ digestTimer = null
744
+ }
745
+ if (current.digestMinutes > 0) {
746
+ digestTimer = setInterval(flushDigest, current.digestMinutes * 60_000)
747
+ }
748
+ }
749
+ setupDigest()
750
+
751
+ // 设置页写入实时生效:换配置快照、重算通道、按新间隔重建 digest 定时器
752
+ scope.watch((next) => {
753
+ current = next
754
+ resolvedChannel = resolveChannel()
755
+ projectRules = parseProjectRules(current.projectRulesJson)
756
+ setupDigest()
757
+ })
758
+
759
+ const enqueue = (kind, title, body, sound, session, options = {}) => {
760
+ const item = makeItem(kind, title, body, sound, session)
761
+ if (!applyProjectRule(item)) return
762
+ if (kind === '完成' && options.durationSec < current.minDurationSec && !item.important) {
763
+ record('suppressed', item, `任务耗时 ${options.durationSec.toFixed(1)} 秒,短于 ${current.minDurationSec} 秒`)
764
+ return
765
+ }
766
+ if (!applyDuplicatePolicy(item)) return
767
+ if (kind === '完成' && current.digestMinutes > 0) {
768
+ digestPending.push(item)
769
+ record('queued', item, `已进入 ${current.digestMinutes} 分钟摘要队列`)
770
+ return
771
+ }
772
+ if (current.coalesceMs <= 0) {
773
+ void gateAndNotify([item], (allowed) => {
774
+ send(title, body + runningSuffix(), sound, allowed)
775
+ })
776
+ return
777
+ }
778
+ pending.push(item)
779
+ record('queued', item, `等待 ${current.coalesceMs} 毫秒合并窗口`)
780
+ if (!flushTimer) flushTimer = setTimeout(flush, current.coalesceMs)
781
+ }
782
+
783
+ if (current.notifyOnLoad) {
784
+ send('DSH', 'macOS 通知插件已加载', current.sounds.completed, [], { detail: '插件加载测试通知' })
785
+ }
786
+
787
+ ctx.on('session/event', (session, event) => {
788
+ if (event.type === 'session/title') {
789
+ titles.set(session.id, event.data.title)
790
+ return
791
+ }
792
+
793
+ if (!current.includeSubagents && session.header?.origin === 'subagent') {
794
+ if (event.type === 'turn/end' || event.type === 'approval/asked') {
795
+ record('suppressed', makeItem('子 Agent', '通知已过滤', label(session), '', session), '配置已排除子 Agent 会话')
796
+ }
797
+ return
798
+ }
799
+
800
+ if (event.type === 'turn/start') {
801
+ running.add(session.id)
802
+ turnStartedAt.set(session.id, event.time)
803
+ return
804
+ }
805
+
806
+ if (event.type === 'turn/end') {
807
+ running.delete(session.id)
808
+ const startedAt = turnStartedAt.get(session.id)
809
+ turnStartedAt.delete(session.id)
810
+ const durationSec = startedAt ? (event.time - startedAt) / 1000 : Infinity
811
+
812
+ const kind = event.data.reason?.kind
813
+ const name = label(session)
814
+ if (kind === 'completed') {
815
+ if (current.onCompleted) enqueue('完成', '任务完成', name, current.sounds.completed, session, { durationSec })
816
+ else record('suppressed', makeItem('完成', '任务完成', name, current.sounds.completed, session), '完成通知已关闭')
817
+ } else if (kind === 'aborted') {
818
+ if (current.onAborted) enqueue('中断', '任务已中断', name, current.sounds.aborted, session)
819
+ else record('suppressed', makeItem('中断', '任务已中断', name, current.sounds.aborted, session), '中断通知已关闭')
820
+ } else if (kind === 'blocked') {
821
+ if (current.onError) enqueue('被阻止', '任务被阻止', name, current.sounds.error, session)
822
+ else record('suppressed', makeItem('被阻止', '任务被阻止', name, current.sounds.error, session), '错误通知已关闭')
823
+ } else if (kind === 'error' && current.onError) {
824
+ const err = event.data.reason?.error
825
+ if (err?.code === 'RATE_LIMIT' || err?.status === 429) {
826
+ const retry = err.providerRetryAfterMs
827
+ ? `,服务商建议 ${Math.ceil(err.providerRetryAfterMs / 1000)} 秒后重试`
828
+ : ''
829
+ enqueue('出错', 'API 限流', `${name}: 请求被限流(429)${retry}`, current.sounds.error, session)
830
+ } else {
831
+ const msg = err?.message ?? '未知错误'
832
+ enqueue('出错', '任务出错', `${name}: ${msg}`, current.sounds.error, session)
833
+ }
834
+ } else if (kind === 'error') {
835
+ record('suppressed', makeItem('出错', '任务出错', name, current.sounds.error, session), '错误通知已关闭')
836
+ }
837
+ return
838
+ }
839
+
840
+ if (event.type === 'approval/asked' && current.onApproval) {
841
+ // 审批需要人处理,立即发,不合并
842
+ const reason = event.data.reason ? ` — ${event.data.reason}` : ''
843
+ const body = `${label(session)}: ${event.data.toolName}${reason}`
844
+ const item = makeItem('审批', '等待审批', body, current.sounds.approval, session)
845
+ if (applyProjectRule(item)) {
846
+ void gateAndNotify([item], (allowed) => send('等待审批', body, current.sounds.approval, allowed))
847
+ }
848
+ } else if (event.type === 'approval/asked') {
849
+ record('suppressed', makeItem('审批', '等待审批', label(session), current.sounds.approval, session), '审批通知已关闭')
850
+ }
851
+ })
852
+
853
+ // 卸载时清掉未发出的通知,避免插件已卸载还弹通知
854
+ ctx.effect(() => () => {
855
+ if (flushTimer) clearTimeout(flushTimer)
856
+ if (digestTimer) clearInterval(digestTimer)
857
+ })
858
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "dsh-macos-notify",
3
+ "version": "0.2.1",
4
+ "description": "Native macOS notifications and configurable sounds for DeepSeek Harness",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": ["index.js", "client.js", "cordis.patch.yml"],
13
+ "keywords": ["dsh-plugin", "deepseek-harness", "macos", "notifications"],
14
+ "author": "CrombastiC",
15
+ "license": "MIT",
16
+ "homepage": "https://github.com/CrombastiC/dsh-macos-notify#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/CrombastiC/dsh-macos-notify/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/CrombastiC/dsh-macos-notify.git"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "os": ["darwin"],
32
+ "scripts": {
33
+ "test": "node --test tests/*.test.mjs"
34
+ },
35
+ "dsh": {
36
+ "bundle": { "patch": "./cordis.patch.yml" },
37
+ "client": {
38
+ "platform": "web",
39
+ "immediately": true,
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-connection",
42
+ "@deepseek-ai/dsh-client-runtime",
43
+ "@deepseek-ai/dsh-client-ui-slots"
44
+ ]
45
+ }
46
+ },
47
+ "dependencies": {
48
+ "@deepseek-ai/schemastery": "^3.18.1"
49
+ }
50
+ }