Skip to content

Commit fe54b2d

Browse files
botus-aiclaude
andcommitted
Rebuild detection on ground truth: Claude Code hooks (v1.1.0)
After repeated both-ways failures (sleeps mid-task / won't sleep after), the root problem was the approach: inferring agent state from transcript formats and CPU/network thresholds is inherently fragile. Vigil now installs a Claude Code hook (merged into ~/.claude/settings.json — one-time backup, idempotent, refuses unparseable JSON, user's hooks untouched) so Claude itself reports state instantly: UserPromptSubmit/PreToolUse/PostToolUse -> active (with claude pid) Notification (permission/idle prompt) -> waiting (human needed; may sleep) Stop/SessionEnd -> idle Detection tiers: hooks (primary, immediate) > transcript semantic (only for sessions without markers) > debounced net/CPU heuristic. Found the main "never sleeps" culprit on real hardware: an IDLE Claude Desktop window burns ~10% CPU compositing its Electron UI, permanently tripping the CPU threshold - GUI apps are now judged by sustained network only. Claude Code CLI processes are dropped from the heuristic when hooks are live. Hardening from adversarial review: pid-less active markers trusted only 10 min; stale markers cross-checked against the transcript (lost-Stop guard: turn ended after marker -> idle; mid-tool -> stays active, so a single long tool survives); hook script lands the event before pid discovery so a slow ps can never lose it. Verified: settings merge diffed (all user keys/hooks preserved, idempotent across relaunches); hook script per-event; detector states incl. dead-pid/ stale/waiting/lost-Stop/long-tool; modes off/keepAwake; live ground truth (real markers from real sessions, active with live pid / idle after Stop). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 595f7c0 commit fe54b2d

12 files changed

Lines changed: 448 additions & 33 deletions

CHANGELOG.md

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

3+
## 1.1.0 — 2026-06-10
4+
5+
**Detection rebuilt on ground truth.** After repeated reports of both failure
6+
modes (sleeps mid-task / won't sleep after), the root problem was the approach:
7+
guessing agent state from transcript formats and CPU/network thresholds is
8+
inherently fragile. Vigil now installs a tiny **Claude Code hook** (merged into
9+
`~/.claude/settings.json` with a one-time backup; removable from the menu), so
10+
Claude itself reports session state the instant it changes:
11+
12+
- `UserPromptSubmit` / `PreToolUse` / `PostToolUse` → session is **working**
13+
- `Notification` (permission prompt / waiting for input) → **a human is needed**
14+
— the Mac may sleep instead of staying awake at a forgotten permission dialog
15+
- `Stop` / `SessionEnd`**turn over** — sleep follows after the short grace
16+
17+
No more format guessing, no thresholds, no polling lag for Claude Code sessions.
18+
Crash-safe: an "active" marker only counts while its claude process is alive
19+
(and never longer than 2h without a refresh).
20+
21+
Also found & fixed the main "never sleeps" culprit on real hardware: **an idle
22+
Claude Desktop window**. Its Electron helpers burn ~10% CPU just compositing the
23+
UI, which permanently tripped the CPU heuristic. GUI apps are now judged by
24+
sustained network only (streaming = working); CPU stays meaningful for CLI
25+
agents. Plus: transcript detection now only covers sessions without hook
26+
markers, and Claude Code CLI processes are dropped from the heuristic when
27+
hooks are live.
28+
329
## 1.0.5 — 2026-06-03
430

531
- **No more confusing/flickering agent count.** The menu used to show "N of M

README.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ Click the menu-bar eye icon:
8282
- **Automatic** — keep awake only while an AI agent is working (the default).
8383
- **Keep Awake** / **Off** — classic manual modes.
8484
- **Watch for…** — pick which AI tools to track.
85-
- **Semantic detection** — read Claude transcripts for precise mid-turn detection.
85+
- **Precise detection (Claude Code hooks)** — ground truth from Claude itself (default on).
86+
- **Semantic detection** — read Claude transcripts (fallback for sessions without hooks).
8687
- **Keep awake with lid closed** — one-time admin approval installs the helper.
8788
- **Stop other keep-awake tools** — quit redundant `caffeinate` / scripts.
8889
- **Launch at Login** — on by default.
@@ -91,17 +92,23 @@ Icons: `eye` = idle · `eye.fill` = keeping awake · `bolt.fill` = force-awake
9192

9293
## How it works
9394

94-
Each agent session is evaluated **individually** (so a crowd of idle/leftover sessions can
95-
never falsely keep the Mac awake). A session is *working* when any of these is true:
95+
Detection is layered, most reliable first:
9696

97-
1. **Semantic state (Claude)** — its transcript shows it's mid-turn (`stop_reason != end_turn`).
98-
2. **API throughput** — sustained network traffic, *including loopback* so agents talking to
99-
a local model server (e.g. ollama on `127.0.0.1`) count too.
100-
3. **Subtree CPU** — the process *and its children* are busy (catches long local test/build runs).
97+
1. **Ground truth — Claude Code hooks.** Vigil installs a tiny hook (merged into
98+
`~/.claude/settings.json`, one-time backup kept, removable from the menu), so Claude
99+
itself reports state the instant it changes: prompt submitted / tool running → working;
100+
permission prompt or waiting for input → a human is needed (sleep allowed); turn done →
101+
idle. No guessing, no polling lag. Crash-safe: a "working" marker only counts while its
102+
claude process is actually alive.
103+
2. **Semantic state (fallback)** — for sessions without hooks, the transcript shows whether
104+
it's mid-turn (`stop_reason != end_turn`).
105+
3. **Network/CPU heuristic (for non-Claude tools)** — sustained traffic *including loopback*
106+
(local ollama counts) or subtree CPU. GUI apps like Claude Desktop are judged by network
107+
only — an idle Electron window burns ~10% CPU just compositing, which is not "work".
101108

102109
To avoid false positives, `claude`-named background tooling (vault sync, memory daemons,
103-
Vigil's own helper) is excluded, and detection is debounced so a single CPU blip can't pin
104-
the Mac awake.
110+
Vigil's own helper) is excluded, and the heuristic is debounced so a single CPU blip can't
111+
pin the Mac awake.
105112

106113
While any agent works, Vigil holds IOKit power assertions for **both** system and display
107114
sleep (so the screen doesn't lock on you mid-task), plus a grace period after work stops.

Resources/Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
<key>CFBundleIdentifier</key>
1010
<string>app.vigil.menubar</string>
1111
<key>CFBundleVersion</key>
12-
<string>1.0.0</string>
12+
<string>1.1.0</string>
1313
<key>CFBundleShortVersionString</key>
14-
<string>1.0.0</string>
14+
<string>1.1.0</string>
1515
<key>CFBundlePackageType</key>
1616
<string>APPL</string>
1717
<key>CFBundleExecutable</key>

Sources/ActivityMonitor.swift

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import Foundation
33
struct ActivitySnapshot {
44
var agentCount: Int // number of detected agent sessions (root processes)
55
var activeAgentCount: Int // how many of those are working right now (combined)
6-
var semanticBusy: Int // Claude sessions mid-turn per transcript (reliable signal)
6+
var hookActive: Int // sessions mid-turn per Claude Code hooks (ground truth)
7+
var semanticBusy: Int // non-hooked sessions mid-turn per transcript (fallback)
78
var heuristicActive: Int // sessions over the network/CPU threshold (noisy signal)
89
var isActive: Bool // at least one agent is working right now
910
var netBytesPerSec: Double // throughput of the busiest agent
1011
var cpuPercent: Double // CPU% of the busiest agent's subtree
1112

12-
static let empty = ActivitySnapshot(agentCount: 0, activeAgentCount: 0, semanticBusy: 0,
13-
heuristicActive: 0, isActive: false,
13+
static let empty = ActivitySnapshot(agentCount: 0, activeAgentCount: 0, hookActive: 0,
14+
semanticBusy: 0, heuristicActive: 0, isActive: false,
1415
netBytesPerSec: 0, cpuPercent: 0)
1516
}
1617

@@ -29,6 +30,14 @@ final class ActivityMonitor {
2930
private var timer: DispatchSourceTimer?
3031

3132
private let semantic = SemanticDetector()
33+
private let hooks = HookSessionDetector()
34+
35+
init() {
36+
// Lost-Stop guard: stale "active" hook markers are validated against the
37+
// session transcript (capture `semantic` directly — no cycle).
38+
let sem = semantic
39+
hooks.transcriptSaysEnded = { sid, marker in sem.turnEnded(sessionId: sid, after: marker) }
40+
}
3241

3342
// Per-PID cumulative byte counters from the previous nettop sample.
3443
private var prevBytesIn: [Int: UInt64] = [:]
@@ -77,23 +86,38 @@ final class ActivityMonitor {
7786
guard !patterns.isEmpty else { return .empty }
7887
let excludes = prefs.watchExcludes.map { $0.lowercased() }.filter { !$0.isEmpty }
7988

80-
// Primary signal for Claude: transcript state. Counts mid-turn sessions.
81-
let semanticBusy = prefs.semanticDetection ? semantic.busySessionCount() : 0
89+
// PRIMARY signal: Claude Code hooks (ground truth — Claude tells us when a
90+
// turn starts/ends). Sessions with a hook marker are "governed": for them
91+
// we trust the hooks completely and skip transcript guessing.
92+
let hookScan = hooks.scan()
93+
let hooksLive = !hookScan.governedSessions.isEmpty
94+
95+
// FALLBACK signal: transcript state, only for sessions hooks don't govern
96+
// (sessions started before hook install, or if hooks are removed).
97+
let semanticBusy = prefs.semanticDetection
98+
? semantic.busySessionCount(excludingSessions: hookScan.governedSessions) : 0
8299

83100
let procs = readProcesses()
84101
// A process is a watched agent if it matches a watch pattern AND is not
85102
// excluded. The exclude list keeps "claude"-named background tooling
86103
// (vault sync, memory daemons, Vigil's own helper) from being mistaken
87-
// for a working agent and pinning the Mac awake.
104+
// for a working agent and pinning the Mac awake. When hooks are live,
105+
// Claude Code CLI processes are governed by hooks — drop them from the
106+
// CPU/network heuristic too, so an idle VS Code session that briefly
107+
// spikes CPU can't fake "working".
88108
let watched = procs.filter { p in
89109
let c = p.command.lowercased()
90110
guard patterns.contains(where: { c.contains($0) }) else { return false }
91-
return !excludes.contains(where: { c.contains($0) })
111+
if excludes.contains(where: { c.contains($0) }) { return false }
112+
if hooksLive && c.contains("native-binary/claude") { return false }
113+
return true
92114
}
93-
guard !watched.isEmpty || semanticBusy > 0 else {
115+
guard !watched.isEmpty || semanticBusy > 0 || hookScan.activeCount > 0 else {
94116
prevBytesIn = [:]
95117
prevSampleDate = nil
96-
return .empty
118+
var snap = ActivitySnapshot.empty
119+
snap.agentCount = hookScan.governedSessions.count
120+
return snap
97121
}
98122

99123
let watchedPIDs = Set(watched.map { $0.pid })
@@ -133,7 +157,11 @@ final class ActivityMonitor {
133157

134158
// Evaluated per session: a single working agent is enough, and a
135159
// crowd of idle ones never sums its way over the threshold.
136-
if rate > prefs.netThresholdBytesPerSec || cpu > prefs.cpuThresholdPercent {
160+
// GUI apps (Claude Desktop & co) idle at ~10% CPU just compositing
161+
// their Electron UI, so CPU is meaningless for them — only sustained
162+
// network (streaming a response) counts. CLI agents keep CPU+network.
163+
let isGUIApp = session.command.contains(".app/Contents/MacOS/")
164+
if rate > prefs.netThresholdBytesPerSec || (!isGUIApp && cpu > prefs.cpuThresholdPercent) {
137165
activeCount += 1
138166
}
139167
peakRate = max(peakRate, rate)
@@ -143,16 +171,17 @@ final class ActivityMonitor {
143171
prevBytesIn = bytesIn
144172
prevSampleDate = now
145173

146-
// Combine the heuristic (network/CPU) with the semantic signal: an agent
147-
// is working if either says so.
148-
let totalAgents = max(sessions.count, semanticBusy)
149-
let activeAgents = max(activeCount, semanticBusy)
174+
// Combine: hooks (ground truth) + semantic (non-hooked fallback) engage
175+
// immediately; the network/CPU heuristic is debounced by the controller.
176+
let immediate = hookScan.activeCount + semanticBusy
177+
let totalAgents = max(sessions.count + hookScan.governedSessions.count, immediate)
150178

151179
return ActivitySnapshot(agentCount: totalAgents,
152-
activeAgentCount: activeAgents,
180+
activeAgentCount: max(activeCount, immediate),
181+
hookActive: hookScan.activeCount,
153182
semanticBusy: semanticBusy,
154183
heuristicActive: activeCount,
155-
isActive: activeAgents > 0,
184+
isActive: immediate > 0,
156185
netBytesPerSec: peakRate,
157186
cpuPercent: peakCPU)
158187
}

Sources/AppController.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ final class AppController {
6969
} else {
7070
consecutiveActiveTicks = 0
7171
}
72-
let working = snap.semanticBusy > 0 || consecutiveActiveTicks >= activateAfterTicks
72+
// Hooks (ground truth) and semantic (fallback) engage immediately; only
73+
// the noisy CPU/network heuristic needs the debounce.
74+
let working = snap.hookActive > 0 || snap.semanticBusy > 0
75+
|| consecutiveActiveTicks >= activateAfterTicks
7376
if working { lastActive = Date() }
7477
liveActive = working
7578
evaluate()
@@ -99,7 +102,7 @@ final class AppController {
99102
let wantDisplay = (prefs.mode == .keepAwake || prefs.keepDisplayAwake) && engaged
100103

101104
if ProcessInfo.processInfo.environment["VIGIL_DEBUG"] != nil {
102-
NSLog("VIGIL mode=\(prefs.mode.rawValue) agents=\(lastSnapshot.agentCount) sem=\(lastSnapshot.semanticBusy) heur=\(lastSnapshot.heuristicActive) ticks=\(consecutiveActiveTicks) live=\(liveActive) engaged=\(engaged) display=\(wantDisplay)")
105+
NSLog("VIGIL mode=\(prefs.mode.rawValue) agents=\(lastSnapshot.agentCount) hooks=\(lastSnapshot.hookActive) sem=\(lastSnapshot.semanticBusy) heur=\(lastSnapshot.heuristicActive) ticks=\(consecutiveActiveTicks) live=\(liveActive) engaged=\(engaged) display=\(wantDisplay)")
103106
}
104107
sleep.apply(awake: engaged, keepDisplayAwake: wantDisplay)
105108

Sources/AppDelegate.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
1818
LaunchAtLogin.set(true)
1919
}
2020

21+
// Out of the box: install the ground-truth Claude Code hook (merged into
22+
// ~/.claude/settings.json with a one-time backup; removable from the menu).
23+
// Called every launch: the settings merge is idempotent, and rewriting
24+
// the hook script keeps it current across app updates.
25+
if Preferences.shared.hooksEnabled, !HooksInstaller().install() {
26+
NSLog("Vigil: hook install failed — falling back to transcript/heuristic detection")
27+
}
28+
2129
// First-run welcome + optional lid-closed setup (after the menu appears).
2230
if !Preferences.shared.didOnboard {
2331
Preferences.shared.didOnboard = true

Sources/HookSessionDetector.swift

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import Foundation
2+
3+
struct HookScan {
4+
var governedSessions: Set<String> // session ids that have any marker (hooks are live for them)
5+
var activeCount: Int // sessions confirmed mid-turn right now
6+
var waitingCount: Int // sessions waiting for the human (permission / idle prompt)
7+
8+
static let empty = HookScan(governedSessions: [], activeCount: 0, waitingCount: 0)
9+
}
10+
11+
/// Ground-truth activity detection via Claude Code hooks.
12+
///
13+
/// Vigil installs a tiny hook script into ~/.claude/settings.json (see
14+
/// HooksInstaller). Claude Code then *tells us* what each session is doing:
15+
/// UserPromptSubmit / PreToolUse / PostToolUse → "active <claude-pid> <ts>"
16+
/// Notification (permission prompt / waiting for input) → "waiting 0 <ts>"
17+
/// Stop / SessionEnd → "idle 0 <ts>"
18+
/// One marker file per session id. This replaces guessing from transcript
19+
/// formats and CPU/network thresholds with events emitted by Claude Code
20+
/// itself — instant on turn start, instant on turn end.
21+
///
22+
/// Crash-safety: an "active" marker is only honoured while the recorded claude
23+
/// process is still alive, and never longer than `activeTrustWindow` without a
24+
/// refresh (a turn that long without a single tool call means a dead/hung
25+
/// session — multi-tool turns refresh on every PostToolUse).
26+
final class HookSessionDetector {
27+
28+
static var sessionsDir: String {
29+
if let o = ProcessInfo.processInfo.environment["VIGIL_SESSIONS_DIR"], !o.isEmpty { return o }
30+
return NSHomeDirectory() + "/Library/Application Support/Vigil/sessions"
31+
}
32+
33+
private let activeTrustWindow: TimeInterval = 2 * 3600
34+
/// An "active" marker with no recorded pid (the hook couldn't identify its
35+
/// claude process) is weaker evidence — trust it only briefly.
36+
private let pidlessTrustWindow: TimeInterval = 600
37+
/// Markers older than this get cross-checked against the transcript, which
38+
/// catches a lost Stop event (turn actually ended but the idle marker never
39+
/// arrived, e.g. the hook was killed at the wrong moment).
40+
private let crossCheckAge: TimeInterval = 600
41+
private let markerTTL: TimeInterval = 48 * 3600
42+
43+
/// Injected: returns true if the session's transcript shows the turn ENDED
44+
/// after the given marker time (terminal stop_reason with a newer timestamp).
45+
var transcriptSaysEnded: ((String, Date) -> Bool)?
46+
47+
func scan() -> HookScan {
48+
var result = HookScan.empty
49+
let fm = FileManager.default
50+
guard let names = try? fm.contentsOfDirectory(atPath: Self.sessionsDir) else { return result }
51+
let now = Date().timeIntervalSince1970
52+
for name in names where !name.hasPrefix(".") {
53+
let path = Self.sessionsDir + "/" + name
54+
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
55+
let parts = content.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" })
56+
guard parts.count >= 3,
57+
let pid = Int32(parts[1]),
58+
let ts = TimeInterval(parts[2]) else { continue }
59+
let age = now - ts
60+
if age > markerTTL { try? fm.removeItem(atPath: path); continue }
61+
result.governedSessions.insert(name)
62+
switch parts[0] {
63+
case "active":
64+
var ok: Bool
65+
if pid > 0 {
66+
// Strong evidence: the claude process must still be alive,
67+
// bounded by the trust window (multi-tool turns refresh on
68+
// every PostToolUse; a single silent tool is covered up to
69+
// the window).
70+
ok = processAlive(pid) && age < activeTrustWindow
71+
} else {
72+
ok = age < pidlessTrustWindow
73+
}
74+
// Lost-Stop guard: if the marker is old but the transcript shows
75+
// the turn ended AFTER it was written, the idle event was lost —
76+
// don't keep the Mac awake on a finished turn.
77+
if ok, age > crossCheckAge,
78+
let ended = transcriptSaysEnded,
79+
ended(name, Date(timeIntervalSince1970: ts)) {
80+
ok = false
81+
}
82+
if ok { result.activeCount += 1 }
83+
case "waiting":
84+
result.waitingCount += 1
85+
default:
86+
break // idle
87+
}
88+
}
89+
return result
90+
}
91+
92+
private func processAlive(_ pid: Int32) -> Bool {
93+
if kill(pid, 0) == 0 { return true }
94+
return errno == EPERM
95+
}
96+
}

0 commit comments

Comments
 (0)