overcode 0.1.3__py3-none-any.whl → 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +7 -2
  3. overcode/implementations.py +74 -8
  4. overcode/monitor_daemon.py +60 -65
  5. overcode/monitor_daemon_core.py +261 -0
  6. overcode/monitor_daemon_state.py +7 -0
  7. overcode/session_manager.py +1 -0
  8. overcode/settings.py +22 -0
  9. overcode/supervisor_daemon.py +48 -47
  10. overcode/supervisor_daemon_core.py +210 -0
  11. overcode/testing/__init__.py +6 -0
  12. overcode/testing/renderer.py +268 -0
  13. overcode/testing/tmux_driver.py +223 -0
  14. overcode/testing/tui_eye.py +185 -0
  15. overcode/testing/tui_eye_skill.md +187 -0
  16. overcode/tmux_manager.py +17 -3
  17. overcode/tui.py +196 -2462
  18. overcode/tui_actions/__init__.py +20 -0
  19. overcode/tui_actions/daemon.py +201 -0
  20. overcode/tui_actions/input.py +128 -0
  21. overcode/tui_actions/navigation.py +117 -0
  22. overcode/tui_actions/session.py +428 -0
  23. overcode/tui_actions/view.py +357 -0
  24. overcode/tui_helpers.py +41 -9
  25. overcode/tui_logic.py +347 -0
  26. overcode/tui_render.py +414 -0
  27. overcode/tui_widgets/__init__.py +24 -0
  28. overcode/tui_widgets/command_bar.py +399 -0
  29. overcode/tui_widgets/daemon_panel.py +153 -0
  30. overcode/tui_widgets/daemon_status_bar.py +245 -0
  31. overcode/tui_widgets/help_overlay.py +71 -0
  32. overcode/tui_widgets/preview_pane.py +69 -0
  33. overcode/tui_widgets/session_summary.py +514 -0
  34. overcode/tui_widgets/status_timeline.py +253 -0
  35. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/METADATA +3 -1
  36. overcode-0.1.4.dist-info/RECORD +68 -0
  37. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  38. overcode-0.1.3.dist-info/RECORD +0 -45
  39. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/WHEEL +0 -0
  40. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  41. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
overcode/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
2
  Overcode - A supervisor for managing multiple Claude Code instances.
3
3
  """
4
4
 
5
- __version__ = "0.1.3"
5
+ __version__ = "0.1.4"
overcode/cli.py CHANGED
@@ -150,7 +150,7 @@ def list_agents(session: SessionOption = "agents"):
150
150
  uptime = calculate_uptime(sess.start_time) if sess.start_time else "?"
151
151
 
152
152
  # Get state times using shared helper
153
- green_time, non_green_time = get_current_state_times(sess.stats)
153
+ green_time, non_green_time, sleep_time = get_current_state_times(sess.stats, is_asleep=sess.is_asleep)
154
154
 
155
155
  # Get stats from Claude Code history and session files
156
156
  stats = get_session_stats(sess)
@@ -159,9 +159,14 @@ def list_agents(session: SessionOption = "agents"):
159
159
  else:
160
160
  stats_display = " -i -"
161
161
 
162
+ # Build time display - show sleep time if agent has slept
163
+ time_display = f"▶{format_duration(green_time):>5} ⏸{format_duration(non_green_time):>5}"
164
+ if sleep_time > 0:
165
+ time_display += f" 💤{format_duration(sleep_time):>5}"
166
+
162
167
  print(
163
168
  f"{symbol} {sess.name:<16} ↑{uptime:>5} "
164
- f"{format_duration(green_time):>5} ⏸{format_duration(non_green_time):>5} "
169
+ f"{time_display} "
165
170
  f"{stats_display} {activity[:50]}"
166
171
  )
167
172
 
@@ -17,7 +17,14 @@ from libtmux._internal.query_list import ObjectDoesNotExist
17
17
 
18
18
 
19
19
  class RealTmux:
20
- """Production implementation of TmuxInterface using libtmux"""
20
+ """Production implementation of TmuxInterface using libtmux.
21
+
22
+ Includes caching to reduce subprocess overhead. libtmux spawns a new
23
+ subprocess for every tmux command, which is expensive at high frequencies.
24
+ """
25
+
26
+ # Cache TTL in seconds - pane objects rarely change
27
+ _CACHE_TTL = 30.0
21
28
 
22
29
  def __init__(self, socket_name: Optional[str] = None):
23
30
  """Initialize with optional socket name for test isolation.
@@ -27,6 +34,10 @@ class RealTmux:
27
34
  # Support OVERCODE_TMUX_SOCKET env var for testing
28
35
  self._socket_name = socket_name or os.environ.get("OVERCODE_TMUX_SOCKET")
29
36
  self._server: Optional[libtmux.Server] = None
37
+ # Cache: (session_name, window_index) -> (pane, timestamp)
38
+ self._pane_cache: Dict[tuple, tuple] = {}
39
+ # Cache: session_name -> (session_obj, timestamp)
40
+ self._session_cache: Dict[str, tuple] = {}
30
41
 
31
42
  @property
32
43
  def server(self) -> libtmux.Server:
@@ -39,9 +50,17 @@ class RealTmux:
39
50
  return self._server
40
51
 
41
52
  def _get_session(self, session: str) -> Optional[libtmux.Session]:
42
- """Get a session by name, or None if it doesn't exist."""
53
+ """Get a session by name, with caching."""
54
+ now = time.time()
55
+ if session in self._session_cache:
56
+ cached_session, cached_time = self._session_cache[session]
57
+ if now - cached_time < self._CACHE_TTL:
58
+ return cached_session
59
+
43
60
  try:
44
- return self.server.sessions.get(session_name=session)
61
+ sess = self.server.sessions.get(session_name=session)
62
+ self._session_cache[session] = (sess, now)
63
+ return sess
45
64
  except (LibTmuxException, ObjectDoesNotExist):
46
65
  return None
47
66
 
@@ -56,11 +75,42 @@ class RealTmux:
56
75
  return None
57
76
 
58
77
  def _get_pane(self, session: str, window: int) -> Optional[libtmux.Pane]:
59
- """Get the first pane of a window."""
78
+ """Get the first pane of a window, with caching."""
79
+ cache_key = (session, window)
80
+ now = time.time()
81
+
82
+ # Check cache first
83
+ if cache_key in self._pane_cache:
84
+ cached_pane, cached_time = self._pane_cache[cache_key]
85
+ if now - cached_time < self._CACHE_TTL:
86
+ return cached_pane
87
+
88
+ # Cache miss - fetch from tmux
60
89
  win = self._get_window(session, window)
61
90
  if win is None or not win.panes:
62
91
  return None
63
- return win.panes[0]
92
+ pane = win.panes[0]
93
+ self._pane_cache[cache_key] = (pane, now)
94
+ return pane
95
+
96
+ def invalidate_cache(self, session: str = None, window: int = None) -> None:
97
+ """Invalidate cached objects.
98
+
99
+ Args:
100
+ session: If provided, invalidate only this session's cache
101
+ window: If provided with session, invalidate only this window's pane
102
+ """
103
+ if session is None:
104
+ self._pane_cache.clear()
105
+ self._session_cache.clear()
106
+ elif window is not None:
107
+ self._pane_cache.pop((session, window), None)
108
+ else:
109
+ self._session_cache.pop(session, None)
110
+ # Remove all panes for this session
111
+ keys_to_remove = [k for k in self._pane_cache if k[0] == session]
112
+ for k in keys_to_remove:
113
+ del self._pane_cache[k]
64
114
 
65
115
  def capture_pane(self, session: str, window: int, lines: int = 100) -> Optional[str]:
66
116
  try:
@@ -74,6 +124,8 @@ class RealTmux:
74
124
  return '\n'.join(captured)
75
125
  return captured
76
126
  except LibTmuxException:
127
+ # Pane may have been killed - invalidate cache and retry once
128
+ self.invalidate_cache(session, window)
77
129
  return None
78
130
 
79
131
  def send_keys(self, session: str, window: int, keys: str, enter: bool = True) -> bool:
@@ -85,9 +137,23 @@ class RealTmux:
85
137
  # For Claude Code: text and Enter must be sent as SEPARATE commands
86
138
  # with a small delay, otherwise Claude Code doesn't process the Enter.
87
139
  if keys:
88
- pane.send_keys(keys, enter=False)
89
- # Small delay for Claude Code to process text
90
- time.sleep(0.1)
140
+ # Special handling for ! commands (#139)
141
+ # Claude Code requires ! to be sent separately to trigger mode switch
142
+ # to bash mode before receiving the rest of the command
143
+ if keys.startswith('!') and len(keys) > 1:
144
+ # Send ! first
145
+ pane.send_keys('!', enter=False)
146
+ # Wait for mode switch to process
147
+ time.sleep(0.15)
148
+ # Send the rest (without the !)
149
+ rest = keys[1:]
150
+ if rest:
151
+ pane.send_keys(rest, enter=False)
152
+ time.sleep(0.1)
153
+ else:
154
+ pane.send_keys(keys, enter=False)
155
+ # Small delay for Claude Code to process text
156
+ time.sleep(0.1)
91
157
 
92
158
  if enter:
93
159
  pane.send_keys('', enter=True)
@@ -17,7 +17,7 @@ This separation ensures:
17
17
  - Clean interface contract via MonitorDaemonState
18
18
  - Platform-agnostic core (presence is optional)
19
19
 
20
- TODO: Add unit tests (currently 0% coverage)
20
+ Pure business logic is extracted to monitor_daemon_core.py for testability.
21
21
  """
22
22
 
23
23
  import os
@@ -56,6 +56,14 @@ from .config import get_relay_config
56
56
  from .status_constants import STATUS_ASLEEP, STATUS_RUNNING, STATUS_TERMINATED
57
57
  from .status_detector import StatusDetector
58
58
  from .status_history import log_agent_status
59
+ from .monitor_daemon_core import (
60
+ calculate_time_accumulation,
61
+ calculate_cost_estimate,
62
+ calculate_total_tokens,
63
+ calculate_median,
64
+ should_sync_stats,
65
+ parse_datetime_safe,
66
+ )
59
67
 
60
68
 
61
69
  # Check for macOS presence APIs (optional)
@@ -202,8 +210,8 @@ class MonitorDaemon:
202
210
  self.last_state_times: Dict[str, datetime] = {}
203
211
  self.operation_start_times: Dict[str, datetime] = {}
204
212
 
205
- # Stats sync throttling - start with min time to force immediate sync on first loop
206
- self._last_stats_sync = datetime.min
213
+ # Stats sync throttling - None forces immediate sync on first loop
214
+ self._last_stats_sync: Optional[datetime] = None
207
215
  self._stats_sync_interval = 60 # seconds
208
216
 
209
217
  # Relay configuration (for pushing state to cloud)
@@ -271,6 +279,7 @@ class MonitorDaemon:
271
279
  status_since=stats.state_since,
272
280
  green_time_seconds=stats.green_time_seconds,
273
281
  non_green_time_seconds=stats.non_green_time_seconds,
282
+ sleep_time_seconds=stats.sleep_time_seconds,
274
283
  interaction_count=stats.interaction_count,
275
284
  input_tokens=stats.input_tokens,
276
285
  output_tokens=stats.output_tokens,
@@ -300,18 +309,11 @@ class MonitorDaemon:
300
309
  if last_time is None:
301
310
  # First observation after daemon (re)start - use last_time_accumulation
302
311
  # to avoid re-adding time that was already accumulated before restart
303
- if current_stats.last_time_accumulation:
304
- try:
305
- last_time = datetime.fromisoformat(current_stats.last_time_accumulation)
306
- except ValueError:
307
- last_time = now
308
- elif current_stats.state_since:
312
+ last_time = parse_datetime_safe(current_stats.last_time_accumulation)
313
+ if last_time is None:
309
314
  # Fallback for sessions without last_time_accumulation
310
- try:
311
- last_time = datetime.fromisoformat(current_stats.state_since)
312
- except ValueError:
313
- last_time = now
314
- else:
315
+ last_time = parse_datetime_safe(current_stats.state_since)
316
+ if last_time is None:
315
317
  last_time = now
316
318
  self.last_state_times[session_id] = last_time
317
319
  return # Don't accumulate on first observation
@@ -321,41 +323,33 @@ class MonitorDaemon:
321
323
  if elapsed <= 0:
322
324
  return
323
325
 
324
- # Accumulate time based on state
325
- green_time = current_stats.green_time_seconds
326
- non_green_time = current_stats.non_green_time_seconds
326
+ # Get session start time for capping
327
+ session_start = parse_datetime_safe(session.start_time)
327
328
 
328
- if status == STATUS_RUNNING:
329
- green_time += elapsed
330
- elif status not in (STATUS_TERMINATED, STATUS_ASLEEP):
331
- # Only count non-green time for non-terminated/non-asleep states (#68)
332
- non_green_time += elapsed
333
- # else: terminated or asleep - don't accumulate time
329
+ # Use pure function for time accumulation (with sleep time tracking #141)
330
+ prev_status = self.previous_states.get(session_id, status)
331
+ result = calculate_time_accumulation(
332
+ current_status=status,
333
+ previous_status=prev_status,
334
+ elapsed_seconds=elapsed,
335
+ current_green=current_stats.green_time_seconds,
336
+ current_non_green=current_stats.non_green_time_seconds,
337
+ current_sleep=current_stats.sleep_time_seconds,
338
+ session_start=session_start,
339
+ now=now,
340
+ )
334
341
 
335
- # INVARIANT CHECK: accumulated time should never exceed uptime
336
- # This catches bugs like multiple daemons running simultaneously
337
- if session.start_time:
338
- try:
339
- session_start = datetime.fromisoformat(session.start_time)
340
- max_allowed = (now - session_start).total_seconds()
341
- total_accumulated = green_time + non_green_time
342
-
343
- if total_accumulated > max_allowed * 1.1: # 10% tolerance for timing jitter
344
- # Reset to sane values based on ratio
345
- ratio = max_allowed / total_accumulated if total_accumulated > 0 else 1.0
346
- green_time = green_time * ratio
347
- non_green_time = non_green_time * ratio
348
- self.log.warn(
349
- f"[{session.name}] Time tracking reset: "
350
- f"accumulated {total_accumulated/3600:.1f}h > uptime {max_allowed/3600:.1f}h"
351
- )
352
- except (ValueError, TypeError):
353
- pass
342
+ if result.was_capped:
343
+ total = current_stats.green_time_seconds + current_stats.non_green_time_seconds + current_stats.sleep_time_seconds
344
+ max_allowed = (now - session_start).total_seconds() if session_start else 0
345
+ self.log.warn(
346
+ f"[{session.name}] Time tracking reset: "
347
+ f"accumulated {total/3600:.1f}h > uptime {max_allowed/3600:.1f}h"
348
+ )
354
349
 
355
350
  # Update state tracking
356
- prev_status = self.previous_states.get(session_id, status)
357
351
  state_since = current_stats.state_since
358
- if prev_status != status:
352
+ if result.state_changed:
359
353
  state_since = now.isoformat()
360
354
  elif not state_since:
361
355
  # Initialize state_since if never set (e.g., new session)
@@ -366,8 +360,9 @@ class MonitorDaemon:
366
360
  session_id,
367
361
  current_state=status,
368
362
  state_since=state_since,
369
- green_time_seconds=green_time,
370
- non_green_time_seconds=non_green_time,
363
+ green_time_seconds=result.green_seconds,
364
+ non_green_time_seconds=result.non_green_seconds,
365
+ sleep_time_seconds=result.sleep_seconds,
371
366
  last_time_accumulation=now.isoformat(),
372
367
  )
373
368
 
@@ -394,19 +389,25 @@ class MonitorDaemon:
394
389
  return
395
390
 
396
391
  now = datetime.now()
397
- total_tokens = (
398
- stats.input_tokens +
399
- stats.output_tokens +
400
- stats.cache_creation_tokens +
401
- stats.cache_read_tokens
392
+ total_tokens = calculate_total_tokens(
393
+ stats.input_tokens,
394
+ stats.output_tokens,
395
+ stats.cache_creation_tokens,
396
+ stats.cache_read_tokens,
402
397
  )
403
398
 
404
- # Estimate cost
405
- cost_estimate = (
406
- (stats.input_tokens / 1_000_000) * 3.0 +
407
- (stats.output_tokens / 1_000_000) * 15.0 +
408
- (stats.cache_creation_tokens / 1_000_000) * 3.75 +
409
- (stats.cache_read_tokens / 1_000_000) * 0.30
399
+ # Estimate cost using configured pricing (defaults to Opus 4.5)
400
+ from .settings import get_user_config
401
+ pricing = get_user_config()
402
+ cost_estimate = calculate_cost_estimate(
403
+ stats.input_tokens,
404
+ stats.output_tokens,
405
+ stats.cache_creation_tokens,
406
+ stats.cache_read_tokens,
407
+ price_input=pricing.price_input,
408
+ price_output=pricing.price_output,
409
+ price_cache_write=pricing.price_cache_write,
410
+ price_cache_read=pricing.price_cache_read,
410
411
  )
411
412
 
412
413
  self.session_manager.update_stats(
@@ -425,13 +426,7 @@ class MonitorDaemon:
425
426
 
426
427
  def _calculate_median_work_time(self, operation_times: List[float]) -> float:
427
428
  """Calculate median operation time."""
428
- if not operation_times:
429
- return 0.0
430
- sorted_times = sorted(operation_times)
431
- n = len(sorted_times)
432
- if n % 2 == 0:
433
- return (sorted_times[n // 2 - 1] + sorted_times[n // 2]) / 2
434
- return sorted_times[n // 2]
429
+ return calculate_median(operation_times)
435
430
 
436
431
  def calculate_interval(self, sessions: list, all_waiting_user: bool) -> int:
437
432
  """Calculate appropriate loop interval.
@@ -590,7 +585,7 @@ class MonitorDaemon:
590
585
 
591
586
  # Sync Claude Code stats BEFORE building session_states so token counts are fresh
592
587
  # This ensures the first loop has accurate data (fixes #103)
593
- if (now - self._last_stats_sync).total_seconds() >= self._stats_sync_interval:
588
+ if should_sync_stats(self._last_stats_sync, now, self._stats_sync_interval):
594
589
  for session in sessions:
595
590
  self.sync_claude_code_stats(session)
596
591
  self._last_stats_sync = now
@@ -0,0 +1,261 @@
1
+ """
2
+ Pure business logic for Monitor Daemon.
3
+
4
+ These functions contain no I/O and are fully unit-testable.
5
+ They are used by MonitorDaemon but can be tested independently.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from typing import Optional, List, Tuple
11
+
12
+ from .status_constants import STATUS_RUNNING, STATUS_TERMINATED, STATUS_ASLEEP
13
+
14
+
15
+ @dataclass
16
+ class TimeAccumulationResult:
17
+ """Result of time accumulation calculation."""
18
+ green_seconds: float
19
+ non_green_seconds: float
20
+ sleep_seconds: float # Track sleep time separately (#141)
21
+ state_changed: bool
22
+ was_capped: bool # True if time was capped to uptime
23
+
24
+
25
+ def calculate_time_accumulation(
26
+ current_status: str,
27
+ previous_status: Optional[str],
28
+ elapsed_seconds: float,
29
+ current_green: float,
30
+ current_non_green: float,
31
+ current_sleep: float,
32
+ session_start: Optional[datetime],
33
+ now: datetime,
34
+ tolerance: float = 1.1, # 10% tolerance for timing jitter
35
+ ) -> TimeAccumulationResult:
36
+ """Calculate accumulated green/non-green/sleep time based on current status.
37
+
38
+ Pure function - no side effects, fully testable.
39
+
40
+ Args:
41
+ current_status: Current agent status (running, waiting_user, etc.)
42
+ previous_status: Previous status (None if first observation)
43
+ elapsed_seconds: Seconds since last observation
44
+ current_green: Current accumulated green time
45
+ current_non_green: Current accumulated non-green time
46
+ current_sleep: Current accumulated sleep time (#141)
47
+ session_start: When the session started (for cap calculation)
48
+ now: Current time
49
+ tolerance: How much accumulated time can exceed uptime (1.1 = 10%)
50
+
51
+ Returns:
52
+ TimeAccumulationResult with updated times and metadata
53
+ """
54
+ if elapsed_seconds <= 0:
55
+ return TimeAccumulationResult(
56
+ green_seconds=current_green,
57
+ non_green_seconds=current_non_green,
58
+ sleep_seconds=current_sleep,
59
+ state_changed=False,
60
+ was_capped=False,
61
+ )
62
+
63
+ green = current_green
64
+ non_green = current_non_green
65
+ sleep = current_sleep
66
+
67
+ # Accumulate based on status
68
+ if current_status == STATUS_RUNNING:
69
+ green += elapsed_seconds
70
+ elif current_status == STATUS_ASLEEP:
71
+ sleep += elapsed_seconds # Track sleep time separately (#141)
72
+ elif current_status != STATUS_TERMINATED:
73
+ non_green += elapsed_seconds
74
+ # else: terminated - don't accumulate time
75
+
76
+ # Cap accumulated time to session uptime
77
+ was_capped = False
78
+ if session_start is not None:
79
+ max_allowed = (now - session_start).total_seconds()
80
+ total_accumulated = green + non_green + sleep
81
+
82
+ if total_accumulated > max_allowed * tolerance:
83
+ # Scale down to sane values
84
+ ratio = max_allowed / total_accumulated if total_accumulated > 0 else 1.0
85
+ green = green * ratio
86
+ non_green = non_green * ratio
87
+ sleep = sleep * ratio
88
+ was_capped = True
89
+
90
+ state_changed = previous_status is not None and previous_status != current_status
91
+
92
+ return TimeAccumulationResult(
93
+ green_seconds=green,
94
+ non_green_seconds=non_green,
95
+ sleep_seconds=sleep,
96
+ state_changed=state_changed,
97
+ was_capped=was_capped,
98
+ )
99
+
100
+
101
+ def calculate_cost_estimate(
102
+ input_tokens: int,
103
+ output_tokens: int,
104
+ cache_creation_tokens: int = 0,
105
+ cache_read_tokens: int = 0,
106
+ price_input: float = 15.0,
107
+ price_output: float = 75.0,
108
+ price_cache_write: float = 18.75,
109
+ price_cache_read: float = 1.50,
110
+ ) -> float:
111
+ """Calculate estimated cost from token counts.
112
+
113
+ Pure function - no side effects, fully testable.
114
+
115
+ Args:
116
+ input_tokens: Number of input tokens
117
+ output_tokens: Number of output tokens
118
+ cache_creation_tokens: Number of cache creation tokens
119
+ cache_read_tokens: Number of cache read tokens
120
+ price_input: Price per million input tokens (default: Opus 4.5)
121
+ price_output: Price per million output tokens (default: Opus 4.5)
122
+ price_cache_write: Price per million cache write tokens (default: Opus 4.5)
123
+ price_cache_read: Price per million cache read tokens (default: Opus 4.5)
124
+
125
+ Returns:
126
+ Estimated cost in USD
127
+ """
128
+ return (
129
+ (input_tokens / 1_000_000) * price_input +
130
+ (output_tokens / 1_000_000) * price_output +
131
+ (cache_creation_tokens / 1_000_000) * price_cache_write +
132
+ (cache_read_tokens / 1_000_000) * price_cache_read
133
+ )
134
+
135
+
136
+ def calculate_total_tokens(
137
+ input_tokens: int,
138
+ output_tokens: int,
139
+ cache_creation_tokens: int = 0,
140
+ cache_read_tokens: int = 0,
141
+ ) -> int:
142
+ """Calculate total token count.
143
+
144
+ Pure function - no side effects, fully testable.
145
+ """
146
+ return input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens
147
+
148
+
149
+ def calculate_median(values: List[float]) -> float:
150
+ """Calculate median of a list of values.
151
+
152
+ Pure function - no side effects, fully testable.
153
+
154
+ Args:
155
+ values: List of numeric values
156
+
157
+ Returns:
158
+ Median value, or 0.0 if list is empty
159
+ """
160
+ if not values:
161
+ return 0.0
162
+ sorted_values = sorted(values)
163
+ n = len(sorted_values)
164
+ if n % 2 == 0:
165
+ return (sorted_values[n // 2 - 1] + sorted_values[n // 2]) / 2
166
+ return sorted_values[n // 2]
167
+
168
+
169
+ def calculate_green_percentage(green_seconds: float, non_green_seconds: float) -> int:
170
+ """Calculate percentage of time spent in green (running) state.
171
+
172
+ Pure function - no side effects, fully testable.
173
+
174
+ Args:
175
+ green_seconds: Total green time
176
+ non_green_seconds: Total non-green time
177
+
178
+ Returns:
179
+ Integer percentage (0-100)
180
+ """
181
+ total = green_seconds + non_green_seconds
182
+ if total <= 0:
183
+ return 0
184
+ return int((green_seconds / total) * 100)
185
+
186
+
187
+ def aggregate_session_stats(
188
+ sessions: List[dict],
189
+ ) -> Tuple[int, float, float, int]:
190
+ """Aggregate statistics across multiple sessions.
191
+
192
+ Pure function - no side effects, fully testable.
193
+
194
+ Args:
195
+ sessions: List of session dicts with 'status', 'green_time_seconds',
196
+ 'non_green_time_seconds', 'is_asleep' keys
197
+
198
+ Returns:
199
+ Tuple of (green_count, total_green_time, total_non_green_time, active_count)
200
+ """
201
+ green_count = 0
202
+ total_green = 0.0
203
+ total_non_green = 0.0
204
+ active_count = 0
205
+
206
+ for session in sessions:
207
+ # Skip asleep sessions
208
+ if session.get('is_asleep', False):
209
+ continue
210
+
211
+ active_count += 1
212
+ status = session.get('status', '')
213
+
214
+ if status == STATUS_RUNNING:
215
+ green_count += 1
216
+
217
+ total_green += session.get('green_time_seconds', 0.0)
218
+ total_non_green += session.get('non_green_time_seconds', 0.0)
219
+
220
+ return green_count, total_green, total_non_green, active_count
221
+
222
+
223
+ def should_sync_stats(
224
+ last_sync: Optional[datetime],
225
+ now: datetime,
226
+ interval_seconds: float,
227
+ ) -> bool:
228
+ """Determine if stats should be synced based on interval.
229
+
230
+ Pure function - no side effects, fully testable.
231
+
232
+ Args:
233
+ last_sync: Time of last sync (None if never synced)
234
+ now: Current time
235
+ interval_seconds: Minimum seconds between syncs
236
+
237
+ Returns:
238
+ True if sync should occur
239
+ """
240
+ if last_sync is None:
241
+ return True
242
+ return (now - last_sync).total_seconds() >= interval_seconds
243
+
244
+
245
+ def parse_datetime_safe(value: Optional[str]) -> Optional[datetime]:
246
+ """Safely parse an ISO datetime string.
247
+
248
+ Pure function - no side effects, fully testable.
249
+
250
+ Args:
251
+ value: ISO format datetime string, or None
252
+
253
+ Returns:
254
+ Parsed datetime, or None if parsing fails
255
+ """
256
+ if value is None:
257
+ return None
258
+ try:
259
+ return datetime.fromisoformat(value)
260
+ except (ValueError, TypeError):
261
+ return None
@@ -46,6 +46,7 @@ class SessionDaemonState:
46
46
  # Time tracking (authoritative - only Monitor Daemon updates these)
47
47
  green_time_seconds: float = 0.0
48
48
  non_green_time_seconds: float = 0.0
49
+ sleep_time_seconds: float = 0.0
49
50
 
50
51
  # Claude Code stats (synced from ~/.claude/projects/)
51
52
  interaction_count: int = 0
@@ -91,6 +92,7 @@ class SessionDaemonState:
91
92
  "status_since": self.status_since,
92
93
  "green_time_seconds": self.green_time_seconds,
93
94
  "non_green_time_seconds": self.non_green_time_seconds,
95
+ "sleep_time_seconds": self.sleep_time_seconds,
94
96
  "interaction_count": self.interaction_count,
95
97
  "input_tokens": self.input_tokens,
96
98
  "output_tokens": self.output_tokens,
@@ -126,6 +128,7 @@ class SessionDaemonState:
126
128
  status_since=data.get("status_since"),
127
129
  green_time_seconds=data.get("green_time_seconds", 0.0),
128
130
  non_green_time_seconds=data.get("non_green_time_seconds", 0.0),
131
+ sleep_time_seconds=data.get("sleep_time_seconds", 0.0),
129
132
  interaction_count=data.get("interaction_count", 0),
130
133
  input_tokens=data.get("input_tokens", 0),
131
134
  output_tokens=data.get("output_tokens", 0),
@@ -178,6 +181,7 @@ class MonitorDaemonState:
178
181
  # Summary metrics (computed from sessions)
179
182
  total_green_time: float = 0.0
180
183
  total_non_green_time: float = 0.0
184
+ total_sleep_time: float = 0.0
181
185
  green_sessions: int = 0
182
186
  non_green_sessions: int = 0
183
187
 
@@ -212,6 +216,7 @@ class MonitorDaemonState:
212
216
  "presence_idle_seconds": self.presence_idle_seconds,
213
217
  "total_green_time": self.total_green_time,
214
218
  "total_non_green_time": self.total_non_green_time,
219
+ "total_sleep_time": self.total_sleep_time,
215
220
  "green_sessions": self.green_sessions,
216
221
  "non_green_sessions": self.non_green_sessions,
217
222
  "total_supervisions": self.total_supervisions,
@@ -247,6 +252,7 @@ class MonitorDaemonState:
247
252
  presence_idle_seconds=data.get("presence_idle_seconds"),
248
253
  total_green_time=data.get("total_green_time", 0.0),
249
254
  total_non_green_time=data.get("total_non_green_time", 0.0),
255
+ total_sleep_time=data.get("total_sleep_time", 0.0),
250
256
  green_sessions=data.get("green_sessions", 0),
251
257
  non_green_sessions=data.get("non_green_sessions", 0),
252
258
  total_supervisions=data.get("total_supervisions", 0),
@@ -264,6 +270,7 @@ class MonitorDaemonState:
264
270
  """Recompute summary metrics from session data."""
265
271
  self.total_green_time = sum(s.green_time_seconds for s in self.sessions)
266
272
  self.total_non_green_time = sum(s.non_green_time_seconds for s in self.sessions)
273
+ self.total_sleep_time = sum(s.sleep_time_seconds for s in self.sessions)
267
274
  self.green_sessions = sum(1 for s in self.sessions if s.current_status == "running")
268
275
  self.non_green_sessions = len(self.sessions) - self.green_sessions
269
276
  self.total_supervisions = sum(s.steers_count for s in self.sessions)
@@ -44,6 +44,7 @@ class SessionStats:
44
44
  state_since: Optional[str] = None # ISO timestamp when current state started
45
45
  green_time_seconds: float = 0.0 # time spent in "running" state
46
46
  non_green_time_seconds: float = 0.0 # time spent in non-running states
47
+ sleep_time_seconds: float = 0.0 # time spent in "asleep" state
47
48
  last_time_accumulation: Optional[str] = None # ISO timestamp when times were last accumulated
48
49
 
49
50
  def to_dict(self) -> dict: