dsh-macos-notify 0.2.1 → 0.4.0

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.md CHANGED
@@ -1,17 +1,18 @@
1
1
  # dsh-macos-notify
2
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.
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 first-level settings page in the DSH Web UI.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - Notification Center alerts when a turn completes, fails, is blocked, or waits for approval.
8
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.
9
+ - System sound picker, in-page sound preview via afplay, and managed custom sound import and deletion from the Web settings page.
10
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
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.
12
+ - Recent notification diagnostics explain whether an event was sent, queued, suppressed, or failed; they persist across restarts.
13
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.
14
+ - Daily quiet hours and temporary pauses with custom durations and a remaining-time display.
15
+ - Common toggles and the sound picker save instantly from the read-only view; number inputs are clamped to safe ranges.
15
16
  - Duplicate error suppression with a configurable cooldown window.
16
17
  - Project path rules for muting, error-only alerts, or important-project bypasses.
17
18
  - Minimum turn-duration filtering avoids notifications for near-instant replies.
@@ -41,7 +42,7 @@ Alternatively, install the latest source directly from GitHub:
41
42
  npx -y @deepseek-ai/dsh plugin --profile web add github:CrombastiC/dsh-macos-notify
42
43
  ```
43
44
 
44
- Then open **Settings → Plugins Plugin configuration macOS notifications**.
45
+ Then open **Settings → macOS notifications** from the first-level settings navigation.
45
46
 
46
47
  To remove the plugin:
47
48
 
@@ -88,7 +89,6 @@ The settings namespace is `macos-notify`. Values changed from the Web card apply
88
89
  | `pauseUntil` | `0` | Temporary pause deadline as Unix milliseconds; managed by quick actions in the Web card. |
89
90
  | `duplicateWindowSec` | `300` | Suppress identical errors from the same session within this window; `0` disables it. |
90
91
  | `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
92
 
93
93
  ### Project rule modes
94
94
 
@@ -96,7 +96,7 @@ The settings namespace is `macos-notify`. Values changed from the Web card apply
96
96
  - `errors` — allow only errors, blocked events, and approval requests.
97
97
  - `important` — bypass minimum-duration, focus, idle, quiet-hour, and temporary-pause filters.
98
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.
99
+ The settings page keeps the last 50 decisions, the duplicate-error cooldown state, and recent session titles, persisted to `~/Library/Application Support/dsh-macos-notify/state.json` so they survive DSH restarts. Each row records whether an event was sent, queued, suppressed, or failed and includes the reason. This history does not contain message content.
100
100
 
101
101
  ## Notification channels
102
102
 
@@ -108,17 +108,17 @@ Sound selection applies to the `osascript` channel. With OSC 9, the terminal con
108
108
 
109
109
  ## Custom sounds
110
110
 
111
- In the settings card, select **Edit → Import sound**. Supported inputs include AAC, AIFF, CAF, FLAC, M4A, MP3, OGG, Opus, and WAV.
111
+ In the settings page, select **Edit → Import sound**. Supported inputs include AAC, AIFF, CAF, FLAC, M4A, MP3, OGG, Opus, and WAV.
112
112
 
113
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
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.
115
+ New imports are recorded in `~/Library/Application Support/dsh-macos-notify/sounds.json` and can be deleted from the settings page. 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
116
 
117
117
  ## Known limitations
118
118
 
119
119
  - The plugin is macOS-only. Native notifications use `osascript`, and custom import uses macOS audio tooling.
120
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.
121
+ - The Web settings page uses the trusted `/macos-notify` RPC channel because the current DSH Web settings proxy has a namespace allowlist for built-in settings.
122
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
123
 
124
124
  ## Development
