Skip to content

Commit 2d71746

Browse files
botus-aiclaude
andcommitted
Fix intermittent sleep-on-close / wont-sleep-after-session (v1.0.3)
Root-caused two opposite failures from the user's live power logs + transcripts: 1) Sleep on lid close mid-task: the semantic detector was effectively blind. Claude Code transcripts end most lines with metadata records (mode, ai-title, ...); the skip-list missed "mode", so in a quiet moment it read idle, dropped the assertion, and the Mac clamshell-slept. Rewrote it to scan for the last real user/assistant turn and ignore ALL metadata. 2) Won't sleep after a session ends: mid-turn/pending staleness was aged by the file mtime, which trailing metadata writes refresh without advancing the conversation, so finished sessions looked busy. Now every busy decision uses the record's own ISO-8601 timestamp. Terminal stop_reasons (end_turn/ stop_sequence/max_tokens/refusal) -> idle; unknown -> busy. Lid-closed helper latency 15s -> ~2s: heartbeat now written in place (atomic writes broke launchd WatchPaths) and the daemon is a reliable long-running 2s loop (KeepAlive, no WatchPaths/StartInterval); still fails safe ~30s after Vigil stops. Grace 300s -> 120s. Engage immediate on the reliable semantic signal, debounced only for the noisy CPU/network heuristic (loopback included for ollama). Verified live (semantic=working mid-tool, idle between turns; false-positives gone; daemon ~2s) and via adversarial review; tool_result confirmed to be a content block inside user records, not a separate type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 61fd8be commit 2d71746

7 files changed

Lines changed: 182 additions & 72 deletions

