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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +154 -51
  3. overcode/config.py +66 -0
  4. overcode/daemon_claude_skill.md +36 -33
  5. overcode/history_reader.py +69 -8
  6. overcode/implementations.py +178 -87
  7. overcode/monitor_daemon.py +87 -97
  8. overcode/monitor_daemon_core.py +261 -0
  9. overcode/monitor_daemon_state.py +24 -15
  10. overcode/pid_utils.py +17 -3
  11. overcode/session_manager.py +54 -0
  12. overcode/settings.py +34 -0
  13. overcode/status_constants.py +1 -1
  14. overcode/status_detector.py +8 -2
  15. overcode/status_patterns.py +19 -0
  16. overcode/summarizer_client.py +72 -27
  17. overcode/summarizer_component.py +87 -107
  18. overcode/supervisor_daemon.py +55 -38
  19. overcode/supervisor_daemon_core.py +210 -0
  20. overcode/testing/__init__.py +6 -0
  21. overcode/testing/renderer.py +268 -0
  22. overcode/testing/tmux_driver.py +223 -0
  23. overcode/testing/tui_eye.py +185 -0
  24. overcode/testing/tui_eye_skill.md +187 -0
  25. overcode/tmux_manager.py +117 -93
  26. overcode/tui.py +399 -1969
  27. overcode/tui_actions/__init__.py +20 -0
  28. overcode/tui_actions/daemon.py +201 -0
  29. overcode/tui_actions/input.py +128 -0
  30. overcode/tui_actions/navigation.py +117 -0
  31. overcode/tui_actions/session.py +428 -0
  32. overcode/tui_actions/view.py +357 -0
  33. overcode/tui_helpers.py +42 -9
  34. overcode/tui_logic.py +347 -0
  35. overcode/tui_render.py +414 -0
  36. overcode/tui_widgets/__init__.py +24 -0
  37. overcode/tui_widgets/command_bar.py +399 -0
  38. overcode/tui_widgets/daemon_panel.py +153 -0
  39. overcode/tui_widgets/daemon_status_bar.py +245 -0
  40. overcode/tui_widgets/help_overlay.py +71 -0
  41. overcode/tui_widgets/preview_pane.py +69 -0
  42. overcode/tui_widgets/session_summary.py +514 -0
  43. overcode/tui_widgets/status_timeline.py +253 -0
  44. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/METADATA +4 -1
  45. overcode-0.1.4.dist-info/RECORD +68 -0
  46. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/WHEEL +1 -1
  47. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  48. overcode-0.1.2.dist-info/RECORD +0 -45
  49. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  50. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,261 @@