@@ -126,7 +126,9 @@ New imports are recorded in `~/Library/Application Support/dsh-macos-notify/soun
126
126
  The package is intentionally build-free:
127
127
 
128
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.
129
+ - `client.js` — hand-written DSH client module for focus reporting and the first-level Web settings page.
130
+ - `src/policy.js` — pure policy helpers (quiet hours, project rules, duplicate merging) covered by unit tests.
131
+ - `src/state.js` — validated, atomic persistence for diagnostics, duplicate state, and session titles.
130
132
  - `cordis.patch.yml` — profile bundle patch.
131
133
 
132
134
  Run the release checks:
@@ -140,7 +142,7 @@ npm pack --dry-run
140
142
 
141
143
  ## 中文说明
142
144
 
143
- 这是一个仅支持 macOS 的 DeepSeek Harness 通知插件。它可以在任务完成、出错、等待审批时发送系统通知,并支持通知诊断、测试矩阵、每日勿扰、临时暂停、自定义声音管理、重复错误抑制、项目规则、焦点抑制、合并通知和定时汇总。推荐从 npm 安装,也可以直接从 GitHub 安装最新版源码。
145
+ 这是一个仅支持 macOS 的 DeepSeek Harness 通知插件。它可以在任务完成、出错、等待审批时发送系统通知,并支持通知诊断(跨重启持久化)、afplay 本地试听、测试矩阵、每日勿扰、自定义时长的临时暂停、自定义声音管理与导入指派、常用开关即时保存、重复错误抑制、项目路径自动补全、焦点抑制、合并通知和定时汇总。推荐从 npm 安装,也可以直接从 GitHub 安装最新版源码。
144
146
 
145
147
  ## License
146
148
 
package/client.js CHANGED
@@ -67,12 +67,19 @@ window.__ModuleLoader__.load({
67
67
  var savedState = React.useState(false), saved = savedState[0], setSaved = savedState[1]
68
68
  var importingState = React.useState(false), importing = importingState[0], setImporting = importingState[1]
69
69
  var testingState = React.useState(''), testing = testingState[0], setTesting = testingState[1]
70
+ var importTargetState = React.useState('none'), importTarget = importTargetState[0], setImportTarget = importTargetState[1]
70
71
  var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
71
72
  var fileInput = React.useRef(null), savedTimer = React.useRef(null), testTimer = React.useRef(null)
72
73
 
73
74
  var loadSettings = function () { return props.call('settings', { op: 'get' }).then(function (value) { setSettings(value); setDraft(toDraft(value)); return value }) }
74
75
  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
+ var loadDiagnostics = function () {
77
+ if (document.hidden) return Promise.resolve()
78
+ return props.call('diagnostics', { op: 'get' }).then(function (next) {
79
+ if (document.hidden) return
80
+ setDiagnostics(function (prev) { return JSON.stringify(prev) === JSON.stringify(next) ? prev : next })
81
+ }).catch(function () {})
82
+ }
76
83
  React.useEffect(function () {
77
84
  Promise.all([loadSettings(), loadCatalog(), loadDiagnostics()]).catch(function (err) { setError(String(err && err.message || '设置读取失败')) })
78
85
  var timer = setInterval(loadDiagnostics, 5000)
@@ -82,11 +89,45 @@ window.__ModuleLoader__.load({
82
89
 
83
90
  var patch = toPatch(draft), dirty = JSON.stringify(patch) !== JSON.stringify(toPatch(toDraft(settings)))
84
91
  var status = diagnostics.status || {}, pauseActive = Number(status.pauseUntil) > Date.now()
92
+ var focusCount = Number(status.focusedTabs) || 0
93
+ var statusNotes = []
94
+ if (status.quietActive) statusNotes.push('勿扰时段中')
95
+ if (pauseActive) statusNotes.push('通知已暂停')
96
+ if (focusCount > 0) statusNotes.push(focusCount + ' 个标签页聚焦中,完成通知将被抑制')
97
+ var statusNote = statusNotes.length ? statusNotes.join(';') : '运行正常'
85
98
  var availableSounds = catalog.names.length ? catalog.names : FALLBACK_SOUNDS
86
99
  var change = function (field, value) { setDraft(Object.assign({}, draft, { [field]: value })) }
87
100
  var changeSound = function (kind, value) { setDraft(Object.assign({}, draft, { sounds: Object.assign({}, draft.sounds, { [kind]: value }) })) }
101
+ var NUMBER_BOUNDS = { minDurationSec: [0, 3600], onlyWhenIdleSec: [0, 3600], digestMinutes: [0, 1440], coalesceMs: [0, 60000], duplicateWindowSec: [0, 86400] }
102
+ var clampNumber = function (field, raw) {
103
+ var bounds = NUMBER_BOUNDS[field] || [0, Infinity]
104
+ var num = Math.floor(Number(raw))
105
+ if (!Number.isFinite(num)) num = bounds[0]
106
+ return Math.min(bounds[1], Math.max(bounds[0], num))
107
+ }
108
+ // 读视图下的单字段即时保存:与快捷暂停同走 op:'set';批量编辑期间退回草稿模式
109
+ var saveNow = function (field, value) {
110
+ setSaving(true); setError('')
111
+ props.call('settings', { op: 'set', field: field, value: value }).then(function (next) {
112
+ setSettings(next); setDraft(toDraft(next)); setSaved(true); loadDiagnostics()
113
+ if (savedTimer.current) clearTimeout(savedTimer.current)
114
+ savedTimer.current = setTimeout(function () { setSaved(false) }, 1800)
115
+ }).catch(function (err) { setError(String(err && err.message || '保存失败')) }).finally(function () { setSaving(false) })
116
+ }
117
+ var previewSound = function (name) {
118
+ if (!name || testing) return
119
+ setTesting('preview:' + name); setError('')
120
+ props.call('sound/preview', { name: name }).catch(function (err) { setError(String(err && err.message || '试听失败')) }).finally(function () { setTesting('') })
121
+ }
122
+ var customPauseState = React.useState('30'), customPause = customPauseState[0], setCustomPause = customPauseState[1]
123
+ var humanizeDuration = function (ms) {
124
+ var minutes = Math.max(1, Math.round(ms / 60000))
125
+ if (minutes < 60) return minutes + ' 分钟'
126
+ var hours = Math.floor(minutes / 60), rest = minutes % 60
127
+ return rest ? hours + ' 小时 ' + rest + ' 分钟' : hours + ' 小时'
128
+ }
88
129
  var beginEdit = function () { setDraft(toDraft(settings)); setEditing(true); setSaved(false); setError('') }
89
- var cancel = function () { setDraft(toDraft(settings)); setEditing(false); setError('') }
130
+ var cancel = function () { if (dirty && !window.confirm('放弃未保存的修改?')) return; setDraft(toDraft(settings)); setEditing(false); setError('') }
90
131
  var save = function () {
91
132
  if (!dirty || saving) return
92
133
  setSaving(true); setError('')
@@ -119,7 +160,8 @@ window.__ModuleLoader__.load({
119
160
  reader.onload = function () {
120
161
  props.call('sound/import', { filename: file.name, data: String(reader.result || '').split(',')[1] || '' }).then(function (result) {
121
162
  if (!result || !result.name) throw new Error('导入结果无效')
122
- changeSound('completed', result.name); return loadCatalog()
163
+ if (importTarget !== 'none') changeSound(importTarget, result.name)
164
+ return loadCatalog()
123
165
  }).catch(function (err) { setError(String(err && err.message || '导入失败')) }).finally(function () { setImporting(false) })
124
166
  }
125
167
  reader.onerror = function () { setImporting(false); setError('声音文件读取失败') }
@@ -145,45 +187,51 @@ window.__ModuleLoader__.load({
145
187
  var removeRule = function (index) { change('projectRules', draft.projectRules.filter(function (_, candidate) { return candidate !== index })) }
146
188
  var addRule = function () { change('projectRules', draft.projectRules.concat({ path: props.getCurrentCwd() || '', mode: 'mute' })) }
147
189
 
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' } },
190
+ return h('div', { className: saved ? 'dsh-notify-saved' : '', style: { border: '1px solid var(--dsw-alias-border-l2,#444)', borderRadius: '10px', padding: editing ? '14px 16px 52px' : '14px 16px', fontSize: '13px', display: 'flex', flexDirection: 'column', gap: '12px' } },
149
191
  h('style', null, css),
150
192
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' } },
151
193
  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 ? '已保存 ✓' : '编辑全部')),
194
+ editing ? [h('span', { style: { padding: '3px 7px', borderRadius: '99px', fontSize: '11px', background: 'rgba(47,111,237,.1)' } }, '整体编辑中'), dirty ? h('span', { style: { padding: '3px 7px', borderRadius: '99px', fontSize: '11px', background: 'rgba(179,107,0,.15)', color: '#b36b00' } }, '未保存') : null] : h('button', { style: buttonStyle, disabled: saved, onClick: beginEdit }, saved ? '已保存 ✓' : '编辑全部')),
153
195
  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
196
 
155
- h('div', { style: sectionStyle }, h(SectionTitle, { title: '运行状态与测试', note: status.quietActive ? '当前处于勿扰时段' : pauseActive ? '通知已暂停' : '运行正常' }),
197
+ h('div', { style: sectionStyle }, h(SectionTitle, { title: '运行状态与测试', note: statusNote }),
156
198
  h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } }, ['completed', 'error', 'approval', 'aborted', 'coalesced', 'digest'].map(function (kind) {
157
199
  var names = { completed: '完成', error: '错误', approval: '审批', aborted: '中断', coalesced: '合并', digest: '摘要' }
158
200
  return h('button', { key: kind, style: buttonStyle, onClick: function () { test(kind, draft.sounds[kind]) } }, testing === kind ? '发送中…' : '测试' + names[kind])
159
201
  })),
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)),
202
+ h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' } }, h('small', { style: { opacity: .55 } }, pauseActive ? '暂停至 ' + new Date(status.pauseUntil).toLocaleString() + '(剩余约 ' + humanizeDuration(status.pauseUntil - Date.now()) + ')' : '临时暂停:'),
203
+ 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 小时'),
204
+ h('input', { 'aria-label': '自定义暂停分钟数', style: Object.assign({}, inputStyle, { width: '76px' }), type: 'number', min: 1, max: 10080, value: customPause, onChange: function (e) { setCustomPause(e.target.value) } }),
205
+ h('button', { style: buttonStyle, onClick: function () { var minutes = Math.floor(Number(customPause)); if (minutes > 0) pause(Math.min(minutes, 10080) * 60000) } }, '暂停'),
206
+ pauseActive ? h('button', { style: buttonStyle, onClick: function () { pause(0) } }, '立即恢复') : null)),
162
207
 
163
208
  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')))),
209
+ [['onCompleted', '任务完成'], ['onError', '错误与阻止'], ['onApproval', '等待审批'], ['onAborted', '用户中断']].map(function (row) { return h(Toggle, { key: row[0], disabled: saving, value: draft[row[0]], label: row[1], onChange: function (value) { if (editing) change(row[0], value); else saveNow(row[0], value) } }) }),
210
+ h(Toggle, { disabled: saving, value: draft.onlyWhenUnfocused, label: '仅在 DSH 页面未聚焦时发送完成通知', onChange: function (value) { if (editing) change('onlyWhenUnfocused', value); else saveNow('onlyWhenUnfocused', value) } }),
211
+ h(Toggle, { disabled: saving, value: draft.includeSubagents, label: '包含子 Agent 会话', onChange: function (value) { if (editing) change('includeSubagents', value); else saveNow('includeSubagents', value) } }),
212
+ [['minDurationSec', '完成通知最短耗时(秒)', 3600], ['onlyWhenIdleSec', '键鼠空闲门槛(秒)', 3600], ['digestMinutes', '摘要间隔(分钟)', 1440], ['coalesceMs', '合并窗口(毫秒)', 60000], ['duplicateWindowSec', '重复错误抑制(秒)', 86400]].map(function (row) { return h(SettingRow, { key: row[0], label: row[1] }, h('input', { style: inputStyle, type: 'number', min: 0, max: row[2], disabled: !editing, value: draft[row[0]], onChange: function (e) { change(row[0], clampNumber(row[0], e.target.value)) } })) }),
213
+ h(SettingRow, { label: '通知通道', note: 'OSC 9 仅支持 iTerm2、WezTerm、Kitty、Ghostty、Warp' }, h('select', { style: inputStyle, disabled: saving, value: draft.channel, onChange: function (e) { var value = e.target.value; if (editing) change('channel', value); else saveNow('channel', value) } }, h('option', { value: 'auto' }, '自动'), h('option', { value: 'osascript' }, 'osascript'), h('option', { value: 'osc9' }, 'OSC 9')))),
169
214
 
170
215
  h('div', { style: sectionStyle }, h(SectionTitle, { title: '每日勿扰', note: '使用本机时间' }),
171
- h(Toggle, { disabled: !editing, value: draft.quietHoursEnabled, label: '启用每日勿扰时段', onChange: function (value) { change('quietHoursEnabled', value) } }),
216
+ h(Toggle, { disabled: saving, value: draft.quietHoursEnabled, label: '启用每日勿扰时段', onChange: function (value) { if (editing) change('quietHoursEnabled', value); else saveNow('quietHoursEnabled', value) } }),
172
217
  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) } })),
218
+ draft.quietHoursEnabled && draft.quietStart === draft.quietEnd ? h('small', { style: { opacity: .7, color: '#b36b00' } }, '开始与结束时间相同视为未启用') : null,
219
+ h(Toggle, { disabled: saving, value: draft.quietAllowCritical, label: '勿扰时仍允许错误、阻止和审批', onChange: function (value) { if (editing) change('quietAllowCritical', value); else saveNow('quietAllowCritical', value) } })),
174
220
 
175
221
  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 ? '播放中…' : '试听')) }),
222
+ status.channel === 'osc9' ? h('small', { style: { opacity: .55 } }, '当前经 OSC 9 输出:通知声音由终端控制;此处设置对 osascript 通道生效,试听为本地播放') : null,
223
+ 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('select', { style: Object.assign({}, inputStyle, { width: '150px' }), value: importTarget, onChange: function (e) { setImportTarget(e.target.value) } }, h('option', { value: 'none' }, '导入后不指派'), SOUND_ROWS.map(function (row) { return h('option', { key: row[0], value: row[0] }, '并指派给' + row[1]) })), h('small', { style: { opacity: .55 } }, '已管理 ' + catalog.managed.length + ' ')) : null,
224
+ 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: saving, value: value, onChange: function (e) { var next = e.target.value; if (editing) changeSound(kind, next); else saveNow('sounds', Object.assign({}, draft.sounds, { [kind]: next })) } }, h('option', { value: '' }, '静音'), choices.map(function (name) { return h('option', { key: name, value: name }, name) })), h('button', { style: buttonStyle, disabled: !value, onClick: function () { previewSound(value) } }, testing === 'preview:' + value ? '播放中…' : '试听')) }),
178
225
  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
226
 
180
227
  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 } }, '尚未配置项目规则'),
228
+ h('datalist', { id: 'dsh-notify-cwds' }, (props.getSessionCwds ? props.getSessionCwds() : []).map(function (cwd) { return h('option', { key: cwd, value: cwd }) })),
229
+ 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, list: 'dsh-notify-cwds', 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
230
  editing ? h('button', { style: Object.assign({}, buttonStyle, { alignSelf: 'flex-start' }), onClick: addRule }, props.getCurrentCwd() ? '添加当前项目' : '添加规则') : null),
183
231
 
184
232
  h('div', { style: sectionStyle }, h(SectionTitle, { title: '最近通知诊断', note: '当前进程最近50条' }),
185
233
  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),
234
+ diagnostics.entries.length ? h('button', { style: Object.assign({}, buttonStyle, { alignSelf: 'flex-start' }), onClick: function () { if (!window.confirm('清空全部诊断记录?')) return; props.call('diagnostics', { op: 'clear' }).then(setDiagnostics) } }, '清空记录') : null),
187
235
 
188
236
  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
237
  }
@@ -196,7 +244,16 @@ window.__ModuleLoader__.load({
196
244
  var timer = setInterval(report, 30000)
197
245
  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
246
  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) })
247
+ var getSessionCwds = function () { try { var state = ctx.sessions.list.getSnapshot(); var seen = {}, cwds = []; Object.keys(state.byId || {}).forEach(function (id) { var cwd = state.byId[id] && state.byId[id].cwd; if (cwd && !seen[cwd]) { seen[cwd] = true; cwds.push(cwd) } }); return cwds } catch { return [] } }
248
+ ctx.slots.inject('settings.section', function () {
249
+ return ctx.slots.register({
250
+ name: 'settings.section',
251
+ id: 'macos-notify',
252
+ order: 100,
253
+ label: 'macOS 通知',
254
+ inject: function () { return { call: call, getCurrentCwd: getCurrentCwd, getSessionCwds: getSessionCwds } },
255
+ }, Card)
256
+ })
200
257
  ctx.effect(function () { return function () { clearInterval(timer); document.removeEventListener('visibilitychange', report); window.removeEventListener('focus', report); window.removeEventListener('blur', report) } })
201
258
  }
202
259
  return module.exports
package/index.js CHANGED
@@ -5,6 +5,19 @@ import { homedir, tmpdir } from 'node:os'
5
5
  import { basename, extname, join, resolve } from 'node:path'
6
6
  import { promisify } from 'node:util'
7
7
  import Schema from '@deepseek-ai/schemastery'
8
+ import {
9
+ DuplicateTracker,
10
+ TtlCache,
11
+ buildNotificationScript,
12
+ duplicateKey,
13
+ isCompletionKind,
14
+ isCriticalKind,
15
+ matchingProjectRule,
16
+ minuteOfDay,
17
+ parseProjectRules,
18
+ quietHoursActive,
19
+ } from './src/policy.js'
20
+ import { loadStateSync, saveState } from './src/state.js'
8
21
 
9
22
  export const name = 'dsh-macos-notify'
10
23
  export const inject = ['sessions', 'settings']
@@ -53,8 +66,8 @@ export const Config = Schema.object({
53
66
  duplicateWindowSec: Schema.number().default(300),
54
67
  /** 项目规则 JSON:[{ path, mode }],mode 为 mute / errors / important */
55
68
  projectRulesJson: Schema.string().default('[]'),
56
- /** 插件加载时发一条测试通知 */
57
- notifyOnLoad: Schema.boolean().default(true),
69
+ /** 兼容旧配置;成功加载现在始终静默,仅初始化失败时提醒 */
70
+ notifyOnLoad: Schema.boolean().default(false),
58
71
  })
59
72
 
60
73
  /** 设置页可编辑的提示音字段 */
@@ -78,6 +91,12 @@ function soundRegistryPath() {
78
91
  return join(homedir(), 'Library/Application Support/dsh-macos-notify/sounds.json')
79
92
  }
80
93
 
94
+ /** 决策历史与重复合并状态的落盘位置;测试可通过 DSH_MACOS_NOTIFY_STATE_FILE 重定向 */
95
+ function stateFilePath() {
96
+ return process.env.DSH_MACOS_NOTIFY_STATE_FILE
97
+ || join(homedir(), 'Library/Application Support/dsh-macos-notify/state.json')
98
+ }
99
+
81
100
  async function readSoundRegistry() {
82
101
  try {
83
102
  const parsed = JSON.parse(await readFile(soundRegistryPath(), 'utf8'))
@@ -245,6 +264,31 @@ async function deleteManagedSound(name) {
245
264
  await writeSoundRegistry(registry)
246
265
  }
247
266
 
267
+ /** 试听:把声音名解析为磁盘文件后交给 afplay 本地播放,不经过通知通道 */
268
+ async function previewSound(name) {
269
+ if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) {
270
+ throw new Error('声音名无效')
271
+ }
272
+ const dir = userSoundsDir()
273
+ const registry = await readSoundRegistry()
274
+ const managed = registry.find((item) => item.name === name)
275
+ const candidates = managed ? [join(dir, managed.filename)] : []
276
+ for (const base of ['/System/Library/Sounds', '/Library/Sounds', dir]) {
277
+ for (const extension of SOUND_EXTENSIONS) candidates.push(join(base, `${name}${extension}`))
278
+ }
279
+ for (const candidate of candidates) {
280
+ try {
281
+ const info = await stat(candidate)
282
+ if (!info.isFile()) continue
283
+ } catch {
284
+ continue
285
+ }
286
+ await execFileAsync('/usr/bin/afplay', [candidate])
287
+ return
288
+ }
289
+ throw new Error('找不到声音文件')
290
+ }
291
+
248
292
  /** macOS 系统与用户声音目录;读取失败的目录直接忽略 */
249
293
  async function systemSoundNames() {
250
294
  const dirs = [
@@ -277,24 +321,23 @@ async function soundCatalog() {
277
321
  }
278
322
  }
279
323
 
280
- function esc(s) {
281
- return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
282
- }
283
-
284
324
  function notify(title, body, sound, channel, onResult = () => {}) {
285
325
  if (channel === 'osc9') {
286
326
  emitOsc9(title, body)
287
327
  onResult(null)
288
328
  return
289
329
  }
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) => {
330
+ execFile('osascript', ['-e', buildNotificationScript(title, body, sound)], (err) => {
293
331
  if (err) console.warn('[dsh-macos-notify] osascript failed:', err.message)
294
332
  onResult(err ?? null)
295
333
  })
296
334
  }
297
335
 
336
+ function notifyStartupFailure(err, sound = 'Basso') {
337
+ const detail = String(err?.message ?? err ?? '未知错误').replace(/\s+/g, ' ').trim().slice(0, 180)
338
+ notify('DSH', `macOS 通知插件加载失败:${detail || '未知错误'}`, sound, 'osascript')
339
+ }
340
+
298
341
  // —— OSC 9 通道(思路参考 kimi-code 的 terminal-notification.ts)——
299
342
 
300
343
  /** 认识 OSC 9 桌面通知的终端白名单;不认识 OSC 9 的终端收到转义序列会打印乱码,所以必须保守 */
@@ -331,54 +374,7 @@ function idleSeconds() {
331
374
  })
332
375
  }
333
376
 
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) {
377
+ function applyImpl(ctx, config) {
382
378
  // 配置三层叠加:schema 默认值 < cordis 组合层(base = 插件 config)< 用户层(设置页)。
383
379
  // 设置页写入后经 watch 实时生效,不需要重启。
384
380
  const scope = ctx.settings.register('macos-notify', Config, { base: config, applies: 'live' })
@@ -393,16 +389,37 @@ export function apply(ctx, config) {
393
389
  // 合并窗口内待发的轮次结束通知(实时通道)
394
390
  let pending = []
395
391
  let flushTimer = null
396
- // digest 通道:只攒「完成」
392
+ // digest 通道:只攒「完成」;发送时刻绑定在已积累的批次上,设置变更不重置已排定的 deadline
397
393
  let digestPending = []
398
394
  let digestTimer = null
395
+ let digestDeadline = 0
399
396
  // 最近的通知决策,仅保存在当前进程内;设置页用于解释“为什么没弹”。
400
397
  let diagnostics = []
401
398
  let diagnosticSeq = 0
402
399
  // 重复错误键 -> 首次发送时间、被抑制次数
403
- const duplicates = new Map()
400
+ let duplicates = new DuplicateTracker()
404
401
  let projectRules = parseProjectRules(current.projectRulesJson)
405
402
 
403
+ // —— 状态持久化:通知决策、重复合并、会话标题。防抖落盘,退出时立即 flush ——
404
+ const stateFile = stateFilePath()
405
+ let persistTimer = null
406
+ const schedulePersist = () => {
407
+ if (persistTimer) return
408
+ persistTimer = setTimeout(() => {
409
+ persistTimer = null
410
+ saveState(stateFile, {
411
+ diagnostics,
412
+ duplicates: duplicates.toJSON(),
413
+ titles: [...titles.entries()],
414
+ }).catch((err) => console.warn('[dsh-macos-notify] state persist failed:', err?.message ?? err))
415
+ }, 500)
416
+ persistTimer.unref?.()
417
+ }
418
+ const restored = loadStateSync(stateFile)
419
+ if (diagnostics.length === 0 && restored.diagnostics.length) diagnostics = restored.diagnostics
420
+ if (duplicates.size === 0 && restored.duplicates.length) duplicates = DuplicateTracker.fromJSON(restored.duplicates)
421
+ if (titles.size === 0 && restored.titles.length) for (const [id, title] of restored.titles) titles.set(id, title)
422
+
406
423
  const record = (status, item, detail, extra = {}) => {
407
424
  diagnostics.unshift({
408
425
  id: ++diagnosticSeq,
@@ -418,6 +435,7 @@ export function apply(ctx, config) {
418
435
  ...extra,
419
436
  })
420
437
  if (diagnostics.length > MAX_DIAGNOSTICS) diagnostics.length = MAX_DIAGNOSTICS
438
+ schedulePersist()
421
439
  }
422
440
 
423
441
  // 通知通道:auto 在支持的终端里走 OSC 9(终端自己转系统通知),否则 osascript
@@ -531,9 +549,21 @@ export function apply(ctx, config) {
531
549
  return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
532
550
  }
533
551
  }
552
+ if (endpoint === 'sound/preview') {
553
+ try {
554
+ await previewSound(typeof payload?.name === 'string' ? payload.name.trim() : '')
555
+ return { ok: true, value: null }
556
+ } catch (err) {
557
+ console.warn('[dsh-macos-notify] sound preview failed:', err?.message ?? err)
558
+ return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
559
+ }
560
+ }
534
561
  if (endpoint === 'diagnostics') {
535
562
  const op = payload?.op ?? 'get'
536
- if (op === 'clear') diagnostics = []
563
+ if (op === 'clear') {
564
+ diagnostics = []
565
+ schedulePersist()
566
+ }
537
567
  anyFocused()
538
568
  return {
539
569
  ok: true,
@@ -547,6 +577,7 @@ export function apply(ctx, config) {
547
577
  pauseUntil: current.pauseUntil,
548
578
  pending: pending.length,
549
579
  digestPending: digestPending.length,
580
+ digestDeadline,
550
581
  },
551
582
  },
552
583
  }
@@ -558,7 +589,7 @@ export function apply(ctx, config) {
558
589
  if (op === 'set' && typeof payload.field === 'string') {
559
590
  try {
560
591
  await scope.update({ [payload.field]: payload.value })
561
- return { ok: true, value: null }
592
+ return { ok: true, value: scope.get() }
562
593
  } catch (err) {
563
594
  return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
564
595
  }
@@ -597,6 +628,7 @@ export function apply(ctx, config) {
597
628
  console.log('[dsh-macos-notify] RPC intercept mounted')
598
629
  } catch (err) {
599
630
  console.error('[dsh-macos-notify] RPC intercept failed:', err)
631
+ notifyStartupFailure(err, current.sounds.error)
600
632
  }
601
633
  })
602
634
 
@@ -649,41 +681,44 @@ export function apply(ctx, config) {
649
681
  }
650
682
 
651
683
  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} 次)`)
