overcode 0.1.2__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 (50) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +154 -51
  3. overcode/config.py +66 -0
  4. overcode/daemon_claude_skill.md +36 -33
  5. overcode/history_reader.py +69 -8
  6. overcode/implementations.py +178 -87
  7. overcode/monitor_daemon.py +87 -97
  8. overcode/monitor_daemon_core.py +261 -0
  9. overcode/monitor_daemon_state.py +24 -15
  10. overcode/pid_utils.py +17 -3
  11. overcode/session_manager.py +54 -0
  12. overcode/settings.py +34 -0
  13. overcode/status_constants.py +1 -1
  14. overcode/status_detector.py +8 -2
  15. overcode/status_patterns.py +19 -0
  16. overcode/summarizer_client.py +72 -27
  17. overcode/summarizer_component.py +87 -107
  18. overcode/supervisor_daemon.py +55 -38
  19. overcode/supervisor_daemon_core.py +210 -0
  20. overcode/testing/__init__.py +6 -0
  21. overcode/testing/renderer.py +268 -0
  22. overcode/testing/tmux_driver.py +223 -0
  23. overcode/testing/tui_eye.py +185 -0
  24. overcode/testing/tui_eye_skill.md +187 -0
  25. overcode/tmux_manager.py +117 -93
  26. overcode/tui.py +399 -1969
  27. overcode/tui_actions/__init__.py +20 -0
  28. overcode/tui_actions/daemon.py +201 -0
  29. overcode/tui_actions/input.py +128 -0
  30. overcode/tui_actions/navigation.py +117 -0
  31. overcode/tui_actions/session.py +428 -0
  32. overcode/tui_actions/view.py +357 -0
  33. overcode/tui_helpers.py +42 -9
  34. overcode/tui_logic.py +347 -0
  35. overcode/tui_render.py +414 -0
  36. overcode/tui_widgets/__init__.py +24 -0
  37. overcode/tui_widgets/command_bar.py +399 -0
  38. overcode/tui_widgets/daemon_panel.py +153 -0
  39. overcode/tui_widgets/daemon_status_bar.py +245 -0
  40. overcode/tui_widgets/help_overlay.py +71 -0
  41. overcode/tui_widgets/preview_pane.py +69 -0
  42. overcode/tui_widgets/session_summary.py +514 -0
  43. overcode/tui_widgets/status_timeline.py +253 -0
  44. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/METADATA +4 -1
  45. overcode-0.1.4.dist-info/RECORD +68 -0
  46. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/WHEEL +1 -1
  47. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  48. overcode-0.1.2.dist-info/RECORD +0 -45
  49. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  50. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
@@ -1,142 +1,233 @@
1
1
  """
2
2
  Real implementations of protocol interfaces.
3
3
 
4
- These are production implementations that make actual subprocess calls
5
- to tmux, perform real file I/O, etc.
4
+ These are production implementations that use libtmux for tmux operations
5
+ and perform real file I/O.
6
6
  """
7
7
 
8
8
  import json
9
- import subprocess
10
9
  import os
11
10
  import time
12
11
  from pathlib import Path
13
12
  from typing import Optional, List, Dict, Any
14
13
 
14
+ import libtmux
15
+ from libtmux.exc import LibTmuxException
16
+ from libtmux._internal.query_list import ObjectDoesNotExist
17
+
15
18
 
16
19
  class RealTmux:
17
- """Production implementation of TmuxInterface using subprocess"""
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
28
+
29
+ def __init__(self, socket_name: Optional[str] = None):
30
+ """Initialize with optional socket name for test isolation.
31
+
32
+ If no socket_name is provided, checks OVERCODE_TMUX_SOCKET env var.
33
+ """
34
+ # Support OVERCODE_TMUX_SOCKET env var for testing
35
+ self._socket_name = socket_name or os.environ.get("OVERCODE_TMUX_SOCKET")
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] = {}
41
+
42
+ @property
43
+ def server(self) -> libtmux.Server:
44
+ """Lazy-load the tmux server connection."""
45
+ if self._server is None:
46
+ if self._socket_name:
47
+ self._server = libtmux.Server(socket_name=self._socket_name)
48
+ else:
49
+ self._server = libtmux.Server()
50
+ return self._server
51
+
52
+ def _get_session(self, session: str) -> Optional[libtmux.Session]:
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
18
59
 
19
- def capture_pane(self, session: str, window: int, lines: int = 100) -> Optional[str]:
20
60
  try:
21
- result = subprocess.run(
22
- ["tmux", "capture-pane", "-t", f"{session}:{window}",
23
- "-p", "-S", f"-{lines}"],
24
- capture_output=True, text=True, timeout=5
25
- )
26
- if result.returncode == 0:
27
- return result.stdout
61
+ sess = self.server.sessions.get(session_name=session)
62
+ self._session_cache[session] = (sess, now)
63
+ return sess
64
+ except (LibTmuxException, ObjectDoesNotExist):
28
65
  return None
29
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
66
+
67
+ def _get_window(self, session: str, window: int) -> Optional[libtmux.Window]:
68
+ """Get a window by session name and window index."""
69
+ sess = self._get_session(session)
70
+ if sess is None:
71
+ return None
72
+ try:
73
+ return sess.windows.get(window_index=str(window))
74
+ except (LibTmuxException, ObjectDoesNotExist):
75
+ return None
76
+
77
+ def _get_pane(self, session: str, window: int) -> Optional[libtmux.Pane]:
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
89
+ win = self._get_window(session, window)
90
+ if win is None or not win.panes:
91
+ return None
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]
114
+
115
+ def capture_pane(self, session: str, window: int, lines: int = 100) -> Optional[str]:
116
+ try:
117
+ pane = self._get_pane(session, window)
118
+ if pane is None:
119
+ return None
120
+ # capture_pane returns list of lines
121
+ # escape_sequences=True preserves ANSI color codes for TUI rendering
122
+ captured = pane.capture_pane(start=-lines, escape_sequences=True)
123
+ if isinstance(captured, list):
124
+ return '\n'.join(captured)
125
+ return captured
126
+ except LibTmuxException:
127
+ # Pane may have been killed - invalidate cache and retry once
128
+ self.invalidate_cache(session, window)
30
129
  return None
31
130
 
32
131
  def send_keys(self, session: str, window: int, keys: str, enter: bool = True) -> bool:
33
132
  try:
133
+ pane = self._get_pane(session, window)
134
+ if pane is None:
135
+ return False
136
+
34
137
  # For Claude Code: text and Enter must be sent as SEPARATE commands
35
138
  # with a small delay, otherwise Claude Code doesn't process the Enter.
36
- target = f"{session}:{window}"
37
-
38
- # Send text first (if any)
39
139
  if keys:
40
- result = subprocess.run(
41
- ["tmux", "send-keys", "-t", target, keys],
42
- timeout=5, capture_output=True
43
- )
44
- if result.returncode != 0:
45
- return False
46
- # Small delay for Claude Code to process text
47
- time.sleep(0.1)
48
-
49
- # Send Enter separately
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)
157
+
50
158
  if enter:
51
- result = subprocess.run(
52
- ["tmux", "send-keys", "-t", target, "Enter"],
53
- timeout=5, capture_output=True
54
- )
55
- if result.returncode != 0:
56
- return False
159
+ pane.send_keys('', enter=True)
57
160
 
58
161
  return True
59
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
162
+ except LibTmuxException:
60
163
  return False
61
164
 
62
165
  def has_session(self, session: str) -> bool:
63
166
  try:
64
- result = subprocess.run(
65
- ["tmux", "has-session", "-t", session],
66
- capture_output=True, timeout=5
67
- )
68
- return result.returncode == 0
69
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
167
+ return self.server.has_session(session)
168
+ except LibTmuxException:
70
169
  return False
71
170
 
72
171
  def new_session(self, session: str) -> bool:
73
172
  try:
74
- result = subprocess.run(
75
- ["tmux", "new-session", "-d", "-s", session],
76
- capture_output=True, timeout=5
77
- )
78
- return result.returncode == 0
79
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
173
+ self.server.new_session(session_name=session, attach=False)
174
+ return True
175
+ except LibTmuxException:
80
176
  return False
81
177
 
82
178
  def new_window(self, session: str, name: str, command: Optional[List[str]] = None,
83
179
  cwd: Optional[str] = None) -> Optional[int]:
84
180
  try:
85
- cmd = ["tmux", "new-window", "-t", session, "-n", name, "-P", "-F", "#{window_index}"]
181
+ sess = self._get_session(session)
182
+ if sess is None:
183
+ return None
184
+
185
+ kwargs: Dict[str, Any] = {'window_name': name, 'attach': False}
86
186
  if cwd:
87
- cmd.extend(["-c", cwd])
187
+ kwargs['start_directory'] = cwd
88
188
  if command:
89
- cmd.append(" ".join(command))
189
+ kwargs['window_shell'] = ' '.join(command)
90
190
 
91
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
92
- if result.returncode == 0:
93
- return int(result.stdout.strip())
94
- return None
95
- except (subprocess.TimeoutExpired, subprocess.SubprocessError, ValueError):
191
+ window = sess.new_window(**kwargs)
192
+ return int(window.window_index)
193
+ except (LibTmuxException, ValueError):
96
194
  return None
97
195
 
98
196
  def kill_window(self, session: str, window: int) -> bool:
99
197
  try:
100
- result = subprocess.run(
101
- ["tmux", "kill-window", "-t", f"{session}:{window}"],
102
- capture_output=True, timeout=5
103
- )
104
- return result.returncode == 0
105
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
198
+ win = self._get_window(session, window)
199
+ if win is None:
200
+ return False
201
+ win.kill()
202
+ return True
203
+ except LibTmuxException:
106
204
  return False
107
205
 
108
206
  def kill_session(self, session: str) -> bool:
109
207
  try:
110
- result = subprocess.run(
111
- ["tmux", "kill-session", "-t", session],
112
- capture_output=True, timeout=5
113
- )
114
- return result.returncode == 0
115
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
208
+ sess = self._get_session(session)
209
+ if sess is None:
210
+ return False
211
+ sess.kill()
212
+ return True
213
+ except LibTmuxException:
116
214
  return False
117
215
 
118
216
  def list_windows(self, session: str) -> List[Dict[str, Any]]:
119
217
  try:
120
- result = subprocess.run(
121
- ["tmux", "list-windows", "-t", session, "-F",
122
- "#{window_index}:#{window_name}:#{window_active}"],
123
- capture_output=True, text=True, timeout=5
124
- )
125
- if result.returncode != 0:
218
+ sess = self._get_session(session)
219
+ if sess is None:
126
220
  return []
127
221
 
128
222
  windows = []
129
- for line in result.stdout.strip().split('\n'):
130
- if line:
131
- parts = line.split(':')
132
- if len(parts) >= 3:
133
- windows.append({
134
- 'index': int(parts[0]),
135
- 'name': parts[1],
136
- 'active': parts[2] == '1'
137
- })
223
+ for win in sess.windows:
224
+ windows.append({
225
+ 'index': int(win.window_index),
226
+ 'name': win.window_name,
227
+ 'active': win.window_active == '1'
228
+ })
138
229
  return windows
139
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
230
+ except LibTmuxException:
140
231
  return []
141
232
 
142
233
  def attach(self, session: str) -> None:
@@ -145,12 +236,12 @@ class RealTmux:
145
236
  def select_window(self, session: str, window: int) -> bool:
146
237
  """Select a window in a tmux session (for external pane sync)."""
147
238
  try:
148
- result = subprocess.run(
149
- ["tmux", "select-window", "-t", f"{session}:{window}"],
150
- capture_output=True, timeout=5
151
- )
152
- return result.returncode == 0
153
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
239
+ win = self._get_window(session, window)
240
+ if win is None:
241
+ return False
242
+ win.select()
243
+ return True
244
+ except LibTmuxException:
154
245
  return False