1
+ """
2
+ Pure business logic for Monitor Daemon.
3
+
4
+ These functions contain no I/O and are fully unit-testable.
5
+ They are used by MonitorDaemon but can be tested independently.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from typing import Optional, List, Tuple
11
+
12
+ from .status_constants import STATUS_RUNNING, STATUS_TERMINATED, STATUS_ASLEEP
13
+
14
+
15
+ @dataclass
16
+ class TimeAccumulationResult:
17
+ """Result of time accumulation calculation."""
18
+ green_seconds: float
19
+ non_green_seconds: float
20
+ sleep_seconds: float # Track sleep time separately (#141)
21
+ state_changed: bool
22
+ was_capped: bool # True if time was capped to uptime
23
+
24
+
25
+ def calculate_time_accumulation(
26
+ current_status: str,
27
+ previous_status: Optional[str],
28
+ elapsed_seconds: float,
29
+ current_green: float,
30
+ current_non_green: float,
31
+ current_sleep: float,
32
+ session_start: Optional[datetime],
33
+ now: datetime,
34
+ tolerance: float = 1.1, # 10% tolerance for timing jitter
35
+ ) -> TimeAccumulationResult:
36
+ """Calculate accumulated green/non-green/sleep time based on current status.
37
+
38
+ Pure function - no side effects, fully testable.
39
+
40
+ Args:
41
+ current_status: Current agent status (running, waiting_user, etc.)
42
+ previous_status: Previous status (None if first observation)
43
+ elapsed_seconds: Seconds since last observation
44
+ current_green: Current accumulated green time
45
+ current_non_green: Current accumulated non-green time
46
+ current_sleep: Current accumulated sleep time (#141)
47
+ session_start: When the session started (for cap calculation)
48
+ now: Current time
49
+ tolerance: How much accumulated time can exceed uptime (1.1 = 10%)
50
+
51
+ Returns:
52
+ TimeAccumulationResult with updated times and metadata
53
+ """
54
+ if elapsed_seconds <= 0:
55
+ return TimeAccumulationResult(
56
+ green_seconds=current_green,
57
+ non_green_seconds=current_non_green,
58
+ sleep_seconds=current_sleep,
59
+ state_changed=False,
60
+ was_capped=False,
61
+ )
62
+
63
+ green = current_green
64
+ non_green = current_non_green
65
+ sleep = current_sleep
66
+
67
+ # Accumulate based on status
68
+ if current_status == STATUS_RUNNING:
69
+ green += elapsed_seconds
70
+ elif current_status == STATUS_ASLEEP:
71
+ sleep += elapsed_seconds # Track sleep time separately (#141)
72
+ elif current_status != STATUS_TERMINATED:
73
+ non_green += elapsed_seconds
74
+ # else: terminated - don't accumulate time
75
+
76
+ # Cap accumulated time to session uptime
77
+ was_capped = False
78
+ if session_start is not None:
79
+ max_allowed = (now - session_start).total_seconds()
80
+ total_accumulated = green + non_green + sleep
81
+
82
+ if total_accumulated > max_allowed * tolerance:
83
+ # Scale down to sane values
84
+ ratio = max_allowed / total_accumulated if total_accumulated > 0 else 1.0
85
+ green = green * ratio
86
+ non_green = non_green * ratio
87
+ sleep = sleep * ratio
88
+ was_capped = True
89
+
90
+ state_changed = previous_status is not None and previous_status != current_status
91
+
92
+ return TimeAccumulationResult(
93
+ green_seconds=green,
94
+ non_green_seconds=non_green,
95
+ sleep_seconds=sleep,
96
+ state_changed=state_changed,
97
+ was_capped=was_capped,
98
+ )
99
+
100
+
101
+ def calculate_cost_estimate(
102
+ input_tokens: int,
103
+ output_tokens: int,
104
+ cache_creation_tokens: int = 0,
105
+ cache_read_tokens: int = 0,
106
+ price_input: float = 15.0,
107
+ price_output: float = 75.0,
108
+ price_cache_write: float = 18.75,
109
+ price_cache_read: float = 1.50,
110
+ ) -> float:
111
+ """Calculate estimated cost from token counts.
112
+
113
+ Pure function - no side effects, fully testable.
114
+
115
+ Args:
116
+ input_tokens: Number of input tokens
117
+ output_tokens: Number of output tokens
118
+ cache_creation_tokens: Number of cache creation tokens
119
+ cache_read_tokens: Number of cache read tokens
120
+ price_input: Price per million input tokens (default: Opus 4.5)
121
+ price_output: Price per million output tokens (default: Opus 4.5)
122
+ price_cache_write: Price per million cache write tokens (default: Opus 4.5)
123
+ price_cache_read: Price per million cache read tokens (default: Opus 4.5)
124
+
125
+ Returns:
126
+ Estimated cost in USD
127
+ """
128
+ return (
129
+ (input_tokens / 1_000_000) * price_input +
130
+ (output_tokens / 1_000_000) * price_output +
131
+ (cache_creation_tokens / 1_000_000) * price_cache_write +
132
+ (cache_read_tokens / 1_000_000) * price_cache_read
133
+ )
134
+
135
+
136
+ def calculate_total_tokens(
137
+ input_tokens: int,
138
+ output_tokens: int,
139
+ cache_creation_tokens: int = 0,
140
+ cache_read_tokens: int = 0,
141
+ ) -> int:
142
+ """Calculate total token count.
143
+
144
+ Pure function - no side effects, fully testable.
145
+ """
146
+ return input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens
147
+
148
+
149
+ def calculate_median(values: List[float]) -> float:
150
+ """Calculate median of a list of values.
151
+
152
+ Pure function - no side effects, fully testable.
153
+
154
+ Args:
155
+ values: List of numeric values
156
+
157
+ Returns:
158
+ Median value, or 0.0 if list is empty
159
+ """
160
+ if not values:
161
+ return 0.0
162
+ sorted_values = sorted(values)
163
+ n = len(sorted_values)
164
+ if n % 2 == 0:
165
+ return (sorted_values[n // 2 - 1] + sorted_values[n // 2]) / 2
166
+ return sorted_values[n // 2]
167
+
168
+
169
+ def calculate_green_percentage(green_seconds: float, non_green_seconds: float) -> int:
170
+ """Calculate percentage of time spent in green (running) state.
171
+
172
+ Pure function - no side effects, fully testable.
173
+
174
+ Args:
175
+ green_seconds: Total green time
176
+ non_green_seconds: Total non-green time
177
+
178
+ Returns:
179
+ Integer percentage (0-100)
180
+ """
181
+ total = green_seconds + non_green_seconds
182
+ if total <= 0:
183
+ return 0
184
+ return int((green_seconds / total) * 100)
185
+
186
+
187
+ def aggregate_session_stats(
188
+ sessions: List[dict],
189
+ ) -> Tuple[int, float, float, int]:
190
+ """Aggregate statistics across multiple sessions.
191
+
192
+ Pure function - no side effects, fully testable.
193
+
194
+ Args:
195
+ sessions: List of session dicts with 'status', 'green_time_seconds',
196
+ 'non_green_time_seconds', 'is_asleep' keys
197
+
198
+ Returns:
199
+ Tuple of (green_count, total_green_time, total_non_green_time, active_count)
200
+ """
201
+ green_count = 0
202
+ total_green = 0.0
203
+ total_non_green = 0.0
204
+ active_count = 0
205
+
206
+ for session in sessions:
207
+ # Skip asleep sessions
208
+ if session.get('is_asleep', False):
209
+ continue
210
+
211
+ active_count += 1
212
+ status = session.get('status', '')
213
+
214
+ if status == STATUS_RUNNING:
215
+ green_count += 1
216
+
217
+ total_green += session.get('green_time_seconds', 0.0)
218
+ total_non_green += session.get('non_green_time_seconds', 0.0)
219
+
220
+ return green_count, total_green, total_non_green, active_count
221
+
222
+
223
+ def should_sync_stats(
224
+ last_sync: Optional[datetime],
225
+ now: datetime,
226
+ interval_seconds: float,
227
+ ) -> bool:
228
+ """Determine if stats should be synced based on interval.
229
+
230
+ Pure function - no side effects, fully testable.
231
+
232
+ Args:
233
+ last_sync: Time of last sync (None if never synced)
234
+ now: Current time
235
+ interval_seconds: Minimum seconds between syncs
236
+
237
+ Returns:
238
+ True if sync should occur
239
+ """
240
+ if last_sync is None:
241
+ return True
242
+ return (now - last_sync).total_seconds() >= interval_seconds
243
+
244
+
245
+ def parse_datetime_safe(value: Optional[str]) -> Optional[datetime]:
246
+ """Safely parse an ISO datetime string.
247
+
248
+ Pure function - no side effects, fully testable.
249
+
250
+ Args:
251
+ value: ISO format datetime string, or None
252
+
253
+ Returns:
254
+ Parsed datetime, or None if parsing fails
255
+ """
256
+ if value is None:
257
+ return None
258
+ try:
259
+ return datetime.fromisoformat(value)
260
+ except (ValueError, TypeError):
261
+ return None
@@ -46,6 +46,7 @@ class SessionDaemonState:
46
46
  # Time tracking (authoritative - only Monitor Daemon updates these)
47
47
  green_time_seconds: float = 0.0
48
48
  non_green_time_seconds: float = 0.0
49
+ sleep_time_seconds: float = 0.0
49
50
 
50
51
  # Claude Code stats (synced from ~/.claude/projects/)
51
52
  interaction_count: int = 0
@@ -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
- # Activity summary (from SummarizerComponent)
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
- # Release the lock (but keep file for tracking)
131
- fcntl.flock(fd, fcntl.LOCK_UN)
132
- os.close(fd)
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
 
@@ -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):
@@ -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: "z", # Lowercase z for sleeping
122
+ STATUS_ASLEEP: "", # Light shade hatching (grey) - sleeping/paused
123
123
  }
124
124
 
125
125
 
@@ -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(content)
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 = content.strip().split('\n')
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
 
@@ -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: