|
| 1 | +# Session Status Hooks — Design Doc |
| 2 | + |
| 3 | +## Goal |
| 4 | + |
| 5 | +Show active session status (working / idle / needs-attention) via colored dots in CodeV's session list, using Claude Code's hooks system for near-zero CPU cost. |
| 6 | + |
| 7 | +## Status Model |
| 8 | + |
| 9 | +| Status | Dot Color | Meaning | Trigger | |
| 10 | +|---|---|---|---| |
| 11 | +| **Working** | Orange `#E8956A` (pulse animation) | Claude is processing | `UserPromptSubmit` hook | |
| 12 | +| **Idle** | Green `#66BB6A` | Waiting for user input | `Stop` hook | |
| 13 | +| **Needs attention** | Orange `#FFA726` | Permission prompt or question | `PermissionRequest` hook, or pending `AskUserQuestion` | |
| 14 | +| **Active (unknown)** | Purple `#CE93D8` | Running but no hook data yet | Detected via `sessions/*.json` but no status file | |
| 15 | + |
| 16 | +## Architecture |
| 17 | + |
| 18 | +``` |
| 19 | +Claude Code session |
| 20 | + → hook fires (Stop/PermissionRequest/UserPromptSubmit/etc.) |
| 21 | + → runs ~/.claude/codev-status-hook.sh |
| 22 | + → writes ~/.claude/codev-status/{sessionId}.json |
| 23 | +
|
| 24 | +CodeV (main process) |
| 25 | + ← fs.watch(~/.claude/codev-status/) detects file change |
| 26 | + ← reads status file |
| 27 | + → IPC to renderer → dot color update |
| 28 | +``` |
| 29 | + |
| 30 | +### On CodeV startup (catch-up for sessions started before CodeV) |
| 31 | + |
| 32 | +``` |
| 33 | +1. Read ~/.claude/sessions/*.json → list of active sessions (PID + sessionId + cwd) |
| 34 | +2. For each active session: |
| 35 | + a. Check ~/.claude/codev-status/{sessionId}.json → use if exists |
| 36 | + b. Otherwise, read last ~50 lines of session JSONL → determine initial status |
| 37 | + - Has pending AskUserQuestion tool use → needs-attention |
| 38 | + - Last assistant message with no pending tool use → idle |
| 39 | + - Note: `stop_reason` can be null in some JSONL entries — don't rely on it |
| 40 | + - Otherwise → working (or unknown) |
| 41 | +``` |
| 42 | + |
| 43 | +## Hook Configuration |
| 44 | + |
| 45 | +### What CodeV writes to `~/.claude/settings.json` |
| 46 | + |
| 47 | +CodeV merges hook entries into the user's existing `~/.claude/settings.json`, never overwriting existing hooks. |
| 48 | + |
| 49 | +```json |
| 50 | +{ |
| 51 | + "hooks": { |
| 52 | + "Stop": [ |
| 53 | + { |
| 54 | + "matcher": "", |
| 55 | + "hooks": [{ "type": "command", "command": "~/.claude/codev-status-hook.sh", "timeout": 5 }] |
| 56 | + } |
| 57 | + ], |
| 58 | + "UserPromptSubmit": [ |
| 59 | + { |
| 60 | + "matcher": "", |
| 61 | + "hooks": [{ "type": "command", "command": "~/.claude/codev-status-hook.sh", "timeout": 5 }] |
| 62 | + } |
| 63 | + ], |
| 64 | + "PermissionRequest": [ |
| 65 | + { |
| 66 | + "matcher": "", |
| 67 | + "hooks": [{ "type": "command", "command": "~/.claude/codev-status-hook.sh", "timeout": 5 }] |
| 68 | + } |
| 69 | + ], |
| 70 | + "SubagentStart": [ |
| 71 | + { |
| 72 | + "matcher": "", |
| 73 | + "hooks": [{ "type": "command", "command": "~/.claude/codev-status-hook.sh", "timeout": 5 }] |
| 74 | + } |
| 75 | + ], |
| 76 | + "SessionEnd": [ |
| 77 | + { |
| 78 | + "matcher": "", |
| 79 | + "hooks": [{ "type": "command", "command": "~/.claude/codev-status-hook.sh", "timeout": 5 }] |
| 80 | + } |
| 81 | + ] |
| 82 | + } |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +### Hook script (`~/.claude/codev-status-hook.sh`) |
| 87 | + |
| 88 | +Receives JSON via stdin with `session_id`, `hook_event_name`, `cwd`, etc. |
| 89 | + |
| 90 | +```bash |
| 91 | +#!/bin/bash |
| 92 | +# CodeV session status hook — writes status for CodeV to watch |
| 93 | +INPUT=$(cat) |
| 94 | +SESSION_ID=$(echo "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4) |
| 95 | +EVENT=$(echo "$INPUT" | grep -o '"hook_event_name":"[^"]*"' | head -1 | cut -d'"' -f4) |
| 96 | +CWD=$(echo "$INPUT" | grep -o '"cwd":"[^"]*"' | head -1 | cut -d'"' -f4) |
| 97 | + |
| 98 | +if [ -z "$SESSION_ID" ]; then exit 0; fi |
| 99 | + |
| 100 | +STATUS_DIR="$HOME/.claude/codev-status" |
| 101 | +mkdir -p "$STATUS_DIR" |
| 102 | + |
| 103 | +case "$EVENT" in |
| 104 | + UserPromptSubmit|SubagentStart) STATUS="working" ;; |
| 105 | + Stop) STATUS="idle" ;; |
| 106 | + PermissionRequest) STATUS="needs-attention" ;; |
| 107 | + SessionEnd) rm -f "$STATUS_DIR/$SESSION_ID.json"; exit 0 ;; |
| 108 | + *) STATUS="unknown" ;; |
| 109 | +esac |
| 110 | + |
| 111 | +echo "{\"status\":\"$STATUS\",\"timestamp\":$(date +%s),\"cwd\":\"$CWD\"}" > "$STATUS_DIR/$SESSION_ID.json" |
| 112 | +``` |
| 113 | + |
| 114 | +### Merge strategy (preserving user's existing hooks) |
| 115 | + |
| 116 | +``` |
| 117 | +For each event (Stop, UserPromptSubmit, etc.): |
| 118 | + 1. Read existing hooks[event] array (or empty if none) |
| 119 | + 2. Check if any entry's hooks[].command contains "codev-status-hook" |
| 120 | + 3. If found → skip (already configured) |
| 121 | + 4. If not found → append our entry to the array |
| 122 | + 5. Write back settings.json |
| 123 | +``` |
| 124 | + |
| 125 | +### Disable behavior |
| 126 | + |
| 127 | +When user disables in Settings: |
| 128 | +1. Remove entries with command containing "codev-status-hook" from each event |
| 129 | +2. Delete `~/.claude/codev-status-hook.sh` |
| 130 | +3. Optionally: delete `~/.claude/codev-status/` directory |
| 131 | + |
| 132 | +## CodeV Settings |
| 133 | + |
| 134 | +In popup.tsx, under General: |
| 135 | +- **Session Status** toggle (default: ON) |
| 136 | + - ON: installs hooks + hook script, starts fs.watch |
| 137 | + - OFF: removes hooks + hook script, stops fs.watch, all dots revert to current behavior (purple = active) |
| 138 | + |
| 139 | +## Relationship with `detectActiveSessions` |
| 140 | + |
| 141 | +Status hooks are an **additional layer** on top of the existing detection system. Detection tells you *which* sessions are running; hooks tell you *what state* those sessions are in. |
| 142 | + |
| 143 | +| Timing | What runs | Purpose | |
| 144 | +|---|---|---| |
| 145 | +| Startup | `fetchClaudeSessions()` → `detectActiveSessions()` | Detect which sessions are alive (PID check) | |
| 146 | +| Tab switch to Sessions | `fetchClaudeSessions()` → `detectActiveSessions()` | Refresh active state | |
| 147 | +| Window focus (from background) | `onFocusWindow` → `fetchClaudeSessions()` | Refresh active state | |
| 148 | +| Startup (once) | `getSessionStatuses()` → JSONL scan | Determine initial status for sessions without hook data | |
| 149 | +| Real-time | `fs.watch` on `codev-status/` | Status updates from hooks (working/idle/needs-attention) | |
| 150 | +| Window focus | `getSessionStatuses()` | Refresh statuses on return from background | |
| 151 | + |
| 152 | +`detectActiveSessions()` has a 5s TTL cache, so overlapping calls (e.g., from `fetchClaudeSessions` and `getSessionStatuses` at startup) typically hit cache. |
| 153 | + |
| 154 | +## Performance |
| 155 | + |
| 156 | +| Operation | Cost | Frequency | |
| 157 | +|---|---|---| |
| 158 | +| Hook script execution | ~5ms (bash + write file) | Per Claude Code turn (~1-5/min) | |
| 159 | +| fs.watch notification | ~0ms (OS-level, no polling) | Per status file change | |
| 160 | +| Status file read | ~1ms | Per fs.watch event | |
| 161 | +| Startup JSONL scan | ~1ms per session (tail 50 lines) | Once on CodeV launch | |
| 162 | +| Total CPU when idle | ~0% | — | |
| 163 | + |
| 164 | +### Architecture note: `get-session-statuses` and duplicate detection |
| 165 | + |
| 166 | +The `get-session-statuses` IPC handler internally calls `detectActiveSessions()` and `readClaudeSessions(500)` to find sessions without status files for JSONL scanning. This duplicates work the renderer already does. |
| 167 | + |
| 168 | +**Why it's acceptable:** |
| 169 | +- Both functions have 5s TTL cache. At startup, the renderer's detection and `get-session-statuses` run within ~1s, so the second call hits cache (~0ms). |
| 170 | +- No race condition — both calls return correct results; at worst there's duplicate work on a cold cache (~45ms total). |
| 171 | +- `getSessionStatuses()` is called **once on mount** + **once per window focus**. After that, updates come from `fs.watch` push via `onSessionStatusesUpdated` (merge, not replace). |
| 172 | + |
| 173 | +**Why not pass `activeMap` from renderer instead:** |
| 174 | +- Adds IPC coupling (renderer must finish detection before requesting statuses). |
| 175 | +- The handler becomes dependent on renderer timing. |
| 176 | +- Cache hit already makes the duplicate cost ~0ms. |
| 177 | +- Keeping the handler self-contained is simpler and more maintainable. |
| 178 | + |
| 179 | +### Hook events: comparison with claude-control |
| 180 | + |
| 181 | +claude-control uses the same hook mechanism with a similar script. Comparison: |
| 182 | + |
| 183 | +| Event | claude-control | CodeV | Notes | |
| 184 | +|---|---|---|---| |
| 185 | +| SessionStart | ✓ | ✗ | Not added — purple (init) dot is acceptable for the few seconds before first prompt | |
| 186 | +| SessionEnd | ✓ | ✓ | Removes status file | |
| 187 | +| Stop | ✓ | ✓ | → idle | |
| 188 | +| UserPromptSubmit | ✓ | ✓ | → working | |
| 189 | +| PermissionRequest | ✓ | ✓ | → needs-attention | |
| 190 | +| SubagentStart | ✓ | ✓ | → working | |
| 191 | +| PostToolUseFailure | ✓ | ✗ | Not added — tool failures aren't always severe; no separate error color needed yet | |
| 192 | + |
| 193 | +Key difference: claude-control keys status files by PID (`$PPID`), CodeV keys by sessionId. SessionId is more stable across resume cycles. |
| 194 | + |
| 195 | +### Bug fix: merge vs replace on status update |
| 196 | + |
| 197 | +`onSessionStatusesUpdated` must **merge** into existing state (`{...prev, ...newStatuses}`), not replace. JSONL-scanned statuses (for sessions without status files) exist only in React state. A replace would clear them when fs.watch fires for unrelated status file changes. |
| 198 | + |
| 199 | +## VS Code Claude Code Sessions |
| 200 | + |
| 201 | +Hooks fire for ALL Claude Code sessions, including VS Code extension (`entrypoint: "claude-vscode"`). This means: |
| 202 | +- VS Code sessions will also have status files in `codev-status/` |
| 203 | +- Combined with `~/.claude/sessions/*.json` detection (PR #78), we can: |
| 204 | + 1. Detect VS Code sessions as active (PID file exists, entrypoint = "claude-vscode") |
| 205 | + 2. Show their status (hook writes status file regardless of entrypoint) |
| 206 | + 3. Future: show first/last prompt by reading their session JSONL |
| 207 | + |
| 208 | +This is a stepping stone for issue #66 item #5 (VS Code Claude Code session support). |
| 209 | + |
| 210 | +## Phase Plan |
| 211 | + |
| 212 | +### Phase 1 (this PR) |
| 213 | +- Hook config management (install/merge/remove) |
| 214 | +- Hook script creation |
| 215 | +- Settings toggle |
| 216 | +- fs.watch for status directory |
| 217 | +- Dot color based on status (working/idle/needs-attention) |
| 218 | +- Startup scan for initial status |
| 219 | + |
| 220 | +### Phase 2 (future) |
| 221 | +- Text question detection (? heuristic with 20s grace period) |
| 222 | +- VS Code session display with status |
| 223 | +- Notification/sound for needs-attention |
| 224 | +- Detail view showing specific tool waiting for approval |
| 225 | + |
| 226 | +## References |
| 227 | + |
| 228 | +- [Claude Code hooks documentation](https://docs.anthropic.com/en/docs/claude-code/hooks) |
| 229 | +- [c9watch PR #66](https://github.com/minchenlee/c9watch/pull/66) — NeedsAttention detection, AskUserQuestion, 20s grace period |
| 230 | +- [c9watch PR #14](https://github.com/minchenlee/c9watch/pull/14) — CPU optimization |
| 231 | +- [cmux claude-hook](~/git/cmux/CLI/cmux.swift) — cmux's hook-based session monitoring |
| 232 | +- [CodeV issue #66 item #2](https://github.com/grimmerk/codev/issues/66) — Feature request |
| 233 | +- [CodeV issue #63](https://github.com/grimmerk/codev/issues/63) — Related: Ghostty TTY support |
0 commit comments