Files
Codeman/docs/reports/notification-backend.md
arkonandClaude Opus 4.6 3795c45cc1 chore: rename Claudeman to Codeman
Full product rename across 109 files (~834 occurrences):
- Env vars: CLAUDEMAN_* → CODEMAN_*
- Data dirs: ~/.claudeman/ → ~/.codeman/, ~/claudeman-cases/ → ~/codeman-cases/
- tmux prefix: claudeman- → codeman-
- localStorage: claudeman-* → codeman-*
- Package/CLI: claudeman → codeman
- GitHub repo: Ark0N/Claudeman → Ark0N/Codeman
- systemd service: claudeman-web → codeman-web
- Class: ClaudemanApp → CodemanApp

Migration infrastructure for seamless transition:
- state-store.ts: auto-migrates data directories on startup
- tmux-manager.ts: dual-prefix detection (legacy claudeman- sessions)
- app.js: localStorage key migration (preserves old keys)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:44:34 +01:00

490 lines
23 KiB
Markdown

# Codeman Notification System - Backend Research Report
Date: 2026-02-17
## Executive Summary
The Codeman 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 Codeman when hooks fire:
```typescript
const curlCmd = (event: HookEventType) =>
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
`-H 'Content-Type: application/json' ` +
`-d "{\\"event\\":\\"${event}\\",\\"sessionId\\":\\"$CODEMAN_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()`:
- `CODEMAN_API_URL` -- server URL (e.g., `http://localhost:3000`)
- `CODEMAN_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 |