155
246
 
156
247
 
@@ -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
@@ -30,7 +30,7 @@ from typing import Dict, List, Optional
30
30
 
31
31
  from .daemon_logging import BaseDaemonLogger
32
32
  from .daemon_utils import create_daemon_helpers
33
- from .history_reader import get_session_stats
33
+ from .history_reader import get_session_stats, get_current_session_id_for_directory
34
34
  from .monitor_daemon_state import (
35
35
  MonitorDaemonState,
36
36
  SessionDaemonState,
@@ -53,10 +53,17 @@ from .settings import (
53
53
  get_supervisor_stats_path,
54
54
  )
55
55
  from .config import get_relay_config
56
- from .status_constants import STATUS_RUNNING, STATUS_TERMINATED
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 .summarizer_component import SummarizerComponent, SummarizerConfig
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
+ )
60
67
 
61
68
 
62
69
  # Check for macOS presence APIs (optional)
@@ -187,12 +194,6 @@ class MonitorDaemon:
187
194
  # Presence tracking (graceful degradation)
188
195
  self.presence = PresenceComponent()
189
196
 
190
- # Summarizer component (graceful degradation if no API key)
191
- self.summarizer = SummarizerComponent(
192
- tmux_session=tmux_session,
193
- config=SummarizerConfig(enabled=False), # Off by default, enable via CLI
194
- )
195
-
196
197
  # Logging - session-specific log file
197
198
  self.log = _create_monitor_logger(session=tmux_session)
198
199
 
@@ -209,8 +210,8 @@ class MonitorDaemon:
209
210
  self.last_state_times: Dict[str, datetime] = {}
210
211
  self.operation_start_times: Dict[str, datetime] = {}
211
212
 
212
- # Stats sync throttling
213
- self._last_stats_sync = datetime.now()
213
+ # Stats sync throttling - None forces immediate sync on first loop
214
+ self._last_stats_sync: Optional[datetime] = None
214
215
  self._stats_sync_interval = 60 # seconds
215
216
 
216
217
  # Relay configuration (for pushing state to cloud)
@@ -278,6 +279,7 @@ class MonitorDaemon:
278
279
  status_since=stats.state_since,
279
280
  green_time_seconds=stats.green_time_seconds,
280
281
  non_green_time_seconds=stats.non_green_time_seconds,
282
+ sleep_time_seconds=stats.sleep_time_seconds,
281
283
  interaction_count=stats.interaction_count,
282
284
  input_tokens=stats.input_tokens,
283
285
  output_tokens=stats.output_tokens,
@@ -293,6 +295,8 @@ class MonitorDaemon:
293
295
  start_time=session.start_time,
294
296
  permissiveness_mode=session.permissiveness_mode,
295
297
  start_directory=session.start_directory,
298
+ is_asleep=session.is_asleep,
299
+ agent_value=session.agent_value,
296
300
  )
297
301
 
298
302
  def _update_state_time(self, session, status: str, now: datetime) -> None:
@@ -305,18 +309,11 @@ class MonitorDaemon:
305
309
  if last_time is None:
306
310
  # First observation after daemon (re)start - use last_time_accumulation
307
311
  # to avoid re-adding time that was already accumulated before restart
308
- if current_stats.last_time_accumulation:
309
- try:
310
- last_time = datetime.fromisoformat(current_stats.last_time_accumulation)
311
- except ValueError:
312
- last_time = now
313
- elif current_stats.state_since:
312
+ last_time = parse_datetime_safe(current_stats.last_time_accumulation)
313
+ if last_time is None:
314
314
  # Fallback for sessions without last_time_accumulation
315
- try:
316
- last_time = datetime.fromisoformat(current_stats.state_since)
317
- except ValueError:
318
- last_time = now
319
- else:
315
+ last_time = parse_datetime_safe(current_stats.state_since)
316
+ if last_time is None:
320
317
  last_time = now
