osdy-pi 1.11.12 → 1.11.13

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
@@ -6,6 +6,8 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
6
6
 
7
7
  ## Release highlights
8
8
 
9
+ **Audio notifications share a bundled 180 ms default sound on macOS and Windows**, with a persistent master mute in `/osdyConfig` → **Sounds**. Custom paths take precedence: startup flag → saved global path → bundled default. Muting preserves those paths and visual alerts; explicit **Test effective** still plays while muted. See [Audio notifications](#audio-notifications).
10
+
9
11
  **The Control Center consolidates Osdy controls in `/osdyConfig`**: ten categories cover appearance, Git, sounds, accounts, usage, and TODO/Agents provider settings. Account quota previews query a selected stored Codex profile without switching it. The header shows the installed Osdy package version when it fits; constrained layouts show only the centered mascot. See [Control Center in Pi](#control-center-in-pi) for confirmation and persistence boundaries.
10
12
 
11
13
  **Codex banked resets show counts and per-reset local expiry** in `/usage` and the Control Center. Press **u** in `/usage` or select **Usage → Use or check banked reset** for Cancel-first, active-account use or pending-attempt checks; stored-profile previews stay read-only. See [Use or check a banked reset](#use-or-check-a-banked-reset).
@@ -15,6 +17,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
15
17
  | Area | What ships |
16
18
  | --- | --- |
17
19
  | Control Center | **Ctrl+Option+O** or `/osdyConfig` opens ten categories, including confirmed TODO/Agents provider selection and explicit per-profile Codex quota previews. |
20
+ | Audio | Shared bundled default, per-event custom paths, and persistent automatic-audio mute; [explicit tests remain available](#audio-notifications). |
18
21
  | Local analytics (1.10.0) | `/osdy-usage` opens Summary, Profiles, Models, and History pages for recorded tokens and estimated cost—not billing or subscription quota. |
19
22
  | Themes introduced in 1.11.0 | Spider-Man Classic, Miles Morales, and Spider-Verse add three distinct dark palettes. |
20
23
  | Themes introduced in 1.9.0 | Halloween and Halloween Killer add two dark palettes. |
@@ -85,7 +88,7 @@ Positive counts also trigger a best-effort read-only reset-details GET within th
85
88
  - A selection shows **Saving** until persistence finishes and **Saved globally** only on success. Failed saves retain the live preference but report **could not be saved**; select again to retry. Application errors report **Not saved**, without claiming the runtime change succeeded. Duplicate selections are ignored while saving.
86
89
  - **Git** shows the current branch (or detached HEAD), changed-entry count/clean status, and the current widget position as a read-only session detail. Working-tree enablement applies and saves through the same owner as `/osdy-pi working-tree`; Git inspection never writes Git. `/osdy-pi diff` and position commands are unchanged.
87
90
  - **Sounds** shows each event's saved and effective path/source. Configure opens a single-line path editor **inside this overlay**: enter a readable `.mp3`/`.wav` path, then **Enter** to validate and save immediately, or **Esc** to cancel editing. Relative paths resolve against the project directory and save as absolute paths. Clear removes only that event's saved path from the existing v1 audio store (`<agent-dir>/extensions/osdy-pi/audio-notifications.json`). Notifications read this store afresh; refresh failures are reported separately from save failures.
88
- - Sounds' master enablement is **unavailable**: the existing schema has paths, not an enable switch or presets. Startup `--osdy-pi-sound-<event>` flags override saved paths, so **clearing a saved path does not disable flag-configured playback**. Only **Test effective** explicitly plays a sound; opening categories and configuring paths never play. Tests report unavailable files/platforms or playback failures; playback remains macOS/Windows only. The legacy `/osdy-pi sound setup` wizard is preserved.
91
+ - **Sounds → Mute automatic audio / Enable automatic audio** is a persistent master switch, enabled by default (including existing v1 files). Muting suppresses all automatic event sounds, even startup-flag paths, without losing saved paths or changing visual notifications. It applies to the next notification without reload; already-started playback is not stopped. **Test effective** still plays while muted; opening categories and configuring paths never play. Startup `--osdy-pi-sound-<event>` flags override saved paths, so **clearing a saved path does not disable flag-configured playback**. Tests report unavailable files/platforms or playback failures; playback remains macOS/Windows only. Configure, Clear, and the legacy `/osdy-pi sound setup` wizard preserve the master state.
89
92
  - **Account** shows one row per validated profile, marked **Active** and **Default** where applicable. Select a profile, then use its visible context bar: **v View usage**, **s Switch** (inactive profiles), **d Set default**, and **c Clear default** (when a default exists). **v** or **Enter** explicitly queries that profile's stored Codex credentials without activating it; no second action list appears. Opening, navigating or highlighting never queries; stored profiles are never polled. A dedicated quota view reuses `/usage` dashboard content: Session/Weekly cards, additional bucket groups, remaining-capacity bars, resets, and the quotas/account summary with plan and credits when available. Identity belongs to the selected stored profile; unknown provider/model metadata is labeled honestly. Checked and updated times use local time; absent data is unknown, not zero. Refresh/Back controls and query feedback stay pinned while long dashboard content scrolls. **r Refresh** repeats the selected profile query; **s Switch**, when available, confirms activation of the currently viewed inactive profile. Cancelling that confirmation preserves the quota view and scroll position. **b Back** or **Esc** returns to the overview with the profile selected, without closing the overlay. In quota details, **↑/↓** selects Refresh/Back and **Enter** activates it; **Page Up/Down** and **Home/End** scroll long quota data. Switch, set default, and clear default require an **in-screen confirmation** with Cancel selected initially; **Esc** cancels confirmation without closing the overlay. Single blank separators group the confirmation title, complete effects, choices, and instructions; gaps yield on short viewports. Resize if the full confirmation cannot fit—acceptance stays hidden and blocked. Switching through `/osdyConfig` waits for Pi to become idle; Ctrl+Option+O refuses busy account changes. Successful switching activates through the existing backend and refreshes subscription usage. Login remains Pi-owned (`/login`); create/rename/remove remain available in `/osdy-account`. Category navigation never changes profiles or credentials.
90
93
  - **Usage** shows the runtime's cached active Codex subscription quota (remaining-capacity bars and percentages, window, reset/fetch times) and the existing local history owner's recorded token/cache totals, cost gaps, and model detail. Select **Range: day/week/month** for the current local calendar period; toggle **Accounts: all/current** for local analytics (unmanaged when no valid current profile exists). Quota always belongs to the active account, not the analytics filter; local token/cost totals never become quota percentages. Select **Refresh quota and local history** explicitly; rendering and filters never fetch. Empty, loading, unavailable, and limited/incomplete coverage are reported honestly. Estimates are not billing or subscription quota. `/usage` remains the subscription dashboard; `/osdy-usage` remains local analytics.
91
94
  - **Esc** closes without undoing live or saved changes, except when cancelling a confirmation/input or returning from Account quota details. Account read-only queries are cancelled on Back/Esc, category exit or close; late results are ignored even if cancellation is not honored. Other in-flight operations still finish and are not claimed cancelled. Late results cannot replace a closed overlay or another category. On narrow terminals, categories and details share the viewport; use Tab to switch.
@@ -323,7 +326,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
323
326
  | Status | Six activity labels with compact per-state glyphs and a shared theme-color pulse, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
324
327
  | Local analytics | Four keyboard-selectable pages with shared filters, token charts, estimated cost, and private metadata-only history from new assistant turns |
325
328
  | Git | Working-tree summary and a centered, filterable diff panel |
326
- | Audio | Optional event sounds on macOS and Windows |
329
+ | Audio | Default-on event sounds on macOS and Windows, with master mute and custom paths |
327
330
 
328
331
  <img width="1280" height="433" alt="Osdy Pi header and editor" src="https://github.com/user-attachments/assets/20c7624d-9ad8-4494-97fb-6b6d81aaf328" />
329
332
 
@@ -554,7 +557,7 @@ Use `working-tree position top` or `bottom` to place the summary above or below
554
557
 
555
558
  ## Audio notifications
556
559
 
557
- Osdy Pi can play readable `.mp3` or `.wav` files on macOS and Windows. Other platforms safely skip playback. macOS playback requires the system `afplay` command; Windows playback requires `powershell.exe` and the Windows Media Player COM component (`WMPlayer.OCX`).
560
+ Osdy Pi plays one shared, short, subtle original bundled WAV by default for every unconfigured audio event on macOS and Windows. Automatic audio is enabled by default; mute it in **Sounds** to turn it off without changing visual alerts. Custom readable `.mp3` or `.wav` files can replace the default per event. Other platforms safely skip playback. macOS playback requires the system `afplay` command; Windows playback requires `powershell.exe` and the Windows Media Player COM component (`WMPlayer.OCX`).
558
561
 
559
562
  | Event | Current meaning |
560
563
  | --- | --- |
@@ -563,7 +566,9 @@ Osdy Pi can play readable `.mp3` or `.wav` files on macOS and Windows. Other pla
563
566
  | `permission` | Hook is available but dormant until an explicit Pi approval integration uses it. |
564
567
  | `question` | Hook is available but dormant until an explicit Pi question integration uses it. |
565
568
 
566
- Run the guided wizard:
569
+ To mute or re-enable automatic audio, open `/osdyConfig` → **Sounds** and select **Mute automatic audio / Enable automatic audio**. Saved paths stay intact; **Test effective** remains available while muted.
570
+
571
+ Run the guided wizard to configure paths (without changing the master switch):
567
572
 
568
573
  ```text
569
574
  /osdy-pi sound setup
@@ -581,7 +586,7 @@ pi \
581
586
  --osdy-pi-sound-question /absolute/path/question.wav
582
587
  ```
583
588
 
584
- Precedence is startup flag, then saved global setting, then unconfigured. Empty flags do not override saved settings; relative startup paths resolve from the current working directory, while the wizard saves normalized absolute paths. Missing, unreadable, or unsupported files are skipped safely.
589
+ Path precedence is **startup flag → saved global path → bundled default**. Clearing a saved path (or choosing **Use bundled default** in the wizard) restores the default unless a startup flag overrides. The bundled asset resolves relative to the installed module, not the project directory or home. The master mute suppresses automatic playback from every source; explicit **Test effective** can still play the default while muted. Empty flags do not override saved settings; relative startup paths resolve from the current working directory, while the wizard saves normalized absolute paths. Invalid configured paths (missing, unreadable, or unsupported) are skipped safely **without falling back** to the default.
585
590
 
586
591
  ## Local install and development
587
592
 
@@ -638,9 +643,9 @@ An isolated agent directory does **not** make custom audio paths or real provide
638
643
  - Account (manual): with disposable profiles only, check Cancel-first switch/set-default/clear-default confirmations, Esc cancellation, idle switching, and current/default labels. Verify Ctrl+Option+O refuses all three changes while busy, succeeds when idle, and `/osdyConfig` still waits for idle. Check one row per profile with **v/s/d/c** context actions and **Enter** usage fallback, without a second action list. From **v** quota details, check eligible **s Switch** opens the same Cancel-first confirmation, with blank separators between the title/effects, choices, and instructions; Cancel/Esc must retain the quota view and scroll position. Check gaps yield on short viewports without hiding safety content or enabling acceptance when the full confirmation cannot fit. Check remaining-capacity bars, **r Refresh**, **b Back/Esc** returning to the selected profile, long-view scrolling, no query on navigation, no stored-profile polling, and cancellation on exit. Create/rename/remove stay in `/osdy-account`, login in `/login`.
639
644
  - Subscription quota (manual): compare **Ctrl+Option+U** and `/usage` (Ctrl+Alt+U on other platforms), including **r Refresh** and Esc/q closing. Verify actual terminal chord delivery; use `/usage` if intercepted or unsupported.
640
645
  - Usage (manual): check day/week/month and all/current filters, empty/unavailable/limited states, and explicit refresh. Authenticated quota and post-switch account correctness need an intentionally authorized live provider check; local estimates are not billing.
641
- - Sounds (manual): use disposable readable `.mp3`/`.wav` files to check configure, cancel, clear, and startup-flag precedence. Only choose **Test effective** when playback is intended; actual macOS/Windows playback still needs a live check.
646
+ - Sounds (manual): use disposable readable `.mp3`/`.wav` files to check configure, cancel, clear, and startup-flag precedence. Only choose **Test effective** when playback is intended. The user reported successful live playback of the bundled default; exhaustive macOS/Windows coverage still needs a live check.
642
647
 
643
- The user confirmed physical Ctrl+Option+O and Ctrl+Option+U delivery on one Mac terminal; other terminals may intercept or not support these chords. Live TUI appearance, real account switching/authenticated quota, and actual audio playback remain unverified; this checklist is not a recorded full pass.
648
+ The user confirmed physical Ctrl+Option+O and Ctrl+Option+U delivery on one Mac terminal; other terminals may intercept or not support these chords. The user also reported that the bundled default audio works correctly in their live session. Other live TUI appearance, real account switching/authenticated quota, and exhaustive audio/platform coverage remain unverified; this checklist is not a recorded full pass.
644
649
 
645
650
  Install a local checkout into Pi with:
646
651
 
@@ -25,3 +25,24 @@ The exporter reads the landing without modifying it and writes only the adjacent
25
25
  The assistant marker uses Pi's real `Image`, at most 3 columns × 2 rows. At default 9 × 18 pixel cells it reserves 3 × 1 on Kitty and 3 × 2 on iTerm2. iTerm2 sends `height=auto`, so width is additionally bounded using current cell geometry. Narrow widths down to 2 columns remain safe; invalid widths render an empty row.
26
26
 
27
27
  PNG preview was inspected. Automated protocol tests are not a live Kitty/iTerm2 visual check; legibility at tiny terminal size still requires the user's trial. Emoji fallback and stored marker lifecycle are unchanged.
28
+
29
+ # Original bundled notification sound
30
+
31
+ `notification-default.wav` is an original synthesized two-note chime created for Osdy Pi, shared by every unconfigured audio event. No recording, external asset, download, or OS sound is used. Distributed under the repository's MIT license.
32
+
33
+ ## Reproduction
34
+
35
+ Generate a 44-byte canonical little-endian RIFF/WAVE header followed by 4,320 signed PCM16 mono samples at 24,000 Hz (180 ms). Header: format chunk size 16, audio format 1, channels 1, sample rate 24000, byte rate 48000, block alignment 2, bits per sample 16, data size 8640, RIFF size 8676. Total file size: 8684 bytes.
36
+
37
+ For sample index `i = 0..4319`, let `t = i / 24000`. For each note with start `s` and frequency `f`, set `u = (t - s) / 0.11` and:
38
+
39
+ ```js
40
+ note(s, f) = u <= 0 || u >= 1
41
+ ? 0
42
+ : Math.sin(Math.PI * u) ** 2 * Math.sin(2 * Math.PI * f * (t - s));
43
+ sample = Math.round(1800 * (note(0, 660) + note(0.07, 880)));
44
+ ```
45
+
46
+ Write each sample with Node's `Buffer.writeInt16LE` at offset `44 + i * 2`. The squared-sine envelope provides smooth attack and release; the low amplitude is intentionally subtle. Regeneration uses JavaScript double-precision arithmetic and `Math.round`, not normalization, dithering, randomness, or metadata. Subjective sound quality and live macOS/Windows playback remain manual checks.
47
+
48
+ SHA-256: `89f8e5e70edacb8c5232c3384d704906d5c6f8aa619f4c988a72be23636955dc`.
@@ -0,0 +1,130 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { registerHooks } from "node:module";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import ts from "typescript";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import type { GlobalAudioNotificationSettings } from "./audio-notification-types.js";
9
+ registerHooks({
10
+ resolve(specifier, context, next) {
11
+ if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
12
+ const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
13
+ if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
14
+ }
15
+ return next(specifier, context);
16
+ },
17
+ load(url, context, next) {
18
+ if (url.endsWith(".ts")) return { format: "module", shortCircuit: true,
19
+ source: ts.transpileModule(readFileSync(fileURLToPath(url), "utf8"), {
20
+ compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
21
+ }).outputText };
22
+ return next(url, context);
23
+ },
24
+ });
25
+ const { resolveAudioNotificationSoundFromSettings, getEffectiveAudioNotificationPath } = await import("./audio-notification-config.js");
26
+ const { createAudioNotificationService } = await import("./audio-notification-service.js");
27
+ const { bindControlCenterSounds } = await import("./control-center-sounds.js");
28
+ const { AUDIO_NOTIFICATION_EVENTS } = await import("./audio-notification-types.js");
29
+ const bundled = fileURLToPath(new URL("./assets/notification-default.wav", import.meta.url));
30
+ const pi = { getFlag: () => "" } as unknown as ExtensionAPI;
31
+ const ctx = { cwd: "/unrelated-project" } as ExtensionContext;
32
+
33
+ void test("every absent event selects the same readable module-relative bundled default", async () => {
34
+ const settings: GlobalAudioNotificationSettings = { version: 1, enabled: true, sounds: {} };
35
+ for (const event of AUDIO_NOTIFICATION_EVENTS) {
36
+ assert.deepEqual(getEffectiveAudioNotificationPath(pi, settings, event), { source: "bundled-default", path: bundled });
37
+ assert.deepEqual(await resolveAudioNotificationSoundFromSettings(pi, ctx, event, settings),
38
+ { ok: true, event, path: bundled, source: "bundled-default" });
39
+ const blank = { ...settings, sounds: { [event]: " " } };
40
+ assert.deepEqual(getEffectiveAudioNotificationPath(pi, blank, event), { source: "bundled-default", path: bundled });
41
+ }
42
+ });
43
+
44
+ void test("saved and flag paths outrank default; invalid explicit paths never fall back", async () => {
45
+ for (const event of AUDIO_NOTIFICATION_EVENTS) {
46
+ const settings: GlobalAudioNotificationSettings = { version: 1, enabled: true, sounds: { [event]: bundled } };
47
+ assert.deepEqual(await resolveAudioNotificationSoundFromSettings(pi, ctx, event, settings),
48
+ { ok: true, event, path: bundled, source: "global" });
49
+ const flagged = { getFlag: () => bundled } as unknown as ExtensionAPI;
50
+ assert.deepEqual(await resolveAudioNotificationSoundFromSettings(flagged, ctx, event, settings),
51
+ { ok: true, event, path: bundled, source: "startup-flag" });
52
+ for (const invalid of ["/missing-notification.wav", "/unsupported.txt", "relative.wav"]) {
53
+ const saved = { ...settings, sounds: { [event]: invalid } };
54
+ const invalidSaved = await resolveAudioNotificationSoundFromSettings(pi, ctx, event, saved);
55
+ assert.equal(invalidSaved.ok, false);
56
+ assert.equal(invalidSaved.source, "global");
57
+ const badFlag = { getFlag: () => invalid } as unknown as ExtensionAPI;
58
+ const result = await resolveAudioNotificationSoundFromSettings(badFlag, ctx, event, settings);
59
+ assert.equal(result.ok, false);
60
+ assert.equal(result.source, "startup-flag");
61
+ }
62
+ }
63
+ });
64
+
65
+ void test("bundled WAV is short PCM16 mono with a nonzero low-peak smoothly bounded waveform", () => {
66
+ const wav = readFileSync(bundled);
67
+ assert.equal(wav.toString("ascii", 0, 4), "RIFF");
68
+ assert.equal(wav.readUInt32LE(4), wav.length - 8);
69
+ assert.equal(wav.toString("ascii", 8, 16), "WAVEfmt ");
70
+ assert.equal(wav.readUInt32LE(16), 16);
71
+ assert.equal(wav.readUInt16LE(20), 1);
72
+ assert.equal(wav.readUInt16LE(22), 1);
73
+ assert.equal(wav.readUInt32LE(24), 24000);
74
+ assert.equal(wav.readUInt32LE(28), 48000);
75
+ assert.equal(wav.readUInt16LE(32), 2);
76
+ assert.equal(wav.readUInt16LE(34), 16);
77
+ assert.equal(wav.toString("ascii", 36, 40), "data");
78
+ assert.equal(wav.readUInt32LE(40), wav.length - 44);
79
+ const count = (wav.length - 44) / 2;
80
+ assert.equal(count / 24000, 0.18);
81
+ let peak = 0;
82
+ let maxStep = 0;
83
+ let previous = 0;
84
+ for (let i = 0; i < count; i++) {
85
+ const sample = wav.readInt16LE(44 + i * 2);
86
+ peak = Math.max(peak, Math.abs(sample));
87
+ maxStep = Math.max(maxStep, Math.abs(sample - previous));
88
+ previous = sample;
89
+ }
90
+ assert.ok(peak > 500 && peak <= 3000);
91
+ assert.ok(maxStep < 600);
92
+ assert.equal(wav.readInt16LE(44), 0);
93
+ assert.equal(previous, 0);
94
+ });
95
+
96
+ void test("default automatic playback obeys mute; explicit Test and clear restore default without changing mute", { timeout: 5000 }, async () => {
97
+ let settings: GlobalAudioNotificationSettings = { version: 1, enabled: false, sounds: {} };
98
+ const store = { path: "/unused", load: () => Promise.resolve(settings),
99
+ save: (value: GlobalAudioNotificationSettings) => { settings = value; return Promise.resolve(); } };
100
+ const played: string[] = [];
101
+ let complete = () => {};
102
+ const playback = { platform: "darwin" as const, play: (path: string) => {
103
+ played.push(path);
104
+ if (played.length === 4) complete();
105
+ return Promise.resolve();
106
+ } };
107
+ const service = createAudioNotificationService(pi, playback, store);
108
+ const sounds = bindControlCenterSounds(pi, ctx, store, playback);
109
+ for (const event of AUDIO_NOTIFICATION_EVENTS) service.notify(event, ctx);
110
+ await new Promise<void>(resolve => setImmediate(resolve));
111
+ assert.deepEqual(played, []);
112
+ assert.equal((await sounds.apply({ kind: "sound-test", event: "completion" })).failed, false);
113
+ assert.deepEqual(played, [bundled]);
114
+ const view = await sounds.read();
115
+ assert.match(view.note, /bundled default/i);
116
+ assert.match(view.rows[1]?.details?.join(" | ") ?? "", /Effective \(bundled-default\)/);
117
+ settings = { ...settings, sounds: { completion: bundled } };
118
+ assert.equal((await sounds.apply({ kind: "sound-clear", event: "completion" })).failed, false);
119
+ assert.equal(settings.enabled, false);
120
+ assert.deepEqual(settings.sounds, {});
121
+ const flagged = { getFlag: () => bundled } as unknown as ExtensionAPI;
122
+ assert.equal(getEffectiveAudioNotificationPath(flagged, settings, "completion").source, "startup-flag", "clearing never removes flag precedence");
123
+ assert.equal((await sounds.apply({ kind: "sound-test", event: "completion" })).failed, false);
124
+ played.length = 0;
125
+ assert.equal((await sounds.apply({ kind: "sound-enabled", value: true })).failed, false);
126
+ const completed = new Promise<void>(resolve => { complete = resolve; });
127
+ for (const event of AUDIO_NOTIFICATION_EVENTS) service.notify(event, ctx);
128
+ await completed;
129
+ assert.deepEqual(played, Array<string>(4).fill(bundled));
130
+ });
@@ -0,0 +1,35 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync } from "node:fs";
3
+ import { registerHooks } from "node:module";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import type { GlobalAudioNotificationSettings } from "./audio-notification-types.js";
8
+ registerHooks({ resolve(specifier, context, next) {
9
+ if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
10
+ const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
11
+ if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
12
+ }
13
+ return next(specifier, context);
14
+ } });
15
+ const { runSoundSetupWizard } = await import("./sound-setup-wizard.js");
16
+
17
+ void test("wizard offers bundled default, explains flag precedence, and saves no asset paths while preserving mute", async () => {
18
+ const writes: GlobalAudioNotificationSettings[] = [];
19
+ const notices: string[] = [];
20
+ let summary = "";
21
+ const ctx = { cwd: "/unrelated-project", ui: {
22
+ select: (_title: string, options: string[]) => {
23
+ const choice = "Use bundled default (startup flags still override)";
24
+ assert.ok(options.includes(choice));
25
+ return Promise.resolve(choice);
26
+ },
27
+ confirm: (_title: string, value: string) => { summary = value; return Promise.resolve(true); },
28
+ notify: (value: string) => { notices.push(value); },
29
+ } } as unknown as ExtensionContext;
30
+ await runSoundSetupWizard(ctx, { path: "/unused", load: () => Promise.resolve({ version: 1, enabled: false, sounds: {} }),
31
+ save: value => { writes.push(value); return Promise.resolve(); } });
32
+ assert.deepEqual(writes, [{ version: 1, enabled: false, sounds: {} }]);
33
+ assert.match(summary, /completion: \(bundled default; startup flags still override\)/);
34
+ assert.match(notices.join(" | "), /bundled default/i);
35
+ });
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { registerHooks } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import ts from "typescript";
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { GlobalAudioNotificationSettings } from "./audio-notification-types.js";
11
+ registerHooks({
12
+ resolve(specifier, context, next) {
13
+ if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
14
+ const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
15
+ if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
16
+ }
17
+ return next(specifier, context);
18
+ },
19
+ load(url, context, next) {
20
+ if (url.endsWith(".ts")) return { format: "module", shortCircuit: true,
21
+ source: ts.transpileModule(readFileSync(fileURLToPath(url), "utf8"), {
22
+ compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
23
+ }).outputText };
24
+ return next(url, context);
25
+ },
26
+ });
27
+ const { createAudioSoundSettingsStore } = await import("./audio-sound-settings.js");
28
+ const { createAudioNotificationService } = await import("./audio-notification-service.js");
29
+ const { bindControlCenterSounds } = await import("./control-center-sounds.js");
30
+ const { AUDIO_NOTIFICATION_EVENTS, AUDIO_NOTIFICATION_FLAG_NAMES } = await import("./audio-notification-types.js");
31
+
32
+ void test("v1 defaults/old files enable automatic audio; only boolean false mutes and roundtrips", async () => {
33
+ const previous = process.env.PI_CODING_AGENT_DIR;
34
+ process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "osdy-master-settings-"));
35
+ try {
36
+ const store = createAudioSoundSettingsStore();
37
+ assert.deepEqual(await store.load(), { version: 1, enabled: true, sounds: {} });
38
+ await store.save({ version: 1, enabled: false, sounds: { completion: "/saved.wav" } });
39
+ assert.deepEqual(await store.load(), { version: 1, enabled: false, sounds: { completion: "/saved.wav" } });
40
+ for (const enabled of [undefined, true, "false", 0, null]) {
41
+ writeFileSync(store.path, JSON.stringify({ version: 1, enabled, sounds: { completion: "/old.wav" } }));
42
+ assert.deepEqual(await store.load(), { version: 1, enabled: true, sounds: { completion: "/old.wav" } });
43
+ }
44
+ for (const content of ["invalid json", JSON.stringify({ version: 2, enabled: false })]) {
45
+ writeFileSync(store.path, content);
46
+ assert.deepEqual(await store.load(), { version: 1, enabled: true, sounds: {} });
47
+ }
48
+ } finally {
49
+ if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
50
+ else process.env.PI_CODING_AGENT_DIR = previous;
51
+ }
52
+ });
53
+
54
+ void test("automatic master gate covers every global/startup sound and refreshes next event; explicit tests bypass", { timeout: 5000 }, async () => {
55
+ const root = mkdtempSync(join(tmpdir(), "osdy-master-playback-"));
56
+ const sound = join(root, "sound.wav");
57
+ writeFileSync(sound, "mock wave bytes");
58
+ const ctx = { cwd: root } as ExtensionContext;
59
+ for (const source of ["global", "startup-flag"] as const) {
60
+ let settings: GlobalAudioNotificationSettings = { version: 1, enabled: false,
61
+ sounds: Object.fromEntries(AUDIO_NOTIFICATION_EVENTS.map(event => [event, sound])) };
62
+ let loads = 0;
63
+ const played: string[] = [];
64
+ const store = { path: "/unused", load: () => { loads++; return Promise.resolve(settings); },
65
+ save: (value: GlobalAudioNotificationSettings) => { settings = value; return Promise.resolve(); } };
66
+ let flagReads = 0;
67
+ const pi = { getFlag: (name: string) => {
68
+ flagReads++;
69
+ return source === "startup-flag" && Object.values(AUDIO_NOTIFICATION_FLAG_NAMES).includes(name) ? "sound.wav" : "";
70
+ } } as ExtensionAPI;
71
+ let playbackComplete = () => {};
72
+ const playback = { platform: "darwin" as const, play: (path: string) => {
73
+ played.push(path);
74
+ if (played.length === 4) playbackComplete();
75
+ return Promise.resolve();
76
+ } };
77
+ const service = createAudioNotificationService(pi, playback, store);
78
+ const sounds = bindControlCenterSounds(pi, ctx, store, playback);
79
+ const notify = async (expectPlayback = false) => {
80
+ const completed = expectPlayback ? new Promise<void>(resolve => { playbackComplete = resolve; }) : Promise.resolve();
81
+ for (const event of AUDIO_NOTIFICATION_EVENTS) service.notify(event, ctx);
82
+ await completed;
83
+ // A turn drains the resolved store/gate and releases the per-event in-flight guard.
84
+ await new Promise<void>(resolve => setImmediate(resolve));
85
+ };
86
+ await notify();
87
+ assert.deepEqual(played, [], `${source} must be muted`);
88
+ assert.equal(loads, 4, "one fresh snapshot per notification");
89
+ assert.equal(flagReads, 0, "muted notifications never resolve even startup paths");
90
+ assert.equal((await sounds.apply({ kind: "sound-test", event: "completion" })).failed, false);
91
+ assert.deepEqual(played, [sound]);
92
+ played.length = 0;
93
+ settings = { ...settings, enabled: true };
94
+ loads = 0;
95
+ await notify(true);
96
+ assert.deepEqual(played, Array<string>(4).fill(sound));
97
+ assert.equal(loads, 4);
98
+ settings = { ...settings, enabled: false };
99
+ played.length = 0;
100
+ await notify();
101
+ assert.deepEqual(played, [], "same service observes re-mute without reload");
102
+ }
103
+ });
@@ -1,6 +1,7 @@
1
1
  import { access, constants, stat } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import type {
5
6
  ExtensionAPI,
6
7
  ExtensionContext,
@@ -147,7 +148,10 @@ export function getEffectiveAudioNotificationPath(
147
148
  if (globalPath !== undefined) {
148
149
  return { source: "global", path: globalPath };
149
150
  }
150
- return { source: "unconfigured" };
151
+ return {
152
+ source: "bundled-default",
153
+ path: fileURLToPath(new URL("./assets/notification-default.wav", import.meta.url)),
154
+ };
151
155
  }
152
156
 
153
157
  export async function resolveAudioNotificationSound(
@@ -156,7 +160,16 @@ export async function resolveAudioNotificationSound(
156
160
  event: AudioNotificationEvent,
157
161
  settingsStore: AudioSoundSettingsStore,
158
162
  ): Promise<ResolvedSoundFile> {
159
- const globalSettings = await settingsStore.load();
163
+ return resolveAudioNotificationSoundFromSettings(pi, ctx, event, await settingsStore.load());
164
+ }
165
+
166
+ /** Resolve paths independently of master enablement so explicit tests still play. */
167
+ export async function resolveAudioNotificationSoundFromSettings(
168
+ pi: ExtensionAPI,
169
+ ctx: ExtensionContext,
170
+ event: AudioNotificationEvent,
171
+ globalSettings: GlobalAudioNotificationSettings,
172
+ ): Promise<ResolvedSoundFile> {
160
173
  const effectivePath = getEffectiveAudioNotificationPath(
161
174
  pi,
162
175
  globalSettings,
@@ -3,7 +3,7 @@ import type {
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import type { AudioSoundSettingsStore } from "./audio-sound-settings.js";
6
- import { resolveAudioNotificationSound } from "./audio-notification-config.js";
6
+ import { resolveAudioNotificationSoundFromSettings } from "./audio-notification-config.js";
7
7
  import type { AudioNotificationEvent } from "./audio-notification-types.js";
8
8
  import type { AudioPlaybackAdapter } from "./audio-playback.js";
9
9
 
@@ -34,11 +34,14 @@ class DefaultAudioNotificationService implements AudioNotificationService {
34
34
  event: AudioNotificationEvent,
35
35
  ctx: ExtensionContext,
36
36
  ): Promise<void> {
37
- const resolvedSound = await resolveAudioNotificationSound(
37
+ // One fresh snapshot gates all automatic audio, including startup flags.
38
+ const settings = await this.settingsStore.load();
39
+ if (!settings.enabled) return;
40
+ const resolvedSound = await resolveAudioNotificationSoundFromSettings(
38
41
  this.pi,
39
42
  ctx,
40
43
  event,
41
- this.settingsStore,
44
+ settings,
42
45
  );
43
46
  if (!resolvedSound.ok) return;
44
47
  await this.playbackAdapter.play(resolvedSound.path);
@@ -23,10 +23,11 @@ export type AudioNotificationConfig = Partial<
23
23
 
24
24
  export type GlobalAudioNotificationSettings = {
25
25
  version: 1;
26
+ enabled: boolean;
26
27
  sounds: AudioNotificationConfig;
27
28
  };
28
29
 
29
- export type AudioConfigSource = "startup-flag" | "global" | "unconfigured";
30
+ export type AudioConfigSource = "startup-flag" | "global" | "bundled-default" | "unconfigured";
30
31
 
31
32
  export type ResolvedSoundFile =
32
33
  | {
@@ -21,7 +21,7 @@ function getAgentDir(): string {
21
21
  }
22
22
 
23
23
  function createDefaultSettings(): GlobalAudioNotificationSettings {
24
- return { version: GLOBAL_SETTINGS_VERSION, sounds: {} };
24
+ return { version: GLOBAL_SETTINGS_VERSION, enabled: true, sounds: {} };
25
25
  }
26
26
 
27
27
  function isAudioEvent(value: string): value is AudioNotificationEvent {
@@ -58,6 +58,8 @@ function normalizeLoadedSettings(
58
58
 
59
59
  return {
60
60
  version: GLOBAL_SETTINGS_VERSION,
61
+ // Old v1 files and invalid values retain automatic playback by default.
62
+ enabled: recordValue.enabled !== false,
61
63
  sounds: normalizeSounds(recordValue.sounds),
62
64
  };
63
65
  }
@@ -33,6 +33,8 @@ void test("Git reads branch/status only and delegates persisted enabled changes
33
33
  ["--no-optional-locks", "status", "--short", "--untracked-files=normal"],
34
34
  ]);
35
35
  assert.deepEqual(applied, []);
36
+ assert.equal((await service.apply({ kind: "sound-enabled", value: false })).failed, true);
37
+ assert.deepEqual(applied, [], "sound master action cannot change Git preferences");
36
38
  assert.equal((await service.apply({ kind: "git-enabled", value: false })).failed, false);
37
39
  assert.deepEqual(applied, [false]);
38
40
  assert.equal((await service.read()).rows[1]?.current, true);
@@ -16,7 +16,7 @@ registerHooks({ resolve(specifier, context, nextResolve) {
16
16
  const { createControlCenterSounds } = await import("./control-center-sounds.js");
17
17
 
18
18
  function fixture(platform: "darwin" | "unsupported" = "darwin") {
19
- let settings: GlobalAudioNotificationSettings = { version: 1, sounds: { completion: "/saved.wav" } };
19
+ let settings: GlobalAudioNotificationSettings = { version: 1, enabled: false, sounds: { completion: "/saved.wav" } };
20
20
  let saveFails = false;
21
21
  let loadFails = false;
22
22
  let playFails = false;
@@ -53,10 +53,12 @@ function fixture(platform: "darwin" | "unsupported" = "darwin") {
53
53
  void test("Sounds shows saved/effective paths and flag precedence without playing or saving", async () => {
54
54
  const f = fixture();
55
55
  const view = await f.service.read();
56
- assert.match(view.summary, /Master.*unavailable/);
57
- assert.match(view.rows[0]?.details?.join(" | ") ?? "", /saved: \/saved.wav.*Effective \(startup-flag\): \/flag.wav/);
56
+ assert.match(view.summary, /Automatic audio: muted/);
57
+ assert.match(view.rows[0]?.label ?? "", /Enable automatic audio/);
58
+ assert.match(view.rows[0]?.details?.join(" | ") ?? "", /explicit.*tests.*play/i);
59
+ assert.match(view.rows[1]?.details?.join(" | ") ?? "", /saved: \/saved.wav.*Effective \(startup-flag\): \/flag.wav/);
58
60
  assert.match(view.note, /clearing.*does not disable/i);
59
- assert.equal(view.rows.length, 12);
61
+ assert.equal(view.rows.length, 13);
60
62
  assert.deepEqual(f.played, []); assert.deepEqual(f.writes, []);
61
63
  });
62
64
 
@@ -65,14 +67,29 @@ void test("configure validates before saving and refreshes; clear keeps v1 and o
65
67
  assert.equal((await f.service.apply({ kind: "sound-set", event: "completion", path: "missing.wav" })).failed, true);
66
68
  assert.equal(f.writes.length, 0);
67
69
  assert.equal((await f.service.apply({ kind: "sound-set", event: "question", path: "valid.wav" })).failed, false);
68
- assert.deepEqual(f.settings(), { version: 1, sounds: { completion: "/saved.wav", question: "/valid.wav" } });
70
+ assert.deepEqual(f.settings(), { version: 1, enabled: false, sounds: { completion: "/saved.wav", question: "/valid.wav" } });
69
71
  const clear = await f.service.apply({ kind: "sound-clear", event: "completion" });
70
72
  assert.equal(clear.failed, false);
71
- assert.deepEqual(f.settings(), { version: 1, sounds: { question: "/valid.wav" } });
72
- assert.match((await f.service.read()).rows[0]?.details?.join(" | ") ?? "", /saved: none.*Effective \(startup-flag\): \/flag.wav/);
73
+ assert.deepEqual(f.settings(), { version: 1, enabled: false, sounds: { question: "/valid.wav" } });
74
+ assert.match((await f.service.read()).rows[1]?.details?.join(" | ") ?? "", /saved: none.*Effective \(startup-flag\): \/flag.wav/);
73
75
  assert.deepEqual(f.played, []);
74
76
  });
75
77
 
78
+ void test("master toggle preserves all paths and exposes the inverse action", async () => {
79
+ const f = fixture();
80
+ assert.equal((await f.service.apply({ kind: "sound-enabled", value: true })).failed, false);
81
+ assert.deepEqual(f.settings(), { version: 1, enabled: true, sounds: { completion: "/saved.wav" } });
82
+ const view = await f.service.read();
83
+ assert.match(view.summary, /Automatic audio: enabled/);
84
+ assert.deepEqual(view.rows[0]?.action, { kind: "sound-enabled", value: false });
85
+ assert.equal((await f.service.apply({ kind: "sound-enabled", value: false })).failed, false);
86
+ assert.deepEqual(f.settings(), { version: 1, enabled: false, sounds: { completion: "/saved.wav" } });
87
+ assert.deepEqual(f.played, []);
88
+ f.failSave();
89
+ assert.equal((await f.service.apply({ kind: "sound-enabled", value: true })).failed, true);
90
+ assert.equal(f.settings().enabled, false);
91
+ });
92
+
76
93
  void test("test playback is explicit, reports unavailable platforms and player failures", async () => {
77
94
  const f = fixture();
78
95
  assert.equal((await f.service.apply({ kind: "sound-test", event: "completion" })).failed, false);
@@ -95,7 +112,7 @@ void test("save errors and refresh errors never falsely report success", async (
95
112
  const result = await h.service.apply({ kind: "sound-clear", event: "completion" });
96
113
  assert.equal(result.failed, true);
97
114
  assert.match(result.message, /Saved globally, but runtime refresh failed/);
98
- assert.deepEqual(h.writes, [{ version: 1, sounds: {} }]);
115
+ assert.deepEqual(h.writes, [{ version: 1, enabled: false, sounds: {} }]);
99
116
  });
100
117
 
101
118
  void test("default validator rejects missing, unsupported and directory paths without saving", async () => {
@@ -22,19 +22,25 @@ export function createControlCenterSounds(dependencies: SoundDependencies): Cont
22
22
  async read() {
23
23
  const settings = await store.load();
24
24
  return {
25
- summary: "Master enablement: unavailable (path-only v1 settings)",
26
- note: "Startup flags override saved paths; clearing a saved path does not disable flag-configured playback.",
27
- rows: AUDIO_NOTIFICATION_EVENTS.flatMap((event) => {
28
- const effective = dependencies.effective(settings, event);
29
- const saved = settings.sounds[event];
30
- const details = [`${event} saved: ${saved ?? "none"}`, `Effective (${effective.source}): ${effective.path ?? "none"}`];
31
- return [
32
- { label: `Configure ${event}`,
33
- current: false, details, action: { kind: "sound-configure" as const, event, path: saved ?? "" } },
34
- { label: `Clear saved ${event}`, current: false, details, action: { kind: "sound-clear" as const, event } },
35
- { label: `Test effective ${event}`, current: false, details, action: { kind: "sound-test" as const, event } },
36
- ];
37
- }),
25
+ summary: `Automatic audio: ${settings.enabled ? "enabled" : "muted"}`,
26
+ note: "Startup flags override saved paths, then the bundled default. Clearing restores the bundled default unless a flag overrides; it does not disable flag-configured playback.",
27
+ rows: [
28
+ { label: settings.enabled ? "Mute automatic audio" : "Enable automatic audio", current: false,
29
+ details: ["Mute all automatic sounds, including startup flags and the bundled default. Explicit sound tests still play.",
30
+ "Saved paths and visual notifications are unchanged. Applies to the next notification without reload; current playback is not stopped."],
31
+ action: { kind: "sound-enabled" as const, value: !settings.enabled } },
32
+ ...AUDIO_NOTIFICATION_EVENTS.flatMap((event) => {
33
+ const effective = dependencies.effective(settings, event);
34
+ const saved = settings.sounds[event];
35
+ const details = [`${event} saved: ${saved ?? "none"}`, `Effective (${effective.source}): ${effective.path ?? "none"}`];
36
+ return [
37
+ { label: `Configure ${event}`,
38
+ current: false, details, action: { kind: "sound-configure" as const, event, path: saved ?? "" } },
39
+ { label: `Clear saved ${event}`, current: false, details, action: { kind: "sound-clear" as const, event } },
40
+ { label: `Test effective ${event}`, current: false, details, action: { kind: "sound-test" as const, event } },
41
+ ];
42
+ }),
43
+ ],
38
44
  };
39
45
  },
40
46
  async apply(action: ControlCenterServiceAction) {
@@ -57,15 +63,18 @@ export function createControlCenterSounds(dependencies: SoundDependencies): Cont
57
63
  const validation = await (dependencies.validate ?? validateAudioNotificationPath)(action.path, { cwd: dependencies.cwd, mode: "global-save" });
58
64
  if (!validation.ok) return { failed: true, message: `Invalid ${action.event} sound: ${validation.reason}. Use a readable .mp3 or .wav file. Not saved.` };
59
65
  sounds[action.event] = validation.persistedPath;
60
- } else delete sounds[action.event];
61
- await store.save({ version: 1, sounds });
66
+ } else if (action.kind === "sound-clear") delete sounds[action.event];
67
+ await store.save({ ...settings, sounds,
68
+ enabled: action.kind === "sound-enabled" ? action.value : settings.enabled });
62
69
  } catch (error) {
63
70
  return { failed: true, message: `Not saved: ${message(error)}` };
64
71
  }
65
72
  // Event playback resolves the same store afresh each time; no separate runtime cache.
66
73
  try {
67
74
  await store.load();
68
- return { failed: false, message: `Saved globally: ${action.event}. Runtime uses current saved paths; startup flags still take precedence.` };
75
+ return { failed: false, message: action.kind === "sound-enabled"
76
+ ? `Saved globally: automatic audio ${action.value ? "enabled" : "muted"}. Applies to the next notification; explicit tests still play.`
77
+ : `Saved globally: ${action.event}. Runtime uses current saved paths; startup flags still take precedence.` };
69
78
  } catch (error) {
70
79
  return { failed: true, message: `Saved globally, but runtime refresh failed: ${message(error)}. Retry refresh by reopening Sounds.` };
71
80
  }
@@ -276,6 +276,29 @@ void test("Agent direct letters never cross loading, query, save or text input b
276
276
  assert.match(plain(account.panel.render(100)), /Loading stored-profile/); assert.equal(calls, 1); account.dispose();
277
277
  });
278
278
 
279
+ void test("Sounds master action routes to its service, refreshes live, and never requests reload", async () => {
280
+ let enabled = true;
281
+ const actions: ControlCenterServiceAction[] = [];
282
+ let reloads = 0;
283
+ const f = fixture(undefined, undefined, { sounds: {
284
+ read: () => Promise.resolve({ summary: `Automatic audio: ${enabled ? "enabled" : "muted"}`, note: "Explicit tests still play.", rows: [
285
+ { label: enabled ? "Mute automatic audio" : "Enable automatic audio", current: false, action: { kind: "sound-enabled", value: !enabled } },
286
+ ] }),
287
+ apply: action => { actions.push(action); if (action.kind === "sound-enabled") enabled = action.value;
288
+ return Promise.resolve({ failed: false, message: "Saved globally" }); },
289
+ } }, () => { reloads++; });
290
+ for (let i = 0; i < 5; i++) f.panel.handleInput(down);
291
+ await settle();
292
+ assert.equal(actions.length, 0);
293
+ f.panel.handleInput(right); f.panel.handleInput("\r"); await settle();
294
+ assert.deepEqual(actions, [{ kind: "sound-enabled", value: false }]);
295
+ assert.match(plain(f.panel.render(100)), /Automatic audio: muted/);
296
+ f.panel.handleInput("\r"); await settle();
297
+ assert.deepEqual(actions[1], { kind: "sound-enabled", value: true });
298
+ assert.equal(reloads, 0);
299
+ f.dispose();
300
+ });
301
+
279
302
  function fixture(names = ["dark", "light"], preferences?: ControlCenterPreferences,
280
303
  services?: Pick<ControlCenterDependencies, "git" | "sounds" | "account" | "usage" | "todo" | "agents">,
281
304
  reload?: ControlCenterDependencies["reload"]) {
@@ -16,6 +16,7 @@ export const CONTROL_CENTER_CATEGORIES = [
16
16
  export type ControlCenterCategory = (typeof CONTROL_CENTER_CATEGORIES)[number];
17
17
  export type ControlCenterServiceAction =
18
18
  | { kind: "git-enabled"; value: boolean }
19
+ | { kind: "sound-enabled"; value: boolean }
19
20
  | { kind: "sound-set"; event: AudioNotificationEvent; path: string }
20
21
  | { kind: "sound-clear"; event: AudioNotificationEvent }
21
22
  | { kind: "sound-test"; event: AudioNotificationEvent };
@@ -404,7 +405,7 @@ export class ControlCenter implements Component, Focusable {
404
405
  };
405
406
  return;
406
407
  }
407
- if (action.kind === "git-enabled" || action.kind === "sound-set" || action.kind === "sound-clear" || action.kind === "sound-test" || action.kind === "usage-refresh" || action.kind === "usage-range" || action.kind === "usage-account" || action.kind === "usage-detail") {
408
+ if (action.kind === "git-enabled" || action.kind === "sound-enabled" || action.kind === "sound-set" || action.kind === "sound-clear" || action.kind === "sound-test" || action.kind === "usage-refresh" || action.kind === "usage-range" || action.kind === "usage-account" || action.kind === "usage-detail") {
408
409
  void this.activateService(action);
409
410
  return;
410
411
  }
@@ -0,0 +1,33 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { registerHooks } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import type { GlobalAudioNotificationSettings } from "./audio-notification-types.js";
10
+ registerHooks({ resolve(specifier, context, next) {
11
+ if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
12
+ const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
13
+ if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
14
+ }
15
+ return next(specifier, context);
16
+ } });
17
+ const { runSoundSetupWizard } = await import("./sound-setup-wizard.js");
18
+
19
+ void test("legacy wizard preserves mute when setting/clearing paths; cancellation never saves", async () => {
20
+ const root = mkdtempSync(join(tmpdir(), "osdy-master-wizard-"));
21
+ const sound = join(root, "sound.wav");
22
+ writeFileSync(sound, "mock wave bytes");
23
+ for (const confirmed of [true, false]) {
24
+ const settings: GlobalAudioNotificationSettings = { version: 1, enabled: false, sounds: { completion: sound, permission: sound } };
25
+ const writes: GlobalAudioNotificationSettings[] = [];
26
+ const actions = ["Clear saved sound", "Set or replace", "Skip", "Use bundled default (startup flags still override)"];
27
+ const ctx = { cwd: root, ui: { select: () => Promise.resolve(actions.shift()), input: () => Promise.resolve(sound),
28
+ confirm: () => Promise.resolve(confirmed), notify: () => {} } } as unknown as ExtensionContext;
29
+ await runSoundSetupWizard(ctx, { path: "/unused", load: () => Promise.resolve(settings),
30
+ save: value => { writes.push(value); return Promise.resolve(); } });
31
+ assert.deepEqual(writes, confirmed ? [{ version: 1, enabled: false, sounds: { error: sound, permission: sound } }] : []);
32
+ }
33
+ });
@@ -14,7 +14,7 @@ const CANCEL_SETUP_OPTION = "Cancel setup";
14
14
  const KEEP_CURRENT_OPTION = "Keep current";
15
15
  const SET_OR_REPLACE_OPTION = "Set or replace";
16
16
  const CLEAR_SAVED_OPTION = "Clear saved sound";
17
- const LEAVE_UNCONFIGURED_OPTION = "Leave unconfigured";
17
+ const LEAVE_UNCONFIGURED_OPTION = "Use bundled default (startup flags still override)";
18
18
 
19
19
  function formatValidationReason(reason: AudioPathValidationReason): string {
20
20
  switch (reason) {
@@ -35,7 +35,7 @@ function formatValidationReason(reason: AudioPathValidationReason): string {
35
35
 
36
36
  function createSummary(settings: AudioNotificationConfig): string {
37
37
  return AUDIO_NOTIFICATION_EVENTS.map((event) => {
38
- const value = settings[event] ?? "(unconfigured)";
38
+ const value = settings[event] ?? "(bundled default; startup flags still override)";
39
39
  return `${event}: ${value}`;
40
40
  }).join("\n");
41
41
  }
@@ -144,6 +144,7 @@ async function revalidateStagedSettings(
144
144
 
145
145
  function createSettingsPayload(
146
146
  stagedSettings: AudioNotificationConfig,
147
+ enabled: boolean,
147
148
  ): GlobalAudioNotificationSettings {
148
149
  const sounds: AudioNotificationConfig = {};
149
150
  for (const event of AUDIO_NOTIFICATION_EVENTS) {
@@ -152,17 +153,18 @@ function createSettingsPayload(
152
153
  sounds[event] = value;
153
154
  }
154
155
  }
155
- return { version: 1, sounds };
156
+ return { version: 1, enabled, sounds };
156
157
  }
157
158
 
158
159
  export async function runSoundSetupWizard(
159
160
  ctx: ExtensionContext,
160
161
  settingsStore: AudioSoundSettingsStore,
161
162
  ): Promise<void> {
162
- const currentSettings = (await settingsStore.load()).sounds;
163
+ const settings = await settingsStore.load();
164
+ const currentSettings = settings.sounds;
163
165
  const stagedSettings: AudioNotificationConfig = { ...currentSettings };
164
166
  ctx.ui.notify(
165
- `Osdy Pi sound setup. Supported formats: .mp3 and .wav. Global config: ${settingsStore.path}`,
167
+ `Osdy Pi sound setup. Supported formats: .mp3 and .wav. No saved path uses the bundled default; startup flags still override. Global config: ${settingsStore.path}`,
166
168
  "info",
167
169
  );
168
170
 
@@ -187,7 +189,7 @@ export async function runSoundSetupWizard(
187
189
  }
188
190
  if (selectedAction === CLEAR_SAVED_OPTION) {
189
191
  delete stagedSettings[event];
190
- ctx.ui.notify(`Cleared saved ${event} sound.`, "info");
192
+ ctx.ui.notify(`Cleared saved ${event} sound. Uses bundled default unless a startup flag overrides.`, "info");
191
193
  continue;
192
194
  }
193
195
  if (selectedAction === SET_OR_REPLACE_OPTION) {
@@ -215,6 +217,6 @@ export async function runSoundSetupWizard(
215
217
  return;
216
218
  }
217
219
 
218
- await settingsStore.save(createSettingsPayload(stagedSettings));
220
+ await settingsStore.save(createSettingsPayload(stagedSettings, settings.enabled));
219
221
  ctx.ui.notify("Osdy Pi sound settings saved.", "info");
220
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osdy-pi",
3
- "version": "1.11.12",
3
+ "version": "1.11.13",
4
4
  "description": "Osdy Pi package: themes, header, and custom editor for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -83,7 +83,7 @@
83
83
  "typescript": "^5.9.3",
84
84
  "typescript-eslint": "^8.46.3"
85
85
  },
86
- "releaseSource": "3939affdb14faf94efa56c142a1927670b0df954",
86
+ "releaseSource": "89f3886287c5062fa45501a1a1869d25e7d46dac",
87
87
  "repository": {
88
88
  "type": "git",
89
89
  "url": "git+https://github.com/OsdyOrtiz/Osdy-Pi.git"