chore: bump version to 0.1527

This commit is contained in:
arkon
2026-02-18 05:36:20 +01:00
parent 38c42fb7e5
commit 9d67a571ba
9 changed files with 2428 additions and 5 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ When user says "COM":
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
**Version**: 0.1526 (must match `package.json` for npm publish)
**Version**: 0.1527 (must match `package.json` for npm publish)
## Project Overview
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claudeman",
"version": "0.1526",
"version": "0.1527",
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+89
View File
@@ -0,0 +1,89 @@
# Notification System Audit - Summary Report
Date: 2026-02-17
## Scope
Full audit of the Claudeman notification system covering:
- Backend event pipeline (server.ts, hooks-config.ts, team-watcher.ts, subagent-watcher.ts)
- Frontend notification manager (app.js NotificationManager class, 4-layer architecture)
- Settings UI and persistence (localStorage + server backup)
- Blinking/visual alerts (title flash, CSS tab animations, badge pulse)
- Desktop vs mobile behavior
- Team agent integration
## Architecture
4-layer notification system in `NotificationManager` class:
1. **In-app drawer** - Sliding panel with badge on bell icon, grouping within 5s windows
2. **Tab title flash** - `setInterval` at 1500ms, warning emoji + unread count when tab hidden
3. **Browser Notification API** - OS-level notifications, rate-limited 1 per 3s, auto-close at 8s
4. **Audio alerts** - Web Audio API 660Hz sine wave beep, 150ms duration
Separate from NotificationManager: **CSS tab alert system** driven by `pendingHooks` state machine (red blink for action, yellow for idle).
## Bugs Found & Fixed
### CRITICAL
| # | Bug | Fix | Files |
|---|-----|-----|-------|
| 1 | **Category/EventType key mismatch** - Per-event notification settings (On/Browser/Sound checkboxes) were completely non-functional. `notify()` used categories like `hook-permission` but `eventTypes` keys were `permission_prompt`. Lookup always failed, falling through to legacy urgency-based logic. | Added `categoryToEventType` mapping object in `notify()` method | `app.js:966-984` |
| 2 | **Cache invalidation bug** - `broadcast()` checked `event === 'respawn:'` (exact match) but all respawn events are `respawn:stateChanged` etc. Respawn state changes never invalidated cached state. | Changed to `event.startsWith('respawn:')` | `server.ts:4648` |
### HIGH
| # | Bug | Fix | Files |
|---|-----|-----|-------|
| 3 | **`session_error.browser` setting** used wrong checkbox - saved from `eventPermissionBrowser` instead of its own value | Changed to preserve current pref value with fallback | `app.js:9516` |
| 4 | **Dead hook events** - `hook:teammate_idle` and `hook:task_completed` broadcast by backend but no frontend handlers | Added SSE listeners with appropriate notifications | `app.js:2924-2950` |
| 5 | **`respawn:error` silently dropped** - No frontend handler for respawn errors | Added SSE listener with critical notification | `app.js:2630-2642` |
| 6 | **Subagent notifications decorative** - Settings had toggles but no code dispatched notifications | Wired `notify()` calls into subagent:discovered and subagent:completed handlers | `app.js:2989,3107` |
### MEDIUM
| # | Bug | Fix | Files |
|---|-----|-----|-------|
| 7 | **AudioContext autoplay policy** - No `resume()` call, first audio silently fails on browsers with autoplay restrictions | Added `audioCtx.state === 'suspended'` check with `resume()` | `app.js:1191` |
## What Works Well (No Changes Needed)
- **Title blinking**: Properly guarded against interval stacking, comprehensive cleanup (onTabVisible, handleInit, markAllRead, clearAll)
- **CSS tab alerts**: Pure CSS infinite animations, zero JS timer overhead, correctly wired to pendingHooks state machine
- **Notification grouping**: 5s sliding window dedup prevents spam, triple-layered stacking protection
- **Mobile/desktop separation**: Separate localStorage keys (`-mobile` suffix), separate defaults (mobile OFF by default)
- **Memory cleanup**: Thorough in handleInit (SSE reconnect), removeSession, all timer paths
- **Browser notification rate limiting**: Global 3s rate limit with auto-close at 8s
- **Visibility API usage**: Correct modern approach (visibilitychange + pageshow for iOS bfcache, no focus/blur)
- **Notification drawer UX**: Urgency-colored borders, relative timestamps, click-to-switch-session, slide-in animations
## Detailed Reports
| Report | File |
|--------|------|
| Backend analysis | `reports/notification-backend.md` |
| Frontend analysis | `reports/notification-frontend.md` |
| Settings flow | `reports/notification-settings.md` |
| Blinking/visual alerts | `reports/notification-blinking.md` |
## Changes Summary
### `src/web/public/app.js`
- Added `categoryToEventType` mapping in `notify()` (lines 966-984)
- Added `hook:teammate_idle` SSE handler (lines 2924-2936)
- Added `hook:task_completed` SSE handler (lines 2938-2950)
- Added `respawn:error` SSE handler (lines 2630-2642)
- Wired `subagent:discovered` → `notify()` call (line 2989)
- Wired `subagent:completed` → `notify()` call (line 3107)
- Fixed `session_error.browser` setting save (line 9516)
- Added `AudioContext.resume()` for autoplay policy (line 1191)
### `src/web/server.ts`
- Fixed cache invalidation: `event === 'respawn:'` → `event.startsWith('respawn:')` (line 4648)
## Known Limitations (Not Addressed)
- **TeamWatcher is unintegrated** - The class exists in `team-watcher.ts` but is never imported in `server.ts`. Team events (member join/leave, task updates, inbox messages) are not broadcast via SSE. This is a larger feature gap, not a notification bug.
- **Browser notification rate limit is global** - A rapid succession of different event types only shows the first browser notification within 3s. This is by design for spam prevention.
- **No dynamic favicon** - Tab favicon is static; could be enhanced to show red/orange badge for unread notifications.
- **Per-session notification settings** - All notification prefs are global, no per-session customization.
+489
View File
@@ -0,0 +1,489 @@
# Claudeman Notification System - Backend Research Report
Date: 2026-02-17
## Executive Summary
The Claudeman notification system is a **multi-layer, event-driven pipeline** that flows from backend event emitters, through SSE broadcasts, to a frontend `NotificationManager` class. The backend itself has no concept of "notifications" -- it broadcasts structured SSE events, and the frontend decides which events warrant user notification (browser notifications, audio alerts, tab title flashing, in-app notification drawer, tab alert badges).
The system handles ~25 distinct notification-triggering SSE events across 5 categories: hook events, session lifecycle, respawn state machine, Ralph Loop, and UI actions.
---
## 1. Server-Side Notification Logic (`src/web/server.ts`)
### 1.1 The `broadcast()` Method (Line 4646)
All real-time client communication flows through a single private method:
```typescript
private broadcast(event: string, data: unknown): void {
// Invalidate caches on state-changing broadcasts
if (event.startsWith('session:') || event === 'respawn:') {
this.cachedLightState = null;
this.cachedSessionsList = null;
}
let message: string;
try {
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
} catch (err) {
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
return;
}
for (const client of this.sseClients) {
this.sendSSEPreformatted(client, message);
}
}
```
Key characteristics:
- Serializes JSON once, then writes to all connected SSE clients
- Has backpressure handling (`sendSSEPreformatted` tracks `backpressuredClients`)
- Silently drops events on serialization failure (circular refs)
- Cache invalidation is broad -- any `session:*` event clears caches
### 1.2 SSE Client Management (Line 547-564)
Clients connect at `GET /api/events`:
- Immediately sent `init` event with lightweight state (no terminal buffers)
- Tracked in `Set<FastifyReply>` (`this.sseClients`)
- Dead client cleanup runs every 30s (`SSE_HEALTH_CHECK_INTERVAL`)
- Max 100 SSE clients (`MAX_SSE_CLIENTS` from `map-limits.ts`)
### 1.3 Complete Catalog of Notification-Relevant Broadcasts
The server emits ~70 distinct SSE event types. Those that trigger frontend notifications are:
| SSE Event | Server Location | Frontend Notification? | Category |
|-----------|----------------|----------------------|----------|
| `hook:idle_prompt` | Line 3431 | Yes - warning | Hook |
| `hook:permission_prompt` | Line 3431 | Yes - critical | Hook |
| `hook:elicitation_dialog` | Line 3431 | Yes - critical | Hook |
| `hook:stop` | Line 3431 | Yes - info | Hook |
| `hook:teammate_idle` | Line 3431 | **NO** (no frontend handler) | Hook |
| `hook:task_completed` | Line 3431 | **NO** (no frontend handler) | Hook |
| `session:error` | Line 3921 | Yes - critical | Session |
| `session:exit` | Line 3939 | Yes - critical (non-zero code) | Session |
| `session:idle` | Line 3976 | Yes - warning (after stuck threshold) | Session |
| `session:autoClear` | Line 4009 | Yes - info | Session |
| `session:ralphCompletionDetected` | Line 4044 | Yes - warning | Ralph |
| `session:circuitBreakerUpdate` | Line 4066 | Yes - critical (OPEN state) | Ralph |
| `session:exitGateMet` | Line 4080 | Yes - warning | Ralph |
| `respawn:blocked` | Line 4158 | Yes - critical | Respawn |
| `respawn:autoAcceptSent` | Line 4177 | Yes - info | Respawn |
Events that update UI but do NOT trigger notifications:
- `session:working` (line 3966) -- clears stuck timer and tab alerts
- `session:completion` (line 3928) -- updates cost display
- `session:updated` (many locations) -- tab/panel state updates
- `respawn:stateChanged` (line 4143) -- banner update only
- `respawn:cycleStarted` (line 4150) -- cycle counter update
- `subagent:discovered` (line 458) -- auto-opens window, no notification
- `subagent:completed` (line 464) -- no notification
- `image:detected` (line 504) -- auto-opens popup, no notification
- `transcript:*` events (lines 3575-3591) -- no frontend handlers for notifications
### 1.4 Terminal Data Batching
Terminal and output data use separate batching pipelines that bypass `broadcast()`:
- `batchTerminalData()` (line 4668) -- adaptive 16-50ms batching for PTY output
- `batchOutputData()` (line 4741) -- 50ms batching for parsed text output
- Both flush through `broadcast('session:terminal', ...)` and `broadcast('session:output', ...)`
---
## 2. Hook Events System (`src/hooks-config.ts`)
### 2.1 Hook Configuration Generator (Lines 24-67)
The `generateHooksConfig()` function creates `.claude/settings.local.json` entries that make Claude Code POST to Claudeman when hooks fire:
```typescript
const curlCmd = (event: HookEventType) =>
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
`curl -s -X POST "$CLAUDEMAN_API_URL/api/hook-event" ` +
`-H 'Content-Type: application/json' ` +
`-d "{\\"event\\":\\"${event}\\",\\"sessionId\\":\\"$CLAUDEMAN_SESSION_ID\\",\\"data\\":$HOOK_DATA}" ` +
`2>/dev/null || true`;
```
Six hook types are configured:
| Hook Category | Matcher | Event Type |
|--------------|---------|------------|
| Notification | `idle_prompt` | `idle_prompt` |
| Notification | `permission_prompt` | `permission_prompt` |
| Notification | `elicitation_dialog` | `elicitation_dialog` |
| Stop | (all stops) | `stop` |
| TeammateIdle | (all) | `teammate_idle` |
| TaskCompleted | (all) | `task_completed` |
### 2.2 Hook Event Flow
```
Claude Code Hook Fires
--> Shell command executes (curl)
--> POST /api/hook-event with {event, sessionId, data}
--> Zod validation (HookEventSchema, schemas.ts:79)
--> Session lookup (must exist)
--> Respawn controller signaling (elicitation/stop/idle_prompt only)
--> Transcript watcher setup (if data.transcript_path present)
--> Data sanitization (sanitizeHookData, server.ts:178)
--> SSE broadcast as `hook:{eventType}`
--> Run summary tracking (recordHookEvent)
```
### 2.3 Data Sanitization (Lines 178-211)
The `sanitizeHookData()` function limits what gets broadcast:
- Allowed keys: `hook_event_name`, `tool_name`, `tool_input`, `session_id`, `cwd`, `permission_mode`, `stop_hook_active`, `transcript_path`
- `tool_input` objects are summarized (command truncated to 500 chars, only summary fields forwarded)
- Total data size capped at `MAX_HOOK_DATA_SIZE` (line 135)
### 2.4 Environment Variables (Lines 70-101)
Two env vars are set per case directory via `updateCaseEnvVars()`:
- `CLAUDEMAN_API_URL` -- server URL (e.g., `http://localhost:3000`)
- `CLAUDEMAN_SESSION_ID` -- session identifier
These are resolved at runtime by the shell, so the hook config is static per case.
### 2.5 Hook Config Writing (Lines 107-129)
`writeHooksConfig()` merges hook config into existing `.claude/settings.local.json`, preserving other keys. Called during case creation (server.ts lines 2197, 2441).
---
## 3. Hook-to-Respawn Controller Integration (`src/web/server.ts`, Lines 3406-3418)
Three of the six hook types signal the respawn controller:
| Hook Event | Controller Method | Effect |
|-----------|------------------|--------|
| `elicitation_dialog` | `signalElicitation()` | Blocks auto-accept (prevents Enter press on question prompts) |
| `stop` | `signalStopHook()` | Definitive idle signal; starts short confirmation timer, skips AI check |
| `idle_prompt` | `signalIdlePrompt()` | Definitive 60s+ idle signal; cancels all detection timers, directly confirms idle |
**Not handled by respawn controller**: `teammate_idle`, `task_completed`, `permission_prompt`. The first two are team-related hooks that have no backend integration beyond being broadcast via SSE (and the frontend has no handlers either -- see Section 7).
---
## 4. Respawn Controller Events (`src/respawn-controller.ts`)
The `RespawnController` extends `EventEmitter` and emits many events that the server wires to SSE broadcasts (server.ts lines 4143-4271).
### 4.1 Notification-Triggering Events
| Controller Event | SSE Broadcast | Frontend Notification? |
|-----------------|---------------|----------------------|
| `respawnBlocked` | `respawn:blocked` | Yes - critical (reason: circuit_breaker, exit_signal, status_blocked, session_error, session_stopped, no_pty) |
| `autoAcceptSent` | `respawn:autoAcceptSent` | Yes - info ("Plan Accepted") |
### 4.2 UI-Only Events (No Notification)
| Controller Event | SSE Broadcast | Frontend Action |
|-----------------|---------------|----------------|
| `stateChanged` | `respawn:stateChanged` | Banner state label update |
| `respawnCycleStarted` | `respawn:cycleStarted` | Cycle counter update |
| `respawnCycleCompleted` | `respawn:cycleCompleted` | (no explicit handler) |
| `detectionUpdate` | `respawn:detectionUpdate` | Detection display update |
| `stepSent` | `respawn:stepSent` | (no-op handler) |
| `stepCompleted` | `respawn:stepCompleted` | (no handler) |
| `aiCheckStarted/Completed/Failed` | `respawn:aiCheck*` | (no-op handlers) |
| `aiCheckCooldown` | `respawn:aiCheckCooldown` | (no-op handler) |
| `planCheckStarted/Completed/Failed` | `respawn:planCheck*` | (no handlers) |
| `timerStarted/Cancelled/Completed` | `respawn:timer*` | Countdown timer UI |
| `actionLog` | `respawn:actionLog` | Action log display |
| `log` | `respawn:log` | Debug log |
| `error` | `respawn:error` | (no handler) |
### 4.3 Respawn-to-Run-Summary Integration
The server wires respawn state changes into the run summary tracker (server.ts line 4143):
```typescript
this.broadcast('respawn:stateChanged', { sessionId, state, prevState });
// Also records in run summary:
const summaryTracker = this.runSummaryTrackers.get(sessionId);
if (summaryTracker) summaryTracker.recordStateChange(state);
```
---
## 5. Team / Agent Notification Flow
### 5.1 SubagentWatcher (`src/subagent-watcher.ts`)
The SubagentWatcher emits events that the server wires to SSE broadcasts (server.ts lines 458-477):
```typescript
discovered: (info) => this.broadcast('subagent:discovered', info),
updated: (info) => this.broadcast('subagent:updated', info),
toolCall: (data) => this.broadcast('subagent:tool_call', data),
toolResult: (data) => this.broadcast('subagent:tool_result', data),
progress: (data) => this.broadcast('subagent:progress', data),
message: (data) => this.broadcast('subagent:message', data),
completed: (info) => this.broadcast('subagent:completed', info),
```
**None of these trigger frontend notifications.** The frontend auto-opens subagent windows on `subagent:discovered` (app.js line 2887), but the `NotificationManager` is not invoked.
### 5.2 TeamWatcher (`src/team-watcher.ts`)
**CRITICAL FINDING: TeamWatcher is completely unintegrated with the server.**
- The class is defined in `src/team-watcher.ts` and extends `EventEmitter`
- It emits events: `teamCreated`, `teamUpdated`, `teamRemoved`, `taskUpdated`, `inboxMessage`
- It is **never imported** in `server.ts` or any other file
- The `hasActiveTeammates()` method (designed for idle detection) is never called
- There is no SSE broadcast for any team event
- The frontend has no SSE listeners for `team:*` events
The frontend does have team-related UI code (teammate badges, team task panel, teammate terminal windows at app.js lines 13057-13501), but this appears to be driven by polling APIs or subagent watcher integration rather than dedicated SSE events.
### 5.3 Team Hook Events (teammate_idle, task_completed)
These are accepted by the API endpoint (validated by `HookEventSchema`) and broadcast as `hook:teammate_idle` and `hook:task_completed` SSE events, but:
- The **respawn controller ignores them** (only handles `elicitation_dialog`, `stop`, `idle_prompt`)
- The **frontend has no SSE listeners** for `hook:teammate_idle` or `hook:task_completed`
- They are recorded in the **run summary** via `recordHookEvent()` but otherwise silently dropped
---
## 6. Run Summary (`src/run-summary.ts`)
### 6.1 Overview
The RunSummaryTracker records a timeline of events per session for "what happened while I was away" views. It is a **recording system**, not a notification system -- it does not trigger any notifications itself.
### 6.2 Event Types Tracked
From `types.ts` (lines 1359-1375):
```typescript
type RunSummaryEventType =
| 'session_started' | 'session_stopped'
| 'respawn_cycle_started' | 'respawn_cycle_completed' | 'respawn_state_change'
| 'error' | 'warning'
| 'token_milestone' | 'auto_compact' | 'auto_clear'
| 'idle_detected' | 'working_detected'
| 'ralph_completion' | 'ai_check_result'
| 'hook_event' | 'state_stuck';
```
### 6.3 Integration Points with Notification System
The run summary and notification system are **parallel but independent**:
- Both consume the same backend events (hooks, idle, working, errors)
- Run summary records for historical review; notifications alert in real-time
- There is no feedback loop between them (e.g., run summary does not trigger delayed notifications)
### 6.4 State Stuck Detection (Lines 402-421)
The RunSummaryTracker has its own state-stuck detection (10-minute threshold, checked every 60s) that records `state_stuck` events. This is separate from the frontend's idle-stuck notification (which uses `stuckThresholdMs`, default 10 minutes, triggered by `session:idle` events).
**Potential overlap**: Both the run summary and the frontend independently detect "stuck" states. The run summary records it; the frontend notifies. They could diverge if their thresholds or detection logic differ.
---
## 7. Types (`src/types.ts`)
### 7.1 Hook Event Types (Line 749)
```typescript
type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog'
| 'stop' | 'teammate_idle' | 'task_completed';
```
### 7.2 Hook Event Request (Lines 754-761)
```typescript
interface HookEventRequest {
event: HookEventType;
sessionId: string;
data?: Record<string, unknown>;
}
```
### 7.3 Run Summary Types (Lines 1355-1470)
- `RunSummaryEventType` -- 16 event types
- `RunSummaryEventSeverity` -- `'info' | 'warning' | 'error' | 'success'`
- `RunSummaryEvent` -- `{id, timestamp, type, severity, title, details?, metadata?}`
- `RunSummaryStats` -- aggregated statistics (cycles, tokens, time active/idle, etc.)
- `RunSummary` -- complete summary `{sessionId, sessionName, startedAt, lastUpdatedAt, events, stats}`
### 7.4 Missing Notification Types
There is **no dedicated notification type** in the backend. The backend has no `Notification` interface or notification-specific data structures. All notification logic lives in the frontend `NotificationManager` class (`app.js` lines 859-1230).
---
## 8. Bugs and Issues
### 8.1 TeamWatcher Not Integrated (Critical Gap)
**File**: `src/team-watcher.ts` (entire file)
**Issue**: TeamWatcher is defined but never instantiated or imported in the server. The `hasActiveTeammates()` method was designed for team-aware idle detection (preventing premature respawn when teammates are still working), but it is never called.
**Impact**:
- The respawn controller has no awareness of active teammates
- Team events (member join/leave, task updates, inbox messages) are never broadcast to clients
- The frontend's team UI must rely on other mechanisms (likely API polling or subagent watcher)
### 8.2 `hook:teammate_idle` and `hook:task_completed` Are Dead Events
**File**: `src/web/server.ts` (line 3431), `src/web/public/app.js`
**Issue**: These hook events are accepted by the API, validated, and broadcast via SSE, but:
- The respawn controller does not handle them (line 3408-3418 -- only checks elicitation, stop, idle_prompt)
- The frontend has no `addListener('hook:teammate_idle', ...)` or `addListener('hook:task_completed', ...)`
- They are recorded in the run summary but otherwise have zero effect
**Impact**: When Claude Code fires TeammateIdle or TaskCompleted hooks, the data is broadcast into the void. No notification, no UI update, no respawn logic.
### 8.3 `respawn:error` Has No Frontend Handler
**File**: `src/web/server.ts` (line 4236), `src/web/public/app.js`
**Issue**: The server broadcasts `respawn:error` events, but the frontend has no listener for this event. Respawn errors are silently ignored on the client side.
**Impact**: If the respawn controller encounters an error (e.g., PTY write failure), the user gets no notification.
### 8.4 `respawn:cycleCompleted` Has No Frontend Handler
**File**: `src/web/server.ts` (line 4154)
**Issue**: `respawn:cycleCompleted` is broadcast but has no frontend listener. The cycle count is updated via `respawn:cycleStarted`, but completion is not acknowledged.
### 8.5 `respawn:stepCompleted` Has No Frontend Handler
**File**: `src/web/server.ts` (line 4169)
**Issue**: Broadcast but not listened to in the frontend.
### 8.6 Image Detection Lacks Notification
**File**: `src/web/public/app.js` (line 3039)
**Issue**: `image:detected` events auto-open a popup window but do not trigger the `NotificationManager`. If the user is on another tab, they get no notification that a screenshot or generated image was detected.
### 8.7 Subagent Discovery/Completion Lacks Notification (By Design?)
**File**: `src/web/public/app.js` (lines 2887, 2912+)
**Issue**: Subagent events auto-open windows but do not trigger notifications. The notification preferences have `subagent_spawn` and `subagent_complete` event types defined (app.js line 906-907) with defaults of `enabled: false`, but no code actually calls `notificationManager.notify()` for these events.
**Impact**: The notification preferences UI shows toggle switches for subagent events, but they do nothing -- the notifications are never triggered regardless of the setting.
### 8.8 `session:autoCompact` Has No Notification
**File**: `src/web/public/app.js`
**Issue**: `session:autoClear` triggers a notification (app.js line 2623), but `session:autoCompact` does not. Both are significant session events (context reset vs. context compaction). The `autoCompact` SSE event is handled (line 2633 area) but only shows a toast if it's the active session, with no `NotificationManager.notify()` call.
**Note**: After reviewing the code more carefully, `session:autoCompact` is not in the file at the lines I checked. It may be handled elsewhere or may genuinely be missing a notification.
### 8.9 Cache Invalidation Pattern Is Overly Broad
**File**: `src/web/server.ts` (line 4648)
**Issue**: `if (event.startsWith('session:') || event === 'respawn:')` -- the `respawn:` check uses exact equality, but all respawn events are formatted as `respawn:stateChanged`, `respawn:blocked`, etc. The check `event === 'respawn:'` will never match. This means respawn events do NOT invalidate the cached state.
```typescript
if (event.startsWith('session:') || event === 'respawn:') {
```
Should likely be:
```typescript
if (event.startsWith('session:') || event.startsWith('respawn:')) {
```
**Impact**: After respawn state changes, the cached `getLightSessionsState()` may serve stale data until a `session:*` event triggers invalidation. Since respawn status is included in session state (via `getSessionStateWithRespawn()`), subsequent API calls to `GET /api/sessions` or SSE reconnects could show outdated respawn info.
---
## 9. Missing Notification Paths
### 9.1 Events That SHOULD Notify But Don't
| Event | Current Behavior | Suggested Notification |
|-------|-----------------|----------------------|
| `hook:teammate_idle` | Broadcast, no handler | Warning: "Teammate idle, may need new task" |
| `hook:task_completed` | Broadcast, no handler | Info: "Team task completed" |
| `respawn:error` | Broadcast, no handler | Critical: "Respawn error: {message}" |
| `image:detected` | Auto-opens popup | Info (when tab unfocused): "Screenshot captured" |
| `subagent:discovered` | Auto-opens window | Info (if enabled): "New subagent spawned: {description}" |
| `subagent:completed` | Updates panel | Info (if enabled): "Subagent completed: {description}" |
| `respawn:cycleCompleted` | Broadcast, no handler | Info: "Respawn cycle #{n} completed" |
### 9.2 Team Events That Need SSE Broadcasting
Since TeamWatcher is not integrated, these events never reach clients:
- Team created/updated/removed
- Task status changes
- New inbox messages
- Teammate count changes
---
## 10. Architecture Diagram
```
Claude Code Hooks
|
curl POST /api/hook-event
|
+-------------------+
| server.ts |
| (Fastify) |
+-------------------+
| | |
Respawn | Broadcast | RunSummary
Signal | via SSE | Record
| | |
+---------+ +---+---+ +--------+
|RespawnCtrl| |SSE Bus| |Summary |
| emits | | | |Tracker |
| events | +---+---+ +--------+
+---------+ |
| |
server.ts Connected
wires to Browsers
SSE via |
broadcast() |
+----+-----+
| app.js |
| Frontend |
+----------+
|
+----------+-----------+
| |
NotificationManager Tab Alert System
(4 layers) (pendingHooks)
1. In-app drawer - action (critical)
2. Tab title flash - idle (warning)
3. Browser Notification API
4. Audio alerts
```
---
## 11. Summary of Key Files and Line References
| File | Lines | Purpose |
|------|-------|---------|
| `src/web/server.ts` | 4646-4663 | `broadcast()` method |
| `src/web/server.ts` | 547-564 | SSE client setup |
| `src/web/server.ts` | 3396-3440 | Hook event endpoint |
| `src/web/server.ts` | 178-211 | `sanitizeHookData()` |
| `src/web/server.ts` | 3900-4103 | Session event wiring |
| `src/web/server.ts` | 4143-4271 | Respawn event wiring |
| `src/web/server.ts` | 458-477 | Subagent event wiring |
| `src/hooks-config.ts` | 24-67 | Hook config generator |
| `src/hooks-config.ts` | 107-129 | Config file writer |
| `src/types.ts` | 749 | `HookEventType` |
| `src/types.ts` | 754-761 | `HookEventRequest` |
| `src/types.ts` | 1359-1375 | `RunSummaryEventType` |
| `src/team-watcher.ts` | 27-338 | TeamWatcher (unintegrated) |
| `src/subagent-watcher.ts` | 217-1346 | SubagentWatcher events |
| `src/respawn-controller.ts` | 467-476 | Event documentation |
| `src/respawn-controller.ts` | 2469-2551 | Hook signal methods |
| `src/respawn-controller.ts` | 2750-2842 | Respawn blocking logic |
| `src/run-summary.ts` | 56-447 | RunSummaryTracker class |
| `src/web/schemas.ts` | 79-83 | HookEventSchema |
| `src/web/public/app.js` | 860-1038 | NotificationManager class |
| `src/web/public/app.js` | 1445-1477 | Pending hooks state machine |
| `src/web/public/app.js` | 2813-2883 | Hook event SSE handlers |
| `src/web/public/app.js` | 2443-2613 | Respawn event SSE handlers |
| `src/web/public/app.js` | 2335-2417 | Session lifecycle SSE handlers |
+549
View File
@@ -0,0 +1,549 @@
# Notification Blinking & Visual Alert System - Deep Dive
## Overview
Claudeman implements a **4-layer notification system** managed by the `NotificationManager` class (app.js lines 860-1265). The layers are:
1. **In-app notification drawer** (Layer 1) - badge + list UI
2. **Document title flashing** (Layer 2) - tab title blinks when hidden
3. **Browser Web Notifications** (Layer 3) - OS-level popups
4. **Audio alerts** (Layer 4) - Web Audio API beeps
In addition, there are **CSS-based tab alert animations** that are independent of NotificationManager and driven by the pending hooks state machine.
---
## 1. Document Title Blinking
### Location
`src/web/public/app.js` lines 1082-1104
### Mechanism
The title blink uses `setInterval` to toggle `document.title` between two states every 1500ms:
```js
// Constants (line 14)
const TITLE_FLASH_INTERVAL_MS = 1500;
// updateTabTitle() - line 1082
updateTabTitle() {
if (this.unreadCount > 0 && !this.isTabVisible) {
if (!this.titleFlashInterval) {
this.titleFlashInterval = setInterval(() => {
this.titleFlashState = !this.titleFlashState;
document.title = this.titleFlashState
? `\u26A0\uFE0F (${this.unreadCount}) Claudeman`
: this.originalTitle;
}, TITLE_FLASH_INTERVAL_MS);
// Set immediately
document.title = `\u26A0\uFE0F (${this.unreadCount}) Claudeman`;
}
}
}
```
The title alternates between:
- Warning emoji + unread count: `"(3) Claudeman"`
- Original title: `"Claudeman"`
### What Triggers It
Title flashing starts when `notify()` is called **while the tab is not visible** (`!this.isTabVisible`). The check is at lines 1026-1028:
```js
if (!this.isTabVisible) {
this.updateTabTitle();
}
```
Every event that calls `notificationManager.notify()` can trigger title blinking. This includes:
- `session:error` - Session errors (critical)
- `session:exit` - Unexpected exits with non-zero codes (critical)
- `session:idle` - Stuck detection after threshold (warning)
- `hook:idle_prompt` - Claude waiting for input (warning)
- `hook:permission_prompt` - Tool approval needed (critical)
- `hook:elicitation_dialog` - Claude asking a question (critical)
- `hook:stop` - Response complete (info)
- `respawn:blocked` - Respawn blocked (critical)
- `respawn:autoAcceptSent` - Plan accepted (info)
- `session:autoClear` - Auto-cleared context (info)
- `session:ralphCompletionDetected` - Loop complete (warning)
- `session:circuitBreakerUpdate` (when OPEN) - Critical
- `session:exitGateMet` - Exit gate met (warning)
- Various Ralph/fix-plan operations
### What Stops It
Title flashing stops via `stopTitleFlash()` (lines 1097-1104):
```js
stopTitleFlash() {
if (this.titleFlashInterval) {
clearInterval(this.titleFlashInterval);
this.titleFlashInterval = null;
this.titleFlashState = false;
document.title = this.originalTitle;
}
}
```
Called from:
1. **`onTabVisible()`** (line 1242) - When tab becomes visible again
2. **`markAllRead()`** (line 1218) - When user marks all notifications read
3. **`clearAll()`** (line 1226) - When user clears all notifications
4. **`handleInit()` cleanup** (lines 3294-3298) - On SSE reconnect
### Interval Safety
The interval guard (`if (!this.titleFlashInterval)`) at line 1084 prevents stacking - only one interval can exist at a time. New notifications while blinking update the `unreadCount` displayed but don't create additional intervals. This is **correct and safe**.
### Memory Leak Risk: LOW
The interval is properly cleaned up in:
- `onTabVisible()` - on every tab return
- `handleInit()` - on SSE reconnect (lines 3294-3298)
- `markAllRead()` and `clearAll()` - user actions
The `handleInit()` cleanup is particularly important because SSE reconnects reset all state. The explicit cleanup at lines 3294-3298 prevents orphaned intervals:
```js
// Clear notification manager title flash interval to prevent memory leak
if (this.notificationManager?.titleFlashInterval) {
clearInterval(this.notificationManager.titleFlashInterval);
this.notificationManager.titleFlashInterval = null;
}
```
---
## 2. Favicon Changes
### Current State: NO dynamic favicon changes
The favicon is defined as an **inline SVG data URI** in `index.html` line 9:
```html
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,...">
```
It shows a lightning bolt icon on a dark background. The favicon is **static** and never changes programmatically.
Browser notifications reference `/favicon.ico` as their icon (line 1130):
```js
const notif = new Notification(`Claudeman: ${title}`, {
body,
tag,
icon: '/favicon.ico',
silent: true,
});
```
This is for the OS notification popup icon, not the browser tab favicon. There is no code that manipulates `link[rel="icon"]` or swaps the favicon for attention.
---
## 3. CSS Animations for Attention
### 3.1 Tab Alert Animations (styles.css lines 328-345)
Two CSS animation classes are applied to session tabs:
```css
/* Red blinking tab - for action-required hooks */
.session-tab.tab-alert-action {
animation: tab-blink-red 2.5s ease-in-out infinite;
}
/* Yellow blinking tab - for idle hooks */
.session-tab.tab-alert-idle {
animation: tab-blink-yellow 3.5s ease-in-out infinite;
}
@keyframes tab-blink-red {
0%, 100% { background: transparent; border-color: transparent; }
50% { background: rgba(239, 68, 68, 0.12); border-color: var(--red); }
}
@keyframes tab-blink-yellow {
0%, 100% { background: transparent; border-color: transparent; }
50% { background: rgba(234, 179, 8, 0.1); border-color: var(--yellow); }
}
```
- **Red blink (2.5s cycle)**: Permission prompts, elicitation dialogs - requires user action
- **Yellow blink (3.5s cycle)**: Idle prompt - Claude waiting for input
These are **CSS-only infinite animations** with no JavaScript timer overhead. They are performant and have zero memory leak risk.
### 3.2 Tab Switch Glow (styles.css lines 241-251)
```css
.session-tab.tab-glow {
animation: tab-glow 0.35s ease-out forwards;
}
```
A one-shot green glow burst when switching tabs. Applied in `selectSession()` (app.js line 3985) and cleaned up via `animationend` event with `{ once: true }`:
```js
activeTab.classList.add('tab-glow');
activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true });
```
This is **safe** - `{ once: true }` auto-removes the listener.
### 3.3 Notification Badge Pulse (styles.css lines 3978-4000)
```css
.notification-badge {
animation: notif-badge-pulse 2s ease-in-out infinite;
}
@keyframes notif-badge-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
```
The red notification count badge in the header bell icon pulses continuously when visible. Pure CSS, no memory concerns.
### 3.4 Status Dot Pulse (styles.css lines 261-265, 347-350)
```css
.session-tab .tab-status.busy {
background: var(--green);
animation: pulse 1.5s infinite;
will-change: opacity;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
```
The green status dot pulses when a session is busy/working. Pure CSS.
### 3.5 Connection Status Animations (styles.css lines 405-413)
```css
.connection-dot.warning {
animation: connection-pulse 1.5s ease-in-out infinite;
}
.connection-dot.error {
animation: connection-pulse 0.8s ease-in-out infinite;
}
```
Connection indicator pulses differently for warning vs error states.
### 3.6 Other CSS Animations
| Animation | Location | Purpose |
|-----------|----------|---------|
| `respawn-blocked-pulse` | styles.css:798 | Respawn blocked indicator |
| `pulse-hook` | styles.css:899 | Hook event indicator |
| `ralph-pulse` | styles.css:1013 | Ralph tracker active state |
| `circuit-breaker-pulse` | styles.css:1057 | Circuit breaker warning |
| `wizard-pulse` | styles.css:5541 | Ralph wizard active indicator |
| `plan-subagent-pulse` | styles.css:5558 | Plan subagent active |
| `notif-slide-in` | styles.css:4088 | Notification drawer slide |
### 3.7 Mobile-Specific Animations (mobile.css)
Mobile CSS is minimal for animations:
- `slideUp` (line 1091) - Mobile toolbar slide animation
- `caseModalSlideUp` (line 1204) - Case modal bottom-sheet animation
No mobile-specific blinking or notification animations exist.
---
## 4. Tab Visibility API
### Implementation (app.js lines 881-893)
The `NotificationManager` constructor sets up two visibility listeners:
```js
// Standard visibility change
document.addEventListener('visibilitychange', () => {
this.isTabVisible = !document.hidden;
if (this.isTabVisible) {
this.onTabVisible();
}
});
// iOS Safari: pageshow fires on back-forward cache restore (bfcache)
window.addEventListener('pageshow', (e) => {
if (e.persisted) {
this.isTabVisible = true;
this.onTabVisible();
}
});
```
### Behavior When Tab Hidden
- `isTabVisible` set to `false`
- New notifications trigger `updateTabTitle()` which starts the title blink interval
- Browser notifications are sent (subject to per-event preferences)
### Behavior When Tab Becomes Visible (`onTabVisible()` - line 1241)
```js
onTabVisible() {
this.stopTitleFlash(); // Stop title blinking
if (this.isDrawerOpen) {
this.markAllRead(); // Mark all read if drawer is open
}
// Re-fit terminal dimensions
if (this.app?.fitAddon && this.app?.activeSessionId) {
this.app.fitAddon.fit();
this.app.sendResize(this.app.activeSessionId);
}
}
```
Key detail: Title flash always stops when tab becomes visible, but **unread count is NOT reset** unless the notification drawer is open. This means the badge count persists until the user interacts with it.
---
## 5. Focus/Blur Handling
### No window focus/blur listeners
Claudeman does **not** use `window.addEventListener('focus')` or `window.addEventListener('blur')`. It relies solely on the Page Visibility API (`visibilitychange` + `pageshow`).
This is the correct modern approach. The `focus`/`blur` events are unreliable (fire for devtools, iframe changes, etc.) while `visibilitychange` accurately reflects whether the user can see the tab.
### Other focus-related listeners
- `document.addEventListener('focusin')` (line 238) - Mobile keyboard handler for scrolling inputs into view
- Various `element.focus()` calls for modal focus trapping (`FocusTrap` class, line 742)
- `window.focus()` in browser notification click handler (line 1135) to bring window to front
None of these are related to notification/blinking behavior.
---
## 6. Multiple Notification Stacking
### Can intervals stack? NO
The `updateTabTitle()` method has a guard (line 1084):
```js
if (!this.titleFlashInterval) {
this.titleFlashInterval = setInterval(() => { ... }, TITLE_FLASH_INTERVAL_MS);
}
```
Only one interval is ever created. Subsequent notifications while the tab is hidden simply update `this.unreadCount` which is read by the existing interval callback. The displayed count stays current without creating new intervals.
### Notification Grouping
Notifications within the same category + session within 5 seconds are **grouped** instead of creating new entries (lines 986-997):
```js
const groupKey = `${category}:${sessionId || 'global'}`;
const existing = this.groupingMap.get(groupKey);
if (existing) {
existing.notification.count = (existing.notification.count || 1) + 1;
existing.notification.message = message;
existing.notification.timestamp = Date.now();
clearTimeout(existing.timeout);
existing.timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS);
this.scheduleRender();
return; // <-- Early return prevents duplicate badge/title/browser/audio triggers
}
```
The grouping early return prevents:
- Duplicate badge increments
- Duplicate title updates
- Duplicate browser notifications
- Duplicate audio alerts
### Browser Notification Rate Limiting
Even without grouping, browser notifications are rate-limited to 1 per 3 seconds (lines 1122-1125):
```js
const now = Date.now();
if (now - this.lastBrowserNotifTime < 3000) return;
this.lastBrowserNotifTime = now;
```
### Notification List Cap
The notification list is capped at 100 entries (line 1014):
```js
if (this.notifications.length > 100) this.notifications.pop();
```
### Grouping Timeout Cleanup
Grouping map entries self-clean after 5 seconds via `setTimeout`. These timeouts are also explicitly cleaned up in `handleInit()` (lines 3299-3304):
```js
if (this.notificationManager?.groupingMap) {
for (const { timeout } of this.notificationManager.groupingMap.values()) {
clearTimeout(timeout);
}
this.notificationManager.groupingMap.clear();
}
```
### Rapid Event Scenario
If 50 events fire while the tab is hidden:
1. First event: creates notification, starts title flash, sends browser notif
2. Events 2-N within 5s of same category+session: grouped (count increments, no new intervals)
3. Events of different categories: new notifications, but title flash interval is singular
4. Browser notifications: only 1 per 3s gets through
**Verdict**: Well-protected against stacking/compounding.
---
## 7. Team Agent Blinking
### Current State: NO team-specific blinking
Searching for `team:` SSE event listeners finds **none**. There are no `addListener('team:...')` handlers in app.js.
Team agent data (teammates, tasks, colors) is tracked via:
- `this.teammateMap` (Map of agent info)
- `this.teammatePanesByName` (Map of pane targets)
- `this.teammateTerminals` (Map of terminal instances)
But these are populated from **subagent data**, not dedicated team events. Teammates appear as standard subagents and are detected by `subagent-watcher.ts` (as noted in MEMORY.md: "Teammates appear as standard subagents").
### What team events could trigger blinking?
Currently, subagent events have their own notification category:
```js
// Default event type preferences (line 906-907)
subagent_spawn: { enabled: false, browser: false, audio: false },
subagent_complete: { enabled: false, browser: false, audio: false },
```
Both are **disabled by default**. Even if enabled, they go through the standard `notify()` path which would trigger title blinking only when the tab is hidden.
### Should team events trigger blinking?
The MEMORY.md implementation priority notes:
> 1. Team-aware idle detection (prevent premature respawn)
There is no `TeammateIdle` or `TaskCompleted` hook handler in the frontend. The hooks are mentioned in MEMORY.md as valid settings schema keys but have no frontend implementation yet.
If/when team hooks are implemented, they should:
- Potentially trigger tab-alert-action (red blink) for teammate stuck/blocked states
- Use the notification system for teammate task completions
- Consider a new notification category (e.g., `teammate_idle`, `team_task_complete`) with configurable per-event preferences
---
## 8. Cleanup Analysis
### All Interval/Timeout Cleanup Points
| Timer | Created | Cleared | Risk |
|-------|---------|---------|------|
| `titleFlashInterval` | `updateTabTitle()` L1085 | `stopTitleFlash()` L1099, `onTabVisible()` L1242, `handleInit()` L3296 | LOW - guarded + multi-path cleanup |
| Grouping timeouts | `notify()` L994/L1017 | Self-expire 5s, `handleInit()` L3301 | LOW - TTL + explicit cleanup |
| Browser notif auto-close | `sendBrowserNotif()` L1143 | Self-expire 8s (via `setTimeout`) | NONE - fires once |
| Audio oscillator | `playAudioAlert()` L1178 | Self-stops 0.15s | NONE - Web Audio manages it |
| Idle timer per session | `session:idle` handler L2384 | `session:working` L2415, `handleInit()` L3269, `removeSession()` L4138 | LOW - cleaned in all paths |
### handleInit Cleanup (SSE Reconnect)
The `handleInit()` method (called on every SSE reconnect) performs comprehensive cleanup (lines 3260-3321):
1. Clears all Maps (sessions, ralphStates, terminalBuffers, etc.)
2. Clears all idle timers
3. Clears flicker filter state
4. Clears pending terminal writes
5. Clears pending hooks and tab alerts
6. **Clears notification title flash interval** (L3295-3298)
7. **Clears notification grouping timeouts** (L3300-3304)
8. Disconnects terminal resize observer
9. Clears plan loading timers
10. Clears countdown intervals
11. Clears run summary auto-refresh timer
### removeSession Cleanup (line 4120-4139)
When a session is removed:
- `pendingHooks.delete(sessionId)` (L4128)
- `tabAlerts.delete(sessionId)` (L4129)
- Idle timer cleared (L4136-4139)
- All floating windows closed
### Browser Notification Auto-Close
The `setTimeout(() => notif.close(), 8000)` at line 1143 creates an anonymous closure over the `notif` variable. This is safe because:
1. The timeout fires once and is garbage collected
2. `notif.close()` is idempotent
3. 8 seconds is short enough to not accumulate
### Edge Case: AudioContext
The `AudioContext` (line 1167) is created once and reused:
```js
if (!this.audioCtx) {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
```
This is never explicitly closed, but `AudioContext` is lightweight when idle and the singleton pattern prevents accumulation. Not a practical concern.
---
## Architecture Diagram
```
notify() called
|
+-----------+-----------+
| |
enabled check preferences check
| |
[Layer 1: Drawer] [Layer 2: Title Flash]
- Add to notifications[] - Only if tab hidden
- Cap at 100 - Single interval guard
- Update badge count - Toggles every 1500ms
- requestAnimationFrame
| |
[Layer 3: Browser Notif] [Layer 4: Audio]
- Per-event prefs - Per-event prefs
- Rate limit 3s - Web Audio API
- Auto-close 8s - 0.15s beep
- Permission check - Singleton AudioContext
INDEPENDENT:
[Tab CSS Alerts]
- Driven by pendingHooks state machine
- tab-alert-action (red, 2.5s cycle)
- tab-alert-idle (yellow, 3.5s cycle)
- Pure CSS animation, no JS timers
```
---
## Summary of Findings
| Aspect | Status | Notes |
|--------|--------|-------|
| Title blink interval safety | SAFE | Guard prevents stacking; 3-path cleanup |
| Favicon changes | NOT IMPLEMENTED | Static inline SVG; no dynamic swapping |
| CSS tab animations | SAFE | Pure CSS infinite animations; no JS timer cost |
| Visibility API usage | CORRECT | `visibilitychange` + `pageshow` (bfcache) |
| Focus/blur handling | NOT USED (correct) | Relies on Visibility API instead |
| Notification stacking | WELL PROTECTED | Grouping, rate limiting, single interval |
| Team agent blinking | NOT IMPLEMENTED | No `team:` SSE handlers; teammates use subagent path |
| Interval cleanup | COMPREHENSIVE | `handleInit`, `onTabVisible`, `removeSession`, `markAllRead`, `clearAll` |
| Memory leak risk | LOW | All timers have explicit cleanup paths |
### Potential Improvements
1. **Dynamic favicon**: Could swap favicon to a red/orange variant when there are unread critical notifications (common pattern in web apps).
2. **Team agent notifications**: When `TeammateIdle` and `TaskCompleted` hooks are implemented, add dedicated notification categories with configurable preferences in the per-event settings grid.
3. **Unread count on tab return**: Currently, returning to the tab stops the title flash but does NOT reset the unread count. The user must open the drawer or click individual notifications. Consider auto-marking as read after a brief delay when the tab becomes visible.
4. **Notification sound variety**: Currently all audio alerts use the same 660Hz sine wave. Different categories could use different tones (e.g., lower pitch for info, higher for critical).
+749
View File
@@ -0,0 +1,749 @@
# Claudeman Frontend Notification System -- Detailed Report
> Generated: 2026-02-17
> Source files analyzed:
> - `/home/arkon/default/claudeman/src/web/public/app.js` (main frontend, ~15k lines)
> - `/home/arkon/default/claudeman/src/web/public/index.html`
> - `/home/arkon/default/claudeman/src/web/public/styles.css`
> - `/home/arkon/default/claudeman/src/web/public/mobile.css`
---
## 1. Architecture Overview
The notification system is a **4-layer** design, all implemented in the `NotificationManager` class (lines 860-1268 of `app.js`). The layers are:
| Layer | Mechanism | When Active |
|-------|-----------|-------------|
| 1 | In-app notification drawer | Always (when enabled) |
| 2 | Tab title flashing | When tab is hidden (background) |
| 3 | Browser Notification API (OS-level) | When enabled + permission granted |
| 4 | Audio alert (Web Audio API) | When enabled for specific events |
The `NotificationManager` is instantiated once at line 1404:
```js
this.notificationManager = new NotificationManager(this);
```
---
## 2. NotificationManager Class (lines 860-1268)
### 2.1 Constructor (lines 861-893)
State initialized:
- `this.notifications = []` -- in-memory log (max 100 items)
- `this.unreadCount = 0` -- badge counter
- `this.isTabVisible = !document.hidden` -- visibility tracking
- `this.isDrawerOpen = false`
- `this.originalTitle = document.title` -- saved for flash restore
- `this.titleFlashInterval = null` -- interval ID for title flashing
- `this.titleFlashState = false` -- toggle state for flash
- `this.lastBrowserNotifTime = 0` -- rate-limit timestamp
- `this.audioCtx = null` -- lazily created Web Audio context
- `this.groupingMap = new Map()` -- debounce grouping (5s window)
Visibility listeners:
- `document.visibilitychange` -- updates `isTabVisible`, calls `onTabVisible()` when tab becomes visible
- `window.pageshow` (with `e.persisted` check) -- handles iOS Safari back-forward cache (bfcache) restore
### 2.2 Preferences System (lines 896-960)
#### Default Event-Type Preferences (lines 897-908)
```js
const defaultEventTypes = {
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 },
};
```
#### Device-Specific Defaults (lines 910-923)
Mobile devices (`MobileDetection.getDeviceType() === 'mobile'`) get notifications **disabled by default**:
```js
const isMobile = MobileDetection.getDeviceType() === 'mobile';
const defaults = {
enabled: !isMobile, // OFF on mobile
browserNotifications: !isMobile, // OFF on mobile
audioAlerts: false, // OFF everywhere
stuckThresholdMs: 600000, // 10 minutes
muteCritical: false, // Legacy urgency muting
muteWarning: false,
muteInfo: false,
eventTypes: defaultEventTypes,
_version: 3,
};
```
#### Storage Keys (lines 952-956)
Device-specific localStorage keys prevent mobile settings from overriding desktop settings:
- Desktop: `claudeman-notification-prefs`
- Mobile: `claudeman-notification-prefs-mobile`
#### Version Migrations (lines 928-940)
- v1 -> v2: `browserNotifications` default changed from `false` to `true`
- v2 -> v3: Added `eventTypes` object with per-event-type preferences
#### Server Sync (lines 9470-9475, 9787-9828)
Notification preferences are saved to the server alongside app settings via `PUT /api/settings`:
```js
body: JSON.stringify({ ...settings, notificationPreferences: notifPrefsToSave })
```
On load, server prefs are applied **only if localStorage has none** (line 9815-9818):
```js
if (notificationPreferences && this.notificationManager) {
const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey());
if (!localNotifPrefs) {
this.notificationManager.preferences = notificationPreferences;
this.notificationManager.savePreferences();
}
}
```
This means localStorage always takes precedence over server-stored prefs.
---
## 3. The `notify()` Flow (lines 962-1038)
```
notify() called
|
+-- preferences.enabled === false? --> RETURN (no-op)
|
+-- Check per-event-type preferences (eventTypes[category])
| |
| +-- Found: eventPref.enabled === false? --> RETURN
| | shouldBrowserNotify = eventPref.browser && prefs.browserNotifications
| | shouldAudioAlert = eventPref.audio && prefs.audioAlerts
| |
| +-- Not found: fall back to legacy urgency-based muting
| if muteCritical/muteWarning/muteInfo matches --> RETURN
| shouldBrowserNotify = prefs.browserNotifications && (critical/warning/!tabVisible)
| shouldAudioAlert = critical && prefs.audioAlerts
|
+-- Grouping: same category+session within 5s? --> increment count, update message, RETURN
|
+-- Create notification object { id, urgency, category, sessionId, sessionName, title, message, timestamp, read, count }
|
+-- Add to this.notifications[] (max 100, FIFO eviction)
|
+-- Track in groupingMap (5s TTL)
|
+-- unreadCount++; updateBadge(); scheduleRender()
|
+-- Layer 2: if tab NOT visible --> updateTabTitle() (start title flashing)
|
+-- Layer 3: if shouldBrowserNotify --> sendBrowserNotif()
|
+-- Layer 4: if shouldAudioAlert --> playAudioAlert()
```
### 3.1 Notification Grouping (lines 986-997)
Within a 5-second window, notifications with the same `category:sessionId` key are grouped:
- Count is incremented on existing notification
- Message is updated to latest
- Timestamp refreshed
- No new notification entry is created
- The grouping timeout is reset (sliding window)
This prevents notification spam for rapid-fire events.
---
## 4. Layer 1: In-App Notification Drawer
### 4.1 HTML Structure (index.html lines 1394-1406)
```html
<div class="notification-drawer" id="notifDrawer">
<div class="notif-drawer-header">
<span class="notif-drawer-title">Notifications</span>
<div class="notif-drawer-actions">
<button onclick="markAllRead()">checkmark</button>
<button onclick="clearAll()">trash</button>
<button onclick="toggleNotifications()">X</button>
</div>
</div>
<div class="notif-drawer-list" id="notifList"></div>
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
</div>
```
### 4.2 Bell Button (index.html lines 63-66)
```html
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()">
<svg><!-- bell icon --></svg>
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
</button>
```
The bell button visibility is controlled by the `enabled` preference (line 9647-9652):
```js
const notifEnabled = this.notificationManager?.preferences?.enabled ?? true;
const notifBtn = document.querySelector('.btn-notifications');
if (notifBtn) {
notifBtn.style.display = notifEnabled ? '' : 'none';
}
```
### 4.3 Badge (lines 1230-1238)
The red badge on the bell shows unread count. It pulses via CSS animation:
```css
.notification-badge {
position: absolute; top: 2px; right: 2px;
background: var(--red); color: #fff;
animation: notif-badge-pulse 2s ease-in-out infinite;
}
@keyframes notif-badge-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
```
Display logic: `badge.style.display = unreadCount > 0 ? 'flex' : 'none'`.
Shows `99+` if count exceeds 99.
### 4.4 Drawer Rendering (lines 1041-1079)
Uses `requestAnimationFrame` for debounced rendering. Each notification item shows:
- Urgency color (left border: red/yellow/blue)
- Title with count multiplier (e.g., "Permission Required x3")
- Relative timestamp ("now", "5m ago", "2h ago")
- Message (truncated with ellipsis)
- Session chip (session name)
- Unread highlight (subtle blue background)
- Slide-in animation (`notif-slide-in`)
Clicking a notification: marks as read, decrements unread count, switches to the notification's session.
### 4.5 Drawer Toggle (lines 1183-1192)
`toggleDrawer()` adds/removes the `open` class. The drawer slides in from the right via CSS transform:
```css
.notification-drawer {
transform: translateX(100%);
transition: transform 0.2s ease;
}
.notification-drawer.open {
transform: translateX(0);
}
```
### 4.6 CSS Styling (styles.css lines 3965-4151)
Drawer is 340px wide, fixed position, full height below header, z-index 10001.
Items have color-coded left borders: red (critical), yellow (warning), blue (info).
Mobile override: full-width with safe area padding (mobile.css lines 1049-1058).
---
## 5. Layer 2: Tab Title Flashing (lines 1082-1103)
### Behavior
When the tab is not visible and there are unread notifications:
1. `setInterval` at 1500ms toggles between:
- Warning emoji + unread count: `"(3) Claudeman"`
- Original title: `"Claudeman"`
2. Set immediately on first notification (no wait for first interval tick)
### Stopping
`onTabVisible()` (line 1241) calls `stopTitleFlash()`, which:
1. Clears the interval
2. Resets `titleFlashState = false`
3. Restores `document.title = this.originalTitle`
If the drawer is open when tab becomes visible, all notifications are marked as read.
### Memory Leak Prevention (lines 3294-3304)
On SSE reconnect (`handleInit()`), the title flash interval is explicitly cleared:
```js
if (this.notificationManager?.titleFlashInterval) {
clearInterval(this.notificationManager.titleFlashInterval);
this.notificationManager.titleFlashInterval = null;
}
```
Grouping timeouts are also cleared to prevent orphaned timers.
### Potential Issue
The title flash uses a Unicode warning emoji (`\u26A0\uFE0F`). This displays correctly on all modern browsers but may not render on very old terminals/browsers.
---
## 6. Layer 3: Browser Notification API (lines 1106-1161)
### Permission Flow (lines 1107-1120)
```
sendBrowserNotif() called
|
+-- prefs.browserNotifications === false? --> RETURN
+-- Notification API undefined? --> RETURN
+-- Notification.permission === 'default'?
| --> Auto-request permission
| --> If granted, re-call sendBrowserNotif() recursively
| --> RETURN (wait for permission dialog)
+-- Notification.permission !== 'granted'? --> RETURN
+-- Rate limit: < 3s since last? --> RETURN
+-- Create Notification
```
### Notification Object (lines 1127-1143)
```js
new Notification(`Claudeman: ${title}`, {
body,
tag, // Groups same-tag notifications (replaces previous with same tag)
icon: '/favicon.ico',
silent: true, // We handle audio ourselves
});
```
- **onclick**: focuses window, switches to session, closes notification
- **Auto-close**: 8 seconds via `setTimeout(() => notif.close(), 8000)`
- **Rate limit**: Max 1 browser notification per 3 seconds (`BROWSER_NOTIF_RATE_LIMIT_MS`)
### Manual Permission Request (lines 1146-1161)
The settings UI has an "Ask" button that calls `requestPermission()`:
- Shows toast on success/failure
- Updates permission status display (checkmark/X/?)
- Auto-enables `browserNotifications` preference on grant
### Permission Status Display
In settings (index.html line 993), a `<span class="settings-status" id="notifPermissionStatus">?</span>` shows:
- `granted` -> checkmark with green background
- `denied` -> X with red background
- `default` -> `?`
### HTTPS Requirement
The settings UI shows a hint (index.html line 996):
```
For remote access, HTTPS is required. Start with: claudeman web --https
```
Browser Notification API requires a secure context (HTTPS or localhost). This hint warns users who access Claudeman remotely over HTTP.
---
## 7. Layer 4: Audio Alerts (lines 1163-1181)
### Implementation
Uses Web Audio API to generate a short sine wave beep:
```js
playAudioAlert() {
const ctx = new AudioContext();
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(660, ctx.currentTime); // 660 Hz (high E)
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); // 150ms duration
}
```
The `AudioContext` is lazily created and reused across alerts. Errors are silently caught.
### Potential Issues
1. **Autoplay policy**: Modern browsers block `AudioContext` creation until user interaction. The first `playAudioAlert()` call may silently fail if the user hasn't clicked anything yet. The code handles this gracefully via try/catch, but the user gets no feedback that audio failed.
2. **Mobile iOS restrictions**: iOS Safari requires `AudioContext.resume()` after user gesture. The current code does not call `resume()`, so audio alerts may never work on iOS unless the user has already interacted with an `AudioContext` (e.g., by clicking something that triggers audio).
3. **No audio indicator**: There is no visual feedback that an audio alert played (or failed to play).
---
## 8. SSE Event -> Notification Mapping
The following table maps every SSE event that triggers a notification, with exact line numbers:
| SSE Event | Category | Urgency | Line | Condition |
|-----------|----------|---------|------|-----------|
| `session:error` | `session-error` | critical | 2341 | Always |
| `session:exit` | `session-crash` | critical | 2360 | Non-zero exit code only |
| `session:idle` | `session-stuck` | warning | 2386 | After stuck threshold timeout (default 10min), only if respawn not enabled |
| `respawn:blocked` | `respawn-blocked` | critical | 2488 | Always (circuit breaker, exit signal, or status blocked) |
| `respawn:autoAcceptSent` | `auto-accept` | info | 2513 | Always |
| `session:autoClear` | `auto-clear` | info | 2623 | Always |
| `session:ralphCompletionDetected` | `ralph-complete` | warning | 2746 | Deduped by completion key (30s cooldown) |
| `session:circuitBreakerUpdate` | `circuit-breaker` | critical | 2772 | Only when state === 'OPEN' |
| `session:exitGateMet` | `exit-gate` | warning | 2787 | Always |
| `hook:idle_prompt` | `hook-idle` | warning | 2823 | Always |
| `hook:permission_prompt` | `hook-permission` | critical | 2841 | Always |
| `hook:elicitation_dialog` | `hook-elicitation` | critical | 2858 | Always |
| `hook:stop` | `hook-stop` | info | 2875 | Always |
| Circuit breaker reset | `circuit-breaker` | info | 10634 | On successful reset |
| Fix plan error | `fix-plan` | error | 10657 | On API error |
| Fix plan copied | `fix-plan` | info | 10719 | On clipboard copy |
| Fix plan written | `fix-plan` | info | 10738 | On successful write |
| Fix plan write error | `fix-plan` | error | 10746 | On write failure |
| Fix plan imported | `fix-plan` | info | 10768 | On successful import |
| Fix plan not found | `fix-plan` | warning | 10777 | When file not found |
### Category-to-EventType Mapping Gap
**Bug identified**: The notification categories used in `notify()` calls do NOT always match the event type keys in `preferences.eventTypes`. For example:
- Category `session-error` is used (line 2343) but the eventType key is `session_error` (underscore, line 902)
- Category `session-crash` (line 2362) has no corresponding eventType entry
- Category `session-stuck` (line 2388) has no corresponding eventType entry
- Category `respawn-blocked` (line 2490) has no corresponding eventType entry
- Category `auto-accept` (line 2515) has no corresponding eventType entry
- Category `auto-clear` (line 2625) has no corresponding eventType entry
- Category `circuit-breaker` (lines 2774, 10636) has no corresponding eventType entry
- Category `exit-gate` (line 2789) has no corresponding eventType entry
- Category `hook-idle` (line 2825) -- should map to `idle_prompt`, but it does not match the key
- Category `hook-permission` (line 2843) -- should map to `permission_prompt`, but does not match
- Category `hook-elicitation` (line 2860) -- should map to `elicitation_dialog`, but does not match
- Category `hook-stop` (line 2877) -- should map to `stop`, but does not match
- Category `fix-plan` (lines 10659, 10721, etc.) has no corresponding eventType entry
**Impact**: When `notify()` is called with a category that does not exist in `preferences.eventTypes`, the code falls through to the legacy urgency-based muting path (lines 976-983). This means per-event-type browser/audio toggles in the settings UI have **no effect** on the actual hook events, because the hook events use different category strings (`hook-permission`) than the eventType keys (`permission_prompt`).
For example, unchecking "Browser" for "Permission prompts" in settings sets `eventTypes.permission_prompt.browser = false`. But the actual notification uses category `hook-permission`, which is not found in eventTypes, so it falls back to urgency-based logic where `critical` urgency always gets browser notifications when `browserNotifications` is enabled.
**This is the most significant bug in the notification system.**
---
## 9. Tab Alert System (Separate from NotificationManager)
### How It Works
Tab alerts are a separate visual indicator system that shows blinking session tabs:
1. **State tracking** (lines 1349-1354):
- `tabAlerts: Map<sessionId, 'action' | 'idle'>` -- current alert state per tab
- `pendingHooks: Map<sessionId, Set<hookType>>` -- pending hook events
2. **setPendingHook()** (lines 1445-1451): Adds hook type to session's pending set, calls `updateTabAlertFromHooks()`.
3. **clearPendingHooks()** (lines 1453-1464): Removes specific hook type or all hooks, calls `updateTabAlertFromHooks()`.
4. **updateTabAlertFromHooks()** (lines 1467-1477):
```
No hooks -> remove alert
Has permission_prompt OR elicitation_dialog -> 'action' alert (red blink)
Has idle_prompt -> 'idle' alert (yellow blink)
```
5. **Visual rendering** (lines 3497-3504, 3581-3582):
Tab elements get CSS classes `tab-alert-action` or `tab-alert-idle`.
### CSS Animations (styles.css lines 329-345)
```css
.session-tab.tab-alert-action {
animation: tab-blink-red 2.5s ease-in-out infinite;
}
.session-tab.tab-alert-idle {
animation: tab-blink-yellow 3.5s ease-in-out infinite;
}
@keyframes tab-blink-red {
0%, 100% { background: transparent; border-color: transparent; }
50% { background: rgba(239, 68, 68, 0.12); border-color: var(--red); }
}
@keyframes tab-blink-yellow {
0%, 100% { background: transparent; border-color: transparent; }
50% { background: rgba(234, 179, 8, 0.1); border-color: var(--yellow); }
}
```
### Alert Clearing
- **`session:working` event** (line 2406-2408): Clears tab alert **only if no pending hooks** remain for that session. This correctly preserves alerts for permission prompts even when Claude starts working again.
- **`hook:stop` event** (line 2873): Clears ALL pending hooks for the session (response complete means all hooks resolved).
- **`selectSession()`** (line 3977): Clears `idle_prompt` hooks (viewing the session means you saw the idle state) but keeps `action` hooks.
- **`sendInput()`** (line 3148): Clears all pending hooks (user sent input, so hooks are resolved).
- **Session deletion** (line 4128-4129): Clears both pending hooks and tab alerts.
- **SSE reconnect** (lines 3287-3289): Clears all pending hooks and tab alerts.
### Tab Glow Effect (lines 3983-3986)
When switching sessions, the newly-active tab gets a brief green glow animation:
```css
@keyframes tab-glow {
0% { box-shadow: none; }
10% { box-shadow: 0 0 18px 6px rgba(34, 197, 94, 0.7); }
100% { box-shadow: none; }
}
```
This is purely cosmetic and not notification-related.
---
## 10. Notification Settings UI (lines 9272-9461)
### Settings Location
Under App Settings modal -> "Notifications" tab (if using tabbed settings layout).
### Controls
**Global Controls** (index.html lines 981-1013):
| Setting | Element ID | Default | Purpose |
|---------|-----------|---------|---------|
| Enabled | `appSettingsNotifEnabled` | true (desktop), false (mobile) | Master switch |
| Browser | `appSettingsNotifBrowser` | true (desktop), false (mobile) | OS-level notifications |
| Audio Alerts | `appSettingsNotifAudio` | false | Beep sounds |
| Idle Threshold | `appSettingsNotifStuckMins` | 10 | Minutes before "stuck" warning |
**Legacy Urgency Levels** (index.html lines 1019-1039):
| Setting | Element ID | Default | Purpose |
|---------|-----------|---------|---------|
| Critical | `appSettingsNotifCritical` | checked | Show critical urgency |
| Warning | `appSettingsNotifWarning` | checked | Show warning urgency |
| Info | `appSettingsNotifInfo` | checked | Show info urgency |
These are stored inverted as `muteCritical`, `muteWarning`, `muteInfo`.
**Per-Event-Type Grid** (index.html lines 1046-1086):
A 4-column grid (Event / On / Browser / Sound) for 7 event types:
| Event | On Default | Browser Default | Sound Default |
|-------|-----------|-----------------|---------------|
| Permission prompts | on | on | on |
| Questions from Claude | on | on | on |
| Session idle | on | on | off |
| Response complete | on | off | off |
| Respawn cycles | on | off | off |
| Task complete | on | on | on |
| Subagent activity | off | off | off |
### Save Flow (lines 9362-9488)
1. Collect all UI values into a `notifPrefsToSave` object
2. Set `this.notificationManager.preferences = notifPrefsToSave`
3. Call `this.notificationManager.savePreferences()` (writes to localStorage)
4. Send to server via `PUT /api/settings` with `notificationPreferences` field
5. Call `applyHeaderVisibilitySettings()` which hides/shows the bell button
### Session Error Event Type Bug (line 9425-9429)
The `session_error` event type in the save flow reuses the `permission_prompt`'s browser checkbox:
```js
session_error: {
enabled: true,
browser: document.getElementById('eventPermissionBrowser').checked, // BUG: wrong checkbox
audio: false,
},
```
This means toggling "Permission prompts -> Browser" also affects `session_error` browser notifications, which is likely unintentional.
---
## 11. Mobile-Specific Behavior
### Device Detection (lines 119-198)
`MobileDetection` object detects:
- Touch capability (via `ontouchstart`, `maxTouchPoints`, media query)
- iOS devices
- Safari browser
- Screen size categories: mobile (<430px), tablet (430-768px), desktop (768px+)
Body classes set: `device-mobile`, `device-tablet`, `device-desktop`, `touch-device`, `ios-device`, `safari-browser`.
### Mobile Notification Defaults (lines 910-914)
On mobile devices:
- `enabled: false` -- notifications disabled by default
- `browserNotifications: false` -- browser notifications disabled
- `audioAlerts: false` -- audio disabled (same as desktop)
### Mobile Storage Key (lines 952-956)
Mobile uses a separate localStorage key (`claudeman-notification-prefs-mobile`) so that enabling notifications on desktop does not accidentally enable them on a mobile device viewing the same Claudeman instance.
### Mobile Drawer Styling (mobile.css lines 1049-1058)
```css
.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);
}
```
The drawer takes full width on mobile and respects iOS safe areas (notch, home indicator).
### Mobile Tab Ordering (lines 3566-3568)
On mobile, the active session tab is always rendered first:
```js
if (MobileDetection.getDeviceType() === 'mobile' && this.activeSessionId) {
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
}
```
This ensures the active tab's alert animation is always visible.
---
## 12. Team/Agent Notifications
### Subagent Event Handling
Subagent events (`subagent:discovered`, `subagent:updated`, etc.) do NOT directly call `notificationManager.notify()`. There are no direct notification calls for subagent spawn/complete events in the SSE handlers.
The `subagent_spawn` and `subagent_complete` event types exist in the preferences (lines 906-907) and settings UI, but no code currently dispatches notifications with these categories.
### Teammate Badges (lines 13063-13095)
Teammate badges are purely visual UI elements on subagent windows -- they are not part of the notification system. They show `@name` with team color (blue, green, yellow).
### Missing Subagent Notifications
Despite having settings for "Subagent activity" (on/browser/sound), the system **never dispatches** notifications with category `subagent_spawn` or `subagent_complete`. The UI settings exist but are non-functional for these event types.
---
## 13. Visual Indicators Summary
### Notification-Related
| Indicator | Element | Behavior |
|-----------|---------|----------|
| Bell badge | `#notifBadge` | Red circle with count, pulsing animation |
| Tab blink (action) | `.tab-alert-action` | Red blink, 2.5s cycle |
| Tab blink (idle) | `.tab-alert-idle` | Yellow blink, 3.5s cycle |
| Title flash | `document.title` | Alternates with unread count, 1.5s interval |
| Drawer slide-in | `.notification-drawer.open` | Right-to-left slide, 0.2s |
| Item slide-in | `.notif-item` | Right-to-left slide, 0.2s |
| Item urgency border | `.notif-item-critical/warning/info` | Red/yellow/blue left border |
| Unread highlight | `.notif-item.unread` | Subtle blue background |
### Non-Notification Visual Indicators
| Indicator | Element | Purpose |
|-----------|---------|---------|
| Tab status dot | `.tab-status` | Green (idle), pulsing green (busy), red (error) |
| Tab glow | `.tab-glow` | Brief green glow on tab switch |
| Connection indicator | `#connectionIndicator` | Shows offline/reconnecting/draining state |
| Ralph status badge | `#ralphStatusBadge` | Active/completed/tracking state |
| Subagent count badge | `#subagentCountBadge` | Active agent count |
| Task badge | `.tab-badge` | Running task count on session tab |
---
## 14. Bugs and Issues
### 14.1 Category/EventType Mismatch (CRITICAL)
**Location**: Lines 962-984 (notify flow) vs lines 897-908 (eventTypes definition)
The categories used in `notify()` calls (`hook-permission`, `hook-idle`, `session-error`, etc.) do not match the eventType keys in preferences (`permission_prompt`, `idle_prompt`, `session_error`, etc.). This means the per-event-type checkboxes in settings have no effect on most notifications.
**Impact**: Users who disable "Permission prompts -> Browser" in settings still get browser notifications for permission prompts, because the notification uses category `hook-permission` which falls through to urgency-based logic.
**Fix**: Either change the categories in `notify()` calls to match the eventType keys, or add a mapping layer in `notify()`.
### 14.2 Session Error Browser Setting Reuse (MINOR)
**Location**: Line 9427
`session_error.browser` reuses `eventPermissionBrowser` checkbox instead of having its own control.
### 14.3 No Subagent Notifications Dispatched (MINOR)
**Location**: Event types `subagent_spawn` and `subagent_complete` exist in defaults (lines 906-907) and UI (lines 1082-1085), but no code ever calls `notify()` with these categories.
### 14.4 AudioContext Autoplay Policy (MINOR)
**Location**: Line 1167
`AudioContext` creation may be blocked by browser autoplay policy. No `resume()` call is made. First audio alert after page load may silently fail.
### 14.5 Rate Limit Applies Across All Events (MINOR)
**Location**: Line 1124
The 3-second rate limit for browser notifications is global -- a rapid succession of different event types (e.g., permission prompt + session error) will only show the first browser notification.
### 14.6 Title Flash Shows Emoji That May Not Gate Properly
**Location**: Line 1088
Title flash always shows when tab is hidden and there are unread notifications, regardless of which event types are enabled/disabled. If a user disables all event types but one, the title flash still fires for all unread items.
This is correct behavior (the flash indicates unread items in the drawer), but it could be confusing if a user thinks disabling an event type should prevent all visual indicators.
### 14.7 onTabVisible Marks All Read If Drawer Open
**Location**: Lines 1243-1246
When the tab becomes visible and the drawer is open, ALL notifications are marked as read. This could be surprising if the user quickly switches tabs and back -- they lose their unread state.
---
## 15. Cleanup and Memory Safety
### SSE Reconnect Cleanup (lines 3280-3305)
On `handleInit()` (SSE reconnect), the following notification state is cleaned up:
- `pendingHooks.clear()` -- prevents stale hook alerts
- `tabAlerts.clear()` -- prevents stale tab blinking
- `_shownCompletions.clear()` -- allows re-notification
- `titleFlashInterval` cleared -- prevents orphaned intervals
- `groupingMap` timeouts cleared -- prevents orphaned timeouts
### Session Deletion Cleanup (lines 4128-4129)
When a session is deleted:
- `pendingHooks.delete(sessionId)`
- `tabAlerts.delete(sessionId)`
### Idle Timer Cleanup (lines 2412-2417)
When session starts working, its stuck detection timer is cleared:
```js
const timer = this.idleTimers.get(data.id);
if (timer) {
clearTimeout(timer);
this.idleTimers.delete(data.id);
}
```
---
## 16. Constants Reference
| Constant | Value | Purpose |
|----------|-------|---------|
| `GROUPING_TIMEOUT_MS` | 5000 | Notification grouping window |
| `NOTIFICATION_LIST_CAP` | 100 | Max notifications in drawer |
| `TITLE_FLASH_INTERVAL_MS` | 1500 | Title blink rate |
| `BROWSER_NOTIF_RATE_LIMIT_MS` | 3000 | Min time between browser notifications |
| `AUTO_CLOSE_NOTIFICATION_MS` | 8000 | Browser notification auto-dismiss |
| `STUCK_THRESHOLD_DEFAULT_MS` | 600000 | Default idle-stuck detection (10 min) |
| `THROTTLE_DELAY_MS` | 100 | General UI throttle |
+459
View File
@@ -0,0 +1,459 @@
# 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)
```javascript
{
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)
```javascript
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:
```css
.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):
```javascript
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):
```javascript
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:
```javascript
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 |
+90 -2
View File
@@ -962,8 +962,30 @@ class NotificationManager {
notify({ urgency, category, sessionId, sessionName, title, message }) {
if (!this.preferences.enabled) return;
// Map notification categories to eventType preference keys
const categoryToEventType = {
'hook-permission': 'permission_prompt',
'hook-elicitation': 'elicitation_dialog',
'hook-idle': 'idle_prompt',
'hook-stop': 'stop',
'session-error': 'session_error',
'session-crash': 'session_error',
'session-stuck': 'idle_prompt',
'respawn-blocked': 'respawn_cycle',
'auto-accept': 'respawn_cycle',
'auto-clear': 'respawn_cycle',
'ralph-complete': 'ralph_complete',
'circuit-breaker': 'respawn_cycle',
'exit-gate': 'ralph_complete',
'subagent-spawn': 'subagent_spawn',
'subagent-complete': 'subagent_complete',
'hook-teammate-idle': 'idle_prompt',
'hook-task-completed': 'stop',
};
const eventTypeKey = categoryToEventType[category] || category;
// Check per-event-type preferences first
const eventPref = this.preferences.eventTypes?.[category];
const eventPref = this.preferences.eventTypes?.[eventTypeKey];
let shouldBrowserNotify = false;
let shouldAudioAlert = false;
@@ -1166,6 +1188,9 @@ class NotificationManager {
if (!this.audioCtx) {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (this.audioCtx.state === 'suspended') {
this.audioCtx.resume();
}
const ctx = this.audioCtx;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
@@ -2602,6 +2627,19 @@ class ClaudemanApp {
}
});
addListener('respawn:error', (e) => {
const data = JSON.parse(e.data);
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'critical',
category: 'session-error',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Respawn Error',
message: data.error || data.message || 'Respawn encountered an error',
});
});
addListener('respawn:actionLog', (e) => {
const data = JSON.parse(e.data);
const { sessionId, action } = data;
@@ -2882,6 +2920,32 @@ class ClaudemanApp {
});
});
addListener('hook:teammate_idle', (e) => {
const data = JSON.parse(e.data);
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'warning',
category: 'hook-teammate-idle',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Teammate Idle',
message: `A teammate is idle in ${session?.name || data.sessionId}`,
});
});
addListener('hook:task_completed', (e) => {
const data = JSON.parse(e.data);
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'info',
category: 'hook-task-completed',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Task Completed',
message: `A team task completed in ${session?.name || data.sessionId}`,
});
});
// ========== Subagent Events (Claude Code Background Agents) ==========
addListener('subagent:discovered', (e) => {
@@ -2914,6 +2978,18 @@ class ClaudemanApp {
requestAnimationFrame(() => {
this.updateConnectionLines();
});
// Notify about new subagent discovery
const parentId = this.subagentParentMap.get(data.agentId);
const parentSession = parentId ? this.sessions.get(parentId) : null;
this.notificationManager?.notify({
urgency: 'info',
category: 'subagent-spawn',
sessionId: parentId || data.sessionId,
sessionName: parentSession?.name || parentId || data.sessionId,
title: 'Subagent Spawned',
message: data.description || 'New background agent started',
});
});
addListener('subagent:updated', (e) => {
@@ -3022,6 +3098,18 @@ class ClaudemanApp {
}
}
// Notify about subagent completion
const parentId = this.subagentParentMap.get(data.agentId);
const parentSession = parentId ? this.sessions.get(parentId) : null;
this.notificationManager?.notify({
urgency: 'info',
category: 'subagent-complete',
sessionId: parentId || existing?.sessionId || data.sessionId,
sessionName: parentSession?.name || parentId || data.sessionId,
title: 'Subagent Completed',
message: existing?.description || data.description || 'Background agent finished',
});
// Clean up activity/tool data for completed agents after 5 minutes
// This prevents memory leaks from long-running sessions with many subagents
setTimeout(() => {
@@ -9424,7 +9512,7 @@ class ClaudemanApp {
},
session_error: {
enabled: true,
browser: document.getElementById('eventPermissionBrowser').checked, // Use permission's browser setting
browser: this.notificationManager?.preferences?.eventTypes?.session_error?.browser ?? true,
audio: false,
},
respawn_cycle: {
+1 -1
View File
@@ -4645,7 +4645,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
private broadcast(event: string, data: unknown): void {
// Invalidate caches on any state-changing broadcast
if (event.startsWith('session:') || event === 'respawn:') {
if (event.startsWith('session:') || event.startsWith('respawn:')) {
this.cachedLightState = null;
this.cachedSessionsList = null;
}