File tree

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.0.3 — 2026-06-02
4+
5+
Definitive reliability pass after a report of two opposite intermittent failures —
6+
the Mac sleeping on lid-close mid-task, *and* not sleeping after a session ended.
7+
Root-caused both from the user's actual power logs and transcripts.
8+
9+
- **Semantic detector was blind (caused sleep-on-close).** Claude Code transcripts end
10+
most lines with metadata records (`mode`, `ai-title`, …); the old detector's skip-list
11+
missed `mode`, so during a quiet moment (model thinking, low CPU/network) it read
12+
"idle," dropped the assertion, and the Mac clamshell-slept mid-task. Rewrote it to scan
13+
for the last real `user`/`assistant` turn and ignore *all* metadata (robust to unknown
14+
types).
15+
- **Staleness judged by the wrong clock (caused won't-sleep).** Mid-turn/pending state was
16+
aged by the file's mtime, which trailing metadata writes refresh without advancing the
17+
conversation — so finished/stale sessions looked "busy" indefinitely. Now every busy
18+
decision uses the *record's own* ISO-8601 timestamp. Terminal stop reasons
19+
(`end_turn`/`stop_sequence`/`max_tokens`/`refusal`) are idle; unknown reasons bias to busy.
20+
- **Lid-closed helper latency 15s → ~2s.** The heartbeat was written atomically (which
21+
breaks launchd `WatchPaths`) and the daemon polled every 15s — the lid-close race window.
22+
Now the heartbeat is written in place and the helper is a reliable long-running 2s loop
23+
(`KeepAlive`, no `WatchPaths`/`StartInterval`); still fails safe (restores sleep ~30s
24+
after Vigil stops).
25+
- Grace 300s → 120s (semantic now reliably bridges pauses, so the idle tail can be shorter).
26+
- Detector exposes semantic vs heuristic signals separately; engage is immediate on the
27+
reliable semantic signal and debounced only for the noisy CPU/network heuristic.
28+
329
## 1.0.2 — 2026-05-30
430

531
Major reliability pass after a report of the Mac sleeping / showing a lock screen

Sources/ActivityMonitor.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import Foundation
22

33
struct ActivitySnapshot {
44
var agentCount: Int // number of detected agent sessions (root processes)
5-
var activeAgentCount: Int // how many of those are working right now
5+
var activeAgentCount: Int // how many of those are working right now (combined)
6+
var semanticBusy: Int // Claude sessions mid-turn per transcript (reliable signal)
7+
var heuristicActive: Int // sessions over the network/CPU threshold (noisy signal)
68
var isActive: Bool // at least one agent is working right now
79
var netBytesPerSec: Double // throughput of the busiest agent
810
var cpuPercent: Double // CPU% of the busiest agent's subtree
911

10-
static let empty = ActivitySnapshot(agentCount: 0, activeAgentCount: 0,
11-
isActive: false, netBytesPerSec: 0, cpuPercent: 0)
12+
static let empty = ActivitySnapshot(agentCount: 0, activeAgentCount: 0, semanticBusy: 0,
13+
heuristicActive: 0, isActive: false,
14+
netBytesPerSec: 0, cpuPercent: 0)
1215
}
1316

1417
/// Polls the system to decide whether any watched AI-agent process is actively
@@ -147,6 +150,8 @@ final class ActivityMonitor {
147150

148151
return ActivitySnapshot(agentCount: totalAgents,
149152
activeAgentCount: activeAgents,
153+
semanticBusy: semanticBusy,
154+
heuristicActive: activeCount,
150155
isActive: activeAgents > 0,
151156
netBytesPerSec: peakRate,
152157
cpuPercent: peakCPU)

Sources/AppController.swift

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,23 @@ final class AppController {
6060

6161
private func handle(_ snap: ActivitySnapshot) {
6262
lastSnapshot = snap
63-
if snap.isActive {
63+
// The semantic signal (transcript mid-turn) is reliable, so it engages
64+
// immediately. The network/CPU heuristic is noisy, so it must persist for
65+
// a couple of polls before it counts — that debounce stops a single CPU
66+
// blip from a background job arming a long hold (the never-sleeps regression).
67+
if snap.heuristicActive > 0 {
6468
consecutiveActiveTicks += 1
6569
} else {
6670
consecutiveActiveTicks = 0
6771
}
68-
if consecutiveActiveTicks >= activateAfterTicks {
69-
lastActive = Date()
70-
}
72+
let working = snap.semanticBusy > 0 || consecutiveActiveTicks >= activateAfterTicks
73+
if working { lastActive = Date() }
74+
liveActive = working
7175
evaluate()
7276
}
7377

74-
/// Whether an agent is confirmed working right now (after debounce).
75-
private var liveActive: Bool { consecutiveActiveTicks >= activateAfterTicks }
78+
/// Whether an agent is confirmed working right now.
79+
private var liveActive = false
7680

7781
private func evaluate() {
7882
let prefs = Preferences.shared
@@ -95,7 +99,7 @@ final class AppController {
9599
let wantDisplay = (prefs.mode == .keepAwake || prefs.keepDisplayAwake) && engaged
96100

97101
if ProcessInfo.processInfo.environment["VIGIL_DEBUG"] != nil {
98-
NSLog("VIGIL mode=\(prefs.mode.rawValue) agents=\(lastSnapshot.agentCount) active=\(lastSnapshot.activeAgentCount) isActive=\(lastSnapshot.isActive) ticks=\(consecutiveActiveTicks) engaged=\(engaged) display=\(wantDisplay)")
102+
NSLog("VIGIL mode=\(prefs.mode.rawValue) agents=\(lastSnapshot.agentCount) sem=\(lastSnapshot.semanticBusy) heur=\(lastSnapshot.heuristicActive) ticks=\(consecutiveActiveTicks) live=\(liveActive) engaged=\(engaged) display=\(wantDisplay)")
99103
}
100104
sleep.apply(awake: engaged, keepDisplayAwake: wantDisplay)
101105

Sources/ClamshellController.swift

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ final class ClamshellController {
1212

1313
static let label = "app.vigil.clamshelld"
1414
private static let staleSeconds = 30
15-
private static let daemonInterval = 5 // backstop poll; WatchPaths fires sooner
15+
private static let pollSeconds = 2 // long-running daemon loop interval
1616

1717
private let fm = FileManager.default
1818

@@ -28,10 +28,27 @@ final class ClamshellController {
2828

2929
/// Write the heartbeat. `engaged == true` asks the daemon to disable sleep;
3030
/// the write also refreshes the file's mtime so the daemon sees it as fresh.
31+
///
32+
/// IMPORTANT: write IN PLACE (not atomically). An atomic write does temp+rename,
33+
/// which replaces the file's inode — and launchd's WatchPaths watches the old
34+
/// inode, so it stops firing after the first atomic write, leaving only the
35+
/// daemon's slow StartInterval poll. Writing in place keeps the same inode, so
36+
/// WatchPaths fires within ~1s of every heartbeat change. Measured: in-place
37+
/// write → daemon applies disablesleep in ~0-1s; atomic write → up to 15s.
38+
/// That latency is the lid-close race (close the lid before disablesleep=1 and
39+
/// the Mac clamshell-sleeps mid-task).
3140
func heartbeat(engaged: Bool) {
3241
let dir = (stateFile as NSString).deletingLastPathComponent
3342
try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true)
34-
try? (engaged ? "1" : "0").write(toFile: stateFile, atomically: true, encoding: .utf8)
43+
let byte = Data(engaged ? [0x31] : [0x30]) // "1" / "0"
44+
if let fh = FileHandle(forWritingAtPath: stateFile) {
45+
try? fh.truncate(atOffset: 0)
46+
try? fh.write(contentsOf: byte)
47+
try? fh.close()
48+
} else {
49+
// File doesn't exist yet — create it (first run / after uninstall).
50+
try? byte.write(to: URL(fileURLWithPath: stateFile))
51+
}
3552
}
3653

3754
// MARK: - Install / uninstall (one admin prompt)
@@ -127,21 +144,33 @@ final class ClamshellController {
127144
private func daemonScript() -> String {
128145
"""
129146
#!/bin/bash
130-
# Vigil clamshell helper. Reads a heartbeat file written by the Vigil app
131-
# and toggles `pmset disablesleep`. If the heartbeat is missing or stale,
132-
# sleep is re-enabled so the Mac can never get stuck awake.
147+
# Vigil clamshell helper (long-running). Polls a heartbeat file written by
148+
# the Vigil app every \(Self.pollSeconds)s and toggles `pmset disablesleep`.
149+
# A long-running loop is used instead of launchd StartInterval/WatchPaths
150+
# because WatchPaths is flaky on atomic writes and StartInterval was too
151+
# coarse (15s) — that latency was the lid-close race. This polls reliably.
152+
# If the heartbeat is missing or stale, sleep is re-enabled so the Mac can
153+
# never get stuck awake even if Vigil crashes.
133154
STATE_FILE="\(stateFile)"
134-
WANT=0
135-
if [ -f "$STATE_FILE" ]; then
136-
NOW=$(date +%s)
137-
MTIME=$(stat -f %m "$STATE_FILE" 2>/dev/null || echo 0)
138-
CONTENT=$(cat "$STATE_FILE" 2>/dev/null)
139-
AGE=$((NOW - MTIME))
140-
if [ "$CONTENT" = "1" ] && [ "$AGE" -le \(Self.staleSeconds) ]; then
141-
WANT=1
155+
while true; do
156+
WANT=0
157+
if [ -f "$STATE_FILE" ]; then
158+
NOW=$(date +%s)
159+
MTIME=$(stat -f %m "$STATE_FILE" 2>/dev/null || echo 0)
160+
CONTENT=$(cat "$STATE_FILE" 2>/dev/null)
161+
AGE=$((NOW - MTIME))
162+
if [ "$CONTENT" = "1" ] && [ "$AGE" -le \(Self.staleSeconds) ]; then
163+
WANT=1
164+
fi
165+
fi
166+
# Only call pmset when the state actually changes, to avoid churn.
167+
CUR=$(/usr/bin/pmset -g | awk '/SleepDisabled/{print $2}')
168+
[ -z "$CUR" ] && CUR=0
169+
if [ "$CUR" != "$WANT" ]; then
170+
/usr/bin/pmset -a disablesleep $WANT
142171
fi
143-
fi
144-
/usr/bin/pmset -a disablesleep $WANT
172+
sleep \(Self.pollSeconds)
173+
done
145174
"""
146175
}
147176

@@ -160,12 +189,8 @@ final class ClamshellController {
160189
</array>
161190
<key>RunAtLoad</key>
162191
<true/>
163-
<key>StartInterval</key>
164-
<integer>\(Self.daemonInterval)</integer>
165-
<key>WatchPaths</key>
166-
<array>
167-
<string>\(stateFile)</string>
168-
</array>
192+
<key>KeepAlive</key>
193+
<true/>
169194
<key>StandardErrorPath</key>
170195
<string>/dev/null</string>
171196
<key>StandardOutPath</key>

Sources/Preferences.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ final class Preferences {
4444
Key.mode: OperatingMode.automatic.rawValue,
4545
Key.keepDisplayAwake: true,
4646
Key.lidClosedMode: false,
47-
Key.gracePeriod: 300.0,
47+
Key.gracePeriod: 120.0,
4848
Key.pollInterval: 5.0,
4949
Key.netThreshold: 2048.0,
5050
Key.cpuThreshold: 5.0,

Sources/SemanticDetector.swift

Lines changed: 89 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,50 @@
11
import Foundation
22

33
/// Claude-specific activity detection by reading session transcripts in
4-
/// ~/.claude/projects/*.jsonl. A session is "busy" when its last meaningful
5-
/// record shows it's mid-turn (a pending user/tool message, or an assistant
6-
/// message whose stop_reason isn't "end_turn"). This is more precise than
4+
/// ~/.claude/projects/**.jsonl. A session is "busy" when its most recent *turn*
5+
/// record shows it's mid-turn a pending user/tool message, or an assistant
6+
/// message whose stop_reason isn't terminal. This is more precise than
77
/// network/CPU heuristics: it knows an agent is working even during a quiet
8-
/// pause between an API turn and the next tool call.
8+
/// pause (model thinking, a slow tool) when CPU and network are near zero.
9+
///
10+
/// Two robustness rules learned the hard way:
11+
///
12+
/// • Ignore metadata. Claude Code transcripts interleave conversation records
13+
/// (`user`, `assistant`) with many *metadata* records (`mode`, `ai-title`,
14+
/// `last-prompt`, `queue-operation`, `system`, `attachment`,
15+
/// `file-history-snapshot`, …) written constantly — including after a turn
16+
/// ends. We do NOT enumerate metadata types to skip (that list is open-ended;
17+
/// missing one silently breaks detection). We scan backwards for the last
18+
/// record that is specifically a `user` or `assistant` turn and judge that.
19+
///
20+
/// • Judge staleness from the RECORD's own timestamp, never the file mtime.
21+
/// Trailing metadata records refresh the file mtime without advancing the
22+
/// conversation, so an abandoned/long-finished turn would look "fresh" by
23+
/// mtime and wrongly keep the Mac awake. Each user/assistant record carries
24+
/// an ISO-8601 `timestamp`; we use that. File mtime is only a cheap
25+
/// pre-filter to avoid reading ancient transcripts.
926
final class SemanticDetector {
1027

11-
private let metaTypes: Set<String> = [
12-
"ai-title", "last-prompt", "custom-title", "file-history-snapshot",
13-
"queue-operation", "attachment", "system",
14-
]
15-
private let recentWindow: TimeInterval = 900 // ignore transcripts older than 15 min
28+
/// Don't even open transcripts whose file hasn't changed in this long.
29+
private let fileSkipWindow: TimeInterval = 3600
30+
/// An assistant mid-turn record older than this (by its own timestamp) is
31+
/// treated as stuck/abandoned → idle. Also bounds how long a silent local
32+
/// tool keeps the Mac awake on the semantic signal alone (~15 min); longer
33+
/// tools are covered by the CPU/network heuristic or Keep Awake mode.
34+
private let midTurnWindow: TimeInterval = 900
35+
/// A pending user/tool_result unanswered this long → the assistant isn't
36+
/// actually working (a working assistant writes within seconds).
37+
private let userPendingStale: TimeInterval = 180
38+
39+
private static let isoFrac: ISO8601DateFormatter = {
40+
let f = ISO8601DateFormatter()
41+
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
42+
return f
43+
}()
44+
private static let isoPlain = ISO8601DateFormatter()
45+
private static func parseTimestamp(_ s: String) -> Date? {
46+
Self.isoFrac.date(from: s) ?? Self.isoPlain.date(from: s)
47+
}
1648

1749
private var projectsDir: URL {
1850
URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".claude/projects")
@@ -36,54 +68,72 @@ final class SemanticDetector {
3668
for case let url as URL in en where url.pathExtension == "jsonl" {
3769
guard let vals = try? url.resourceValues(forKeys: [.contentModificationDateKey]),
3870
let mtime = vals.contentModificationDate else { continue }
39-
let age = now.timeIntervalSince(mtime)
40-
if age > recentWindow { continue }
41-
if isBusy(url, age: age) { busy += 1 }
71+
let fileAge = now.timeIntervalSince(mtime)
72+
if fileAge > fileSkipWindow { continue } // cheap pre-filter only
73+
if isBusy(url, now: now, fileAge: fileAge) { busy += 1 }
4274
}
4375
return busy
4476
}
4577

46-
private func isBusy(_ url: URL, age: TimeInterval) -> Bool {
47-
guard let obj = lastMeaningfulRecord(url) else { return false }
48-
// The caller already filtered to transcripts modified within recentWindow,
49-
// so reaching here means the session wrote in the last 15 min. We do NOT
50-
// apply a shorter "zombie" cutoff: a long-running tool (a 10-min test, a
51-
// slow build, a model thinking) writes a single tool_use/user record and
52-
// then goes silent — penalising that with a 3-min cutoff is exactly how a
53-
// working agent gets slept. The 15-min window is the single staleness bound.
54-
switch obj["type"] as? String {
55-
case "user":
56-
// A pending user message or tool_result → the assistant is working.
57-
return true
58-
case "assistant":
59-
let msg = obj["message"] as? [String: Any] ?? [:]
60-
// end_turn = finished and waiting for the user → idle. Anything else
61-
// (tool_use / max_tokens / pause_turn / null) → still working.
62-
return (msg["stop_reason"] as? String) != "end_turn"
63-
default:
64-
return false
78+
private enum TurnKind { case user, assistant }
79+
private struct Turn { var kind: TurnKind; var stopReason: String?; var timestamp: Date? }
80+
81+
private func isBusy(_ url: URL, now: Date, fileAge: TimeInterval) -> Bool {
82+
guard let turn = lastTurn(url) else { return false }
83+
// Age of the turn itself, from its own timestamp; fall back to file mtime
84+
// only if the record carries no parseable timestamp.
85+
let age = turn.timestamp.map { now.timeIntervalSince($0) } ?? fileAge
86+
switch turn.kind {
87+
case .assistant:
88+
// Terminal reasons mean the turn is over, waiting for the human → idle.
89+
// Everything else (nil = streaming, tool_use, pause_turn, or any
90+
// unknown/future reason) is treated as still working — bias to awake.
91+
if isTerminal(turn.stopReason) { return false }
92+
return age < midTurnWindow
93+
case .user:
94+
// Pending user/tool_result: the assistant owes a response. Only counts
95+
// while genuinely fresh — a long-unanswered message is stuck/idle.
96+
return age < userPendingStale
6597
}
6698
}
6799

68-
/// Read the tail of the file and return the last non-metadata JSON record.
69-
private func lastMeaningfulRecord(_ url: URL) -> [String: Any]? {
100+
private func isTerminal(_ stopReason: String?) -> Bool {
101+
guard let sr = stopReason else { return false } // nil = still streaming
102+
return sr == "end_turn" || sr == "stop_sequence" || sr == "max_tokens" || sr == "refusal"
103+
}
104+
105+
/// Scan the tail backwards for the last `user`/`assistant` turn record.
106+
private func lastTurn(_ url: URL) -> Turn? {
70107
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
71108
defer { try? handle.close() }
72109
let size = handle.seekToEndOfFile()
73-
let tail: UInt64 = 8192
110+
// Read a generous tail so the last user/assistant record is included even
111+
// after a burst of trailing metadata records (observed up to ~15 KB; 128 KB
112+
// gives a large margin). Tool results are top-level `user` records, so they
113+
// are matched normally — there is no separate `tool_result` record type.
114+
let tail: UInt64 = 131_072
74115
handle.seek(toFileOffset: size > tail ? size - tail : 0)
75-
let data = handle.readDataToEndOfFile()
76-
guard !data.isEmpty,
116+
guard let data = try? handle.readToEnd(), !data.isEmpty,
77117
let text = String(data: data, encoding: .utf8) else { return nil }
78118

79119
for line in text.split(separator: "\n").reversed() {
80120
let trimmed = line.trimmingCharacters(in: .whitespaces)
81121
guard !trimmed.isEmpty,
82122
let lineData = trimmed.data(using: .utf8),
83-
let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any]
123+
let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
124+
let type = obj["type"] as? String
84125
else { continue }
85-
if let type = obj["type"] as? String, metaTypes.contains(type) { continue }
86-
return obj
126+
127+
let ts = (obj["timestamp"] as? String).flatMap(Self.parseTimestamp)
128+
switch type {
129+
case "assistant":
130+
let sr = (obj["message"] as? [String: Any])?["stop_reason"] as? String
131+
return Turn(kind: .assistant, stopReason: sr, timestamp: ts)
132+
case "user":
133+
return Turn(kind: .user, stopReason: nil, timestamp: ts)
134+
default:
135+
continue // metadata (mode/ai-title/system/…) — keep scanning back
136+
}
87137
}
88138
return nil
89139
}

Sources/main.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ if CommandLine.arguments.contains("--diagnose") {
1919
let s = ActivityMonitor().diagnostic()
2020
print("""
2121
agents detected : \(s.agentCount)
22-
agents working : \(s.activeAgentCount)
22+
agents working : \(s.activeAgentCount) (semantic=\(s.semanticBusy), heuristic=\(s.heuristicActive))
2323
keeping awake? : \(s.isActive)
2424
busiest agent : \(String(format: "%.0f", s.netBytesPerSec)) B/s, \(String(format: "%.1f", s.cpuPercent))% CPU
2525
watch patterns : \(Preferences.shared.watchPatterns.joined(separator: ", "))

0 commit comments

Comments
 (0)