osdy-pi 1.11.11 → 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 +33 -12
- package/extensions/osdy-pi/assets/PROVENANCE.md +21 -0
- package/extensions/osdy-pi/assets/notification-default.wav +0 -0
- package/extensions/osdy-pi/audio-default-sound.test.ts +130 -0
- package/extensions/osdy-pi/audio-default-wizard.test.ts +35 -0
- package/extensions/osdy-pi/audio-master-mute.test.ts +103 -0
- package/extensions/osdy-pi/audio-notification-config.ts +15 -2
- package/extensions/osdy-pi/audio-notification-service.ts +6 -3
- package/extensions/osdy-pi/audio-notification-types.ts +2 -1
- package/extensions/osdy-pi/audio-sound-settings.ts +3 -1
- package/extensions/osdy-pi/codex-reset-store.test.ts +236 -0
- package/extensions/osdy-pi/codex-reset-store.ts +174 -0
- package/extensions/osdy-pi/codex-reset-ui.test.ts +70 -0
- package/extensions/osdy-pi/codex-reset-ui.ts +52 -0
- package/extensions/osdy-pi/codex-reset.test.ts +525 -0
- package/extensions/osdy-pi/codex-reset.ts +213 -0
- package/extensions/osdy-pi/codex-usage-ui.test.ts +132 -2
- package/extensions/osdy-pi/codex-usage-ui.ts +75 -30
- package/extensions/osdy-pi/codex-usage.test.ts +370 -1
- package/extensions/osdy-pi/codex-usage.ts +198 -19
- package/extensions/osdy-pi/control-center-git.test.ts +2 -0
- package/extensions/osdy-pi/control-center-sounds.test.ts +25 -8
- package/extensions/osdy-pi/control-center-sounds.ts +25 -16
- package/extensions/osdy-pi/control-center-usage.test.ts +96 -3
- package/extensions/osdy-pi/control-center-usage.ts +15 -4
- package/extensions/osdy-pi/control-center.test.ts +51 -2
- package/extensions/osdy-pi/control-center.ts +16 -3
- package/extensions/osdy-pi/profile-codex-usage.test.ts +34 -0
- package/extensions/osdy-pi/runtime.test.ts +289 -0
- package/extensions/osdy-pi/runtime.ts +98 -2
- package/extensions/osdy-pi/sound-setup-wizard.test.ts +33 -0
- package/extensions/osdy-pi/sound-setup-wizard.ts +9 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,13 +6,18 @@ 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
|
|
|
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).
|
|
14
|
+
|
|
11
15
|
**1.11.0 adds three Spider-Man-inspired dark palettes**, bringing the bundle to **23 themes**. It retains the four-page local usage analytics introduced in 1.10.0, with shared period and profile/provider/model filters, keyboard navigation, and metadata-only history. These version introductions are historical, not a fixed latest-version claim. After npm Trusted Publishing is configured, pushes to main (including direct pushes) run checks and attempt to publish an automatically allocated patch to npm. Failed checks publish nothing; authentication or registry failures can also stop publication. Verify the resolved npm version before installing. Maintainers: [automatic npm publishing setup](https://github.com/OsdyOrtiz/Osdy-Pi/blob/main/docs/npm-publishing.md).
|
|
12
16
|
|
|
13
17
|
| Area | What ships |
|
|
14
18
|
| --- | --- |
|
|
15
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). |
|
|
16
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. |
|
|
17
22
|
| Themes introduced in 1.11.0 | Spider-Man Classic, Miles Morales, and Spider-Verse add three distinct dark palettes. |
|
|
18
23
|
| Themes introduced in 1.9.0 | Halloween and Halloween Killer add two dark palettes. |
|
|
@@ -70,6 +75,10 @@ On macOS, **Option = Alt** in Pi's shortcut names: these bindings register as `c
|
|
|
70
75
|
|
|
71
76
|
Ctrl+Option+U opens the existing `/usage` dashboard with the same refresh behavior; it does not open `/osdy-usage`.
|
|
72
77
|
|
|
78
|
+
When Codex reports an available banked reset count, `/usage`, **Account → View usage**, and the **Usage** category show **Banked resets: N**, including zero. Unknown counts are omitted. Banked resets are separate from paid **Credits** and legacy **Credit resets**; local analytics filters do not change the count. Displaying it never consumes a reset or changes the compact footer.
|
|
79
|
+
|
|
80
|
+
Positive counts also trigger a best-effort read-only reset-details GET within the same timeout budget. Dashboards label listed resets **Reset 1:**, **Reset 2:**, and so on, with expiry date/time in your machine's **local timezone**; the Usage count row includes these lines in its expanded help (`?`). Missing or invalid expiry shows **Expiry not provided**. Past dates show **expired** with **(cached; local time)** and are excluded from new-reset selection. **Reset expiry details unavailable** or **Partial expiry details: M of N resets listed** identifies missing or incomplete details. The usage summary remains the count authority even when details are empty, capped, inconsistent, or fail. Quotas and the count remain visible on details failure.
|
|
81
|
+
|
|
73
82
|
- Use **↑/↓**, **Home/End**, or **Page Up/Down** to navigate; **Tab** or **←/→** switches between categories and details. **Enter** selects. Single blank lines separate the heading, summary, compact options, selected-item help, status, and action hints—not individual options. Gaps yield on short viewports so the selected option and controls remain reachable. Press **?** to expand/collapse the selected help and view notes; resize if expanded help is clipped. Confirmation effects remain complete, and **?** never toggles help during confirmation, path editing, loading, querying, or saving.
|
|
74
83
|
- TODO shows configured selection, current eligibility, and actually loaded SDK registration separately. Opt-in/out requires an idle session and a Cancel-first inline confirmation naming the target, owned Gentle filter effects, and reload. Successful selection closes/disposes the panel and releases its editor hold before automatic command reload or the Ctrl+Option+O manual-reload notice described above; restart Pi if reload fails. Rejections stay in the panel. No installs or task-history changes occur; legacy `/osdy-pi todo on|off|status` remains available.
|
|
75
84
|
- Agents shows concise **Joker / Gentle** choices without activating anything. With detail focus, visible lowercase-only **j Joker / g Gentle** letters open the existing confirmation directly; only available, unambiguous rows get shortcuts. **↑/↓ + Enter** remains the fallback. Profile and Agent selections survive category navigation; unavailable choices fall back to the current row. Letters never bypass category focus, confirmation, path input, loading, querying, or saving. Selection is idle-only and Cancel-first confirmed, naming the real target, possible Joker installation, owned agent filters, and preserved unrelated resources. Normal personal Pi only: `pi:dev` and isolated/override sessions are blocked, never redirected to personal settings. Success closes/disposes the panel and releases its editor hold before automatic command reload or the Ctrl+Option+O manual-reload notice described above; restart Pi if reload fails. Legacy `/osdy-pi agents setup|on|off|status` is unchanged.
|
|
@@ -79,9 +88,9 @@ Ctrl+Option+U opens the existing `/usage` dashboard with the same refresh behavi
|
|
|
79
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.
|
|
80
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.
|
|
81
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.
|
|
82
|
-
- Sounds
|
|
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.
|
|
83
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.
|
|
84
|
-
- **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`
|
|
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.
|
|
85
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.
|
|
86
95
|
|
|
87
96
|
This modal requires Pi's terminal UI, not RPC, JSON, or print mode. `/osdy-pi` remains the legacy extension command; the external shell command `osdy` (`bin/osdy.mjs`) remains the isolated launcher described below.
|
|
@@ -229,7 +238,7 @@ For a project-local installation, run `pi remove -l npm:@juicesharp/rpiv-todo` i
|
|
|
229
238
|
|
|
230
239
|
**Quick path:** `/osdyConfig` → **Account** → select a profile → **v** (or **Enter**) for quota bars. Use **r Refresh** to query again or **b Back** / **Esc** to return with the profile selected. From the overview or quota dashboard, **s Switch** opens the same Cancel-first confirmation for the eligible inactive profile; cancelling from quota preserves the view and scroll position. From the overview, **d** sets the default and **c** clears an existing default, also with Cancel-first confirmation. The active profile also supports explicit stored-credential queries.
|
|
231
240
|
|
|
232
|
-
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh or stored-profile polling. Only **View usage** and the quota view's **Refresh** query; profile navigation does not. It never activates, borrows active credentials, refreshes tokens, writes auth, changes models/session state or overwrites active quota. Missing, malformed, expired or remotely rejected snapshots are unavailable—not zero quota. Even the current profile's stored snapshot may be stale: log in and save through the normal account workflow outside preview. Profile labels and decoded JWT claims do not verify human identity or server validity. Local history remains token/cost estimates, not subscription quota. Legacy `/osdy-account`, `/usage` and `/osdy-usage` remain
|
|
241
|
+
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh or stored-profile polling. Only **View usage** and the quota view's **Refresh** query; profile navigation does not. It never activates, borrows active credentials, refreshes tokens, writes auth, changes models/session state or overwrites active quota. Missing, malformed, expired or remotely rejected snapshots are unavailable—not zero quota. Even the current profile's stored snapshot may be stale: log in and save through the normal account workflow outside preview. Profile labels and decoded JWT claims do not verify human identity or server validity. Local history remains token/cost estimates, not subscription quota. Legacy `/osdy-account`, `/usage` and `/osdy-usage` remain available; Agents configuration remains normal personal Pi only.
|
|
233
242
|
|
|
234
243
|
Osdy Pi can keep multiple ChatGPT Plus/Pro accounts authenticated and let you choose which one starts Pi. `personal` and `work` are only examples—you can create as many named profiles as you need.
|
|
235
244
|
|
|
@@ -294,7 +303,7 @@ To resume a specific session directly from the terminal:
|
|
|
294
303
|
osdy-pi account use work -- --session /absolute/path/to/session.jsonl
|
|
295
304
|
```
|
|
296
305
|
|
|
297
|
-
> **Privacy:** each profile keeps a private `auth.json`; Pi runs against its canonical shared `auth.json`. Account activation/preservation atomically copies opaque auth files. The separate explicit Account **View usage / Refresh** query reads a bounded stored auth snapshot, parses its Codex OAuth fields and unverified JWT claims, and sends the access token/account ID only to the fixed HTTPS Codex usage
|
|
306
|
+
> **Privacy:** each profile keeps a private `auth.json`; Pi runs against its canonical shared `auth.json`. Account activation/preservation atomically copies opaque auth files. The separate explicit Account **View usage / Refresh** query reads a bounded stored auth snapshot, parses its Codex OAuth fields and unverified JWT claims, and sends the access token/account ID only to the fixed HTTPS Codex usage and optional reset-details endpoints. It never refreshes tokens, logs in, switches accounts, writes auth, or queries automatically; it does not print or log credentials. Session history, settings, installed packages, and extension resources are shared, so every profile can access that local state. It keeps Pi's canonical `openai-codex` provider and delegates authentication to Pi's built-in `/login` flow.
|
|
298
307
|
|
|
299
308
|
Account switching is serialized in-process and protected by a private cross-process lock. Osdy Pi accepts only bounded regular auth files, refuses symlink-based auth sources, writes replacements with private permissions, and restores the previous canonical auth and account metadata when activation fails. A separate namespace lock protects concurrent profile creation, rename, and removal. Launchers activate the selected auth before starting Pi while continuing to use the shared agent directory; existing managed-directory links from earlier profile layouts are resolved for compatibility.
|
|
300
309
|
|
|
@@ -317,7 +326,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
|
|
|
317
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 |
|
|
318
327
|
| Local analytics | Four keyboard-selectable pages with shared filters, token charts, estimated cost, and private metadata-only history from new assistant turns |
|
|
319
328
|
| Git | Working-tree summary and a centered, filterable diff panel |
|
|
320
|
-
| Audio |
|
|
329
|
+
| Audio | Default-on event sounds on macOS and Windows, with master mute and custom paths |
|
|
321
330
|
|
|
322
331
|
<img width="1280" height="433" alt="Osdy Pi header and editor" src="https://github.com/user-attachments/assets/20c7624d-9ad8-4494-97fb-6b6d81aaf328" />
|
|
323
332
|
|
|
@@ -446,7 +455,17 @@ Active quota loads when an enabled session starts and refreshes when **Ctrl+Opti
|
|
|
446
455
|
|
|
447
456
|
Automatic refresh retains the last snapshot during loading and on error, so an older reading can remain visible until a successful query replaces it. Manual refresh clears the previous snapshot before authentication resolves and supersedes any in-flight automatic request. Account switching refreshes active quota through that same clearing path. Session replacement and shutdown stop the timer, cancel pending requests and clear quota state; late results are ignored.
|
|
448
457
|
|
|
449
|
-
> **Privacy:** this active-account `/usage` path resolves OAuth through Pi's `modelRegistry`, rather than directly reading auth files. The separate Control Center Account **View usage / Refresh** path reads the selected profile's bounded stored credentials snapshot; it never borrows active credentials or refreshes tokens, logs in, switches accounts, writes auth, or queries automatically. Both paths transmit the access token/account ID only to the fixed HTTPS Codex usage
|
|
458
|
+
> **Privacy:** this active-account `/usage` path resolves OAuth through Pi's `modelRegistry`, rather than directly reading auth files. The separate Control Center Account **View usage / Refresh** path reads the selected profile's bounded stored credentials snapshot; it never borrows active credentials or refreshes tokens, logs in, switches accounts, writes auth, or queries automatically. Both paths transmit the access token/account ID only to the fixed HTTPS Codex usage and optional reset-details endpoints, reject redirects, and bound timeout and response size. Quota previews do not persist or log credentials or display account IDs, tokens, raw response bodies, or endpoint internals. Only an explicitly selected and confirmed active-account reset additionally POSTs to the fixed Codex reset-consume endpoint.
|
|
459
|
+
|
|
460
|
+
#### Use or check a banked reset
|
|
461
|
+
|
|
462
|
+
1. In `/usage`, press **u**; or select **Usage → Use or check banked reset** in `/osdyConfig`. Account → View usage remains read-only, including previews of the current profile.
|
|
463
|
+
2. The overlay closes first. Choose an indexed reset with its **local expiry date/time** (or **Expiry not provided**), then explicitly select **Use reset**. **Cancel is first**; Cancel/Esc sends nothing. The confirmation affects only the **active Codex account** and explains that a successful reset consumes one banked reset. Auth/account/session are checked again before POST.
|
|
464
|
+
3. `/usage` reopens with the result snapshot if the same context remains active; Control Center exits, so reopen Usage to inspect it. A failed quota refresh or local bookkeeping failure is reported separately from a confirmed server outcome. `no_credit` and `nothing_to_reset` do not consume a reset; `already_redeemed` confirms prior success.
|
|
465
|
+
|
|
466
|
+
**Unknown outcome:** do not start another reset or infer success from a changed count. Use **u** or the Usage row again and explicitly choose **Check pending attempt** to recheck the **same stored request UUID and reset ID**, which may finish an earlier unconfirmed reset—not start a new attempt. A private, durable local pending journal stores attempt identifiers, never credentials; reload does not automatically retry or replay it. Checking remains available with active Codex authentication even when the displayed count is zero or quota is unavailable.
|
|
467
|
+
|
|
468
|
+
**Blocked storage/lock:** stop for manual verification of the previous attempt and any other running Pi process. Lock acquisition or pre-dispatch journal-save failure prevents a reset POST. Locks are not automatically reclaimed, even when apparently stale; orphaned locks can require manual recovery. There is no automatic lock cleanup or guarantee of perfect crash recovery. This flow has mocked transport tests, not live API, real-reset, or manual TUI verification.
|
|
450
469
|
|
|
451
470
|
### Local usage analytics (starting in 1.10.0)
|
|
452
471
|
|
|
@@ -461,7 +480,7 @@ Run `/osdy-usage` in interactive terminal Pi. **Summary** opens by default; use
|
|
|
461
480
|
|
|
462
481
|
Narrow terminals stack metrics and wrap names; every group remains reachable by scrolling. Shares use the selected total tokens; bars scale to each ranking's maximum. Loading, stale, scan-limit/warning and unpriced/partial-cost states remain on every page and in help. Sticky status lines wrap instead of concealing cost status behind scan warnings, including at End. When height is scarce, statuses take priority over optional headings, charts and footer controls; the active page remains visible when space permits. Press `?` for methodology and full controls, then `?` again to return. Choosing a page also leaves help without discarding filters.
|
|
463
482
|
|
|
464
|
-
This is separate from the
|
|
483
|
+
This is separate from the `/usage` subscription quota panel. RPC, JSON and print modes receive a short notification instead of charts. The four-page UI passed independent scripted synthetic PTY checks for navigation, shared selection and responsive statuses at 12/32/100 columns, including a five-row terminal. Human visual confirmation and provider-backed capture remain unverified.
|
|
465
484
|
|
|
466
485
|
| Control | Action |
|
|
467
486
|
| --- | --- |
|
|
@@ -538,7 +557,7 @@ Use `working-tree position top` or `bottom` to place the summary above or below
|
|
|
538
557
|
|
|
539
558
|
## Audio notifications
|
|
540
559
|
|
|
541
|
-
Osdy Pi
|
|
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`).
|
|
542
561
|
|
|
543
562
|
| Event | Current meaning |
|
|
544
563
|
| --- | --- |
|
|
@@ -547,7 +566,9 @@ Osdy Pi can play readable `.mp3` or `.wav` files on macOS and Windows. Other pla
|
|
|
547
566
|
| `permission` | Hook is available but dormant until an explicit Pi approval integration uses it. |
|
|
548
567
|
| `question` | Hook is available but dormant until an explicit Pi question integration uses it. |
|
|
549
568
|
|
|
550
|
-
|
|
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):
|
|
551
572
|
|
|
552
573
|
```text
|
|
553
574
|
/osdy-pi sound setup
|
|
@@ -565,7 +586,7 @@ pi \
|
|
|
565
586
|
--osdy-pi-sound-question /absolute/path/question.wav
|
|
566
587
|
```
|
|
567
588
|
|
|
568
|
-
|
|
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.
|
|
569
590
|
|
|
570
591
|
## Local install and development
|
|
571
592
|
|
|
@@ -622,9 +643,9 @@ An isolated agent directory does **not** make custom audio paths or real provide
|
|
|
622
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`.
|
|
623
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.
|
|
624
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.
|
|
625
|
-
- 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;
|
|
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.
|
|
626
647
|
|
|
627
|
-
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.
|
|
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.
|
|
628
649
|
|
|
629
650
|
Install a local checkout into Pi with:
|
|
630
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`.
|
|
Binary file
|
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|