@wjm200509/dsh-client-ui-task-notify 0.0.0-stage → 1.0.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 JiaMingWang-CN
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 CHANGED
@@ -1,3 +1,45 @@
1
- # Temporary Holding Version
1
+ # @wjm200509/dsh-client-ui-task-notify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **中文** · 任务完成通知插件:主会话从运行中变为完成、且当前页面不在前台时,发送一条系统通知。不修改 DSH 源码,卸载即恢复。
4
+
5
+ ## 功能
6
+
7
+ - 只在主会话完成时通知,子 Agent 完成不通知;
8
+ - 五种提示音:系统默认、叮、叮咚、上升、静音;
9
+ - 提示音由 Web Audio 生成,音量跟随系统;
10
+ - 开关与提示音写入 DSH 的持久化设置,重启后保留。
11
+
12
+ ## 安装
13
+
14
+ ```powershell
15
+ dsh plugin --profile web add -w "@wjm200509/dsh-client-ui-task-notify"
16
+ ```
17
+
18
+ 安装后重启 DSH。用本地目录时把 `-w` 换成目录的绝对路径。
19
+
20
+ ## 使用
21
+
22
+ 1. 打开 DSH 设置,进入 **任务通知**;
23
+ 2. 开启通知并选择提示音;
24
+ 3. 浏览器首次请求系统通知权限时选择允许。
25
+
26
+ 页面聚焦时不会通知;只有主会话由运行中转为完成、且页面未聚焦才发送。
27
+
28
+ ## 兼容
29
+
30
+ 适配 DSH **0.2.2-rc.1**;`engines.dsh` 同时保留 `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.x` 的范围。
31
+
32
+ ## 开发
33
+
34
+ ```powershell
35
+ cd dsh-client-ui-task-notify
36
+ npm test
37
+ ```
38
+
39
+ 目录:
40
+
41
+ ```text
42
+ lib/index.js Host 半边:持久化设置命名空间
43
+ lib/client.js 浏览器半边:设置页、菜单项、通知与提示音
44
+ patch.yml loader 组合补丁(插入本插件行)
45
+ ```
package/lib/client.js ADDED
@@ -0,0 +1,217 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@wjm200509/dsh-client-ui-task-notify",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ const React = react;
9
+ //#region lib/client.js
10
+ /** Toggle-row stylesheet, themed through the shell's alias tokens. */
11
+ const CSS = '.dsh-notify-page{max-width:720px;display:flex;flex-direction:column;gap:16px;color:var(--dsw-alias-label-primary)}' +
12
+ '.dsh-notify-row{display:flex;align-items:center;justify-content:space-between;gap:24px}' +
13
+ '.dsh-notify-title{font-weight:600;font-size:14px}' +
14
+ '.dsh-notify-desc{margin-top:4px;font-size:12px;color:var(--dsw-alias-label-secondary)}' +
15
+ '.dsh-notify-switch{position:relative;box-sizing:border-box;width:40px;height:22px;border-radius:11px;padding:0;cursor:pointer;flex:none;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);transition:background .15s,border-color .15s}' +
16
+ '.dsh-notify-switch.on{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}' +
17
+ '.dsh-notify-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-bg-overlay,#fff);box-shadow:0 1px 2px rgba(0,0,0,.2);transition:transform .15s}' +
18
+ '.dsh-notify-switch.on .dsh-notify-knob{transform:translateX(18px)}' +
19
+ '.dsh-notify-warn{font-size:12px;color:var(--dsw-alias-state-warn-primary)}' +
20
+ '.dsh-notify-seg{display:flex;gap:8px;flex-wrap:wrap}' +
21
+ '.dsh-notify-segbtn{box-sizing:border-box;padding:4px 12px;border-radius:14px;font-size:12px;cursor:pointer;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);transition:background .15s,border-color .15s,color .15s}' +
22
+ '.dsh-notify-segbtn.on{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-overlay,#fff)}';
23
+ /** Stable Cordis plugin name. */
24
+ const name = "ui-task-notify";
25
+ /** The session list store carries running state; slots carry the settings
26
+ * seat, configForms the durable toggle storage, uiWorkspace the
27
+ * notification click-through. */
28
+ const inject = ["slots", "sessions", "configForms", "uiWorkspace"];
29
+ /** Main sessions only: subagent children carry origin/parentId on their summary. */
30
+ function isMainSession(entry) {
31
+ return entry !== void 0 && entry.origin === void 0 && entry.parentId === void 0;
32
+ }
33
+ /** The session the main view currently shows: the list marks it by
34
+ * main-view retention, not by a `current` field. */
35
+ function shownInView(list) {
36
+ return Object.values(list.byId).find((row) => (row?.retainedBy?.mainView ?? 0) > 0)?.id;
37
+ }
38
+ /** Off the DSH interface: tab hidden, minimized, or another window focused. */
39
+ function offScreen() {
40
+ return document.visibilityState !== "visible" || !document.hasFocus();
41
+ }
42
+ /**
43
+ * Completion-sound presets synthesized with Web Audio: each entry is a
44
+ * note table ({ freq Hz, at, dur } in seconds) played at fixed unity
45
+ * gain, so the perceived loudness tracks the OS volume with no private
46
+ * scaling stage (a volume control here would break that alignment).
47
+ */
48
+ const SOUNDS = {
49
+ ding: [{ freq: 880, at: 0, dur: 0.35 }],
50
+ chime: [{ freq: 660, at: 0, dur: 0.25 }, { freq: 880, at: 0.18, dur: 0.35 }],
51
+ rise: [{ freq: 523.25, at: 0, dur: 0.18 }, { freq: 659.25, at: 0.12, dur: 0.18 }, { freq: 783.99, at: 0.24, dur: 0.4 }],
52
+ };
53
+ /** Segmented-control options for the settings row. */
54
+ const SOUND_OPTIONS = [
55
+ { id: "system", label: "\u7CFB\u7EDF\u9ED8\u8BA4" },
56
+ { id: "ding", label: "\u53EE" },
57
+ { id: "chime", label: "\u53EE\u54DA" },
58
+ { id: "rise", label: "\u4E0A\u5347" },
59
+ { id: "silent", label: "\u9759\u97F3" },
60
+ ];
61
+ /** Shared AudioContext; created/resumed on user gestures (selector clicks). */
62
+ let audioCtx;
63
+ /** Ensure the context exists and runs; without a gesture it stays suspended. */
64
+ function ensureAudio() {
65
+ audioCtx ??= new AudioContext();
66
+ if (audioCtx.state === "suspended") audioCtx.resume().catch(() => {});
67
+ return audioCtx;
68
+ }
69
+ /** Play one synthesized preset; `system`/`silent` never reach here. */
70
+ function playSound(id) {
71
+ const notes = SOUNDS[id];
72
+ if (notes === void 0) return;
73
+ const ctx = ensureAudio();
74
+ const base = ctx.currentTime + 0.02;
75
+ for (const note of notes) {
76
+ const osc = ctx.createOscillator();
77
+ const gain = ctx.createGain();
78
+ osc.type = "sine";
79
+ osc.frequency.value = note.freq;
80
+ gain.gain.setValueAtTime(1, base + note.at);
81
+ gain.gain.exponentialRampToValueAtTime(0.001, base + note.at + note.dur);
82
+ osc.connect(gain).connect(ctx.destination);
83
+ osc.start(base + note.at);
84
+ osc.stop(base + note.at + note.dur + 0.05);
85
+ }
86
+ }
87
+ /**
88
+ * Settings page: the toggle row, the sound-selection row (clicking a
89
+ * segment also previews it — the click doubles as the user gesture that
90
+ * unlocks Web Audio for later background playback), and a hint when the
91
+ * browser denied notification permission. Displayed values follow the
92
+ * durable settings form through the bound configuration scope, never the click echo.
93
+ */
94
+ function SettingsPage(props) {
95
+ const host = props.host;
96
+ const readEnabled = () => host.getSnapshot().value?.enabled === true;
97
+ const readSound = () => host.getSnapshot().value?.sound ?? "system";
98
+ const [on, setOn] = React.useState(readEnabled);
99
+ const [sound, setSound] = React.useState(readSound);
100
+ const [denied, setDenied] = React.useState(typeof Notification !== "undefined" && Notification.permission === "denied");
101
+ React.useEffect(() => host.subscribe(() => {
102
+ setOn(readEnabled());
103
+ setSound(readSound());
104
+ }), [host]);
105
+ const toggle = () => {
106
+ const next = !on;
107
+ if (next && typeof Notification !== "undefined" && Notification.permission === "default") {
108
+ Notification.requestPermission().then((p) => setDenied(p === "denied")).catch(() => setDenied(true));
109
+ }
110
+ host.set("enabled", next);
111
+ setOn(next);
112
+ };
113
+ const selectSound = (id) => {
114
+ ensureAudio();
115
+ host.set("sound", id);
116
+ setSound(id);
117
+ if (id !== "system" && id !== "silent") playSound(id);
118
+ };
119
+ return React.createElement("div", { className: "dsh-notify-page" },
120
+ React.createElement("div", { className: "dsh-notify-row" },
121
+ React.createElement("div", null,
122
+ React.createElement("div", { className: "dsh-notify-title" }, "\u4F1A\u8BDD\u5B8C\u6210\u901A\u77E5"),
123
+ React.createElement("div", { className: "dsh-notify-desc" }, "\u4E3B\u4F1A\u8BDD\u5B8C\u6210\u4EFB\u52A1\u4E14\u672A\u9009\u4E2D\u8BE5\u4F1A\u8BDD\u6216\u4E0D\u5728\u672C\u754C\u9762\u65F6\uFF0C\u53D1\u9001\u7CFB\u7EDF\u901A\u77E5\uFF1B\u5B50 Agent \u5B8C\u6210\u4E0D\u901A\u77E5\u3002"),
124
+ ),
125
+ React.createElement("button", {
126
+ type: "button",
127
+ className: "dsh-notify-switch" + (on ? " on" : ""),
128
+ role: "switch",
129
+ "aria-checked": on ? "true" : "false",
130
+ "aria-label": "\u4F1A\u8BDD\u5B8C\u6210\u901A\u77E5",
131
+ onClick: toggle,
132
+ }, React.createElement("span", { className: "dsh-notify-knob" })),
133
+ ),
134
+ React.createElement("div", { className: "dsh-notify-row" },
135
+ React.createElement("div", null,
136
+ React.createElement("div", { className: "dsh-notify-title" }, "\u63D0\u793A\u97F3"),
137
+ React.createElement("div", { className: "dsh-notify-desc" }, "\u5B8C\u6210\u901A\u77E5\u7684\u63D0\u793A\u97F3\uFF1B\u70B9\u9009\u5373\u8BD5\u542C\uFF0C\u54CD\u5EA6\u8DDF\u968F\u7CFB\u7EDF\u97F3\u91CF\u3002"),
138
+ ),
139
+ React.createElement("div", { className: "dsh-notify-seg" },
140
+ SOUND_OPTIONS.map((option) => React.createElement("button", {
141
+ key: option.id,
142
+ type: "button",
143
+ className: "dsh-notify-segbtn" + (sound === option.id ? " on" : ""),
144
+ "aria-pressed": sound === option.id ? "true" : "false",
145
+ onClick: () => selectSound(option.id),
146
+ }, option.label)),
147
+ ),
148
+ ),
149
+ on && denied ? React.createElement("div", { className: "dsh-notify-warn" }, "\u7CFB\u7EDF\u901A\u77E5\u6743\u9650\u5DF2\u88AB\u62D2\u7EDD\uFF0C\u8BF7\u5728\u6D4F\u89C8\u5668\u7AD9\u70B9\u8BBE\u7F6E\u4E2D\u5141\u8BB8\u901A\u77E5\u540E\u91CD\u8BD5\u3002") : null,
150
+ );
151
+ }
152
+ /**
153
+ * Client plugin body: the completion watcher over the session list store and
154
+ * the settings.section toggle page.
155
+ * @param ctx - client root context.
156
+ */
157
+ function apply(ctx) {
158
+ const host = ctx.configForms.get(name);
159
+ /** Seed from the durable section (undefined while the read is in flight → off). */
160
+ const enabled = { value: host.getSnapshot().value?.enabled === true };
161
+ /** Last-observed running bit per session; the true→false edge arms a notification. */
162
+ const prevRunning = new Map();
163
+ ctx.effect(() => {
164
+ const tag = document.createElement("style");
165
+ tag.textContent = CSS;
166
+ document.head.append(tag);
167
+ return () => tag.remove();
168
+ }, "ui-task-notify: styles");
169
+ ctx.effect(() => host.subscribe(() => {
170
+ const section = host.getSnapshot().value;
171
+ if (section !== void 0) enabled.value = section.enabled === true;
172
+ }), "ui-task-notify: settings adoption");
173
+ ctx.effect(() => {
174
+ const sessions = ctx.sessions;
175
+ const snap = sessions.list.getSnapshot();
176
+ for (const id of snap.ids) prevRunning.set(id, snap.byId[id]?.running === true);
177
+ return sessions.list.subscribe(() => {
178
+ const list = sessions.list.getSnapshot();
179
+ for (const id of list.ids) {
180
+ const entry = list.byId[id];
181
+ const now = entry?.running === true;
182
+ const was = prevRunning.get(id);
183
+ prevRunning.set(id, now);
184
+ if (was !== true || now !== false) continue;
185
+ if (!isMainSession(entry)) continue;
186
+ if (!enabled.value) continue;
187
+ if (typeof Notification === "undefined" || Notification.permission !== "granted") continue;
188
+ if (shownInView(list) === id && !offScreen()) continue;
189
+ const sound = host.getSnapshot().value?.sound ?? "system";
190
+ const notification = new Notification("\u4EFB\u52A1\u5B8C\u6210", {
191
+ body: (entry.displayTitle || id) + " \u5DF2\u5B8C\u6210\u5F53\u524D\u4EFB\u52A1",
192
+ tag: "dsh-task-notify-" + id,
193
+ /* Custom or muted: drop the OS sound so it never doubles the synth (or noise). */
194
+ silent: sound !== "system",
195
+ });
196
+ if (sound !== "system" && sound !== "silent") playSound(sound);
197
+ notification.onclick = () => {
198
+ try { ctx.uiWorkspace.openSession(id); } catch (error) { console.error("ui-task-notify: open session failed", error); }
199
+ window.focus();
200
+ };
201
+ }
202
+ const live = new Set(list.ids);
203
+ for (const id of prevRunning.keys()) if (!live.has(id)) prevRunning.delete(id);
204
+ });
205
+ }, "ui-task-notify: completion watcher");
206
+ ctx.slots.inject("settings.section", () => ctx.slots.register(
207
+ { name: "settings.section", id: "session-notify", order: 25, label: "\u4EFB\u52A1\u901A\u77E5" },
208
+ () => React.createElement(SettingsPage, { host }),
209
+ ));
210
+ }
211
+ //#endregion
212
+ exports.apply = apply;
213
+ exports.inject = inject;
214
+ exports.name = name;
215
+ return module.exports;
216
+ }
217
+ });
package/lib/index.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * dsh-client-ui-task-notify — host half.
3
+ *
4
+ * The notification feature lives in the browser half (`lib/client.js`);
5
+ * this half owns the durable settings schema: the `Config` export is the
6
+ * `ui-task-notify` profile entry's form, which dsh projects into the shared
7
+ * settings document. The client toggle reads and writes it through
8
+ * `ctx.configForms.get(...)`, so the switch state persists with the DSH
9
+ * configuration instead of browser-local storage.
10
+ */
11
+ import z from "@deepseek-ai/schemastery";
12
+
13
+ const name = "ui-task-notify";
14
+
15
+ /** Field carrying the persisted toggle state. */
16
+ const NOTIFY_ENABLED_FIELD = "enabled";
17
+ /** Field carrying the persisted completion-sound selection. */
18
+ const NOTIFY_SOUND_FIELD = "sound";
19
+ /** Accepted completion-sound ids; `system` defers to the OS notification sound. */
20
+ const NOTIFY_SOUNDS = ["system", "ding", "chime", "rise", "silent"];
21
+ /**
22
+ * Durable toggle schema; the default keeps the feature off until the user opts
23
+ * in. Every field is volatile: the settings form only edits volatile fields,
24
+ * and a volatile-only write lands in the running plugin without a remount.
25
+ */
26
+ const Config = z.object({
27
+ [NOTIFY_ENABLED_FIELD]: z.boolean().default(false).volatile(),
28
+ [NOTIFY_SOUND_FIELD]: z.union([...NOTIFY_SOUNDS]).default("system").volatile()
29
+ });
30
+
31
+ /**
32
+ * The host half owns no behavior beyond its settings schema: the automatic
33
+ * config page is suppressed because the feature renders its own
34
+ * `settings.section` page from the browser half.
35
+ * @param ctx - Host plugin context.
36
+ */
37
+ function apply(ctx) {
38
+ ctx.inject(["settings"], (child) => {
39
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
40
+ });
41
+ }
42
+
43
+ export { apply, name, Config };
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@wjm200509/dsh-client-ui-task-notify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Session completion system notifications with selectable completion sound; toggle and sound persist in DSH settings",
5
+ "type": "module",
6
+ "author": "JiaMingWang-CN",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/JiaMingWang-CN/dsh-bundles#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/JiaMingWang-CN/dsh-bundles",
12
+ "directory": "dsh-client-ui-task-notify"
13
+ },
14
+ "keywords": [
15
+ "dsh",
16
+ "dsh-plugin",
17
+ "deepseek-harness",
18
+ "notification",
19
+ "web"
20
+ ],
21
+ "files": [
22
+ "lib",
23
+ "patch.yml",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "test": "node --test"
28
+ },
29
+ "engines": {
30
+ "dsh": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-rc.1"
31
+ },
32
+ "dependencies": {
33
+ "@deepseek-ai/schemastery": "^3.18.4"
34
+ },
35
+ "exports": {
36
+ ".": "./lib/index.js",
37
+ "./client": "./lib/client.js"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dsh": {
43
+ "client": {
44
+ "platform": "web"
45
+ },
46
+ "bundle": {
47
+ "patch": "./patch.yml"
48
+ }
49
+ }
50
+ }
package/patch.yml ADDED
@@ -0,0 +1,4 @@
1
+ # Session completion system notification plugin (client half + durable settings namespace).
2
+ - insert:
3
+ - id: ui-task-notify
4
+ name: "@wjm200509/dsh-client-ui-task-notify"