osdy-pi 1.11.9 → 1.11.11

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
@@ -12,7 +12,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
12
12
 
13
13
  | Area | What ships |
14
14
  | --- | --- |
15
- | Control Center | `/osdyConfig` opens ten categories, including confirmed TODO/Agents provider selection and explicit per-profile Codex quota previews. |
15
+ | Control Center | **Ctrl+Option+O** or `/osdyConfig` opens ten categories, including confirmed TODO/Agents provider selection and explicit per-profile Codex quota previews. |
16
16
  | 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
17
  | Themes introduced in 1.11.0 | Spider-Man Classic, Miles Morales, and Spider-Verse add three distinct dark palettes. |
18
18
  | Themes introduced in 1.9.0 | Halloween and Halloween Killer add two dark palettes. |
@@ -20,7 +20,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
20
20
  | Message cards | Pi-native colored user cards without emojis and plain assistant Markdown preceded by a separate `🦝` transcript row. |
21
21
  | Header and mascot | Independent header/mascot choices, an installed-package version label, and mascot-only fallback when full artwork cannot fit. |
22
22
  | Accounts | `/osdy-account` switches Codex profiles in place with atomic activation, rollback, bounded auth files, and process-safe locks. |
23
- | Quota | `/usage` and compact bars emphasize remaining Codex quota at warning (40% or less) and error (15% or less) thresholds. |
23
+ | Quota | **Ctrl+Option+U** or `/usage` opens subscription quota; compact bars emphasize remaining Codex quota at warning (40% or less) and error (15% or less) thresholds. |
24
24
  | TODO (starting in 1.5.0, opt-in) | Default-off first-party `todo` snapshots track the Pi session branch; `/todos` reads them without editing. ODD Markdown remains separate. |
25
25
 
26
26
  ## Prerequisites
@@ -57,19 +57,30 @@ Osdy Pi's own extension and themes are bundled—do not install them separately.
57
57
 
58
58
  ### Control Center in Pi
59
59
 
60
- Run **`/osdyConfig`** in interactive Pi to open the Osdy Control Center. The ten categories—**Theme, Header, Mascot, Editor, Git, Sounds, Account, Usage, TODO, and Agents**—provide inline controls or data in the same modal. Existing commands remain available.
60
+ Press **Ctrl+Option+O** or run **`/osdyConfig`** in interactive Pi to open the Osdy Control Center. The ten categories—**Theme, Header, Mascot, Editor, Git, Sounds, Account, Usage, TODO, and Agents**—provide inline controls or data in the same modal. Existing commands remain available.
61
+
62
+ | Shortcut | Panel | Command fallback |
63
+ | --- | --- | --- |
64
+ | **Ctrl+Option+O** | Osdy Control Center | `/osdyConfig` |
65
+ | **Ctrl+Option+U** | Active Codex subscription quota—not local token/cost analytics | `/usage` |
66
+
67
+ On macOS, **Option = Alt** in Pi's shortcut names: these bindings register as `ctrl+alt+o` and `ctrl+alt+u`. On other platforms, use **Ctrl+Alt+O** and **Ctrl+Alt+U**. Your terminal must send Option as **Alt/Meta** and support **Ctrl+Alt chords**; registration does not guarantee actual hardware delivery. Check **`/hotkeys`** for bindings, and use **`/osdyConfig`** or **`/usage`** if the terminal, OS, or another extension intercepts or cannot deliver the chord.
68
+
69
+ **Ctrl+Option+O capability limits:** Account **Switch**, **Set default**, and **Clear default** refuse changes while Pi is busy; retry when idle. The same actions through `/osdyConfig` retain idle waiting, as does switching through `/osdy-account`. Read-only account quota previews remain available while busy. Successful TODO/Agents selection from Ctrl+Option+O saves settings, closes/disposes the panel, releases its editor hold, then asks you to run **`/reload` manually** (or restart Pi). From `/osdyConfig`, the same sequence still reloads automatically once; restart Pi if reload fails.
70
+
71
+ Ctrl+Option+U opens the existing `/usage` dashboard with the same refresh behavior; it does not open `/osdy-usage`.
61
72
 
62
73
  - 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.
63
- - 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 reloading once; 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.
64
- - 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 reloading once; restart Pi if reload fails. Legacy `/osdy-pi agents setup|on|off|status` is unchanged.
74
+ - 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
+ - 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.
65
76
  - Theme lists Pi's available themes and marks the current theme. Explicit **Page x/N** pages contain at most **8 themes**, fewer in short viewports or with expanded help. **Page Up/Down** changes pages, **Home/End** reaches the first/last theme, and arrows cross page boundaries. Selection survives category navigation and resizing; resizing adjusts the page around the selected theme. A successful selection applies live and saves globally in the active Pi agent directory; project settings can override it at startup. Failures appear in the modal.
66
- - Header offers **osdy-theme / neon**; Mascot offers **current / Bts**; Editor offers **auto / extended / simple**, with the current preference marked. Editor also shows the effective mode: small terminals stay simple/native even with extended selected.
77
+ - Header offers **osdy-theme / neon**; Mascot offers **current / Bts / Osdy-Halloween**; Editor offers **auto / extended / simple**, with the current preference marked. Editor also shows the effective mode: small terminals stay simple/native even with extended selected.
67
78
  - Preferences save immediately through the same runtime and global Osdy settings store as `/osdy-pi` commands (`<agent-dir>/extensions/osdy-pi/settings.json`). Header/Mascot apply live without closing the panel. Editor replacement waits until all overlays close; if another overlay remains, the next existing responsive watcher tick applies the latest choice. The effective mode reports the actually mounted editor while queued. Opening a category only reads values; while Osdy is disabled, choices save for later without enabling it.
68
79
  - 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.
69
80
  - **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.
70
81
  - **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.
71
82
  - 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.
72
- - **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 waits for Pi to become idle, 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.
83
+ - **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.
73
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` and `/osdy-usage` remain unchanged.
74
85
  - **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.
75
86
 
@@ -300,7 +311,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
300
311
  | Area | Included behavior |
301
312
  | --- | --- |
302
313
  | Themes | 23 built-in themes, including Osdy, Kanagawa, Dracula, Gruvbox, Nord, Rosé Pine, Daniela Cute, Halloween, Halloween Killer, Catppuccin, Matrix, Lucent Orange, and three Spider-Man-inspired palettes |
303
- | Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
314
+ | Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts`/`osdy-halloween` mascots |
304
315
  | Messages | Native colored user cards without emojis and plain assistant Markdown with a separate assistant `🦝` row |
305
316
  | Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
306
317
  | 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 |
@@ -366,10 +377,12 @@ Header and mascot choices are independent:
366
377
  | Element | Choices | Command |
367
378
  | --- | --- | --- |
368
379
  | Header | `osdy-theme` (default), `neon` | `/osdy-pi header osdy-theme|neon` |
369
- | Mascot | `current` (default), `bts` | `/osdy-pi mascot current|bts` |
380
+ | Mascot | `current` (default), `bts`, `osdy-halloween` | `/osdy-pi mascot current\|bts\|osdy-halloween` |
370
381
 
371
382
  Both commands update the UI immediately. Add `status` instead of a choice to inspect the current selection. Choices persist in the global Osdy Pi settings across reloads and sessions. The old top-level style commands and `classic` header aliases are no longer registered.
372
383
 
384
+ Choose **Osdy-Halloween** in `/osdyConfig` → **Mascot**, or run `/osdy-pi mascot osdy-halloween`. It dresses the same Current raccoon in ivory fangs and a high-collared dark/crimson vampire cape. Its glow follows the active theme's `accent`, `mdHeading`, and `mdLink` colors; no Halloween theme is required. Current remains the default, and Current/Bts artwork and version-1 settings compatibility are unchanged.
385
+
373
386
  The header also shows **`osdy-pi v<version>`** at the top right in the theme's accent color, read from the installed Osdy package—not Pi's host version or the workspace manifest. It uses an empty first row or adds a row without overwriting artwork; invalid/unreadable versions or labels wider than the terminal are omitted.
374
387
 
375
388
  In normal mode, the selected header and mascot render side by side when both fit. The Neon header derives its highlights from the active theme rather than using one fixed palette. Each mascot keeps its own tone map while its animated edge glow can resolve through theme colors. Header and mascot scaling remain independent, so any combination follows the same responsive layout rules.
@@ -400,21 +413,21 @@ The enabled state, editor mode, working-tree visibility preference, header, and
400
413
 
401
414
  | Group | Command |
402
415
  | --- | --- |
403
- | Control Center | `/osdyConfig` (interactive terminal UI) |
416
+ | Control Center | **Ctrl+Option+O** or `/osdyConfig` (interactive terminal UI) |
404
417
  | Main | `/osdy-pi` |
405
418
  | Main | `/osdy-pi enable\|disable\|on\|off\|status` |
406
419
  | Accounts | `/osdy-account` |
407
420
  | TODO selection | `/osdy-pi todo on\|off\|status` |
408
421
  | Session TODO (opt-in, starting in 1.5.0) | `/todos` (read-only) |
409
422
  | Header | `/osdy-pi header osdy-theme\|neon\|status` |
410
- | Mascot | `/osdy-pi mascot current\|bts\|status` |
423
+ | Mascot | `/osdy-pi mascot current\|bts\|osdy-halloween\|status` |
411
424
  | Editor | `/osdy-pi editor auto\|extended\|simple\|on\|off\|toggle\|status` |
412
425
  | Working tree | `/osdy-pi working-tree on\|off\|toggle\|status` |
413
426
  | Working tree | `/osdy-pi working-tree position top\|bottom\|status` |
414
427
  | Audio | `/osdy-pi sound setup` |
415
428
  | Diff | `/osdy-pi diff` |
416
429
  | Package | `/osdy-pi uninstall` |
417
- | Codex subscription | `/usage` |
430
+ | Codex subscription | **Ctrl+Option+U** or `/usage` |
418
431
  | Local usage analytics (starting in 1.10.0) | `/osdy-usage` |
419
432
 
420
433
  `/osdy-pi uninstall` finds a unique Osdy Pi package in Pi's configured package list and asks you to confirm its exact source and user/project scope before calling Pi's `remove` command. Run it from a trusted project with interactive UI. Git registrations are checked against Pi's host/path identity; a local registration must point to this running extension's package root and have a matching manifest. Ambiguous or changed registrations, unrecognized sources, and cancelled confirmation remove nothing. It removes only the Pi package registration, not Osdy profiles, accounts, the globally installed CLI, or other packages. Restart Pi afterward to unload the extension. If no unique source is found, inspect `pi list` and use `pi remove <source> [-l]` manually.
@@ -423,13 +436,13 @@ The enabled state, editor mode, working-tree visibility preference, header, and
423
436
 
424
437
  ### Codex subscription usage
425
438
 
426
- Run `/usage` to open the Codex subscription dashboard for the active managed `openai-codex` profile and model. Press `r` to refresh; `esc` or `q` closes it. The dashboard shows these controls at the bottom.
439
+ Press **Ctrl+Option+U** or run `/usage` to open the same Codex subscription dashboard for the active managed `openai-codex` profile and model. Press `r` to refresh; `esc` or `q` closes it. The dashboard shows these controls at the bottom.
427
440
 
428
441
  The main windows are labeled **Session** and **Weekly** (the API/domain remains primary/secondary). Their remaining-capacity bars are full at 100% remaining and empty at 0%; labels use the active theme's bold accent, Session uses the accent fill, Weekly uses `mdLink`, and empty segments are muted. The remaining percentage stays muted above 40%, changes to the theme's warning color at 40% or less, and changes to its error color at 15% or less. The same thresholds apply in the `/usage` dashboard and compact footer/editor quota bars. A double themed frame, section dividers, and spacing separate the display. Detail cards show each duration and relative, local, and UTC reset times. Plan, availability, credits/reset count, and additional buckets appear only when the service supplies them; absent optional values are omitted.
429
442
 
430
443
  At wide widths, the modal pairs the Session and Weekly detail cards. Below 72 content columns, cards and bars stack and account/model data wraps. The native footer and extended editor also show compact Session/Weekly remaining-capacity bars below model and thinking metadata whenever a current Codex snapshot exists. Wide widths combine those bars; narrow widths stack them. Additional buckets appear in `/usage` and the selected-profile Control Center quota view, not compact editor/footer bars.
431
444
 
432
- Active quota loads when an enabled session starts and refreshes when `/usage` opens or `r` is pressed. It also refreshes while idle on a **60-second** schedule, only after settings are loaded, with Osdy enabled, an interactive terminal UI, an `openai-codex` model selected, and no quota request already in flight. Automatic failures double the retry delay up to **10 minutes**; a successful automatic query restores the 60-second interval. This scheduler never polls stored profiles.
445
+ Active quota loads when an enabled session starts and refreshes when **Ctrl+Option+U** or `/usage` opens the dashboard, or `r` is pressed. It also refreshes while idle on a **60-second** schedule, only after settings are loaded, with Osdy enabled, an interactive terminal UI, an `openai-codex` model selected, and no quota request already in flight. Automatic failures double the retry delay up to **10 minutes**; a successful automatic query restores the 60-second interval. This scheduler never polls stored profiles.
433
446
 
434
447
  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.
435
448
 
@@ -604,13 +617,14 @@ This launches `pi` from `PATH`, loads this checkout with `-e`, and uses `.pi-dev
604
617
  The explicit argument bypasses the launcher's default-account activation; Pi's `--no-session` disables session persistence, **not settings writes or provider access**.
605
618
  An isolated agent directory does **not** make custom audio paths or real provider accounts harmless. Use intentional disposable test profiles/files for profile writes, switches, default confirmations, and audio tests; do not assume an isolated account store makes real credentials safe.
606
619
 
607
- - Open `/osdyConfig` in regular and fullscreen Pi. Check arrows/Home/End/Page Up/Down, Tab or left/right, Enter, and Esc; shrink and restore the terminal, checking both panes remain reachable.
620
+ - Check `/hotkeys` lists `ctrl+alt+o` and `ctrl+alt+u`. On macOS, configure Option to send Alt/Meta and verify the terminal supports Ctrl+Alt chords; actual Ctrl+Option delivery still needs a hardware check. Open both **Ctrl+Option+O** and `/osdyConfig` in regular and fullscreen Pi (Ctrl+Alt+O on other platforms); use the slash command if the chord is intercepted or unsupported. Check arrows/Home/End/Page Up/Down, Tab or left/right, Enter, and Esc; shrink and restore the terminal, checking both panes remain reachable. With disposable TODO/Agents settings, verify Ctrl+Option+O asks for manual `/reload` only after closing, while `/osdyConfig` reloads automatically.
608
621
  - In disposable settings, change a visual preference, wait for **Saved globally**, close and reopen, then reload/restart to check persistence. Esc does not undo changes; distinguish save/application errors from success.
609
- - Account (manual): with disposable profiles only, check Cancel-first switch/set-default/clear-default confirmations, Esc cancellation, idle switching, and current/default labels. 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`.
622
+ - 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
+ - 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.
610
624
  - 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.
611
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; actual macOS/Windows playback still needs a live check.
612
626
 
613
- Live TUI appearance, real account switching/authenticated quota, and actual audio playback remain unverified; this checklist is not a recorded pass.
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. Live TUI appearance, real account switching/authenticated quota, and actual audio playback remain unverified; this checklist is not a recorded full pass.
614
628
 
615
629
  Install a local checkout into Pi with:
616
630
 
@@ -460,12 +460,14 @@ void test("manages profile rename and permanent removal prompts through authorit
460
460
 
461
461
  void test("switches in place after idle without spawning or shutting down", async () => {
462
462
  const events: string[] = [];
463
+ let idle = false;
463
464
  await switchAccountInPlace(
464
465
  {
465
- isIdle: () => false,
466
+ isIdle: () => idle,
466
467
  waitForIdle: () =>
467
468
  Promise.resolve().then(() => {
468
469
  events.push("idle");
470
+ idle = true;
469
471
  }),
470
472
  ui: {
471
473
  notify: (message: string) => events.push(message),
@@ -495,6 +497,46 @@ void test("switches in place after idle without spawning or shutting down", asyn
495
497
  ]);
496
498
  });
497
499
 
500
+ void test("base-context account switches require idle without a command wait hook", async () => {
501
+ const previous = process.env.OSDY_PI_PROFILE_NAME;
502
+ try {
503
+ for (const idle of [false, true]) {
504
+ process.env.OSDY_PI_PROFILE_NAME = "personal";
505
+ const events: string[] = [];
506
+ const notices: string[] = [];
507
+ const ctx = { isIdle: () => idle, ui: {
508
+ notify: (message: string) => notices.push(message),
509
+ select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined),
510
+ } };
511
+ assert.equal("waitForIdle" in ctx, false);
512
+ const switched = await switchAccountInPlace(ctx, "work",
513
+ profile => { events.push(`activate:${profile}`); return Promise.resolve(); },
514
+ () => { events.push("refresh"); return Promise.resolve(); },
515
+ () => { events.push("render"); });
516
+ assert.equal(switched, idle);
517
+ assert.deepEqual(events, idle ? ["activate:work", "render", "refresh"] : []);
518
+ assert.equal(process.env.OSDY_PI_PROFILE_NAME, idle ? "work" : "personal");
519
+ assert.match(notices.join(" "), idle ? /Switched to work/ : /require Pi to be idle/);
520
+ }
521
+ } finally {
522
+ if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
523
+ else process.env.OSDY_PI_PROFILE_NAME = previous;
524
+ }
525
+ });
526
+
527
+ void test("a command wait hook cannot activate an account if Pi remains busy", async () => {
528
+ const events: string[] = [];
529
+ const switched = await switchAccountInPlace({ isIdle: () => false,
530
+ waitForIdle: () => { events.push("wait"); return Promise.resolve(); },
531
+ ui: { notify: message => events.push(message), select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined) },
532
+ }, "work", () => { events.push("activate"); return Promise.resolve(); },
533
+ () => { events.push("refresh"); return Promise.resolve(); }, () => { events.push("render"); });
534
+ assert.equal(switched, false);
535
+ assert.equal(events[0], "wait");
536
+ assert.match(events[1] ?? "", /require Pi to be idle/);
537
+ assert.equal(events.length, 2);
538
+ });
539
+
498
540
  void test("keeps the current account when in-place activation fails", async () => {
499
541
  const notices: string[] = [];
500
542
  let renderRequested = false;
@@ -12,10 +12,9 @@ function sameProfile(left: string | undefined, right: string | undefined): boole
12
12
  return left?.toLowerCase() === right?.toLowerCase();
13
13
  }
14
14
 
15
- export type AccountContext = Pick<
16
- ExtensionCommandContext,
17
- "isIdle" | "waitForIdle"
18
- > & {
15
+ export type AccountContext = Pick<ExtensionContext, "isIdle"> & {
16
+ /** Only slash-command contexts can wait; base shortcut contexts must already be idle. */
17
+ waitForIdle?: ExtensionCommandContext["waitForIdle"];
19
18
  shutdown?: ExtensionCommandContext["shutdown"];
20
19
  sessionManager?: Pick<
21
20
  ExtensionCommandContext["sessionManager"],
@@ -128,7 +127,14 @@ export async function switchAccountInPlace(
128
127
  );
129
128
  return false;
130
129
  }
131
- if (!ctx.isIdle()) await ctx.waitForIdle();
130
+ if (!ctx.isIdle() && ctx.waitForIdle) await ctx.waitForIdle();
131
+ if (!ctx.isIdle()) {
132
+ ctx.ui.notify(
133
+ "Account changes require Pi to be idle. Wait for active work to finish and retry.",
134
+ "warning",
135
+ );
136
+ return false;
137
+ }
132
138
  try {
133
139
  await activate(profile);
134
140
  } catch {
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { registerHooks } from "node:module";
3
3
  import test from "node:test";
4
4
  // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
5
- import { HEADER_VARIANTS } from "./constants.ts";
5
+ import { HEADER_VARIANTS, mascotForChoice } from "./constants.ts";
6
6
  import type { SimpleTheme } from "./types.js";
7
7
 
8
8
  registerHooks({
@@ -19,7 +19,7 @@ registerHooks({
19
19
  });
20
20
 
21
21
  // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
22
- const { colorAsciiCharacter } = await import("./animation.ts");
22
+ const { colorAsciiCharacter, animateAsciiLineWithToneMap } = await import("./animation.ts");
23
23
 
24
24
  class RecordingTheme implements SimpleTheme {
25
25
  readonly calls: Array<{ name: string; text: string }> = [];
@@ -74,6 +74,33 @@ void test("Neon renders main and dark pink through bounded accent styling", () =
74
74
  assert.equal(`${main}${dark.join("")}`.includes("\u001B[48;"), false);
75
75
  });
76
76
 
77
+ void test("Osdy-Halloween resolves edge glow against the live theme in static and animated frames", () => {
78
+ const { art, tonePalette } = mascotForChoice("osdy-halloween");
79
+ assert.deepEqual([tonePalette.p, tonePalette.c, tonePalette.v], ["accent", "mdHeading", "mdLink"]);
80
+ let themeId = "first";
81
+ const calls: string[] = [];
82
+ const theme: SimpleTheme = { fg(name, text) { calls.push(name); return `<${themeId}:${name}:${text}>`; } };
83
+ for (const style of ["static", "animated"] as const) {
84
+ for (const frame of [0, 7, 28]) {
85
+ const render = () => art.mascot.map((line, row) =>
86
+ animateAsciiLineWithToneMap(line, art.toneMap[row] ?? "", row, frame, theme, tonePalette, style)).join("\n");
87
+ themeId = "first";
88
+ const first = render();
89
+ themeId = "second";
90
+ const second = render();
91
+ assert.notEqual(first, second);
92
+ assert.equal(first.replaceAll("first:", "second:"), second, "only theme-resolved colors change");
93
+ assert.ok(second.includes("\u001B[38;2;"), "fixed body/costume foreground colors remain");
94
+ assert.ok(!second.includes("\u001B[48;"), "transparent background");
95
+ }
96
+ }
97
+ assert.deepEqual([...new Set(calls)].sort(), ["accent", "mdHeading", "mdLink"].sort());
98
+ for (let row = 0; row < art.mascot.length; row++) {
99
+ const occupied = Array.from(art.mascot[row] ?? "").flatMap((glyph, column) => glyph === " " ? [] : [column]);
100
+ assert.deepEqual(occupied.slice(-3).reverse().map((column) => art.toneMap[row]?.[column]), ["p", "c", "v"].slice(0, occupied.length));
101
+ }
102
+ });
103
+
77
104
  void test("Neon never maps a tone to mdHeading or mdLink", () => {
78
105
  for (const color of Object.values(neonPalette())) {
79
106
  if (typeof color === "string") {
@@ -457,6 +457,52 @@ function addRightEdgeGlow(art: MascotArt): MascotArt {
457
457
  };
458
458
  }
459
459
 
460
+ // Transparent overlay at Current's mouth/shoulders (row 17 onward).
461
+ // ▓ = dark cape, ▒ = crimson lining. Keep the face and tail.
462
+ const VAMPIRE_COSTUME = [
463
+ " ▓ ▓",
464
+ " ▓▒▓ ▓▒▒▓",
465
+ " ▓▒▒▒▓ ▓▒▒▒▒▓",
466
+ " ▓▒▒▒▒▒▒▓ ▓▒▒▒▒▒▒▓",
467
+ " ▓▓▒▒▒▒▒▓▓ ▓▓▒▒▒▒▒▓▓",
468
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▒▓▓▓",
469
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓▓",
470
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▒▓▓",
471
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓",
472
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓",
473
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓",
474
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓",
475
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓",
476
+ " ▓▓▓▓▒▒▓▓ ▓▓▒▒▓▓",
477
+ " ▓▓▓▓▒▒▓▓ ▓▓▒▓▓",
478
+ " ▓▓▓▓▓▓ ▓▓▓",
479
+ ] as const;
480
+
481
+ function addVampireCostume(art: MascotArt): MascotArt {
482
+ const rows = art.mascot.map((line, row) => {
483
+ const glyphs = Array.from(line);
484
+ // Reserve b for lining; retain the few original eye glints in ivory h.
485
+ const tones = Array.from((art.toneMap[row] ?? "").replaceAll("b", "h"));
486
+ for (const [column, glyph] of Array.from(VAMPIRE_COSTUME[row - 17] ?? "").entries()) {
487
+ if (glyph === " ") continue;
488
+ glyphs[column] = glyph;
489
+ if (glyph === "▓") tones[column] = "d";
490
+ else if (glyph === "▒") tones[column] = "b";
491
+ else tones[column] = "h";
492
+ }
493
+ return { mascot: glyphs.join(""), toneMap: tones.join("") };
494
+ });
495
+ return { mascot: rows.map((row) => row.mascot), toneMap: rows.map((row) => row.toneMap) };
496
+ }
497
+
498
+ const HALLOWEEN_MASCOT_TONES: MascotTonePalette = {
499
+ ...HTML_MASCOT_TONES,
500
+ b: "#9F2342",
501
+ };
502
+ const HALLOWEEN_MASCOT = addRightEdgeGlow(addVampireCostume({
503
+ mascot: HTML_MASCOT,
504
+ toneMap: HTML_MASCOT_MAP,
505
+ }));
460
506
  const ROSE_MASCOT = addRightEdgeGlow({
461
507
  mascot: HTML_MASCOT,
462
508
  toneMap: HTML_MASCOT_MAP,
@@ -468,6 +514,7 @@ const BTS_MASCOT_ART = addRightEdgeGlow({
468
514
  for (const [art, palette] of [
469
515
  [ROSE_MASCOT, HTML_MASCOT_TONES],
470
516
  [BTS_MASCOT_ART, BTS_MASCOT_TONES],
517
+ [HALLOWEEN_MASCOT, HALLOWEEN_MASCOT_TONES],
471
518
  ] as const) {
472
519
  assertRawHexPalette(palette);
473
520
  assertMascotStructure(art, palette);
@@ -481,6 +528,7 @@ export type MascotConfig = {
481
528
  export const MASCOTS: Record<MascotChoice, MascotConfig> = {
482
529
  current: { art: ROSE_MASCOT, tonePalette: HTML_MASCOT_TONES },
483
530
  bts: { art: BTS_MASCOT_ART, tonePalette: BTS_MASCOT_TONES },
531
+ "osdy-halloween": { art: HALLOWEEN_MASCOT, tonePalette: HALLOWEEN_MASCOT_TONES },
484
532
  };
485
533
 
486
534
  export function mascotForChoice(choice: MascotChoice): MascotConfig {
@@ -138,6 +138,87 @@ void test("Bound Account reuses idle switching, quota refresh, and authoritative
138
138
  }
139
139
  });
140
140
 
141
+ void test("Bound base-context Account mutations refuse busy Pi and work when idle", async () => {
142
+ const previous = process.env.OSDY_PI_PROFILE_NAME;
143
+ try {
144
+ for (const idle of [false, true]) {
145
+ process.env.OSDY_PI_PROFILE_NAME = "personal";
146
+ const events: string[] = [];
147
+ const commands: string[][] = [];
148
+ const ctx = { isIdle: () => idle, ui: {
149
+ notify: (text: string) => events.push(text),
150
+ select: () => Promise.reject(new Error("dialog forbidden")), input: () => Promise.reject(new Error("dialog forbidden")),
151
+ } };
152
+ assert.equal("waitForIdle" in ctx, false);
153
+ const service = bindControlCenterAccount(ctx,
154
+ () => { events.push("refresh"); return Promise.resolve(); }, () => { events.push("render"); },
155
+ { profiles: () => Promise.resolve(["work", "personal"]),
156
+ activate: name => { events.push(`activate:${name}`); return Promise.resolve(); },
157
+ run: args => { commands.push(args); return Promise.resolve({ code: 0, stdout: "No default account.", stderr: "" }); } });
158
+ assert.equal((await service.apply({ kind: "account-switch", profile: "work" })).failed, !idle);
159
+ assert.equal((await service.apply({ kind: "account-default", profile: "work" })).failed, !idle);
160
+ assert.equal((await service.apply({ kind: "account-default", profile: undefined })).failed, !idle);
161
+ assert.deepEqual(commands, idle ? [["account", "default", "work"], ["account", "default", "--clear"]] : []);
162
+ assert.equal(events.includes("activate:work"), idle);
163
+ assert.equal(events.includes("render"), idle);
164
+ assert.equal(events.includes("refresh"), idle);
165
+ assert.equal(process.env.OSDY_PI_PROFILE_NAME, idle ? "work" : "personal");
166
+ }
167
+ } finally {
168
+ if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
169
+ else process.env.OSDY_PI_PROFILE_NAME = previous;
170
+ }
171
+ });
172
+
173
+ void test("Bound command Account defaults wait for idle and recheck idle and runtime freshness", async () => {
174
+ for (const outcome of ["idle", "busy", "stale", "rejected"] as const) {
175
+ let idle = false;
176
+ let current = true;
177
+ const events: string[] = [];
178
+ const service = bindControlCenterAccount({ isIdle: () => idle,
179
+ waitForIdle: () => {
180
+ events.push("wait");
181
+ idle = outcome !== "busy";
182
+ current = outcome !== "stale";
183
+ return outcome === "rejected" ? Promise.reject(new Error("wait failed")) : Promise.resolve();
184
+ },
185
+ ui: { notify: () => {}, select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined) },
186
+ }, () => { throw new Error("refresh forbidden"); }, () => {},
187
+ { profiles: () => Promise.resolve(["work"]), run: args => {
188
+ events.push(args.join(" ")); return Promise.resolve({ code: 0, stdout: "", stderr: "" });
189
+ } }, { isCurrent: () => current });
190
+ assert.equal((await service.apply({ kind: "account-default", profile: "work" })).failed, outcome !== "idle");
191
+ assert.deepEqual(events, outcome === "idle" ? ["wait", "account default work"] : ["wait"]);
192
+ }
193
+ });
194
+
195
+ void test("Bound base-context Account rechecks idle after asynchronous profile validation", async () => {
196
+ const previous = process.env.OSDY_PI_PROFILE_NAME;
197
+ process.env.OSDY_PI_PROFILE_NAME = "personal";
198
+ let idle = true;
199
+ let finish: (profiles: string[]) => void = () => {};
200
+ const events: string[] = [];
201
+ const service = bindControlCenterAccount({ isIdle: () => idle,
202
+ ui: { notify: () => {}, select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined) },
203
+ }, () => { events.push("refresh"); return Promise.resolve(); }, () => { events.push("render"); },
204
+ { profiles: () => new Promise(resolve => { finish = resolve; }),
205
+ activate: () => { events.push("activate"); return Promise.resolve(); },
206
+ run: () => { events.push("default"); return Promise.resolve({ code: 0, stdout: "", stderr: "" }); } });
207
+ try {
208
+ for (const kind of ["account-switch", "account-default"] as const) {
209
+ idle = true;
210
+ const applying = service.apply({ kind, profile: "work" });
211
+ idle = false;
212
+ finish(["work"]);
213
+ assert.equal((await applying).failed, true);
214
+ }
215
+ assert.deepEqual(events, []);
216
+ } finally {
217
+ if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
218
+ else process.env.OSDY_PI_PROFILE_NAME = previous;
219
+ }
220
+ });
221
+
141
222
  void test("Bound Account validates live external active names without rendering unsafe metadata", async () => {
142
223
  const previous = process.env.OSDY_PI_PROFILE_NAME;
143
224
  const service = bindControlCenterAccount({ isIdle: () => true, waitForIdle: () => Promise.resolve(),
@@ -136,7 +136,7 @@ export function bindControlCenterAccount(ctx: AccountContext, refreshUsage: () =
136
136
  await backend.activate!(name);
137
137
  }, refreshUsage, requestRender) : Promise.resolve(false),
138
138
  setDefault: async profile => {
139
- if (!ctx.isIdle()) await ctx.waitForIdle();
139
+ if (!ctx.isIdle() && ctx.waitForIdle) await ctx.waitForIdle();
140
140
  if (!(options.isCurrent?.() ?? true) || !ctx.isIdle()) return false;
141
141
  return (await backend.run(["account", "default", profile ?? "--clear"])).code === 0;
142
142
  },
@@ -21,7 +21,7 @@ const snapshot: VisualPreferenceSnapshot = { enabled: true, headerVariant: "osdy
21
21
  void test("preference rows expose every catalog value and exactly one current indicator", () => {
22
22
  for (const [category, field, values] of [
23
23
  ["Header", "headerVariant", ["osdy-theme", "neon"]],
24
- ["Mascot", "mascot", ["current", "bts"]],
24
+ ["Mascot", "mascot", ["current", "bts", "osdy-halloween"]],
25
25
  ["Editor", "editorMode", ["auto", "extended", "simple"]],
26
26
  ] as const) {
27
27
  for (const value of values) {
@@ -30,7 +30,9 @@ void test("preference rows expose every catalog value and exactly one current in
30
30
  assert.deepEqual(detail.rows.map((row) => "value" in row.action ? row.action.value : undefined), values);
31
31
  assert.equal(detail.rows.filter((row) => row.current).length, 1);
32
32
  assert.equal(detail.rows.find((row) => row.current)?.action.kind, category.toLowerCase());
33
- assert.match(detail.summary, new RegExp(`Current: ${value === "bts" ? "Bts" : value}`));
33
+ const label = value === "bts" ? "Bts" : value === "osdy-halloween" ? "Osdy-Halloween" : value;
34
+ assert.match(detail.summary, new RegExp(`Current: ${label}`));
35
+ assert.equal(detail.rows.find((row) => row.current)?.label, label);
34
36
  }
35
37
  }
36
38
  });
@@ -17,7 +17,11 @@ export interface ControlCenterPreferences {
17
17
  }
18
18
 
19
19
  export function visualPreferenceLabel(action: VisualPreferenceAction): string {
20
- return action.kind === "mascot" && action.value === "bts" ? "Bts" : action.value;
20
+ if (action.kind === "mascot") {
21
+ if (action.value === "bts") return "Bts";
22
+ if (action.value === "osdy-halloween") return "Osdy-Halloween";
23
+ }
24
+ return action.value;
21
25
  }
22
26
 
23
27
  /** Explicit view adaptation only; application and persistence belong to runtime. */
@@ -336,7 +336,7 @@ void test("inline preferences show all supported values and live current/effecti
336
336
  const f = preferenceFixture();
337
337
  for (const [category, choices, current] of [
338
338
  ["Header", ["osdy-theme", "neon"], "osdy-theme"],
339
- ["Mascot", ["current", "Bts"], "current"],
339
+ ["Mascot", ["current", "Bts", "Osdy-Halloween"], "current"],
340
340
  ["Editor", ["auto", "extended", "simple"], "auto"],
341
341
  ] as const) {
342
342
  f.panel.handleInput(down);
@@ -350,6 +350,20 @@ void test("inline preferences show all supported values and live current/effecti
350
350
  assert.deepEqual(f.applied, []);
351
351
  });
352
352
 
353
+ void test("Mascot selects Osdy-Halloween and reports its exact saved label", async () => {
354
+ const f = preferenceFixture();
355
+ f.panel.handleInput(down);
356
+ f.panel.handleInput(down);
357
+ f.panel.handleInput(right);
358
+ f.panel.handleInput("\x1b[F");
359
+ f.panel.handleInput("\r");
360
+ assert.deepEqual(f.applied, [{ kind: "mascot", value: "osdy-halloween" }]);
361
+ assert.match(f.panel.render(100).join("\n"), /Saving.*Osdy-Halloween/);
362
+ f.complete(true);
363
+ await Promise.resolve();
364
+ assert.match(f.panel.render(100).join("\n"), /Saved globally: Osdy-Halloween/);
365
+ });
366
+
353
367
  void test("changing categories resets selection to their current row, even after a longer theme list", () => {
354
368
  const f = preferenceFixture();
355
369
  f.panel.handleInput(right);
@@ -427,7 +441,7 @@ void test("inline preference rendering stays bounded at narrow widths and differ
427
441
  f.panel.handleInput(down);
428
442
  f.panel.handleInput(right);
429
443
  f.panel.handleInput("\x1b[F");
430
- assert.match(f.panel.render(48).join("\n"), new RegExp(`> ${["neon", "Bts", "simple"][category]}`));
444
+ assert.match(f.panel.render(48).join("\n"), new RegExp(`> ${["neon", "Osdy-Halloween", "simple"][category]}`));
431
445
  for (const height of [1, 4, 7, 9, 18]) {
432
446
  f.resize(height);
433
447
  for (const width of [1, 8, 24, 48, 80]) {
@@ -97,7 +97,7 @@ void test("editor settings normalize only supported versioned modes", () => {
97
97
  });
98
98
 
99
99
  void test("mascot choices preserve the version-one current default, migrate raccoon to Bts, and persist Bts", async () => {
100
- assert.deepEqual(MASCOT_CHOICES, ["current", "bts"]);
100
+ assert.deepEqual(MASCOT_CHOICES, ["current", "bts", "osdy-halloween"]);
101
101
  assert.equal(
102
102
  normalizeEditorSettings({ version: 1, mascot: "invalid" }).mascot,
103
103
  "current",
@@ -129,6 +129,18 @@ void test("mascot choices preserve the version-one current default, migrate racc
129
129
  assert.equal((await store.load()).mascot, "bts");
130
130
  });
131
131
 
132
+ void test("Osdy-Halloween round-trips through a fresh version-one settings store", async () => {
133
+ const fileSystem = new MemoryEditorSettingsFileSystem();
134
+ const path = "/agent/extensions/osdy-pi/settings.json";
135
+ const settings = normalizeEditorSettings({ version: 1, mascot: "osdy-halloween" });
136
+ assert.equal(settings.mascot, "osdy-halloween");
137
+ await createEditorSettingsStore(path, fileSystem).save(settings);
138
+ const persisted: unknown = JSON.parse(fileSystem.content ?? "{}");
139
+ assert.deepEqual(persisted, { ...settings, version: 1 });
140
+ assert.deepEqual(await createEditorSettingsStore(path, fileSystem).load(), settings);
141
+ assert.equal(normalizeEditorSettings({ version: 2, mascot: "osdy-halloween" }).mascot, "current");
142
+ });
143
+
132
144
  void test("enabled defaults safely and persists globally", async () => {
133
145
  assert.equal(
134
146
  normalizeEditorSettings({ version: 1, editorMode: "simple" }).enabled,