@falling-ts/dsh-web-ding 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 falling-ts
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.cn.md ADDED
@@ -0,0 +1,61 @@
1
+ # dsh-web-ding
2
+
3
+ 一个 DSH Cordis 插件:当 **agent 回合结束**(所有轮次安静、转入空闲)时,在
4
+ **浏览器里**播放一声"叮"。声音**完全由前端 JavaScript(Web Audio API)合成**——
5
+ Node/后端从不发声,也不走 Windows/系统通知。
6
+
7
+ 架构参照 [dsh-force-compact](https://github.com/falling-ts/dsh-force-compact):
8
+ 纯监听器的 Host 半部 + 浏览器 client 半部,之间用官方
9
+ `settings/document-updated` 镜像通道相连。
10
+
11
+ ## 工作方式
12
+
13
+ | 层 | 行为 |
14
+ |----|------|
15
+ | Host(`index.js` + `src/`) | 监听 `agent/status` 的 **idle 转变**(全部轮次与子代理结束、下一次人为对话之前;新建会话从未运行过的首次 idle 与重复 idle tick 都保持静默)。向 `falling-ts-web-ding` 设置命名空间写入 `{ phase:'done', at, sessionId }`。绝不发声、绝不调用系统。 |
16
+ | 浏览器(`web/client.js`) | 经 `settingsScope` 实时镜像命名空间;收到 `at` 严格更新的 `done` 信号后用 Web Audio API 合成一声短"叮"(三个正弦振荡器 + 指数衰减包络),经标签页播放。 |
17
+
18
+ ## 安装
19
+
20
+ ```bash
21
+ dsh plugin --profile web add github:falling-ts/dsh-web-ding
22
+ ```
23
+
24
+ (需要 Web 应用带上 client bundle——`package.json` 的 `dsh.client` 声明会自动
25
+ 完成。)
26
+
27
+ ## 配置(`falling-ts-web-ding` 命名空间,$DSH_HOME/settings.yaml)
28
+
29
+ | 字段 | 类型 | 默认 | 含义 |
30
+ |------|------|------|------|
31
+ | `enabled` | boolean | `true` | 总开关(关闭时 Host 跳过发布)。 |
32
+ | `volume` | number 0..1 | `0.7` | Web Audio 播放音量。 |
33
+ | `freq` | number 80..4000 | `880` | "叮"的基频(Hz)。 |
34
+ | `decayMs` | number 100..4000 | `900` | 音色衰减时长(ms)。 |
35
+
36
+ 也可以在 **设置 → 回合结束提示音** 面板里调整,面板带"试听"按钮。
37
+
38
+ 任何会话的 agent 回合结束(转入空闲)都会提示;同一会话内重复 idle tick 与新建后从未运行过的会话保持静默。
39
+
40
+ ## 浏览器自动播放策略
41
+
42
+ 浏览器要求一次用户手势后才允许出声。客户端在首次指针/按键交互(以及点击
43
+ "试听"按钮)时预热 `AudioContext`,所以:与页面交互一次(或点一下试听),之后
44
+ 每次回合结束就能听到叮。后台标签页里的 AudioContext 可能被浏览器自身挂起——
45
+ 保持标签页可见才能听到声音。
46
+
47
+ ## 开发
48
+
49
+ ```bash
50
+ # 作为开发覆盖层挂载(plain JS,无构建步骤)
51
+ dsh web --patch $(pwd)/cordis.patch.yml # 你的 CLI 支持该选项时
52
+ # 或从本地路径安装
53
+ dsh plugin --profile web add /path/to/dsh-web-ding
54
+ ```
55
+
56
+ 插件自身规则见 `AGENTS.md`(纯 Host 监听器、后端不发声、不弹系统通知、只用
57
+ 官方 settings 镜像通道通向浏览器)。
58
+
59
+ ## License
60
+
61
+ MIT
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # dsh-web-ding
2
+
3
+ A DSH Cordis plugin that plays a **"ding"** in the **browser** the moment an
4
+ agent finishes (runs to idle). The sound is synthesized **entirely by front-end
5
+ JavaScript (Web Audio API)** — the Node/Host side never plays audio and no
6
+ Windows/system notification is used.
7
+
8
+ Built by following the architecture of
9
+ [dsh-force-compact](https://github.com/falling-ts/dsh-force-compact):
10
+ a pure-listener Host half + a browser client half connected by the official
11
+ `settings/document-updated` mirror channel.
12
+
13
+ ## What it does
14
+
15
+ | Layer | What happens |
16
+ |-------|--------------|
17
+ | Host (`index.js` + `src/`) | Listens for the `agent/status` **idle transition** (all turns done, including sub-agents, before the next human turn; a fresh idle session that never ran, and repeated idle ticks, stay silent). Publishes `{ phase:'done', at, sessionId }` into the `falling-ts-web-ding` settings namespace. Never emits audio, never calls the OS. |
18
+ | Browser (`web/client.js`) | Mirrors the namespace live via `settingsScope`. On a strictly-newer `done` signal it synthesizes a short `ding` (three sine oscillators + exponential decay envelopes) with the Web Audio API and plays it through the tab. |
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ dsh plugin --profile web add github:falling-ts/dsh-web-ding
24
+ ```
25
+
26
+ Requires the web app to ship the client bundle (the `dsh.client` declaration in
27
+ `package.json` does that automatically).
28
+
29
+ ## Configuration (`falling-ts-web-ding` namespace, $DSH_HOME/settings.yaml)
30
+
31
+ | Field | Type | Default | Meaning |
32
+ |-------|------|---------|---------|
33
+ | `enabled` | boolean | `true` | Master switch (Host skips publishing when off). |
34
+ | `volume` | number 0..1 | `0.7` | Web Audio playback gain. |
35
+ | `freq` | number 80..4000 | `880` | Fundamental frequency of the ding (Hz). |
36
+ | `decayMs` | number 100..4000 | `900` | Tone decay length (ms). |
37
+
38
+ Every session's agent turn end (transition to idle) rings; repeated idle ticks of an already-idle session and brand-new sessions that never ran stay silent.
39
+
40
+ You can also adjust these from the **设置 → 回合结束提示音** panel, which
41
+ includes a "试听" (preview) button.
42
+
43
+ ## Browser autoplay policy
44
+
45
+ Browsers block audio until a user gesture. The client warms the
46
+ `AudioContext` on the first pointer/key interaction (and on the preview
47
+ button), so: interact with the page once (or press 试听) and you will hear the
48
+ ding at every agent turn end. An audio context in a background tab may be
49
+ suspended by the browser itself — keep the tab visible to hear the tone.
50
+
51
+ ## Development
52
+
53
+ ```bash
54
+ # mount as a dev overlay (plain JS, no build step)
55
+ dsh web --patch $(pwd)/cordis.patch.yml # if supported by your CLI
56
+ # or install from a local path
57
+ dsh plugin --profile web add /path/to/dsh-web-ding
58
+ ```
59
+
60
+ See `AGENTS.md` for the plugin's own rules (pure Host listener, no backend
61
+ audio, no OS notification, only sanctioned settings-mirror channel to the
62
+ browser).
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,12 @@
1
+ # Opt-in layer: mount the dsh-web-ding function plugin over the
2
+ # current composition without changing shipped defaults.
3
+ #
4
+ # dsh plugin --profile web add github:falling-ts/dsh-web-ding # install
5
+ #
6
+ # The Host half is a pure listener on agent/status; it pushes a "agent
7
+ # finished" signal into the falling-ts-web-ding settings namespace, which the
8
+ # browser client half mirrors and answers with a Web-Audio "ding".
9
+
10
+ - insert:
11
+ - id: web-ding
12
+ name: '@falling-ts/dsh-web-ding'
package/index.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * dsh-web-ding — a DSH Cordis function plugin.
3
+ *
4
+ * Announces the moment an agent finishes: the Host half listens for the
5
+ * agent/status 'idle' TRANSITION (all turns done, including sub-agents, before
6
+ * the next human turn) and publishes a tiny 'done' signal into the
7
+ * `falling-ts-web-ding` settings namespace. The browser client half
8
+ * (web/client.js) mirrors that namespace live and answers the signal by
9
+ * synthesizing a short "ding" with the Web Audio API — ENTIRELY front-end JS.
10
+ *
11
+ * The Node/Host side deliberately never plays audio and never raises a
12
+ * Windows/system notification: the sound lives in the browser tab.
13
+ *
14
+ * Layout:
15
+ * - index.js — this file; the Cordis plugin entry (listener registrations).
16
+ * - core/settings.js — the `falling-ts-web-ding` settings namespace (parameters + schema).
17
+ * - core/signal.js — the host→browser signal publisher (settings field write).
18
+ * - hooks/idle.js — the `agent/status` idle-transition observer.
19
+ * - web/client.js — the browser half: mirrors the namespace, plays the ding, registers a settings.section.
20
+ *
21
+ * @module @falling-ts/dsh-web-ding
22
+ */
23
+
24
+ import { registerNamespace } from './src/core/settings.js'
25
+ import { handleAgentStatus } from './src/hooks/idle.js'
26
+
27
+ /** @type {string} the function plugin's display name. */
28
+ export const name = 'web-ding'
29
+
30
+ /**
31
+ * Register the `agent/status` listener and the `falling-ts-web-ding` settings
32
+ * namespace (the "回合结束提示音" surface).
33
+ *
34
+ * No `inject` is declared: the `settings` service arrives with the preset
35
+ * plane AFTER this plugin's boot-time effect runs, and a boot-time `inject`
36
+ * would fail the boot assertion (the same late-mount ordering documented by
37
+ * dsh-force-compact). The namespace registration is therefore lazy +
38
+ * idempotent: attempted at boot and again atop every `agent/status` emission,
39
+ * with a bounded self-cancelling retry while the service is still absent. The
40
+ * retry is installation bookkeeping (it settles and cancels itself on
41
+ * success) — not a persistent timer or long-lived state.
42
+ *
43
+ * @param {import('@deepseek-ai/cordis').Context} ctx
44
+ */
45
+ const __applyInner = (ctx) => {
46
+ ctx.logger.info('[web-ding] apply START; settings=' + (ctx.get('settings') !== undefined ? 'present' : 'ABSENT'))
47
+
48
+ // ── Lazy namespace install (settings service may arrive after boot) ──────
49
+ const settingsState = { settled: false, installed: false }
50
+ const RETRY_DELAY_MS = 1000
51
+ const RETRY_MAX_ATTEMPTS = 30
52
+ const retryTimer = { value: undefined }
53
+ const tryRegisterOnce = async () => {
54
+ if (settingsState.settled) return
55
+ const settings = ctx.get('settings')
56
+ if (settings === undefined || typeof settings.register !== 'function') return // keep retrying
57
+ try {
58
+ const ok = await registerNamespace(ctx)
59
+ settingsState.settled = true
60
+ if (ok) {
61
+ settingsState.installed = true
62
+ ctx.logger.info('[web-ding] registered settings namespace "falling-ts-web-ding"')
63
+ } else {
64
+ ctx.logger.warn('[web-ding] settings present but schema build failed — namespace NOT registered')
65
+ }
66
+ } catch (error) {
67
+ const message = error instanceof Error ? (error.stack || error.message) : String(error)
68
+ ctx.logger.warn(`[web-ding] settings namespace registration threw — ${message}`)
69
+ }
70
+ }
71
+ const maybeRetryRegister = () => {
72
+ if (settingsState.settled || retryTimer.value !== undefined) return
73
+ let attempts = 0
74
+ const attempt = () => {
75
+ retryTimer.value = undefined
76
+ if (settingsState.settled) return
77
+ attempts += 1
78
+ void (async () => {
79
+ await tryRegisterOnce()
80
+ if (settingsState.settled) {
81
+ if (retryTimer.value !== undefined) clearTimeout(retryTimer.value)
82
+ retryTimer.value = undefined
83
+ return
84
+ }
85
+ if (attempts >= RETRY_MAX_ATTEMPTS) {
86
+ settingsState.settled = true // give up; agent/status listeners stay as safety net
87
+ return
88
+ }
89
+ retryTimer.value = setTimeout(attempt, RETRY_DELAY_MS)
90
+ })().catch(() => {})
91
+ }
92
+ attempt()
93
+ }
94
+ const maybeRegisterSettingsNamespace = () => {
95
+ if (settingsState.settled) return
96
+ void (async () => {
97
+ await tryRegisterOnce()
98
+ if (!settingsState.settled) maybeRetryRegister()
99
+ })().catch(() => {})
100
+ }
101
+ // Cancel any pending retry on teardown.
102
+ ctx.effect(() => () => {
103
+ if (retryTimer.value !== undefined) clearTimeout(retryTimer.value)
104
+ }, 'web-ding: settings install retry cleanup')
105
+
106
+ // Fire the eager attempt once NOW so a cold start with no agent traffic
107
+ // still lands the namespace (the client panel depends on it).
108
+ maybeRegisterSettingsNamespace()
109
+
110
+ // ── Turn-end ding: agent/status idle transition → publish 'done' signal ──
111
+ // agent/status is a SYNC event; the heavy work (settings write) is handed off
112
+ // to an async IIFE with its own catch so nothing escapes the dispatch.
113
+ ctx.on('agent/status', (payload) => {
114
+ maybeRegisterSettingsNamespace() // re-armed; cheap no-op once settled
115
+ void (async () => {
116
+ await handleAgentStatus(ctx, payload)
117
+ })().catch((error) => {
118
+ const message = error instanceof Error ? error.message : String(error)
119
+ ctx.logger.warn(`[web-ding] agent/status handler degraded (swallowed) — ${message}`)
120
+ })
121
+ })
122
+
123
+ ctx.logger.info('[web-ding] apply END (listeners + namespace attempts done)')
124
+ }
125
+
126
+ /**
127
+ * Plugin entry.
128
+ * @param {import('@deepseek-ai/cordis').Context} ctx
129
+ */
130
+ export const apply = (ctx) => {
131
+ try {
132
+ return __applyInner(ctx)
133
+ } catch (error) {
134
+ const message = error instanceof Error ? (error.stack || error.message) : String(error)
135
+ try {
136
+ ctx.logger.error(`[web-ding] apply FAILED — ${message}`)
137
+ } catch { /* never */ }
138
+ throw error
139
+ }
140
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@falling-ts/dsh-web-ding",
3
+ "version": "0.1.0",
4
+ "description": "DSH Cordis plugin: announces the moment an agent finishes (turns idle) by pushing a 'done' signal from the Host half into the falling-ts-web-ding settings namespace, which the browser client half mirrors live and answers with a synthesized 'ding' tone via the Web Audio API. The tone is played ENTIRELY by front-end JS in the browser — the Host/node backend never emits audio and no Windows/system notification is used.",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./web/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "index.js",
14
+ "src/",
15
+ "web/",
16
+ "cordis.patch.yml"
17
+ ],
18
+ "license": "MIT",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ },
26
+ "client": {
27
+ "platform": "web",
28
+ "inject": [
29
+ "@deepseek-ai/dsh-client-ui-settings"
30
+ ]
31
+ }
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/falling-ts/dsh-web-ding.git"
36
+ },
37
+ "keywords": [
38
+ "dsh",
39
+ "cordis",
40
+ "agent",
41
+ "turn-end",
42
+ "idle",
43
+ "notification",
44
+ "ding",
45
+ "web-audio",
46
+ "sound",
47
+ "client"
48
+ ]
49
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * dsh-web-ding settings — the "回合结束提示音" (turn-end ding) surface.
3
+ *
4
+ * User-tunable parameters are registered under the `falling-ts-web-ding`
5
+ * settings namespace (the `falling-ts-` prefix prevents collisions with
6
+ * other plugins' keys):
7
+ *
8
+ * - `enabled` (boolean, default `true`): master switch. When `false` the
9
+ * Host half still observes `agent/status` but skips publishing the signal,
10
+ * so the browser never hears a ding.
11
+ * - `volume` (number 0..1, default `0.7`): playback gain applied by the
12
+ * browser client's Web Audio synth (a per-oscillator peak, not the master).
13
+ * - `freq` (number 80..4000, default `880`): fundamental frequency of
14
+ * the synthesized "ding" in Hz. The client stacks a soft higher-octave and
15
+ * a faint bell partial on top automatically.
16
+ * - `decayMs` (number 100..4000, default `900`): exponential-decay length
17
+ * of the tone in milliseconds.
18
+ *
19
+ * The `signal` field is the PLUGIN-PRIVATE host→browser messenger (the same
20
+ * pattern dsh-force-compact uses for its `liveUi` field): the Host half is
21
+ * its only writer, the browser client never writes it, and it deliberately
22
+ * persists to settings.yaml like every other field (harmless cosmetic residue
23
+ * — the client's own last-at latch ignores anything it saw before load).
24
+ *
25
+ * @module @falling-ts/dsh-web-ding/settings
26
+ */
27
+
28
+ /** The settings namespace key (settings.get / settings.register address). */
29
+ export const NS = 'falling-ts-web-ding'
30
+
31
+ /** The settings field carrying the host→browser turn-end signal. */
32
+ export const SIGNAL_FIELD = 'signal'
33
+
34
+ /** Defaults — also the base passed to settings.register. */
35
+ export const DEFAULTS = Object.freeze({
36
+ enabled: true,
37
+ volume: 0.7,
38
+ freq: 880,
39
+ decayMs: 900,
40
+ })
41
+
42
+ /**
43
+ * Read ONE raw field of the namespace without a full parse. Never throws.
44
+ * @param {import('@deepseek-ai/cordis').Context} ctx
45
+ * @param {string} field
46
+ * @returns {Promise<unknown>} the raw stored value, or `undefined` when the
47
+ * settings service is not mounted or the field is unset.
48
+ */
49
+ export async function readRawSetting(ctx, field) {
50
+ try {
51
+ const settings = ctx.get('settings')
52
+ if (settings === undefined || typeof settings.get !== 'function') return undefined
53
+ const value = settings.get(NS)
54
+ if (value === undefined || value === null) return undefined
55
+ if (typeof value !== 'object') return undefined
56
+ return value[field]
57
+ } catch {
58
+ return undefined
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Resolve the schemastery `z` constructor, tolerating BOTH layouts:
64
+ * - a monorepo/dev layout where `@deepseek-ai/schemastery` resolves as a
65
+ * bare specifier;
66
+ * - this plugin as a STANDALONE repo whose node_modules lacks schemastery
67
+ * (it lives in the sibling `deepseek-harness/vendor/` copy). Then walk up
68
+ * from this file looking for the vendored build and import it via a
69
+ * file:// URL (required on Windows).
70
+ * @returns {Promise<object|undefined>} resolved `z`, or undefined.
71
+ */
72
+ async function resolveZ() {
73
+ try {
74
+ const mod = await import('@deepseek-ai/schemastery')
75
+ const z = mod.default ?? mod
76
+ if (typeof z.object === 'function') return z
77
+ } catch { /* fall through to candidate 2 */ }
78
+ try {
79
+ const { fileURLToPath, pathToFileURL } = await import('node:url')
80
+ const { dirname, join } = await import('node:path')
81
+ const { existsSync } = await import('node:fs')
82
+ let dir = dirname(fileURLToPath(import.meta.url))
83
+ for (let hop = 0; hop < 8; hop += 1) {
84
+ const cand = join(dir, 'deepseek-harness/vendor/schemastery/lib/index.mjs')
85
+ if (existsSync(cand)) {
86
+ const mod = await import(pathToFileURL(cand).href)
87
+ const z = mod.default ?? mod
88
+ if (typeof z.object === 'function') return z
89
+ }
90
+ const parent = dirname(dir)
91
+ if (parent === dir) break
92
+ dir = parent
93
+ }
94
+ } catch { /* candidate 2 unavailable */ }
95
+ return undefined
96
+ }
97
+
98
+ /**
99
+ * Build the `falling-ts-web-ding` schema through @deepseek-ai/schemastery.
100
+ * @returns {Promise<((section: unknown) => unknown) & { toJSON: () => unknown } | null>}
101
+ */
102
+ export async function buildSchema() {
103
+ try {
104
+ const z = await resolveZ()
105
+ if (z === undefined) return null
106
+ return z.object({
107
+ enabled: z.boolean().default(DEFAULTS.enabled),
108
+ volume: z.number().default(DEFAULTS.volume),
109
+ freq: z.number().default(DEFAULTS.freq),
110
+ decayMs: z.number().default(DEFAULTS.decayMs),
111
+ // TRANSIENT host→browser messenger (src/core/signal.js): host-written
112
+ // { phase:'done', at, sessionId }. z.any() because the vendored
113
+ // schemastery exposes only object/any/string/number/boolean/array.
114
+ signal: z.any(),
115
+ })
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Register the namespace when a `settings` service is mounted. Idempotent.
123
+ * Falls back to a callable placeholder schema so the panel still loads when
124
+ * schemastery is unresolvable.
125
+ * @param {import('@deepseek-ai/cordis').Context} ctx
126
+ * @returns {Promise<boolean>}
127
+ */
128
+ export async function registerNamespace(ctx) {
129
+ const settings = ctx.get('settings')
130
+ if (settings === undefined || typeof settings.register !== 'function') return false
131
+ const schema = await buildSchema()
132
+ const thirdArg = { base: { ...DEFAULTS } }
133
+ const placeholderSchema = (section) => section
134
+ placeholderSchema.toJSON = () => ({})
135
+ try {
136
+ settings.register(NS, schema !== null ? schema : placeholderSchema, thirdArg)
137
+ return true
138
+ } catch {
139
+ return false
140
+ }
141
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Turn-end signal messenger — the plugin-private bridge between the HOST half
3
+ * ("this agent just finished") and the CLIENT half ("play the ding").
4
+ *
5
+ * Why settings at all
6
+ * ------------------
7
+ * The browser client half mirrors the `falling-ts-web-ding` namespace through
8
+ * `settingsScope.bind` → `createSnapshotStore`, so ANY field the Host writes
9
+ * here is reflected in the browser live (the SettingsScope revision-fencing
10
+ * contract, the same `settings/document-updated` broadcast dsh-force-compact
11
+ * rides for its liveUi badge). That is the ONLY sanctioned host→browser
12
+ * live-data channel an independent plugin bundle can use.
13
+ *
14
+ * The `signal` payload is deliberately minimal:
15
+ * { phase: 'done', at: <monotonic epoch ms>, sessionId?: string }
16
+ * `at` doubles as the sequence number — the client ignores any signal older
17
+ * than the one it last played, so restarts and stale residue never re-ding.
18
+ *
19
+ * Guarantees:
20
+ * • NEVER throws — a settings-service absence or a rejected write is caught
21
+ * and logged at most once per lifetime (cosmetic only; the agent/status
22
+ * dispatch proceeds untouched).
23
+ * • Fire-and-forget from the caller's perspective.
24
+ *
25
+ * @module @falling-ts/dsh-web-ding/signal
26
+ */
27
+
28
+ import { NS, SIGNAL_FIELD } from './settings.js'
29
+
30
+ /** Process-local monotonic high-water mark so two same-millisecond idles can never collide. */
31
+ let lastAt = 0
32
+
33
+ /**
34
+ * Publish one "agent finished" signal onto the `signal` field of the
35
+ * `falling-ts-web-ding` namespace. THE host→browser delivery point.
36
+ * @param {import('@deepseek-ai/cordis').Context} ctx
37
+ * @param {string|undefined} sessionId the agent session id that just went idle
38
+ * @returns {Promise<void>}
39
+ */
40
+ let warnedOnce = false
41
+ export async function publishDingSignal(ctx, sessionId) {
42
+ try {
43
+ const settings = ctx.get('settings')
44
+ if (settings === undefined || typeof settings.update !== 'function') return
45
+ let at = Date.now()
46
+ if (at <= lastAt) at = lastAt + 1
47
+ lastAt = at
48
+ const signal = {
49
+ phase: 'done',
50
+ at,
51
+ ...(typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : {}),
52
+ }
53
+ await settings.update(NS, { [SIGNAL_FIELD]: signal })
54
+ if (!warnedOnce) {
55
+ warnedOnce = true
56
+ try {
57
+ ctx.logger.debug(`[web-ding] signal published via ${NS}.${SIGNAL_FIELD} (at=${at})`)
58
+ } catch { /* logging must never propagate */ }
59
+ }
60
+ } catch (error) {
61
+ const message = error instanceof Error ? error.message : String(error)
62
+ try {
63
+ ctx.logger.warn(`[web-ding] signal publish failed (ignored, cosmetic only) — ${message}`)
64
+ } catch { /* never */ }
65
+ }
66
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The turn-end ding trigger — observes `agent/status` and publishes the
3
+ * "agent finished" signal exactly once per idle TRANSITION.
4
+ *
5
+ * Why the transition bookkeeping:
6
+ * - The agent starts idle for a fresh session (before any turn) — nobody
7
+ * asked for a ding then, and a "new session created" ding would be noise.
8
+ * - The agent/status idle tick can repeat while a session stays idle (the
9
+ * same recurrence dsh-force-compact documents for its idle compaction), so
10
+ * the raw `status === 'idle'` guard alone would re-ding every tick.
11
+ *
12
+ * Two process-local latches (pure listener state, no timer, no persistence):
13
+ * prevStatus: sessionId → last observed status (Map)
14
+ * everBusy: sessionIds that were observed in a non-idle status (Set)
15
+ * A signal is published only when BOTH hold: the previous status was NOT
16
+ * 'idle' (this is a genuine running→idle transition) AND the session was
17
+ * observed busy at least once (so the very first idle after session creation
18
+ * stays silent).
19
+ *
20
+ * @module @falling-ts/dsh-web-ding/turn-end
21
+ */
22
+
23
+ import { readRawSetting } from '../core/settings.js'
24
+ import { publishDingSignal } from '../core/signal.js'
25
+
26
+ /** @type {Map<string,string>} sessionId → last observed agent/status. */
27
+ const prevStatus = new Map()
28
+ /** @type {Set<string>} sessionIds that have been observed busy (non-idle). */
29
+ const everBusy = new Set()
30
+
31
+ /**
32
+ * Handle one `agent/status` emission. Never throws out of the listener (any
33
+ * anomaly logs and settles).
34
+ * @param {import('@deepseek-ai/cordis').Context} ctx
35
+ * @param {{agent: import('@deepseek-ai/dsh-agent').Agent, status: string}} payload
36
+ * @returns {Promise<void>}
37
+ */
38
+ export async function handleAgentStatus(ctx, payload) {
39
+ try {
40
+ if (payload === null || typeof payload !== 'object') return
41
+ const agent = payload.agent
42
+ const status = payload.status
43
+ if (typeof status !== 'string') return
44
+ const session = (agent && typeof agent === 'object') ? agent.session : undefined
45
+ const sid = (session && typeof session.id === 'string') ? session.id : '?'
46
+
47
+ if (status !== 'idle') {
48
+ if (status === 'running') everBusy.add(sid)
49
+ prevStatus.set(sid, status)
50
+ return
51
+ }
52
+
53
+ const prev = prevStatus.get(sid)
54
+ prevStatus.set(sid, 'idle')
55
+ if (prev === 'idle') return // repeated idle tick — already handled
56
+ if (!everBusy.has(sid)) return // fresh session that never ran — no ding
57
+
58
+ const enabled = await readRawSetting(ctx, 'enabled')
59
+ if (enabled === false) {
60
+ ctx.logger.debug(`[web-ding] ${sid}: idle transition ignored — enabled=false`)
61
+ return
62
+ }
63
+ await publishDingSignal(ctx, sid)
64
+ } catch (error) {
65
+ const message = error instanceof Error ? (error.stack || error.message) : String(error)
66
+ try {
67
+ ctx.logger.warn(`[web-ding] handleAgentStatus degraded (swallowed) — ${message}`)
68
+ } catch { /* never */ }
69
+ }
70
+ }
package/web/client.js ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * dsh-web-ding 浏览器半部:回合结束提示音(设置分区 + Web Audio 播放器)。
3
+ *
4
+ * 这是一个闭包工厂 artifact:window.__ModuleLoader__.load({ id, factory }),
5
+ * factory(require) 通过注入的 require 解析外部模块(react、client-runtime),
6
+ * 并返回插件面 { name, inject, apply }。宿主半部(根 index.js)与本文件是
7
+ * 同一 package 的两个面:宿主半部由 main 入口加载,本文件由 exports["./client"]
8
+ * 导出,经 dsh.client 声明被 client module 系统自动组成并服务。
9
+ *
10
+ * 职责:
11
+ * 1. settingsScope 镜像 falling-ts-web-ding 命名空间,订阅其快照翻转——这
12
+ * 就是宿主的"事件时钟":宿主在 agent/status idle 转变时写入 signal 字段,
13
+ * 经 settings/document-updated 广播到达这里。
14
+ * 2. 检测新的 'done' 信号(at 严格大于本页面最后播放的 at 才响应,首帧只做
15
+ * 基线不播放,重启残留/重复快照都不会重复响)后,用 Web Audio API 合成
16
+ * 一声"叮"。声音 100% 由浏览器 JS 生成——宿主 Node 端从不发声,也不发
17
+ * Windows/系统通知。
18
+ * 3. 注册 settings.section "回合结束提示音" 分区:开关、音量、音色频率、
19
+ * 时长与"试听"按钮(点击试听同时完成音频解锁)。
20
+ *
21
+ * 浏览器自动播放策略:AudioContext 需要一次用户手势才能出声。首次
22
+ * pointerdown/keydown 做一次性预热(创建并 resume),"试听"按钮点击本身
23
+ * 也是一次手势,所以点过试听或与本页面交互过后即可正常听到回合结束的叮。
24
+ *
25
+ * @module @falling-ts/dsh-web-ding/client
26
+ */
27
+
28
+ window.__ModuleLoader__.load({
29
+ id: "@falling-ts/dsh-web-ding",
30
+ factory: (require) => {
31
+ var module = { exports: {} };
32
+ var exports = module.exports;
33
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
34
+ const React = require("react");
35
+ const h = React.createElement;
36
+ // 基线外部(web 平台预载):把 settingsScope 镜像成 uSES 安全的 SnapshotStore。
37
+ const { createSnapshotStore } = require("@deepseek-ai/dsh-client-runtime/client");
38
+
39
+ /** 宿主侧设置命名空间(settings.get 读取的键)。 */
40
+ const NS_SETTINGS = "falling-ts-web-ding";
41
+
42
+ /** 必需服务(slots 提供分区注册;settingsScope 由 ui-settings 提供)。 */
43
+ const inject = ["slots", "settingsScope"];
44
+
45
+ // ── Web Audio "叮" 播放器 --------------------------------------------------
46
+ // 完全前端合成:无音频资产、无系统通知。三个正弦振荡器叠加:
47
+ // 基频 + 高八度泛音(轻)+ 2.5 倍铃感泛音(更轻),各自带指数衰减包络。
48
+ let audio = null;
49
+ function ensureAudio() {
50
+ if (audio) {
51
+ if (audio.ctx.state === "closed") audio = null;
52
+ else return audio;
53
+ }
54
+ const Ctx = window.AudioContext || window.webkitAudioContext;
55
+ if (!Ctx) return null;
56
+ const ctx = new Ctx();
57
+ audio = { ctx };
58
+ return audio;
59
+ }
60
+ // 用户手势一次性预热:创建 AudioContext 并 resume,解除自动播放静音。
61
+ function warmup() {
62
+ const a = ensureAudio();
63
+ if (a && a.ctx.state === "suspended") {
64
+ void a.ctx.resume().catch(() => {});
65
+ }
66
+ }
67
+ if (typeof window !== "undefined") {
68
+ window.addEventListener("pointerdown", warmup, { once: true });
69
+ window.addEventListener("keydown", warmup, { once: true });
70
+ }
71
+ /**
72
+ * 播放一声"叮"。
73
+ * @param {{volume?: number, freq?: number, decayMs?: number}} opts
74
+ * @returns {boolean} 是否成功调度(不支持 Web Audio 时返回 false)
75
+ */
76
+ function playDing(opts) {
77
+ const o = opts || {};
78
+ const a = ensureAudio();
79
+ if (!a) return false;
80
+ const ctx = a.ctx;
81
+ if (ctx.state === "suspended") void ctx.resume().catch(() => {});
82
+ const volume = Math.min(1, Math.max(0, Number(o.volume) || 0.7));
83
+ const freq = Math.min(4000, Math.max(80, Number(o.freq) || 880));
84
+ const decay = Math.min(4000, Math.max(100, Number(o.decayMs) || 900)) / 1000;
85
+ const t0 = ctx.currentTime + 0.02;
86
+ const schedule = (f, peak, start, dur) => {
87
+ const osc = ctx.createOscillator();
88
+ const g = ctx.createGain();
89
+ osc.type = "sine";
90
+ osc.frequency.setValueAtTime(f, start);
91
+ g.gain.setValueAtTime(0.0001, start);
92
+ g.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), start + 0.012);
93
+ g.gain.exponentialRampToValueAtTime(0.0001, start + dur);
94
+ osc.connect(g);
95
+ g.connect(ctx.destination);
96
+ osc.start(start);
97
+ osc.stop(start + dur + 0.05);
98
+ };
99
+ schedule(freq, volume * 0.55, t0, decay); // 基频主体
100
+ schedule(freq * 2.0, volume * 0.2, t0, decay * 0.75); // 高八度泛音
101
+ schedule(freq * 2.5, volume * 0.07, t0 + 0.004, decay * 0.6); // 铃感泛音
102
+ return true;
103
+ }
104
+
105
+ // ── 命名空间快照 → 信号检测 → 播放 ------------------------------------------
106
+ // lastAt:本页面最后响应过的 signal.at。首帧(页面加载时已存在的残留)只做
107
+ // 基线、不播放;之后 at 严格增长的新 'done' 信号才叮一声。
108
+ let lastAt = null;
109
+ function maybePlayFromValue(value) {
110
+ if (!value || typeof value !== "object") return;
111
+ const sig = value.signal;
112
+ if (!sig || typeof sig !== "object") return;
113
+ if (sig.phase !== "done") return;
114
+ const at = typeof sig.at === "number" ? sig.at : 0;
115
+ if (lastAt === null) {
116
+ lastAt = at; // 首帧基线:页面打开前已发生的信号不播
117
+ return;
118
+ }
119
+ if (!(at > lastAt)) return;
120
+ lastAt = at;
121
+ if (value.enabled !== false) {
122
+ playDing({ volume: value.volume, freq: value.freq, decayMs: value.decayMs });
123
+ }
124
+ }
125
+
126
+ // ── 设置分区 UI --------------------------------------------------------------
127
+ const divider = "rgba(0,0,0,0.08)";
128
+ const hintColor = "rgba(0,0,0,0.45)";
129
+ const gridCols = "200px minmax(0,1fr)";
130
+ const wrapStyle = { padding: "4px 0" };
131
+ const titleStyle = { margin: "2px 0 2px", fontSize: 15, lineHeight: 1.4 };
132
+ const introStyle = { margin: "0 0 6px", color: hintColor, lineHeight: 1.65, fontSize: 13, maxWidth: 680 };
133
+ const rowStyle = { display: "grid", gridTemplateColumns: gridCols, columnGap: 16, rowGap: 5, padding: "13px 0", borderBottom: "1px solid " + divider, alignItems: "center" };
134
+ const lastRowStyle = { ...rowStyle, borderBottom: "none" };
135
+ const labelStyle = { fontSize: 13.5, fontWeight: 500, lineHeight: 1.35 };
136
+ const controlStyle = { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" };
137
+ const hintStyle = { gridColumn: "1 / 3", color: hintColor, fontSize: 12, lineHeight: 1.55 };
138
+ const valueStyle = { fontVariantNumeric: "tabular-nums", fontSize: 13, color: hintColor, minWidth: 52, textAlign: "right" };
139
+ const buttonStyle = { padding: "6px 14px", borderRadius: 8, border: "1px solid rgba(0,0,0,0.22)", background: "transparent", cursor: "pointer", fontSize: 13, fontWeight: 500 };
140
+ const inputRangeStyle = { flex: 1, minWidth: 140 };
141
+
142
+ function DingSection(props) {
143
+ // The renderer binds the injected hooks compartment ('ding' key) into a
144
+ // use<Name> hook; the store is the bare observable, so a selector reads
145
+ // its latest snapshot (the sanctioned client pattern, see force-compact).
146
+ const { update, play } = props;
147
+ const snap = props.useDing((s) => s);
148
+ const value = snap.value;
149
+ const ph = hintStyle;
150
+ if (snap.status === "unavailable") {
151
+ return h("div", { style: wrapStyle },
152
+ h("h2", { style: titleStyle }, "回合结束提示音"),
153
+ h("p", { style: ph }, "设置不可用(宿主端未注册 falling-ts-web-ding 命名空间)。"));
154
+ }
155
+ if (snap.status === "loading" || value === undefined) {
156
+ return h("div", { style: wrapStyle },
157
+ h("h2", { style: titleStyle }, "回合结束提示音"),
158
+ h("p", { style: ph }, "加载中…"));
159
+ }
160
+ const disabled = !snap.writable;
161
+ const v = (value && typeof value === "object") ? value : {};
162
+ const pct = (n) => Math.round((Number(n) || 0) * 100) + "%";
163
+ const sliderRow = (labelText, props2, display) => h("div", { key: labelText, style: rowStyle },
164
+ h("span", { style: labelStyle }, labelText),
165
+ h("span", { style: controlStyle },
166
+ h("input", { type: "range", disabled: disabled, style: inputRangeStyle, ...props2 }),
167
+ h("span", { style: valueStyle }, display)),
168
+ );
169
+ return h("div", { style: wrapStyle },
170
+ h("h2", { style: titleStyle }, "回合结束提示音"),
171
+ h("p", { style: introStyle }, "agent 回合结束时(转入空闲)由浏览器 JS 播放一声合成“叮”——纯前端 Web Audio,宿主不发声、不弹 Windows/系统通知。"),
172
+ h("div", { key: "enabled", style: rowStyle },
173
+ h("span", { style: labelStyle }, "启用"),
174
+ h("span", { style: controlStyle },
175
+ h("input", {
176
+ type: "checkbox",
177
+ checked: v.enabled !== false,
178
+ disabled: disabled,
179
+ onChange: (ev) => update("enabled", ev.target.checked),
180
+ })),
181
+ ),
182
+ sliderRow("音量",
183
+ { min: 0, max: 1, step: 0.05, value: Number(v.volume) || 0.7, disabled: disabled, onChange: (ev) => update("volume", Number(ev.target.value)) },
184
+ pct(v.volume)),
185
+ sliderRow("音色频率(Hz)",
186
+ { min: 120, max: 2000, step: 10, value: Number(v.freq) || 880, disabled: disabled, onChange: (ev) => update("freq", Number(ev.target.value)) },
187
+ Math.round(Number(v.freq) || 880) + " Hz"),
188
+ sliderRow("衰减时长(ms)",
189
+ { min: 100, max: 2000, step: 50, value: Number(v.decayMs) || 900, disabled: disabled, onChange: (ev) => update("decayMs", Number(ev.target.value)) },
190
+ Math.round(Number(v.decayMs) || 900) + " ms"),
191
+ h("div", { key: "preview", style: lastRowStyle },
192
+ h("span", { style: labelStyle }, "试听"),
193
+ h("span", { style: controlStyle },
194
+ h("button", {
195
+ style: buttonStyle,
196
+ disabled: disabled,
197
+ onClick: () => play({ volume: v.volume, freq: v.freq, decayMs: v.decayMs }),
198
+ }, "播放一声")),
199
+ ),
200
+ h("p", { style: { gridColumn: "1 / 3", color: hintColor, fontSize: 12, lineHeight: 1.55 } },
201
+ "浏览器自动播放策略:首次与页面交互(点击/按键)或点击试听后,回合结束提示音才会出声。设置写入 $DSH_HOME/settings.yaml 的 falling-ts-web-ding 段。"));
202
+ }
203
+
204
+ /**
205
+ * 注册分区、绑定命名空间、把信号接进播放器。
206
+ * @param {import('@deepseek-ai/cordis').Context} ctx - client 根上下文。
207
+ */
208
+ function apply(ctx) {
209
+ const scope = ctx.settingsScope.bind({ namespace: NS_SETTINGS });
210
+ const store = createSnapshotStore({ status: "loading", value: undefined, writable: false });
211
+ const derive = () => {
212
+ try {
213
+ const s = scope.getSnapshot();
214
+ if (s === undefined || s === null || typeof s !== "object") return;
215
+ store.update((d) => {
216
+ d.status = s.status;
217
+ d.value = s.value;
218
+ d.writable = s.writable;
219
+ });
220
+ if (s.status === "ready") maybePlayFromValue(s.value);
221
+ } catch { /* never let a cosmetic derive take down the panel */ }
222
+ };
223
+ const unsub = scope.subscribe(derive);
224
+ derive();
225
+ ctx.effect(() => unsub, "web-ding: scope subscription");
226
+ const injected = () => ({
227
+ hooks: { ding: store },
228
+ update: (field, value) => scope.set(field, value),
229
+ play: (opts) => {
230
+ warmup();
231
+ return playDing(opts || {});
232
+ },
233
+ });
234
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
235
+ name: "settings.section",
236
+ id: "web-ding",
237
+ order: 80,
238
+ label: () => "回合结束提示音",
239
+ inject: injected,
240
+ }, DingSection));
241
+ }
242
+
243
+ exports.apply = apply;
244
+ exports.inject = inject;
245
+ return module.exports;
246
+ }
247
+ });