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.
- overcode/__init__.py +1 -1
- overcode/cli.py +154 -51
- overcode/config.py +66 -0
- overcode/daemon_claude_skill.md +36 -33
- overcode/history_reader.py +69 -8
- overcode/implementations.py +178 -87
- overcode/monitor_daemon.py +87 -97
- overcode/monitor_daemon_core.py +261 -0
- overcode/monitor_daemon_state.py +24 -15
- overcode/pid_utils.py +17 -3
- overcode/session_manager.py +54 -0
- overcode/settings.py +34 -0
- overcode/status_constants.py +1 -1
- overcode/status_detector.py +8 -2
- overcode/status_patterns.py +19 -0
- overcode/summarizer_client.py +72 -27
- overcode/summarizer_component.py +87 -107
- overcode/supervisor_daemon.py +55 -38
- overcode/supervisor_daemon_core.py +210 -0
- overcode/testing/__init__.py +6 -0
- overcode/testing/renderer.py +268 -0
- overcode/testing/tmux_driver.py +223 -0
- overcode/testing/tui_eye.py +185 -0
- overcode/testing/tui_eye_skill.md +187 -0
- overcode/tmux_manager.py +117 -93
- overcode/tui.py +399 -1969
- overcode/tui_actions/__init__.py +20 -0
- overcode/tui_actions/daemon.py +201 -0
- overcode/tui_actions/input.py +128 -0
- overcode/tui_actions/navigation.py +117 -0
- overcode/tui_actions/session.py +428 -0
- overcode/tui_actions/view.py +357 -0
- overcode/tui_helpers.py +42 -9
- overcode/tui_logic.py +347 -0
- overcode/tui_render.py +414 -0
- overcode/tui_widgets/__init__.py +24 -0
- overcode/tui_widgets/command_bar.py +399 -0
- overcode/tui_widgets/daemon_panel.py +153 -0
- overcode/tui_widgets/daemon_status_bar.py +245 -0
- overcode/tui_widgets/help_overlay.py +71 -0
- overcode/tui_widgets/preview_pane.py +69 -0
- overcode/tui_widgets/session_summary.py +514 -0
- overcode/tui_widgets/status_timeline.py +253 -0
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/METADATA +4 -1
- overcode-0.1.4.dist-info/RECORD +68 -0
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/WHEEL +1 -1
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
- overcode-0.1.2.dist-info/RECORD +0 -45
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
overcode/implementations.py
CHANGED
|
@@ -1,142 +1,233 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Real implementations of protocol interfaces.
|
|
3
3
|
|
|
4
|
-
These are production implementations that
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
162
|
+
except LibTmuxException:
|
|
60
163
|
return False
|
|
61
164
|
|
|
62
165
|
def has_session(self, session: str) -> bool:
|
|
63
166
|
try:
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
+
kwargs['start_directory'] = cwd
|
|
88
188
|
if command:
|
|
89
|
-
|
|
189
|
+
kwargs['window_shell'] = ' '.join(command)
|
|
90
190
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
return
|
|
105
|
-
except
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
)
|
|
114
|
-
return
|
|
115
|
-
except
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
)
|
|
152
|
-
return
|
|
153
|
-
except
|
|
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
|
|
overcode/monitor_daemon.py
CHANGED
|
@@ -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
|
|
@@ -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 .
|
|
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 =
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
#
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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=
|
|
375
|
-
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,
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|