321
318
  self.last_state_times[session_id] = last_time
322
319
  return # Don't accumulate on first observation
@@ -326,41 +323,33 @@ class MonitorDaemon:
326
323
  if elapsed <= 0:
327
324
  return
328
325
 
329
- # Accumulate time based on state
330
- green_time = current_stats.green_time_seconds
331
- 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)
332
328
 
333
- if status == STATUS_RUNNING:
334
- green_time += elapsed
335
- elif status != STATUS_TERMINATED:
336
- # Only count non-green time for non-terminated states
337
- non_green_time += elapsed
338
- # else: terminated - 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
+ )
339
341
 
340
- # INVARIANT CHECK: accumulated time should never exceed uptime
341
- # This catches bugs like multiple daemons running simultaneously
342
- if session.start_time:
343
- try:
344
- session_start = datetime.fromisoformat(session.start_time)
345
- max_allowed = (now - session_start).total_seconds()
346
- total_accumulated = green_time + non_green_time
347
-
348
- if total_accumulated > max_allowed * 1.1: # 10% tolerance for timing jitter
349
- # Reset to sane values based on ratio
350
- ratio = max_allowed / total_accumulated if total_accumulated > 0 else 1.0
351
- green_time = green_time * ratio
352
- non_green_time = non_green_time * ratio
353
- self.log.warn(
354
- f"[{session.name}] Time tracking reset: "
355
- f"accumulated {total_accumulated/3600:.1f}h > uptime {max_allowed/3600:.1f}h"
356
- )
357
- except (ValueError, TypeError):
358
- 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
+ )
359
349
 
360
350
  # Update state tracking
361
- prev_status = self.previous_states.get(session_id, status)
362
351
  state_since = current_stats.state_since
363
- if prev_status != status:
352
+ if result.state_changed:
364
353
  state_since = now.isoformat()
365
354
  elif not state_since:
366
355
  # Initialize state_since if never set (e.g., new session)
@@ -371,8 +360,9 @@ class MonitorDaemon:
371
360
  session_id,
372
361
  current_state=status,
373
362
  state_since=state_since,
374
- green_time_seconds=green_time,
375
- 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,
376
366
  last_time_accumulation=now.isoformat(),
377
367
  )
378
368
 
@@ -381,24 +371,43 @@ class MonitorDaemon:
381
371
  def sync_claude_code_stats(self, session) -> None:
382
372
  """Sync token/interaction stats from Claude Code history files."""
383
373
  try:
374
+ # Capture current Claude sessionId if not already tracked (#119)
375
+ # This ensures accurate context window calculation for this agent
376
+ if session.start_directory:
377
+ try:
378
+ session_start = datetime.fromisoformat(session.start_time)
379
+ current_id = get_current_session_id_for_directory(
380
+ session.start_directory, session_start
381
+ )
382
+ if current_id:
383
+ self.session_manager.add_claude_session_id(session.id, current_id)
384
+ except (ValueError, TypeError):
385
+ pass
386
+
384
387
  stats = get_session_stats(session)
385
388
  if stats is None:
386
389
  return
387
390
 
388
391
  now = datetime.now()
389
- total_tokens = (
390
- stats.input_tokens +
391
- stats.output_tokens +
392
- stats.cache_creation_tokens +
393
- 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,
394
397
  )
395
398
 
