Skip to content

Commit f750fcc

Browse files
authored
Merge pull request #92 from grimmerk/feat/session-status-hooks
feat: session status hooks (working/idle/needs-attention)
2 parents 234708b + 0714daa commit f750fcc

10 files changed

Lines changed: 772 additions & 6 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 1.0.62
4+
5+
- Feat: session status hooks — colored dots for working (pulse) / idle / needs-attention (blink)
6+
- Feat: auto-install Claude Code hooks for session status detection (toggle in Settings → Sessions)
7+
- Fix: legacy fallback detection now supports npm-installed Claude Code (#95)
8+
- Known: if hooks are removed externally while CodeV is running, restart CodeV to recover (#93)
9+
310
## 1.0.61
411

512
- Fix: terminal cursor — white non-blinking block (matching iTerm2 style)
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "CodeV",
33
"productName": "CodeV",
4-
"version": "1.0.61",
4+
"version": "1.0.62",
55
"description": "Quick switcher for VS Code, Cursor, and Claude Code sessions",
66
"repository": {
77
"type": "git",

src/claude-session-utility.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ const detectActiveSessionsLegacy = async (activeMap: Map<string, number>): Promi
685685
const claimedSessionIds = new Set<string>();
686686

687687
const output = await execPromise(
688-
'ps aux | grep -E "[c]laude" | grep -v "Claude.app" | grep -v "claude-history" | grep -v "ClaudeHistory" | grep -v "node"'
688+
'ps aux | grep -E "[c]laude" | grep -v "Claude.app" | grep -v "claude-history" | grep -v "ClaudeHistory"'
689689
);
690690
if (!output) return;
691691

src/electron-api.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ interface IElectronAPI {
4040
getLoginItemSettings: () => Promise<{ openAtLogin: boolean }>;
4141
setLoginItemSettings: (openAtLogin: boolean) => void;
4242

43+
// Session status hooks
44+
getSessionStatusHooksEnabled: () => Promise<boolean>;
45+
setSessionStatusHooksEnabled: (enabled: boolean) => void;
46+
getSessionStatuses: () => Promise<Record<string, string | null>>;
47+
onSessionStatusesUpdated: (callback: IpcCallback) => void;
48+
4349
// Claude Code sessions
4450
getClaudeSessions: (limit?: number) => Promise<any>;
4551
searchClaudeSessions: (query: string) => Promise<any>;

src/main.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ import {
2626
loadSessionEnrichment,
2727
loadLastAssistantResponses,
2828
} from './claude-session-utility';
29+
import {
30+
installHooks,
31+
removeHooks,
32+
isHooksInstalled,
33+
readAllStatuses,
34+
watchStatusDir,
35+
scanInitialStatuses,
36+
writeStatusFile,
37+
cleanupStaleStatuses,
38+
SessionStatus,
39+
} from './session-status-hooks';
2940
import {
3041
deleteRecentProjectRecord,
3142
openVSCodeBasedIDE,
@@ -1072,6 +1083,9 @@ const trayToggleEvtHandler = async () => {
10721083
}
10731084
}, 1000); // Delay by 1 second to not interfere with main window initialization
10741085

1086+
// Initialize session status hooks (install if enabled, start watching)
1087+
initSessionStatusHooks();
1088+
10751089
// Pre-spawn terminal for faster first Terminal tab switch
10761090
setTimeout(() => {
10771091
if (!ptyProcess) {
@@ -1777,6 +1791,89 @@ ipcMain.on('set-login-item-settings', (_event, openAtLogin: boolean) => {
17771791
app.setLoginItemSettings({ openAtLogin });
17781792
});
17791793

1794+
// Session status hooks
1795+
let statusWatcherCleanup: (() => void) | null = null;
1796+
1797+
const initSessionStatusHooks = async () => {
1798+
try {
1799+
const enabled = (await settings.get('session-status-hooks')) !== false; // default: true
1800+
if (enabled) {
1801+
installHooks();
1802+
// Start watching for status changes
1803+
if (!statusWatcherCleanup) {
1804+
statusWatcherCleanup = watchStatusDir((statuses) => {
1805+
const obj: Record<string, SessionStatus> = {};
1806+
statuses.forEach((v, k) => { obj[k] = v; });
1807+
switcherWindow?.webContents.send('session-statuses-updated', obj);
1808+
});
1809+
}
1810+
}
1811+
} catch (e) {
1812+
if (isDebug) console.error('Failed to init session status hooks:', e);
1813+
}
1814+
};
1815+
1816+
ipcMain.handle('get-session-status-hooks-enabled', async () => {
1817+
return (await settings.get('session-status-hooks')) !== false;
1818+
});
1819+
1820+
ipcMain.on('set-session-status-hooks-enabled', async (_event, enabled: boolean) => {
1821+
await settings.set('session-status-hooks', enabled);
1822+
if (enabled) {
1823+
installHooks();
1824+
if (!statusWatcherCleanup) {
1825+
statusWatcherCleanup = watchStatusDir((statuses) => {
1826+
const obj: Record<string, SessionStatus> = {};
1827+
statuses.forEach((v, k) => { obj[k] = v; });
1828+
switcherWindow?.webContents.send('session-statuses-updated', obj);
1829+
});
1830+
}
1831+
} else {
1832+
removeHooks();
1833+
if (statusWatcherCleanup) {
1834+
statusWatcherCleanup();
1835+
statusWatcherCleanup = null;
1836+
}
1837+
// Clear renderer dots immediately
1838+
switcherWindow?.webContents.send('session-statuses-updated', {});
1839+
}
1840+
});
1841+
1842+
ipcMain.handle('get-session-statuses', async () => {
1843+
// Return empty when hooks disabled — all dots revert to purple
1844+
const enabled = (await settings.get('session-status-hooks')) !== false;
1845+
if (!enabled) return {};
1846+
1847+
const fileStatuses = readAllStatuses();
1848+
const obj: Record<string, SessionStatus> = {};
1849+
fileStatuses.forEach((v, k) => { obj[k] = v; });
1850+
1851+
// Scan active sessions that don't have status files yet + cleanup stale ones
1852+
try {
1853+
const activeMap = await detectActiveSessions();
1854+
cleanupStaleStatuses(new Set(activeMap.keys()));
1855+
const allSessions = readClaudeSessions(500); // hoisted out of loop
1856+
const sessionsWithoutStatus = Array.from(activeMap.entries())
1857+
.filter(([sessionId]) => !obj[sessionId])
1858+
.map(([sessionId]) => {
1859+
const session = allSessions.find((s: any) => s.sessionId === sessionId);
1860+
return session ? { sessionId, project: session.project } : null;
1861+
})
1862+
.filter(Boolean) as { sessionId: string; project: string }[];
1863+
1864+
if (sessionsWithoutStatus.length > 0) {
1865+
const scanned = await scanInitialStatuses(sessionsWithoutStatus);
1866+
scanned.forEach((v, k) => {
1867+
obj[k] = v;
1868+
// Persist scanned status to file so fs.watch treats all statuses uniformly
1869+
writeStatusFile(k, v as string);
1870+
});
1871+
}
1872+
} catch {}
1873+
1874+
return obj;
1875+
});
1876+
17801877
ipcMain.handle('get-session-terminal-app', async () => {
17811878
return (await settings.get('session-terminal-app')) || 'iterm2';
17821879
});

0 commit comments

Comments
 (0)