overcode 0.1.3__tar.gz → 0.1.4__tar.gz
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.
- {overcode-0.1.3/src/overcode.egg-info → overcode-0.1.4}/PKG-INFO +3 -1
- {overcode-0.1.3 → overcode-0.1.4}/pyproject.toml +4 -1
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/__init__.py +1 -1
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/cli.py +7 -2
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/implementations.py +74 -8
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/monitor_daemon.py +60 -65
- overcode-0.1.4/src/overcode/monitor_daemon_core.py +261 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/monitor_daemon_state.py +7 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/session_manager.py +1 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/settings.py +22 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/supervisor_daemon.py +48 -47
- overcode-0.1.4/src/overcode/supervisor_daemon_core.py +210 -0
- overcode-0.1.4/src/overcode/testing/__init__.py +6 -0
- overcode-0.1.4/src/overcode/testing/renderer.py +268 -0
- overcode-0.1.4/src/overcode/testing/tmux_driver.py +223 -0
- overcode-0.1.4/src/overcode/testing/tui_eye.py +185 -0
- overcode-0.1.4/src/overcode/testing/tui_eye_skill.md +187 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/tmux_manager.py +17 -3
- overcode-0.1.4/src/overcode/tui.py +1266 -0
- overcode-0.1.4/src/overcode/tui_actions/__init__.py +20 -0
- overcode-0.1.4/src/overcode/tui_actions/daemon.py +201 -0
- overcode-0.1.4/src/overcode/tui_actions/input.py +128 -0
- overcode-0.1.4/src/overcode/tui_actions/navigation.py +117 -0
- overcode-0.1.4/src/overcode/tui_actions/session.py +428 -0
- overcode-0.1.4/src/overcode/tui_actions/view.py +357 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/tui_helpers.py +41 -9
- overcode-0.1.4/src/overcode/tui_logic.py +347 -0
- overcode-0.1.4/src/overcode/tui_render.py +414 -0
- overcode-0.1.4/src/overcode/tui_widgets/__init__.py +24 -0
- overcode-0.1.4/src/overcode/tui_widgets/command_bar.py +399 -0
- overcode-0.1.4/src/overcode/tui_widgets/daemon_panel.py +153 -0
- overcode-0.1.4/src/overcode/tui_widgets/daemon_status_bar.py +245 -0
- overcode-0.1.4/src/overcode/tui_widgets/help_overlay.py +71 -0
- overcode-0.1.4/src/overcode/tui_widgets/preview_pane.py +69 -0
- overcode-0.1.4/src/overcode/tui_widgets/session_summary.py +514 -0
- overcode-0.1.4/src/overcode/tui_widgets/status_timeline.py +253 -0
- {overcode-0.1.3 → overcode-0.1.4/src/overcode.egg-info}/PKG-INFO +3 -1
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode.egg-info/SOURCES.txt +23 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode.egg-info/entry_points.txt +1 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode.egg-info/requires.txt +2 -0
- overcode-0.1.3/src/overcode/tui.py +0 -3532
- {overcode-0.1.3 → overcode-0.1.4}/LICENSE +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/MANIFEST.in +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/README.md +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/setup.cfg +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/config.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/daemon_claude_skill.md +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/daemon_logging.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/daemon_utils.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/data_export.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/dependency_check.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/exceptions.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/history_reader.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/interfaces.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/launcher.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/logging_config.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/mocks.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/pid_utils.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/presence_logger.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/protocols.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/standing_instructions.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/status_constants.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/status_detector.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/status_history.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/status_patterns.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/summarizer_client.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/summarizer_component.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/supervisor_layout.sh +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/web_api.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/web_chartjs.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/web_server.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/web_server_runner.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode/web_templates.py +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode.egg-info/dependency_links.txt +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/src/overcode.egg-info/top_level.txt +0 -0
- {overcode-0.1.3 → overcode-0.1.4}/tests/test_e2e_multi_agent_jokes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: overcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A supervisor for managing multiple Claude Code instances in tmux
|
|
5
5
|
Author: Mike Bond
|
|
6
6
|
Project-URL: Homepage, https://github.com/mkb23/overcode
|
|
@@ -31,6 +31,8 @@ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
|
31
31
|
Requires-Dist: pytest-timeout>=2.1.0; extra == "dev"
|
|
32
32
|
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
33
33
|
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pyte>=0.8.0; extra == "dev"
|
|
35
|
+
Requires-Dist: pillow>=10.0.0; extra == "dev"
|
|
34
36
|
Provides-Extra: presence
|
|
35
37
|
Requires-Dist: pyobjc-framework-Quartz>=10.0; extra == "presence"
|
|
36
38
|
Requires-Dist: pyobjc-framework-ApplicationServices>=10.0; extra == "presence"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "overcode"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "A supervisor for managing multiple Claude Code instances in tmux"
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Mike Bond"}
|
|
@@ -45,6 +45,8 @@ dev = [
|
|
|
45
45
|
"pytest-timeout>=2.1.0",
|
|
46
46
|
"black>=23.0.0",
|
|
47
47
|
"mypy>=1.0.0",
|
|
48
|
+
"pyte>=0.8.0",
|
|
49
|
+
"pillow>=10.0.0",
|
|
48
50
|
]
|
|
49
51
|
|
|
50
52
|
# macOS presence detection (optional)
|
|
@@ -60,6 +62,7 @@ export = [
|
|
|
60
62
|
|
|
61
63
|
[project.scripts]
|
|
62
64
|
overcode = "overcode.cli:main"
|
|
65
|
+
tui-eye = "overcode.testing.tui_eye:main"
|
|
63
66
|
|
|
64
67
|
[tool.setuptools.packages.find]
|
|
65
68
|
where = ["src"]
|
|
@@ -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"
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
#
|
|
90
|
-
|
|
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
|
-
|
|
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 -
|
|
206
|
-
self._last_stats_sync =
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
#
|
|
325
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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=
|
|
370
|
-
non_green_time_seconds=
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|