396
- # Estimate cost
397
- cost_estimate = (
398
- (stats.input_tokens / 1_000_000) * 3.0 +
399
- (stats.output_tokens / 1_000_000) * 15.0 +
400
- (stats.cache_creation_tokens / 1_000_000) * 3.75 +
401
- (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,
402
411
  )
403
412
 
404
413
  self.session_manager.update_stats(
@@ -417,13 +426,7 @@ class MonitorDaemon:
417
426
 
418
427
  def _calculate_median_work_time(self, operation_times: List[float]) -> float:
419
428
  """Calculate median operation time."""
420
- if not operation_times:
421
- return 0.0
422
- sorted_times = sorted(operation_times)
423
- n = len(sorted_times)
424
- if n % 2 == 0:
425
- return (sorted_times[n // 2 - 1] + sorted_times[n // 2]) / 2
426
- return sorted_times[n // 2]
429
+ return calculate_median(operation_times)
427
430
 
428
431
  def calculate_interval(self, sessions: list, all_waiting_user: bool) -> int:
429
432
  """Calculate appropriate loop interval.
@@ -481,13 +484,6 @@ class MonitorDaemon:
481
484
  except (json.JSONDecodeError, OSError):
482
485
  pass
483
486
 
484
- # Update summarizer stats
485
- self.state.summarizer_available = self.summarizer.available
486
- self.state.summarizer_enabled = self.summarizer.enabled
487
- self.state.summarizer_calls = self.summarizer.total_calls
488
- # Estimate cost: ~$0.0007 per call (4K input tokens + 150 output tokens)
489
- self.state.summarizer_cost_usd = round(self.summarizer.total_calls * 0.0007, 4)
490
-
491
487
  self.state.save(self.state_path)
492
488
 
493
489
  # Push to relay if configured and interval elapsed
@@ -587,6 +583,13 @@ class MonitorDaemon:
587
583
  # Get all sessions
588
584
  sessions = self.session_manager.list_sessions()
589
585
 
586
+ # Sync Claude Code stats BEFORE building session_states so token counts are fresh
587
+ # This ensures the first loop has accurate data (fixes #103)
588
+ if should_sync_stats(self._last_stats_sync, now, self._stats_sync_interval):
589
+ for session in sessions:
590
+ self.sync_claude_code_stats(session)
591
+ self._last_stats_sync = now
592
+
590
593
  # Detect status and track stats for each session
591
594
  session_states = []
592
595
  all_waiting_user = True
@@ -610,12 +613,14 @@ class MonitorDaemon:
610
613
  continue
611
614
 
612
615
  # Track stats and build state
613
- session_state = self.track_session_stats(session, status)
616
+ # Use "asleep" status if session is marked as sleeping (#68)
617
+ effective_status = STATUS_ASLEEP if session.is_asleep else status
618
+ session_state = self.track_session_stats(session, effective_status)
614
619
  session_state.current_activity = activity
615
620
  session_states.append(session_state)
616
621
 
617
622
  # Log status history to session-specific file
618
- log_agent_status(session.name, status, activity, history_file=self.history_path)
623
+ log_agent_status(session.name, effective_status, activity, history_file=self.history_path)
619
624
 
620
625
  # Track if any session is not waiting for user
621
626
  if status != "waiting_user":
@@ -630,20 +635,6 @@ class MonitorDaemon:
630
635
  for stale_id in stale_ids:
631
636
  del self.previous_states[stale_id]
632
637
 
633
- # Sync Claude Code stats periodically (git context is refreshed every loop above)
634
- if (now - self._last_stats_sync).total_seconds() >= self._stats_sync_interval:
635
- for session in sessions:
636
- self.sync_claude_code_stats(session)
637
- self._last_stats_sync = now
638
-
639
- # Update summaries (if enabled)
640
- summaries = self.summarizer.update(sessions)
641
- for session_state in session_states:
642
- summary = summaries.get(session_state.session_id)
643
- if summary:
644
- session_state.activity_summary = summary.text
645
- session_state.activity_summary_updated = summary.updated_at
646
-
647
638
  # Calculate interval
648
639
  interval = self.calculate_interval(sessions, all_waiting_user)
649
640
  self.state.current_interval = interval
@@ -673,7 +664,6 @@ class MonitorDaemon:
673
664
  finally:
674
665
  self.log.info("Monitor daemon shutting down")
675
666
  self.presence.stop()
676
- self.summarizer.stop()
677
667
  self.state.status = "stopped"
678
668
  self.state.save(self.state_path)
679
669
  remove_pid_file(self.pid_path)