Files
Codeman/docs/reports/notification-settings.md
T
2026-02-24 13:07:18 +01:00

21 KiB

Notification Settings & Configuration System - Deep Dive

1. Settings UI

The notification settings live in the App Settings modal under the "Notifications" tab. The modal is opened via openAppSettings() at line 9234 of src/web/public/app.js, and the HTML structure is in src/web/public/index.html starting at line 974.

Settings Tab Layout (5 tabs total)

The App Settings modal has tabs: Display, Claude CLI, Models, Paths, Notifications. The Notifications tab contains:

Master Control Section

Setting Element ID Type Default (Desktop) Default (Mobile)
Enable Notifications appSettingsNotifEnabled checkbox true false
Browser Notifications appSettingsNotifBrowser checkbox true false
Browser Permission notifPermissionStatus status badge shows checkmark/X/? same

The Browser row includes an "Ask" button that calls requestPermission() and a status badge showing the current Notification.permission state.

There is also a hint: "For remote access, HTTPS is required. Start with: claudeman web --https"

Alerts Section

Setting Element ID Type Default
Audio Alerts appSettingsNotifAudio checkbox false
Idle Threshold appSettingsNotifStuckMins number input (1-120) 10 minutes

Notification Levels Section (3-column grid)

Level Element ID Default
Critical appSettingsNotifCritical checked
Warning appSettingsNotifWarning checked
Info appSettingsNotifInfo checked

These map to legacy muteCritical/muteWarning/muteInfo boolean fields (inverted: checked = not muted).

Per-Event Settings (4-column grid: Event / On / Browser / Sound)

Event Enabled Browser Audio
Permission prompts eventPermissionEnabled (default: on) eventPermissionBrowser (on) eventPermissionAudio (on)
Questions from Claude eventQuestionEnabled (on) eventQuestionBrowser (on) eventQuestionAudio (on)
Session idle eventIdleEnabled (on) eventIdleBrowser (on) eventIdleAudio (off)
Response complete eventStopEnabled (on) eventStopBrowser (off) eventStopAudio (off)
Respawn cycles eventRespawnEnabled (on) eventRespawnBrowser (off) eventRespawnAudio (off)
Task complete eventRalphEnabled (on) eventRalphBrowser (on) eventRalphAudio (on)
Subagent activity eventSubagentEnabled (off) eventSubagentBrowser (off) eventSubagentAudio (off)

2. Settings Persistence

Dual-layer persistence: localStorage + Server

Notification preferences are stored in two places simultaneously:

Layer 1: localStorage (primary, device-specific)

  • Storage key: claudeman-notification-prefs (desktop) or claudeman-notification-prefs-mobile (mobile)
  • Determined by NotificationManager.getStorageKey() at line 953, which calls MobileDetection.getDeviceType()
  • Device type is based on window.innerWidth: <430 = mobile, 430-768 = tablet, >=768 = desktop
  • Read in loadPreferences() (line 896), written in savePreferences() (line 958)

Layer 2: Server-side (~/.claudeman/settings.json)

  • On save, notification prefs are bundled with app settings: { ...settings, notificationPreferences: notifPrefsToSave } (line 9475)
  • Sent via PUT /api/settings to the Fastify server
  • Server does a shallow merge: const merged = { ...existing, ...settings } then writes to ~/.claudeman/settings.json (line 3098 of server.ts)
  • The notificationPreferences key sits at the top level of the settings JSON alongside app settings

Load priority

On startup, loadAppSettingsFromServer() (line 9787) fetches from server and:

  1. Extracts notificationPreferences from the response (line 9793)
  2. Only applies server notification prefs if localStorage has none (line 9816): if (!localNotifPrefs)
  3. This means localStorage always wins over server for notification prefs, making the server copy essentially a backup for new devices

Preferences schema (version 3)

{
  enabled: true,              // Master toggle
  browserNotifications: true, // Browser Notification API toggle
  audioAlerts: false,         // Web Audio API toggle
  stuckThresholdMs: 600000,   // 10 minutes default
  muteCritical: false,        // Legacy urgency muting
  muteWarning: false,
  muteInfo: false,
  eventTypes: {               // Per-event-type prefs (added in v3)
    permission_prompt:   { enabled: true, browser: true, audio: true },
    elicitation_dialog:  { enabled: true, browser: true, audio: true },
    idle_prompt:         { enabled: true, browser: true, audio: false },
    stop:                { enabled: true, browser: false, audio: false },
    session_error:       { enabled: true, browser: true, audio: false },
    respawn_cycle:       { enabled: true, browser: false, audio: false },
    token_milestone:     { enabled: true, browser: false, audio: false },
    ralph_complete:      { enabled: true, browser: true, audio: true },
    subagent_spawn:      { enabled: false, browser: false, audio: false },
    subagent_complete:   { enabled: false, browser: false, audio: false },
  },
  _version: 3,
}

Migration path

  • v1 -> v2: browserNotifications was changed from defaulting false to true (line 932)
  • v2 -> v3: Added eventTypes object (line 937)
  • Migration happens on load and writes back to localStorage immediately

App settings (separate from notification prefs)

App settings use a different device-specific localStorage key:

  • Desktop: claudeman-app-settings
  • Mobile: claudeman-app-settings-mobile
  • Determined by getSettingsStorageKey() at line 9562

3. Settings Application - How Toggles Take Effect

The notify() method decision tree (line 962)

When notify() is called:

  1. Master check: If !preferences.enabled, return immediately (no notification at all)
  2. Event type lookup: Look up preferences.eventTypes[category]
  3. If event type found:
    • If !eventPref.enabled, return (event type disabled)
    • shouldBrowserNotify = eventPref.browser && preferences.browserNotifications
    • shouldAudioAlert = eventPref.audio && preferences.audioAlerts
  4. If event type NOT found (fallback for unknown categories):
    • Check legacy muteCritical/muteWarning/muteInfo based on urgency
    • shouldBrowserNotify = global browser toggle AND (critical/warning OR tab hidden)
    • shouldAudioAlert = critical urgency AND global audio toggle

CRITICAL BUG: Category Key Mismatch

The eventTypes keys in the preferences schema do NOT match the category values used in actual notify() calls. This means per-event-type settings have no effect for most notification categories:

eventTypes Key Actual category Used in notify() Match?
permission_prompt hook-permission NO
elicitation_dialog hook-elicitation NO
idle_prompt hook-idle NO
stop hook-stop NO
session_error session-error NO
respawn_cycle respawn-blocked NO
token_milestone (not used anywhere) N/A
ralph_complete ralph-complete NO
subagent_spawn (used in subagent code) Needs verification
subagent_complete (used in subagent code) Needs verification

Impact: When notify() receives category: 'hook-permission', it looks up eventTypes['hook-permission'], finds nothing, and falls through to the legacy urgency-based logic. The per-event toggles in the settings UI are effectively non-functional for all hook-based and most other notifications.

The only categories that have a chance of matching are those used in subagent notification code, which would need separate verification.

Additional uncategorized notifications that always fall through to urgency-based logic:

  • session-crash
  • session-stuck
  • auto-accept
  • auto-clear
  • circuit-breaker
  • exit-gate
  • fix-plan

Settings application timing

Settings changes take effect immediately because:

  1. saveAppSettings() sets this.notificationManager.preferences = notifPrefsToSave directly (line 9459)
  2. Calls savePreferences() to persist to localStorage (line 9460)
  3. Calls applyHeaderVisibilitySettings() which hides/shows the notification bell icon (line 9647-9657)

Bell icon visibility

The notification bell icon in the header (btn-notifications) is hidden when preferences.enabled is false (line 9649-9651). If notifications are disabled while the drawer is open, the drawer is force-closed (line 9654-9657).

4. Default Values

Desktop defaults

Setting Default Source
enabled true loadPreferences() line 913
browserNotifications true line 914, negated isMobile
audioAlerts false line 915
stuckThresholdMs 600000 (10 min) STUCK_THRESHOLD_DEFAULT_MS constant, line 11
muteCritical/Warning/Info false (not muted) lines 918-920

Mobile defaults

Setting Default Source
enabled false line 913, negated !isMobile
browserNotifications false line 914
audioAlerts false line 915

CLAUDE.md documentation

CLAUDE.md states: "Key defaults: Most panels hidden (monitor, subagents shown), notifications enabled (audio disabled), subagent tracking on, Ralph tracking off."

This is accurate for desktop but does not mention the mobile-specific defaults where notifications are entirely disabled.

Constants (line 11-16 of app.js)

const STUCK_THRESHOLD_DEFAULT_MS = 600000;  // 10 minutes
const GROUPING_TIMEOUT_MS = 5000;           // 5 seconds - notification grouping window
const NOTIFICATION_LIST_CAP = 100;          // Max notifications in list
const TITLE_FLASH_INTERVAL_MS = 1500;       // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000;   // Rate limit for browser notifications
const AUTO_CLOSE_NOTIFICATION_MS = 8000;    // Auto-close browser notifications

5. Desktop vs Mobile Settings

Separate storage keys - YES

Desktop and mobile use completely separate localStorage keys:

  • Notification prefs: claudeman-notification-prefs vs claudeman-notification-prefs-mobile
  • App settings: claudeman-app-settings vs claudeman-app-settings-mobile

Different defaults - YES

Mobile defaults disable everything:

  • enabled: false (master toggle off)
  • browserNotifications: false
  • All tracking features disabled
  • All panels hidden

Desktop defaults enable notifications but keep audio off.

Server-side sync behavior

When loading from server, display settings (which include panel visibility, tracking toggles, etc.) are filtered out to avoid overwriting mobile-specific defaults (lines 9796-9806). Notification prefs from the server only apply if the device has no local prefs yet (line 9816).

Mobile CSS adjustments

mobile.css line 1050 makes the notification drawer full-width on mobile:

.notification-drawer {
  width: 100%;
  max-width: 100%;
  right: 0;
  border-radius: 0;
  padding-left: var(--safe-area-left);
  padding-right: var(--safe-area-right);
  padding-bottom: var(--safe-area-bottom);
}

Device type detection

MobileDetection.getDeviceType() (line 153) uses a simple width check:

  • < 430px = mobile
  • 430-768px = tablet (treated as desktop for settings keys)
  • >= 768px = desktop

Note: Only mobile vs non-mobile matters for settings keys. Tablet uses the desktop key.

6. Notification Permission Flow

Auto-request on first notification

When sendBrowserNotif() is called and Notification.permission === 'default' (never asked), the app auto-requests permission (line 1110-1118):

if (Notification.permission === 'default') {
  Notification.requestPermission().then(result => {
    if (result === 'granted') {
      this.sendBrowserNotif(title, body, tag, sessionId);  // Re-send
    }
  });
  return;
}

The first notification that would trigger a browser notification causes the permission prompt. If granted, the notification is re-sent.

Manual request via settings

The "Ask" button in the Notifications settings tab calls requestPermission() (line 1146):

async requestPermission() {
  if (typeof Notification === 'undefined') {
    this.app.showToast('Browser notifications not supported', 'warning');
    return;
  }
  const result = await Notification.requestPermission();
  // Update status badge
  if (result === 'granted') {
    this.preferences.browserNotifications = true;
    this.savePreferences();
    this.app.showToast('Notifications enabled', 'success');
  }
}

Side effect: Granting permission also auto-enables browserNotifications toggle (line 1155).

Permission status display

The settings UI shows the current permission state via a status badge:

  • Checkmark (granted) - green background
  • X (denied) - red background
  • ? (default/not asked) - neutral

HTTPS requirement

Browser notifications require HTTPS for remote access. The settings UI includes a hint: "For remote access, HTTPS is required. Start with: claudeman web --https". On localhost, HTTP works fine.

7. Audio Setting

Toggle: audioAlerts

The global audioAlerts toggle (default: false) controls whether audio can play at all. Per-event audio toggles further refine which events produce sound.

Audio generation

Audio is generated via the Web Audio API (line 1163-1181), NOT via audio file playback:

playAudioAlert() {
  const ctx = new AudioContext();
  const oscillator = ctx.createOscillator();
  const gain = ctx.createGain();
  oscillator.type = 'sine';
  oscillator.frequency.setValueAtTime(660, ctx.currentTime);   // 660 Hz (E5)
  gain.gain.setValueAtTime(0.15, ctx.currentTime);              // Low volume
  gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);  // 150ms fade
  oscillator.start(ctx.currentTime);
  oscillator.stop(ctx.currentTime + 0.15);
}

This produces a short 150ms sine wave beep at 660 Hz with a quick exponential fade.

Audio decision logic

For audio to play, ALL of these must be true:

  1. preferences.enabled = true (master toggle)
  2. preferences.audioAlerts = true (global audio toggle)
  3. For known event types: eventTypes[category].audio = true
  4. For unknown categories (fallback): urgency must be 'critical'

AudioContext lazy initialization

The AudioContext is created lazily on first use (line 1166-1168). This is important because browsers require a user gesture before creating an AudioContext. The first audio alert may silently fail if no user interaction has occurred.

Does it actually work?

Yes, given the prerequisites above are met. However, due to the category key mismatch (Section 3), per-event audio settings are mostly non-functional. The fallback logic means audio only plays for critical urgency notifications when the category is unrecognized (which is most of them).

8. Per-Session vs Global Settings

Global only

Notification preferences are strictly global. There is no per-session notification configuration.

  • The NotificationManager is a singleton on the ClaudemanApp instance (line 1404)
  • Preferences are loaded once from localStorage (line 878)
  • All sessions share the same notification rules

Per-session data in notifications

While settings are global, each notification carries sessionId and sessionName for:

  • Displaying which session triggered the notification (session chip in drawer items)
  • Click-to-switch: clicking a notification selects that session tab (line 1206-1208)
  • Browser notification onclick: focuses the window and selects the session (line 1134-1139)