684
+ if (!['出错', '被阻止'].includes(item.kind)) return true
685
+ const decision = duplicates.admit(duplicateKey(item), Date.now(), current.duplicateWindowSec * 1000)
686
+ schedulePersist()
687
+ if (!decision.send) {
688
+ record('suppressed', item, `重复通知已合并(本窗口第 ${decision.count} 次)`)
660
689
  return false
661
690
  }
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
- }
691
+ if (decision.suffix) item.body += decision.suffix
667
692
  return true
668
693
  }
669
694
 
695
+ // 键鼠空闲查询要 fork ioreg,5 秒内复用上次结果
696
+ const idleCache = new TtlCache(5000)
697
+ const idleSecondsCached = async () => {
698
+ const cached = idleCache.get(Date.now())
699
+ if (cached.hit) return cached.value
700
+ const idle = await idleSeconds()
701
+ idleCache.set(Date.now(), idle)
702
+ return idle
703
+ }
704
+
670
705
  // 发送前再检查实时策略;摘要可能在进入队列后才跨入勿扰时段。
671
706
  const gateAndNotify = async (items, sendBatch) => {
672
707
  let allowed = items.filter(applyTimePolicy)
673
708
  if (allowed.length === 0) return
674
709
  if (current.onlyWhenUnfocused && anyFocused()) {
675
710
  allowed = allowed.filter((item) => {
676
- if (item.kind !== '完成' || item.important) return true
711
+ if (!isCompletionKind(item.kind) || item.important) return true
677
712
  record('suppressed', item, 'DSH Web 页面当前处于聚焦状态')
678
713
  return false
679
714
  })
680
715
  }
681
- const idleCandidates = allowed.filter((item) => item.kind === '完成' && !item.important)
716
+ const idleCandidates = allowed.filter((item) => isCompletionKind(item.kind) && !item.important)
682
717
  if (current.onlyWhenIdleSec > 0 && idleCandidates.length) {
683
- const idle = await idleSeconds()
718
+ const idle = await idleSecondsCached()
684
719
  if (idle < current.onlyWhenIdleSec) {
685
720
  allowed = allowed.filter((item) => {
686
- if (item.kind !== '完成' || item.important) return true
721
+ if (!isCompletionKind(item.kind) || item.important) return true
687
722
  record('suppressed', item, `键鼠仅空闲 ${Math.floor(idle)} 秒,要求 ${current.onlyWhenIdleSec} 秒`)
688
723
  return false
689
724
  })
@@ -737,40 +772,52 @@ export function apply(ctx, config) {
737
772
  })
738
773
  }
739
774
 
740
- const setupDigest = () => {
775
+ // 摘要按绝对 deadline 触发:入队时排定,设置变更只重挂定时器、不重置等待时间;
776
+ // 关闭摘要时立即发掉已积累的批次,避免通知滞留。
777
+ const armDigest = () => {
741
778
  if (digestTimer) {
742
- clearInterval(digestTimer)
779
+ clearTimeout(digestTimer)
743
780
  digestTimer = null
744
781
  }
745
- if (current.digestMinutes > 0) {
746
- digestTimer = setInterval(flushDigest, current.digestMinutes * 60_000)
782
+ if (current.digestMinutes <= 0 || digestPending.length === 0) {
783
+ digestDeadline = 0
784
+ return
747
785
  }
786
+ if (!digestDeadline) digestDeadline = Date.now() + current.digestMinutes * 60_000
787
+ digestTimer = setTimeout(() => {
788
+ digestTimer = null
789
+ digestDeadline = 0
790
+ flushDigest()
791
+ }, Math.max(0, digestDeadline - Date.now()))
792
+ digestTimer.unref?.()
748
793
  }
749
- setupDigest()
750
794
 
751
- // 设置页写入实时生效:换配置快照、重算通道、按新间隔重建 digest 定时器
795
+ // 设置页写入实时生效:换配置快照、重算通道、按新间隔重挂 digest 定时器
752
796
  scope.watch((next) => {
753
797
  current = next
754
798
  resolvedChannel = resolveChannel()
755
799
  projectRules = parseProjectRules(current.projectRulesJson)
756
- setupDigest()
800
+ if (current.digestMinutes <= 0 && digestPending.length) flushDigest()
801
+ armDigest()
757
802
  })
758
803
 
759
804
  const enqueue = (kind, title, body, sound, session, options = {}) => {
760
805
  const item = makeItem(kind, title, body, sound, session)
761
806
  if (!applyProjectRule(item)) return
762
- if (kind === '完成' && options.durationSec < current.minDurationSec && !item.important) {
807
+ if (isCompletionKind(kind) && options.durationSec < current.minDurationSec && !item.important) {
763
808
  record('suppressed', item, `任务耗时 ${options.durationSec.toFixed(1)} 秒,短于 ${current.minDurationSec} 秒`)
764
809
  return
765
810
  }
766
811
  if (!applyDuplicatePolicy(item)) return
767
- if (kind === '完成' && current.digestMinutes > 0) {
812
+ if (isCompletionKind(kind) && current.digestMinutes > 0) {
768
813
  digestPending.push(item)
769
814
  record('queued', item, `已进入 ${current.digestMinutes} 分钟摘要队列`)
815
+ armDigest()
770
816
  return
771
817
  }
772
818
  if (current.coalesceMs <= 0) {
773
819
  void gateAndNotify([item], (allowed) => {
820
+ const { title, body, sound } = render(allowed)
774
821
  send(title, body + runningSuffix(), sound, allowed)
775
822
  })
776
823
  return
@@ -780,13 +827,12 @@ export function apply(ctx, config) {
780
827
  if (!flushTimer) flushTimer = setTimeout(flush, current.coalesceMs)
781
828
  }
782
829
 
783
- if (current.notifyOnLoad) {
784
- send('DSH', 'macOS 通知插件已加载', current.sounds.completed, [], { detail: '插件加载测试通知' })
785
- }
786
-
787
830
  ctx.on('session/event', (session, event) => {
788
831
  if (event.type === 'session/title') {
832
+ // 标题按会话累积,封顶避免长驻进程缓慢泄漏
833
+ if (titles.size >= 200) titles.delete(titles.keys().next().value)
789
834
  titles.set(session.id, event.data.title)
835
+ schedulePersist()
790
836
  return
791
837
  }
792
838
 
@@ -840,10 +886,12 @@ export function apply(ctx, config) {
840
886
  if (event.type === 'approval/asked' && current.onApproval) {
841
887
  // 审批需要人处理,立即发,不合并
842
888
  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)
889
+ const item = makeItem('审批', '等待审批', `${label(session)}: ${event.data.toolName}${reason}`, current.sounds.approval, session)
845
890
  if (applyProjectRule(item)) {
846
- void gateAndNotify([item], (allowed) => send('等待审批', body, current.sounds.approval, allowed))
891
+ void gateAndNotify([item], (allowed) => {
892
+ const { title, body, sound } = render(allowed)
893
+ send(title, body, sound, allowed)
894
+ })
847
895
  }
848
896
  } else if (event.type === 'approval/asked') {
849
897
  record('suppressed', makeItem('审批', '等待审批', label(session), current.sounds.approval, session), '审批通知已关闭')
@@ -854,5 +902,24 @@ export function apply(ctx, config) {
854
902
  ctx.effect(() => () => {
855
903
  if (flushTimer) clearTimeout(flushTimer)
856
904
  if (digestTimer) clearInterval(digestTimer)
905
+ if (persistTimer) {
906
+ clearTimeout(persistTimer)
907
+ persistTimer = null
908
+ }
909
+ saveState(stateFile, {
910
+ diagnostics,
911
+ duplicates: duplicates.toJSON(),
912
+ titles: [...titles.entries()],
913
+ }).catch(() => {})
857
914
  })
858
915
  }
916
+
917
+ export function apply(ctx, config) {
918
+ try {
919
+ return applyImpl(ctx, config)
920
+ } catch (err) {
921
+ console.error('[dsh-macos-notify] plugin initialization failed:', err)
922
+ notifyStartupFailure(err, config?.sounds?.error)
923
+ throw err
924
+ }
925
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-macos-notify",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Native macOS notifications and configurable sounds for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -9,7 +9,7 @@
9
9
  "./client": "./client.js",
10
10
  "./package.json": "./package.json"
11
11
  },
12
- "files": ["index.js", "client.js", "cordis.patch.yml"],
12
+ "files": ["index.js", "client.js", "src/policy.js", "src/state.js", "cordis.patch.yml"],
13
13
  "keywords": ["dsh-plugin", "deepseek-harness", "macos", "notifications"],
14
14
  "author": "CrombastiC",
15
15
  "license": "MIT",
@@ -30,7 +30,8 @@
30
30
  },
31
31
  "os": ["darwin"],
32
32
  "scripts": {
33
- "test": "node --test tests/*.test.mjs"
33
+ "test": "node --test tests/*.test.mjs",
34
+ "prepublishOnly": "node --check index.js && node --check client.js && node --check src/policy.js && node --check src/state.js && npm test"
34
35
  },
35
36
  "dsh": {
36
37
  "bundle": { "patch": "./cordis.patch.yml" },
@@ -40,6 +41,7 @@
40
41
  "inject": [
41
42
  "@deepseek-ai/dsh-client-connection",
42
43
  "@deepseek-ai/dsh-client-runtime",
44
+ "@deepseek-ai/dsh-client-ui-settings",
43
45
  "@deepseek-ai/dsh-client-ui-slots"
44
46
  ]
45
47
  }
package/src/policy.js ADDED
@@ -0,0 +1,137 @@
1
+ // 纯策略函数:时间窗、项目规则、重复合并。不碰 IO,便于脱离插件宿主单测。
2
+ import { resolve } from 'node:path'
3
+
4
+ /** 关键事件:勿扰时段和「errors」项目规则下仍然放行 */
5
+ export function isCriticalKind(kind) {
6
+ return kind === '出错' || kind === '被阻止' || kind === '审批'
7
+ }
8
+
9
+ /** 「完成类」通知:唯一受 最短耗时 / 聚焦 / 空闲 / 摘要 过滤约束的类型 */
10
+ export function isCompletionKind(kind) {
11
+ return kind === '完成'
12
+ }
13
+
14
+ /** 本机 HH:mm -> 当日分钟数;格式无效返回 null */
15
+ export function minuteOfDay(value) {
16
+ const match = /^(\d{2}):(\d{2})$/.exec(String(value))
17
+ if (!match) return null
18
+ const hour = Number(match[1])
19
+ const minute = Number(match[2])
20
+ if (hour > 23 || minute > 59) return null
21
+ return hour * 60 + minute
22
+ }
23
+
24
+ /** 每日勿扰是否生效;支持跨午夜区间,start === end 视为未配置 */
25
+ export function quietHoursActive(config, now = new Date()) {
26
+ if (!config.quietHoursEnabled) return false
27
+ const start = minuteOfDay(config.quietStart)
28
+ const end = minuteOfDay(config.quietEnd)
29
+ if (start === null || end === null || start === end) return false
30
+ const currentMinute = now.getHours() * 60 + now.getMinutes()
31
+ return start < end
32
+ ? currentMinute >= start && currentMinute < end
33
+ : currentMinute >= start || currentMinute < end
34
+ }
35
+
36
+ /** 项目规则 JSON -> [{ path, mode }];非法项丢弃,最多 50 条,相对路径按本进程 cwd 解析 */
37
+ export function parseProjectRules(raw) {
38
+ try {
39
+ const value = JSON.parse(raw)
40
+ if (!Array.isArray(value)) return []
41
+ return value.slice(0, 50).flatMap((item) => {
42
+ const path = typeof item?.path === 'string' ? item.path.trim() : ''
43
+ const mode = item?.mode
44
+ if (!path || !['mute', 'errors', 'important'].includes(mode)) return []
45
+ return [{ path: resolve(path), mode }]
46
+ })
47
+ } catch {
48
+ return []
49
+ }
50
+ }
51
+
52
+ /** 命中 cwd 的最具体规则;前缀比较要求完整路径段,避免 /tmp 误匹配 /tmpx */
53
+ export function matchingProjectRule(rules, cwd) {
54
+ if (!cwd) return null
55
+ const target = resolve(cwd)
56
+ return rules
57
+ .filter((rule) => target === rule.path || target.startsWith(`${rule.path}/`))
58
+ .sort((a, b) => b.path.length - a.path.length)[0] ?? null
59
+ }
60
+
61
+ export function duplicateKey(item) {
62
+ return `${item.sessionId ?? ''}\u0000${item.kind}\u0000${item.body}`
63
+ }
64
+
65
+ /**
66
+ * 重复通知合并器:相同 key 在窗口内只发第一条;窗口过期后的第一条会带上
67
+ * 「此前重复 N 次」的汇总。窗口按条目各自的首次发送时间独立计算。
68
+ */
69
+ export class DuplicateTracker {
70
+ #entries = new Map()
71
+
72
+ /**
73
+ * @param {string} key 重复判定键
74
+ * @param {number} now 当前时间戳(毫秒),显式传入便于测试
75
+ * @param {number} windowMs 抑制窗口毫秒数;<=0 时直通且不记录状态
76
+ * @returns {{ send: boolean, count?: number, suffix: string }}
77
+ * send=false 时 count 为本窗口内已合并次数;send=true 时 suffix 需追加到正文(可为空串)
78
+ */
79
+ admit(key, now, windowMs) {
80
+ if (!(windowMs > 0)) return { send: true, suffix: '' }
81
+ const previous = this.#entries.get(key)
82
+ if (previous && now - previous.at < windowMs) {
83
+ previous.count += 1
84
+ return { send: false, count: previous.count }
85
+ }
86
+ const suffix = previous?.count ? `(此前重复 ${previous.count} 次)` : ''
87
+ this.#entries.set(key, { at: now, count: 0 })
88
+ const threshold = Math.max(windowMs * 2, 60_000)
89
+ for (const [candidate, value] of this.#entries) {
90
+ if (now - value.at > threshold) this.#entries.delete(candidate)
91
+ }
92
+ return { send: true, suffix }
93
+ }
94
+
95
+ get size() {
96
+ return this.#entries.size
97
+ }
98
+
99
+ /** 供持久化:[key, { at, count }] 数组 */
100
+ toJSON() {
101
+ return [...this.#entries.entries()]
102
+ }
103
+
104
+ static fromJSON(entries) {
105
+ const tracker = new DuplicateTracker()
106
+ for (const [key, value] of entries) tracker.#entries.set(key, value)
107
+ return tracker
108
+ }
109
+ }
110
+
111
+ /** AppleScript 字符串转义 + 通知脚本组装;换行会截断 display notification 的字面量,压成空格 */
112
+ export function buildNotificationScript(title, body, sound) {
113
+ const clean = (value) => String(value).replace(/\r?\n/g, ' ')
114
+ const quote = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
115
+ const soundPart = sound ? ` sound name "${quote(sound)}"` : ''
116
+ return `display notification "${quote(clean(body))}" with title "${quote(clean(title))}"${soundPart}`
117
+ }
118
+
119
+ /** 极简 TTL 缓存:命中返回 { hit: true, value },过期或未填充返回 { hit: false } */
120
+ export class TtlCache {
121
+ constructor(ttlMs) {
122
+ this.ttlMs = ttlMs
123
+ this.value = null
124
+ this.at = 0
125
+ this.filled = false
126
+ }
127
+
128
+ get(now) {
129
+ return this.filled && now - this.at < this.ttlMs ? { hit: true, value: this.value } : { hit: false, value: null }
130
+ }
131
+
132
+ set(now, value) {
133
+ this.at = now
134
+ this.value = value
135
+ this.filled = true
136
+ }
137
+ }
package/src/state.js ADDED
@@ -0,0 +1,58 @@
1
+ // 通知决策历史、重复合并状态、会话标题的磁盘持久化。
2
+ // 文件缺失或损坏一律按空状态处理,绝不让坏文件拖垮通知插件。
3
+ import { readFileSync } from 'node:fs'
4
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
5
+ import { dirname } from 'node:path'
6
+
7
+ export const STATE_VERSION = 1
8
+ export const MAX_PERSISTED_DIAGNOSTICS = 50
9
+ export const MAX_PERSISTED_DUPLICATES = 500
10
+ export const MAX_PERSISTED_TITLES = 200
11
+
12
+ /** 校验并收敛状态;各字段独立取舍,坏条目丢弃而不是整体作废 */
13
+ export function sanitizeState(raw) {
14
+ const state = { diagnostics: [], duplicates: [], titles: [] }
15
+ if (!raw || typeof raw !== 'object') return state
16
+ if (Array.isArray(raw.diagnostics)) {
17
+ state.diagnostics = raw.diagnostics
18
+ .filter((item) => item && typeof item === 'object')
19
+ .slice(0, MAX_PERSISTED_DIAGNOSTICS)
20
+ }
21
+ if (Array.isArray(raw.duplicates)) {
22
+ state.duplicates = raw.duplicates.slice(0, MAX_PERSISTED_DUPLICATES).flatMap((entry) => {
23
+ const value = Array.isArray(entry) ? entry[1] : null
24
+ if (!entry || typeof entry[0] !== 'string' || !value
25
+ || typeof value.at !== 'number' || typeof value.count !== 'number') return []
26
+ return [[entry[0], { at: value.at, count: value.count }]]
27
+ })
28
+ }
29
+ if (Array.isArray(raw.titles)) {
30
+ state.titles = raw.titles
31
+ .slice(-MAX_PERSISTED_TITLES)
32
+ .flatMap((entry) => (
33
+ Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'string'
34
+ ? [[entry[0], entry[1]]]
35
+ : []
36
+ ))
37
+ }
38
+ return state
39
+ }
40
+
41
+ /** 初始化时同步读取:保证恢复完成后再处理任何事件,不存在恢复与事件的竞态 */
42
+ export function loadStateSync(file) {
43
+ try {
44
+ return sanitizeState(JSON.parse(readFileSync(file, 'utf8')))
45
+ } catch {
46
+ return { diagnostics: [], duplicates: [], titles: [] }
47
+ }
48
+ }
49
+
50
+ /** 先写临时文件再 rename,避免崩溃留下半个 JSON */
51
+ export async function saveState(file, state) {
52
+ const clean = sanitizeState(state)
53
+ const payload = JSON.stringify({ version: STATE_VERSION, ...clean }, null, 2)
54
+ const temp = `${file}.tmp`
55
+ await mkdir(dirname(file), { recursive: true })
56
+ await writeFile(temp, payload, { mode: 0o600 })
57
+ await rename(temp, file)
58
+ }