11import 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.
926final 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 }
0 commit comments