Stuck detection is per-session

The idle/stuck detection timer is per-session (using this.idleTimers Map at line 2383), but the threshold comes from the global stuckThresholdMs setting. Respawn-enabled sessions are excluded from stuck detection (line 2381).

Case settings (separate system)

There is a separate "case settings" system (caseSettings_<caseName> in localStorage, lines 14513-14520) but it does not include notification preferences.

9. Notification Layers (4-layer system)

The notification system operates in 4 independent layers:

Layer Description Always On? Controlled By
1. Drawer In-app notification list (slide-out panel) Yes (if enabled) preferences.enabled
2. Tab Title Flashing title with unread count when tab unfocused Yes (if enabled) preferences.enabled + tab visibility
3. Browser OS-level Web Notifications Conditional preferences.browserNotifications + per-event browser + Notification.permission
4. Audio Web Audio API beep Conditional preferences.audioAlerts + per-event audio

Rate limiting

Browser notifications are rate-limited to 1 per 3 seconds (line 1124, BROWSER_NOTIF_RATE_LIMIT_MS).

Notification grouping

Same-category notifications for the same session within 5 seconds are grouped (count incremented) instead of creating new entries (line 986-996, GROUPING_TIMEOUT_MS).

Auto-close

Browser notifications auto-close after 8 seconds (line 1143, AUTO_CLOSE_NOTIFICATION_MS).

List cap

The in-app notification list is capped at 100 entries (line 1014, FIFO eviction).

10. Key Issues and Recommendations

Issue 1: Category Key Mismatch (HIGH PRIORITY)

The per-event settings in the UI are effectively non-functional because the category strings used in notify() calls (hook-permission, hook-idle, session-error, etc.) do not match the eventTypes keys in preferences (permission_prompt, idle_prompt, session_error, etc.).

Fix options:

  • A) Change all notify() category values to match the eventTypes keys
  • B) Change the eventTypes keys to match the categories used in notify() calls
  • C) Add a mapping function in notify() that normalizes categories to eventType keys

Option A is the cleanest since the eventTypes keys match the hook event names from Claude Code.

Issue 2: session_error hardcoded in save

In saveAppSettings() at line 9425-9429, session_error has its browser setting hardcoded to mirror permission_prompt's browser toggle and its audio is always false. There is no dedicated UI row for session errors. Similarly, token_milestone is hardcoded to enabled: true, browser: false, audio: false with no UI controls (lines 9435-9439).

Issue 3: Subagent spawn/complete share a single UI row

Both subagent_spawn and subagent_complete are controlled by a single "Subagent activity" row in the UI (lines 9445-9454). This is intentional but worth noting.

Issue 4: Mobile default discoverability

Mobile users have notifications disabled by default. There is no onboarding prompt or toast suggesting they enable notifications. A user on mobile would need to find Settings > Notifications and enable the master toggle.

Issue 5: Server-side prefs are write-only in practice

Because localStorage always wins over server prefs (unless localStorage is empty), the server copy of notification preferences is effectively a one-time bootstrap for new devices. Changes made on one device do not propagate to another device that already has local prefs.

11. File Reference

File Lines What
src/web/public/app.js 11-16 Constants (thresholds, caps, intervals)
src/web/public/app.js 120-180 MobileDetection utility
src/web/public/app.js 859-1253 NotificationManager class
src/web/public/app.js 896-950 loadPreferences() with migration
src/web/public/app.js 952-960 getStorageKey() and savePreferences()
src/web/public/app.js 962-1039 notify() decision logic
src/web/public/app.js 1106-1161 Browser notification + permission request
src/web/public/app.js 1163-1181 Audio alert via Web Audio API
src/web/public/app.js 9234-9338 openAppSettings() - populates notification UI
src/web/public/app.js 9362-9487 saveAppSettings() - saves all prefs
src/web/public/app.js 9562-9624 Device-aware settings storage keys
src/web/public/app.js 9626-9657 applyHeaderVisibilitySettings() - bell icon visibility
src/web/public/app.js 9787-9828 loadAppSettingsFromServer() - server sync
src/web/public/app.js 2335-2883 SSE event handlers that call notify()
src/web/public/index.html 63-66 Notification bell button + badge
src/web/public/index.html 974-1092 Notifications settings tab HTML
src/web/public/index.html 1395-1406 Notification drawer HTML
src/web/public/styles.css 2586-2748 Settings grid + event type grid CSS
src/web/public/styles.css 3978-4151 Notification badge, drawer, items CSS
src/web/public/mobile.css 1050-1058 Mobile notification drawer override
src/web/server.ts 3073-3129 GET/PUT /api/settings endpoints