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
|
@@ -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
|
overcode/monitor_daemon_state.py
CHANGED
|
@@ -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
|
|
@@ -67,10 +68,18 @@ class SessionDaemonState:
|
|
|
67
68
|
start_time: Optional[str] = None # ISO timestamp when session started
|
|
68
69
|
permissiveness_mode: str = "normal" # normal, permissive, bypass
|
|
69
70
|
start_directory: Optional[str] = None # For git diff stats
|
|
71
|
+
is_asleep: bool = False # Agent is paused and excluded from stats (#70)
|
|
70
72
|
|
|
71
|
-
#
|
|
73
|
+
# Agent priority value (#61)
|
|
74
|
+
agent_value: int = 1000 # Default 1000, higher = more important
|
|
75
|
+
|
|
76
|
+
# Activity summaries (from SummarizerComponent)
|
|
77
|
+
# Short: current activity - what's happening right now (~50 chars)
|
|
72
78
|
activity_summary: str = ""
|
|
73
79
|
activity_summary_updated: Optional[str] = None # ISO timestamp
|
|
80
|
+
# Context: wider context - what's being worked on overall (~80 chars)
|
|
81
|
+
activity_summary_context: str = ""
|
|
82
|
+
activity_summary_context_updated: Optional[str] = None # ISO timestamp
|
|
74
83
|
|
|
75
84
|
def to_dict(self) -> dict:
|
|
76
85
|
"""Convert to dictionary for JSON serialization."""
|
|
@@ -83,6 +92,7 @@ class SessionDaemonState:
|
|
|
83
92
|
"status_since": self.status_since,
|
|
84
93
|
"green_time_seconds": self.green_time_seconds,
|
|
85
94
|
"non_green_time_seconds": self.non_green_time_seconds,
|
|
95
|
+
"sleep_time_seconds": self.sleep_time_seconds,
|
|
86
96
|
"interaction_count": self.interaction_count,
|
|
87
97
|
"input_tokens": self.input_tokens,
|
|
88
98
|
"output_tokens": self.output_tokens,
|
|
@@ -98,8 +108,12 @@ class SessionDaemonState:
|
|
|
98
108
|
"start_time": self.start_time,
|
|
99
109
|
"permissiveness_mode": self.permissiveness_mode,
|
|
100
110
|
"start_directory": self.start_directory,
|
|
111
|
+
"is_asleep": self.is_asleep,
|
|
112
|
+
"agent_value": self.agent_value,
|
|
101
113
|
"activity_summary": self.activity_summary,
|
|
102
114
|
"activity_summary_updated": self.activity_summary_updated,
|
|
115
|
+
"activity_summary_context": self.activity_summary_context,
|
|
116
|
+
"activity_summary_context_updated": self.activity_summary_context_updated,
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
@classmethod
|
|
@@ -114,6 +128,7 @@ class SessionDaemonState:
|
|
|
114
128
|
status_since=data.get("status_since"),
|
|
115
129
|
green_time_seconds=data.get("green_time_seconds", 0.0),
|
|
116
130
|
non_green_time_seconds=data.get("non_green_time_seconds", 0.0),
|
|
131
|
+
sleep_time_seconds=data.get("sleep_time_seconds", 0.0),
|
|
117
132
|
interaction_count=data.get("interaction_count", 0),
|
|
118
133
|
input_tokens=data.get("input_tokens", 0),
|
|
119
134
|
output_tokens=data.get("output_tokens", 0),
|
|
@@ -129,8 +144,12 @@ class SessionDaemonState:
|
|
|
129
144
|
start_time=data.get("start_time"),
|
|
130
145
|
permissiveness_mode=data.get("permissiveness_mode", "normal"),
|
|
131
146
|
start_directory=data.get("start_directory"),
|
|
147
|
+
is_asleep=data.get("is_asleep", False),
|
|
148
|
+
agent_value=data.get("agent_value", 1000),
|
|
132
149
|
activity_summary=data.get("activity_summary", ""),
|
|
133
150
|
activity_summary_updated=data.get("activity_summary_updated"),
|
|
151
|
+
activity_summary_context=data.get("activity_summary_context", ""),
|
|
152
|
+
activity_summary_context_updated=data.get("activity_summary_context_updated"),
|
|
134
153
|
)
|
|
135
154
|
|
|
136
155
|
|
|
@@ -162,6 +181,7 @@ class MonitorDaemonState:
|
|
|
162
181
|
# Summary metrics (computed from sessions)
|
|
163
182
|
total_green_time: float = 0.0
|
|
164
183
|
total_non_green_time: float = 0.0
|
|
184
|
+
total_sleep_time: float = 0.0
|
|
165
185
|
green_sessions: int = 0
|
|
166
186
|
non_green_sessions: int = 0
|
|
167
187
|
|
|
@@ -180,12 +200,6 @@ class MonitorDaemonState:
|
|
|
180
200
|
relay_last_push: Optional[str] = None # ISO timestamp of last successful push
|
|
181
201
|
relay_last_status: str = "disabled" # "ok", "error", "disabled"
|
|
182
202
|
|
|
183
|
-
# Summarizer status
|
|
184
|
-
summarizer_enabled: bool = False
|
|
185
|
-
summarizer_available: bool = False # True if OPENAI_API_KEY is set
|
|
186
|
-
summarizer_calls: int = 0
|
|
187
|
-
summarizer_cost_usd: float = 0.0
|
|
188
|
-
|
|
189
203
|
def to_dict(self) -> dict:
|
|
190
204
|
"""Convert to dictionary for JSON serialization."""
|
|
191
205
|
return {
|
|
@@ -202,6 +216,7 @@ class MonitorDaemonState:
|
|
|
202
216
|
"presence_idle_seconds": self.presence_idle_seconds,
|
|
203
217
|
"total_green_time": self.total_green_time,
|
|
204
218
|
"total_non_green_time": self.total_non_green_time,
|
|
219
|
+
"total_sleep_time": self.total_sleep_time,
|
|
205
220
|
"green_sessions": self.green_sessions,
|
|
206
221
|
"non_green_sessions": self.non_green_sessions,
|
|
207
222
|
"total_supervisions": self.total_supervisions,
|
|
@@ -213,10 +228,6 @@ class MonitorDaemonState:
|
|
|
213
228
|
"relay_enabled": self.relay_enabled,
|
|
214
229
|
"relay_last_push": self.relay_last_push,
|
|
215
230
|
"relay_last_status": self.relay_last_status,
|
|
216
|
-
"summarizer_enabled": self.summarizer_enabled,
|
|
217
|
-
"summarizer_available": self.summarizer_available,
|
|
218
|
-
"summarizer_calls": self.summarizer_calls,
|
|
219
|
-
"summarizer_cost_usd": self.summarizer_cost_usd,
|
|
220
231
|
}
|
|
221
232
|
|
|
222
233
|
@classmethod
|
|
@@ -241,6 +252,7 @@ class MonitorDaemonState:
|
|
|
241
252
|
presence_idle_seconds=data.get("presence_idle_seconds"),
|
|
242
253
|
total_green_time=data.get("total_green_time", 0.0),
|
|
243
254
|
total_non_green_time=data.get("total_non_green_time", 0.0),
|
|
255
|
+
total_sleep_time=data.get("total_sleep_time", 0.0),
|
|
244
256
|
green_sessions=data.get("green_sessions", 0),
|
|
245
257
|
non_green_sessions=data.get("non_green_sessions", 0),
|
|
246
258
|
total_supervisions=data.get("total_supervisions", 0),
|
|
@@ -252,16 +264,13 @@ class MonitorDaemonState:
|
|
|
252
264
|
relay_enabled=data.get("relay_enabled", False),
|
|
253
265
|
relay_last_push=data.get("relay_last_push"),
|
|
254
266
|
relay_last_status=data.get("relay_last_status", "disabled"),
|
|
255
|
-
summarizer_enabled=data.get("summarizer_enabled", False),
|
|
256
|
-
summarizer_available=data.get("summarizer_available", False),
|
|
257
|
-
summarizer_calls=data.get("summarizer_calls", 0),
|
|
258
|
-
summarizer_cost_usd=data.get("summarizer_cost_usd", 0.0),
|
|
259
267
|
)
|
|
260
268
|
|
|
261
269
|
def update_summaries(self) -> None:
|
|
262
270
|
"""Recompute summary metrics from session data."""
|
|
263
271
|
self.total_green_time = sum(s.green_time_seconds for s in self.sessions)
|
|
264
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)
|
|
265
274
|
self.green_sessions = sum(1 for s in self.sessions if s.current_status == "running")
|
|
266
275
|
self.non_green_sessions = len(self.sessions) - self.green_sessions
|
|
267
276
|
self.total_supervisions = sum(s.steers_count for s in self.sessions)
|
overcode/pid_utils.py
CHANGED
|
@@ -88,6 +88,10 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
88
88
|
Uses file locking to prevent TOCTOU race conditions when multiple
|
|
89
89
|
processes try to start the daemon simultaneously.
|
|
90
90
|
|
|
91
|
+
IMPORTANT: The lock is held for the daemon's entire lifetime. The lock
|
|
92
|
+
file descriptor is stored in a module-level variable and released
|
|
93
|
+
automatically when the process exits (normal or crash).
|
|
94
|
+
|
|
91
95
|
Args:
|
|
92
96
|
pid_file: Path to the PID file
|
|
93
97
|
|
|
@@ -96,6 +100,8 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
96
100
|
- (True, None) if lock was acquired and PID file written
|
|
97
101
|
- (False, existing_pid) if another daemon is already running
|
|
98
102
|
"""
|
|
103
|
+
global _held_lock_fd
|
|
104
|
+
|
|
99
105
|
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
100
106
|
|
|
101
107
|
# Use a separate lock file to avoid truncation issues
|
|
@@ -110,12 +116,14 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
110
116
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
111
117
|
|
|
112
118
|
# We have the lock - now check if another daemon is running
|
|
119
|
+
# (handles case where previous daemon crashed without releasing lock)
|
|
113
120
|
if pid_file.exists():
|
|
114
121
|
try:
|
|
115
122
|
existing_pid = int(pid_file.read_text().strip())
|
|
116
123
|
# Check if process is still alive
|
|
117
124
|
os.kill(existing_pid, 0)
|
|
118
125
|
# Process exists - another daemon is running
|
|
126
|
+
# This shouldn't happen if locking works, but check anyway
|
|
119
127
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
120
128
|
os.close(fd)
|
|
121
129
|
return False, existing_pid
|
|
@@ -127,9 +135,10 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
127
135
|
current_pid = os.getpid()
|
|
128
136
|
pid_file.write_text(str(current_pid))
|
|
129
137
|
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
# IMPORTANT: Keep the lock held for the daemon's entire lifetime!
|
|
139
|
+
# The OS will automatically release it when the process exits.
|
|
140
|
+
# Store fd in module-level variable to prevent garbage collection.
|
|
141
|
+
_held_lock_fd = fd
|
|
133
142
|
|
|
134
143
|
return True, None
|
|
135
144
|
|
|
@@ -150,6 +159,11 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
150
159
|
return False, None
|
|
151
160
|
|
|
152
161
|
|
|
162
|
+
# Module-level variable to hold the lock file descriptor.
|
|
163
|
+
# This prevents garbage collection from closing the fd and releasing the lock.
|
|
164
|
+
_held_lock_fd: Optional[int] = None
|
|
165
|
+
|
|
166
|
+
|
|
153
167
|
def count_daemon_processes(pattern: str = "monitor_daemon", session: str = None) -> int:
|
|
154
168
|
"""Count running daemon processes matching the pattern.
|
|
155
169
|
|
overcode/session_manager.py
CHANGED
|
@@ -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:
|
|
@@ -91,6 +92,17 @@ class Session:
|
|
|
91
92
|
# Sleep mode - agent is paused and excluded from stats
|
|
92
93
|
is_asleep: bool = False
|
|
93
94
|
|
|
95
|
+
# Agent value - priority indicator for sorting/attention (#61)
|
|
96
|
+
# Default 1000, higher = more important
|
|
97
|
+
agent_value: int = 1000
|
|
98
|
+
|
|
99
|
+
# Human annotation - user's notes about this agent (#74)
|
|
100
|
+
human_annotation: str = ""
|
|
101
|
+
|
|
102
|
+
# Claude sessionIds owned by this overcode session (#119)
|
|
103
|
+
# Used to accurately calculate context window for this specific agent
|
|
104
|
+
claude_session_ids: List[str] = field(default_factory=list)
|
|
105
|
+
|
|
94
106
|
def to_dict(self) -> dict:
|
|
95
107
|
data = asdict(self)
|
|
96
108
|
# Convert stats to dict
|
|
@@ -607,3 +619,45 @@ class SessionManager:
|
|
|
607
619
|
def set_permissiveness(self, session_id: str, mode: str):
|
|
608
620
|
"""Set permissiveness mode (normal, permissive, strict)"""
|
|
609
621
|
self.update_session(session_id, permissiveness_mode=mode)
|
|
622
|
+
|
|
623
|
+
def set_agent_value(self, session_id: str, value: int):
|
|
624
|
+
"""Set agent value for priority sorting (#61).
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
session_id: The session ID
|
|
628
|
+
value: Priority value (default 1000, higher = more important)
|
|
629
|
+
"""
|
|
630
|
+
self.update_session(session_id, agent_value=value)
|
|
631
|
+
|
|
632
|
+
def set_human_annotation(self, session_id: str, annotation: str):
|
|
633
|
+
"""Set human annotation for a session (#74)."""
|
|
634
|
+
self.update_session(session_id, human_annotation=annotation)
|
|
635
|
+
|
|
636
|
+
def add_claude_session_id(self, session_id: str, claude_session_id: str) -> bool:
|
|
637
|
+
"""Add a Claude sessionId to a session's owned list if not already present.
|
|
638
|
+
|
|
639
|
+
This tracks which Claude sessionIds belong to this overcode agent,
|
|
640
|
+
enabling accurate context window calculation when multiple agents
|
|
641
|
+
run in the same directory (#119).
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
session_id: The overcode session ID
|
|
645
|
+
claude_session_id: The Claude Code sessionId to add
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
True if the sessionId was added, False if already present or session not found
|
|
649
|
+
"""
|
|
650
|
+
session = self.get_session(session_id)
|
|
651
|
+
if not session or claude_session_id in session.claude_session_ids:
|
|
652
|
+
return False
|
|
653
|
+
|
|
654
|
+
def do_update(state):
|
|
655
|
+
if session_id in state:
|
|
656
|
+
ids = state[session_id].get('claude_session_ids', [])
|
|
657
|
+
if claude_session_id not in ids:
|
|
658
|
+
ids.append(claude_session_id)
|
|
659
|
+
state[session_id]['claude_session_ids'] = ids
|
|
660
|
+
return state
|
|
661
|
+
|
|
662
|
+
self._atomic_update(do_update)
|
|
663
|
+
return True
|
overcode/settings.py
CHANGED
|
@@ -216,6 +216,12 @@ class UserConfig:
|
|
|
216
216
|
default_standing_instructions: str = ""
|
|
217
217
|
tmux_session: str = "agents"
|
|
218
218
|
|
|
219
|
+
# Token pricing (per million tokens) - defaults to Opus 4.5
|
|
220
|
+
price_input: float = 5.0 # $/MTok for input tokens
|
|
221
|
+
price_output: float = 25.0 # $/MTok for output tokens
|
|
222
|
+
price_cache_write: float = 6.25 # $/MTok for cache creation
|
|
223
|
+
price_cache_read: float = 0.50 # $/MTok for cache reads
|
|
224
|
+
|
|
219
225
|
@classmethod
|
|
220
226
|
def load(cls) -> "UserConfig":
|
|
221
227
|
"""Load configuration from config file."""
|
|
@@ -230,11 +236,18 @@ class UserConfig:
|
|
|
230
236
|
if not isinstance(data, dict):
|
|
231
237
|
return cls()
|
|
232
238
|
|
|
239
|
+
# Load pricing config (nested under 'pricing' key)
|
|
240
|
+
pricing = data.get("pricing", {})
|
|
241
|
+
|
|
233
242
|
return cls(
|
|
234
243
|
default_standing_instructions=data.get(
|
|
235
244
|
"default_standing_instructions", ""
|
|
236
245
|
),
|
|
237
246
|
tmux_session=data.get("tmux_session", "agents"),
|
|
247
|
+
price_input=pricing.get("input", 5.0),
|
|
248
|
+
price_output=pricing.get("output", 25.0),
|
|
249
|
+
price_cache_write=pricing.get("cache_write", 6.25),
|
|
250
|
+
price_cache_read=pricing.get("cache_read", 0.50),
|
|
238
251
|
)
|
|
239
252
|
except (yaml.YAMLError, IOError):
|
|
240
253
|
return cls()
|
|
@@ -380,6 +393,13 @@ class TUIPreferences:
|
|
|
380
393
|
daemon_panel_visible: bool = False
|
|
381
394
|
view_mode: str = "tree" # tree, list_preview
|
|
382
395
|
tmux_sync: bool = False # sync navigation to external tmux pane
|
|
396
|
+
show_terminated: bool = False # keep killed sessions visible in timeline
|
|
397
|
+
hide_asleep: bool = False # hide sleeping agents from display
|
|
398
|
+
sort_mode: str = "alphabetical" # alphabetical, by_status, by_value (#61)
|
|
399
|
+
summary_content_mode: str = "ai_short" # ai_short, ai_long, orders, annotation (#98)
|
|
400
|
+
baseline_minutes: int = 60 # 0=now (instantaneous), 15/30/.../180 = minutes back for mean spin
|
|
401
|
+
monochrome: bool = False # B&W mode for terminals with ANSI issues (#138)
|
|
402
|
+
show_cost: bool = False # Show $ cost instead of token counts
|
|
383
403
|
# Session IDs of stalled agents that have been visited by the user
|
|
384
404
|
visited_stalled_agents: Set[str] = field(default_factory=set)
|
|
385
405
|
|
|
@@ -405,6 +425,13 @@ class TUIPreferences:
|
|
|
405
425
|
daemon_panel_visible=data.get("daemon_panel_visible", False),
|
|
406
426
|
view_mode=data.get("view_mode", "tree"),
|
|
407
427
|
tmux_sync=data.get("tmux_sync", False),
|
|
428
|
+
show_terminated=data.get("show_terminated", False),
|
|
429
|
+
hide_asleep=data.get("hide_asleep", False),
|
|
430
|
+
sort_mode=data.get("sort_mode", "alphabetical"),
|
|
431
|
+
summary_content_mode=data.get("summary_content_mode", "ai_short"),
|
|
432
|
+
baseline_minutes=data.get("baseline_minutes", 0),
|
|
433
|
+
monochrome=data.get("monochrome", False),
|
|
434
|
+
show_cost=data.get("show_cost", False),
|
|
408
435
|
visited_stalled_agents=set(data.get("visited_stalled_agents", [])),
|
|
409
436
|
)
|
|
410
437
|
except (json.JSONDecodeError, IOError):
|
|
@@ -425,6 +452,13 @@ class TUIPreferences:
|
|
|
425
452
|
"daemon_panel_visible": self.daemon_panel_visible,
|
|
426
453
|
"view_mode": self.view_mode,
|
|
427
454
|
"tmux_sync": self.tmux_sync,
|
|
455
|
+
"show_terminated": self.show_terminated,
|
|
456
|
+
"hide_asleep": self.hide_asleep,
|
|
457
|
+
"sort_mode": self.sort_mode,
|
|
458
|
+
"summary_content_mode": self.summary_content_mode,
|
|
459
|
+
"baseline_minutes": self.baseline_minutes,
|
|
460
|
+
"monochrome": self.monochrome,
|
|
461
|
+
"show_cost": self.show_cost,
|
|
428
462
|
"visited_stalled_agents": list(self.visited_stalled_agents),
|
|
429
463
|
}, f, indent=2)
|
|
430
464
|
except (IOError, OSError):
|
overcode/status_constants.py
CHANGED
|
@@ -119,7 +119,7 @@ AGENT_TIMELINE_CHARS = {
|
|
|
119
119
|
STATUS_WAITING_SUPERVISOR: "▒",
|
|
120
120
|
STATUS_WAITING_USER: "░",
|
|
121
121
|
STATUS_TERMINATED: "×", # Small X - terminated
|
|
122
|
-
STATUS_ASLEEP: "
|
|
122
|
+
STATUS_ASLEEP: "░", # Light shade hatching (grey) - sleeping/paused
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
|
overcode/status_detector.py
CHANGED
|
@@ -19,6 +19,7 @@ from .status_patterns import (
|
|
|
19
19
|
is_command_menu_line,
|
|
20
20
|
count_command_menu_lines,
|
|
21
21
|
clean_line,
|
|
22
|
+
strip_ansi,
|
|
22
23
|
StatusPatterns,
|
|
23
24
|
)
|
|
24
25
|
|
|
@@ -94,12 +95,17 @@ class StatusDetector:
|
|
|
94
95
|
if not content:
|
|
95
96
|
return self.STATUS_WAITING_USER, "Unable to read pane", ""
|
|
96
97
|
|
|
98
|
+
# Strip ANSI escape sequences for pattern matching
|
|
99
|
+
# The raw content (with colors) is returned for display, but pattern
|
|
100
|
+
# matching needs plain text since escape codes break string matching
|
|
101
|
+
clean_content = strip_ansi(content)
|
|
102
|
+
|
|
97
103
|
# Content change detection - if content is changing, Claude is actively working
|
|
98
104
|
# Key by session.id, not window index, to avoid stale hashes when windows are recycled
|
|
99
105
|
# IMPORTANT: Filter out status bar lines before hashing to avoid false positives
|
|
100
106
|
# from dynamic status bar elements (token counts, elapsed time) that update when idle
|
|
101
107
|
session_id = session.id
|
|
102
|
-
content_for_hash = self._filter_status_bar_for_hash(
|
|
108
|
+
content_for_hash = self._filter_status_bar_for_hash(clean_content)
|
|
103
109
|
content_hash = hash(content_for_hash)
|
|
104
110
|
content_changed = False
|
|
105
111
|
if session_id in self._previous_content:
|
|
@@ -107,7 +113,7 @@ class StatusDetector:
|
|
|
107
113
|
self._previous_content[session_id] = content_hash
|
|
108
114
|
self._content_changed[session_id] = content_changed
|
|
109
115
|
|
|
110
|
-
lines =
|
|
116
|
+
lines = clean_content.strip().split('\n')
|
|
111
117
|
# Get more lines for better context (menu prompts can be 5+ lines)
|
|
112
118
|
last_lines = [l.strip() for l in lines[-10:] if l.strip()]
|
|
113
119
|
|
overcode/status_patterns.py
CHANGED
|
@@ -10,9 +10,28 @@ Claude's current state. Centralizing these makes them:
|
|
|
10
10
|
Each pattern set includes documentation about when it's used and what it matches.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
import re
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from typing import List
|
|
15
16
|
|
|
17
|
+
# Regex to match ANSI escape sequences (colors, cursor movement, etc.)
|
|
18
|
+
ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def strip_ansi(text: str) -> str:
|
|
22
|
+
"""Remove ANSI escape sequences from text.
|
|
23
|
+
|
|
24
|
+
This is needed because tmux capture_pane with escape_sequences=True
|
|
25
|
+
preserves color codes, but pattern matching needs plain text.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
text: Text potentially containing ANSI escape sequences
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Text with all ANSI escape sequences removed
|
|
32
|
+
"""
|
|
33
|
+
return ANSI_ESCAPE_PATTERN.sub('', text)
|
|
34
|
+
|
|
16
35
|
|
|
17
36
|
@dataclass
|
|
18
37
|
class StatusPatterns:
|