overcode 0.1.0__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 (43) hide show
  1. overcode/__init__.py +5 -0
  2. overcode/cli.py +812 -0
  3. overcode/config.py +72 -0
  4. overcode/daemon.py +1184 -0
  5. overcode/daemon_claude_skill.md +180 -0
  6. overcode/daemon_state.py +113 -0
  7. overcode/data_export.py +257 -0
  8. overcode/dependency_check.py +227 -0
  9. overcode/exceptions.py +219 -0
  10. overcode/history_reader.py +448 -0
  11. overcode/implementations.py +214 -0
  12. overcode/interfaces.py +49 -0
  13. overcode/launcher.py +434 -0
  14. overcode/logging_config.py +193 -0
  15. overcode/mocks.py +152 -0
  16. overcode/monitor_daemon.py +808 -0
  17. overcode/monitor_daemon_state.py +358 -0
  18. overcode/pid_utils.py +225 -0
  19. overcode/presence_logger.py +454 -0
  20. overcode/protocols.py +143 -0
  21. overcode/session_manager.py +606 -0
  22. overcode/settings.py +412 -0
  23. overcode/standing_instructions.py +276 -0
  24. overcode/status_constants.py +190 -0
  25. overcode/status_detector.py +339 -0
  26. overcode/status_history.py +164 -0
  27. overcode/status_patterns.py +264 -0
  28. overcode/summarizer_client.py +136 -0
  29. overcode/summarizer_component.py +312 -0
  30. overcode/supervisor_daemon.py +1000 -0
  31. overcode/supervisor_layout.sh +50 -0
  32. overcode/tmux_manager.py +228 -0
  33. overcode/tui.py +2549 -0
  34. overcode/tui_helpers.py +495 -0
  35. overcode/web_api.py +279 -0
  36. overcode/web_server.py +138 -0
  37. overcode/web_templates.py +563 -0
  38. overcode-0.1.0.dist-info/METADATA +87 -0
  39. overcode-0.1.0.dist-info/RECORD +43 -0
  40. overcode-0.1.0.dist-info/WHEEL +5 -0
  41. overcode-0.1.0.dist-info/entry_points.txt +2 -0
  42. overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. overcode-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,190 @@
1
+ """
2
+ Status constants and mappings for Overcode.
3
+
4
+ Centralizes all status-related constants, colors, emojis, and display
5
+ mappings used throughout the application.
6
+ """
7
+
8
+ from typing import Tuple
9
+
10
+
11
+ # =============================================================================
12
+ # Agent Status Values
13
+ # =============================================================================
14
+
15
+ STATUS_RUNNING = "running"
16
+ STATUS_NO_INSTRUCTIONS = "no_instructions"
17
+ STATUS_WAITING_SUPERVISOR = "waiting_supervisor"
18
+ STATUS_WAITING_USER = "waiting_user"
19
+ STATUS_TERMINATED = "terminated" # Claude Code exited, shell prompt showing
20
+
21
+ # All valid agent status values
22
+ ALL_STATUSES = [
23
+ STATUS_RUNNING,
24
+ STATUS_NO_INSTRUCTIONS,
25
+ STATUS_WAITING_SUPERVISOR,
26
+ STATUS_WAITING_USER,
27
+ STATUS_TERMINATED,
28
+ ]
29
+
30
+
31
+ # =============================================================================
32
+ # Daemon Status Values
33
+ # =============================================================================
34
+
35
+ DAEMON_STATUS_ACTIVE = "active"
36
+ DAEMON_STATUS_IDLE = "idle"
37
+ DAEMON_STATUS_WAITING = "waiting"
38
+ DAEMON_STATUS_SUPERVISING = "supervising"
39
+ DAEMON_STATUS_SLEEPING = "sleeping"
40
+ DAEMON_STATUS_STOPPED = "stopped"
41
+ DAEMON_STATUS_NO_AGENTS = "no_agents"
42
+
43
+
44
+ # =============================================================================
45
+ # Presence State Values
46
+ # =============================================================================
47
+
48
+ PRESENCE_LOCKED = 1
49
+ PRESENCE_INACTIVE = 2
50
+ PRESENCE_ACTIVE = 3
51
+
52
+
53
+ # =============================================================================
54
+ # Status to Emoji Mappings
55
+ # =============================================================================
56
+
57
+ STATUS_EMOJIS = {
58
+ STATUS_RUNNING: "🟢",
59
+ STATUS_NO_INSTRUCTIONS: "🟡",
60
+ STATUS_WAITING_SUPERVISOR: "🟠",
61
+ STATUS_WAITING_USER: "🔴",
62
+ STATUS_TERMINATED: "⚫", # Black circle - Claude exited
63
+ }
64
+
65
+
66
+ def get_status_emoji(status: str) -> str:
67
+ """Get emoji for an agent status."""
68
+ return STATUS_EMOJIS.get(status, "⚪")
69
+
70
+
71
+ # =============================================================================
72
+ # Status to Color Mappings (for Rich/Textual styling)
73
+ # =============================================================================
74
+
75
+ STATUS_COLORS = {
76
+ STATUS_RUNNING: "green",
77
+ STATUS_NO_INSTRUCTIONS: "yellow",
78
+ STATUS_WAITING_SUPERVISOR: "orange1",
79
+ STATUS_WAITING_USER: "red",
80
+ STATUS_TERMINATED: "dim", # Grey for terminated
81
+ }
82
+
83
+
84
+ def get_status_color(status: str) -> str:
85
+ """Get color name for an agent status."""
86
+ return STATUS_COLORS.get(status, "dim")
87
+
88
+
89
+ # =============================================================================
90
+ # Status to Symbol+Color (combined for display)
91
+ # =============================================================================
92
+
93
+ STATUS_SYMBOLS = {
94
+ STATUS_RUNNING: ("🟢", "green"),
95
+ STATUS_NO_INSTRUCTIONS: ("🟡", "yellow"),
96
+ STATUS_WAITING_SUPERVISOR: ("🟠", "orange1"),
97
+ STATUS_WAITING_USER: ("🔴", "red"),
98
+ STATUS_TERMINATED: ("⚫", "dim"),
99
+ }
100
+
101
+
102
+ def get_status_symbol(status: str) -> Tuple[str, str]:
103
+ """Get (emoji, color) tuple for an agent status."""
104
+ return STATUS_SYMBOLS.get(status, ("⚪", "dim"))
105
+
106
+
107
+ # =============================================================================
108
+ # Timeline Character Mappings
109
+ # =============================================================================
110
+
111
+ AGENT_TIMELINE_CHARS = {
112
+ STATUS_RUNNING: "█",
113
+ STATUS_NO_INSTRUCTIONS: "▓",
114
+ STATUS_WAITING_SUPERVISOR: "▒",
115
+ STATUS_WAITING_USER: "░",
116
+ STATUS_TERMINATED: "×", # Small X - terminated
117
+ }
118
+
119
+
120
+ def get_agent_timeline_char(status: str) -> str:
121
+ """Get timeline character for an agent status."""
122
+ return AGENT_TIMELINE_CHARS.get(status, "─")
123
+
124
+
125
+ PRESENCE_TIMELINE_CHARS = {
126
+ PRESENCE_LOCKED: "░",
127
+ PRESENCE_INACTIVE: "▒",
128
+ PRESENCE_ACTIVE: "█",
129
+ }
130
+
131
+
132
+ def get_presence_timeline_char(state: int) -> str:
133
+ """Get timeline character for a presence state."""
134
+ return PRESENCE_TIMELINE_CHARS.get(state, "─")
135
+
136
+
137
+ # =============================================================================
138
+ # Presence State Colors
139
+ # =============================================================================
140
+
141
+ PRESENCE_COLORS = {
142
+ PRESENCE_LOCKED: "red",
143
+ PRESENCE_INACTIVE: "yellow",
144
+ PRESENCE_ACTIVE: "green",
145
+ }
146
+
147
+
148
+ def get_presence_color(state: int) -> str:
149
+ """Get color for a presence state."""
150
+ return PRESENCE_COLORS.get(state, "dim")
151
+
152
+
153
+ # =============================================================================
154
+ # Daemon Status Display
155
+ # =============================================================================
156
+
157
+ DAEMON_STATUS_STYLES = {
158
+ DAEMON_STATUS_ACTIVE: ("●", "green"),
159
+ DAEMON_STATUS_IDLE: ("○", "yellow"),
160
+ DAEMON_STATUS_WAITING: ("◐", "yellow"),
161
+ DAEMON_STATUS_SUPERVISING: ("●", "cyan"),
162
+ DAEMON_STATUS_SLEEPING: ("○", "dim"),
163
+ DAEMON_STATUS_STOPPED: ("○", "red"),
164
+ DAEMON_STATUS_NO_AGENTS: ("○", "dim"),
165
+ }
166
+
167
+
168
+ def get_daemon_status_style(status: str) -> Tuple[str, str]:
169
+ """Get (symbol, color) for daemon status display."""
170
+ return DAEMON_STATUS_STYLES.get(status, ("?", "dim"))
171
+
172
+
173
+ # =============================================================================
174
+ # Status Categorization
175
+ # =============================================================================
176
+
177
+
178
+ def is_green_status(status: str) -> bool:
179
+ """Check if a status is considered 'green' (actively working)."""
180
+ return status == STATUS_RUNNING
181
+
182
+
183
+ def is_waiting_status(status: str) -> bool:
184
+ """Check if a status is a waiting state."""
185
+ return status in (STATUS_WAITING_SUPERVISOR, STATUS_WAITING_USER)
186
+
187
+
188
+ def is_user_blocked(status: str) -> bool:
189
+ """Check if status indicates user intervention is required."""
190
+ return status == STATUS_WAITING_USER
@@ -0,0 +1,339 @@
1
+ """
2
+ Status detection for Claude sessions in tmux.
3
+ """
4
+
5
+ from typing import Optional, Tuple, TYPE_CHECKING
6
+
7
+ from .status_constants import (
8
+ STATUS_RUNNING,
9
+ STATUS_NO_INSTRUCTIONS,
10
+ STATUS_WAITING_SUPERVISOR,
11
+ STATUS_WAITING_USER,
12
+ STATUS_TERMINATED,
13
+ )
14
+ from .status_patterns import (
15
+ get_patterns,
16
+ matches_any,
17
+ find_matching_line,
18
+ is_status_bar_line,
19
+ is_command_menu_line,
20
+ count_command_menu_lines,
21
+ clean_line,
22
+ StatusPatterns,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from .interfaces import TmuxInterface
27
+
28
+
29
+ class StatusDetector:
30
+ """Detects the current status of a Claude session"""
31
+
32
+ # Re-export status constants for backwards compatibility
33
+ STATUS_RUNNING = STATUS_RUNNING
34
+ STATUS_NO_INSTRUCTIONS = STATUS_NO_INSTRUCTIONS
35
+ STATUS_WAITING_SUPERVISOR = STATUS_WAITING_SUPERVISOR
36
+ STATUS_WAITING_USER = STATUS_WAITING_USER
37
+ STATUS_TERMINATED = STATUS_TERMINATED
38
+
39
+ def __init__(
40
+ self,
41
+ tmux_session: str,
42
+ tmux: "TmuxInterface" = None,
43
+ patterns: StatusPatterns = None
44
+ ):
45
+ """Initialize the status detector.
46
+
47
+ Args:
48
+ tmux_session: Name of the tmux session to monitor
49
+ tmux: TmuxInterface implementation (defaults to RealTmux for production)
50
+ patterns: StatusPatterns to use for detection (defaults to DEFAULT_PATTERNS)
51
+ """
52
+ self.tmux_session = tmux_session
53
+
54
+ # Dependency injection for testability
55
+ if tmux is None:
56
+ from .interfaces import RealTmux
57
+ tmux = RealTmux()
58
+ self.tmux = tmux
59
+
60
+ # Use provided patterns or default
61
+ self.patterns = patterns or get_patterns()
62
+
63
+ # Track previous content per session for change detection
64
+ self._previous_content: dict[int, str] = {} # window -> content hash
65
+ self._content_changed: dict[int, bool] = {} # window -> changed flag
66
+
67
+ def get_pane_content(self, window: int, num_lines: int = 50) -> Optional[str]:
68
+ """Get the last N meaningful lines from a tmux pane.
69
+
70
+ Captures more content than requested and filters out trailing blank lines
71
+ to find the actual content (Claude Code often has blank lines at bottom).
72
+ """
73
+ content = self.tmux.capture_pane(self.tmux_session, window, lines=150)
74
+ if content is None:
75
+ return None
76
+
77
+ # Strip trailing blank lines, then return last num_lines
78
+ lines = content.rstrip().split('\n')
79
+ meaningful_lines = lines[-num_lines:] if len(lines) > num_lines else lines
80
+ return '\n'.join(meaningful_lines)
81
+
82
+ def detect_status(self, session) -> Tuple[str, str, str]:
83
+ """
84
+ Detect session status and current activity.
85
+
86
+ Returns:
87
+ Tuple of (status, current_activity, pane_content)
88
+ - status: one of STATUS_* constants
89
+ - current_activity: single line description of what's happening
90
+ - pane_content: the raw pane content (to avoid duplicate tmux calls)
91
+ """
92
+ content = self.get_pane_content(session.tmux_window)
93
+
94
+ if not content:
95
+ return self.STATUS_WAITING_USER, "Unable to read pane", ""
96
+
97
+ # Content change detection - if content is changing, Claude is actively working
98
+ # Key by session.id, not window index, to avoid stale hashes when windows are recycled
99
+ # IMPORTANT: Filter out status bar lines before hashing to avoid false positives
100
+ # from dynamic status bar elements (token counts, elapsed time) that update when idle
101
+ session_id = session.id
102
+ content_for_hash = self._filter_status_bar_for_hash(content)
103
+ content_hash = hash(content_for_hash)
104
+ content_changed = False
105
+ if session_id in self._previous_content:
106
+ content_changed = self._previous_content[session_id] != content_hash
107
+ self._previous_content[session_id] = content_hash
108
+ self._content_changed[session_id] = content_changed
109
+
110
+ lines = content.strip().split('\n')
111
+ # Get more lines for better context (menu prompts can be 5+ lines)
112
+ last_lines = [l.strip() for l in lines[-10:] if l.strip()]
113
+
114
+ if not last_lines:
115
+ return self.STATUS_WAITING_USER, "No output", content
116
+
117
+ last_line = last_lines[-1]
118
+
119
+ # Check for shell prompt (Claude Code has terminated)
120
+ # Shell prompts typically end with $ or % and have username@hostname pattern
121
+ # Also check for absence of Claude Code UI elements
122
+ if self._is_shell_prompt(last_lines):
123
+ return self.STATUS_TERMINATED, "Claude exited - shell prompt", content
124
+
125
+ # Filter out UI chrome lines before pattern matching
126
+ content_lines = [l for l in last_lines if not is_status_bar_line(l, self.patterns)]
127
+
128
+ # Join more lines for pattern matching (menus have multiple lines)
129
+ last_few = ' '.join(content_lines[-6:]).lower() if content_lines else ''
130
+
131
+ # Check for permission/confirmation prompts (HIGHEST priority)
132
+ # This MUST come before active indicator checks because permission dialogs
133
+ # can contain tool names like "Web Search commands in" that would falsely
134
+ # match active indicators.
135
+ if matches_any(last_few, self.patterns.permission_patterns):
136
+ request_text = self._extract_permission_request(last_lines)
137
+ return self.STATUS_WAITING_USER, f"Permission: {request_text}", content
138
+
139
+ # Check for command menu display (slash command autocomplete)
140
+ # When user types a slash command, Claude shows a menu of available commands.
141
+ # This means Claude is waiting for the user to complete/select a command.
142
+ # We check if most of the last lines are menu entries.
143
+ menu_lines = count_command_menu_lines(last_lines, self.patterns)
144
+ if menu_lines >= 3 and menu_lines >= len(last_lines) * 0.4:
145
+ return self.STATUS_WAITING_USER, "Command menu - waiting for input", content
146
+
147
+ # Content change detection - if pane content is actively changing, Claude is working
148
+ # This is the most reliable indicator as it catches streaming output
149
+ if content_changed:
150
+ activity = self._extract_last_activity(last_lines)
151
+ return self.STATUS_RUNNING, f"Active: {activity}", content
152
+
153
+ # Check for ACTIVE WORK indicators BEFORE checking for prompt
154
+ # These indicate Claude is busy even if the prompt is visible
155
+ if matches_any(last_few, self.patterns.active_indicators):
156
+ matching_line = find_matching_line(
157
+ last_lines, self.patterns.active_indicators, reverse=True
158
+ )
159
+ if matching_line:
160
+ return self.STATUS_RUNNING, clean_line(matching_line, self.patterns), content
161
+ return self.STATUS_RUNNING, "Processing...", content
162
+
163
+ # Check for tool execution indicators (case-sensitive)
164
+ matching_line = find_matching_line(
165
+ last_lines, self.patterns.execution_indicators, case_sensitive=True, reverse=True
166
+ )
167
+ if matching_line:
168
+ return self.STATUS_RUNNING, clean_line(matching_line, self.patterns), content
169
+
170
+ # Check for thinking/planning
171
+ if any("thinking" in line.lower() for line in last_lines):
172
+ return self.STATUS_RUNNING, "Thinking...", content
173
+
174
+ # NOW check for Claude's prompt (user input prompt) - means waiting for user
175
+ # Only check after ruling out active work indicators above
176
+ # We need to distinguish:
177
+ # - Empty prompt `>` or `› ` = waiting for user input
178
+ # - User input `> some text` with no Claude response = stalled
179
+ for line in last_lines[-4:]:
180
+ stripped = line.strip()
181
+ # Empty prompt ready for input
182
+ if stripped in self.patterns.prompt_chars:
183
+ return self.STATUS_WAITING_USER, "Waiting for user input", content
184
+ # Autocomplete suggestion showing (prompt with suggested content + send indicator)
185
+ # This means Claude is idle and waiting for user input
186
+ if any(stripped.startswith(c) for c in self.patterns.prompt_chars):
187
+ if '↵' in stripped and 'send' in stripped.lower():
188
+ return self.STATUS_WAITING_USER, "Waiting for user input", content
189
+
190
+ # Check if there's user input that Claude hasn't responded to (stalled)
191
+ # Look for `> text` followed by no Claude response (no ⏺ line after it)
192
+ # Note: ⏺ is Claude's output indicator, ⏵⏵ in status bar is just UI chrome
193
+ # Note: Claude Code uses \xa0 (non-breaking space) after prompt, not regular space
194
+ found_user_input = False
195
+ found_claude_response = False
196
+ for line in last_lines:
197
+ stripped = line.strip()
198
+ # Skip autocomplete suggestion lines - they end with "↵ send" indicator
199
+ # These are not actual user input, just UI showing suggested completions
200
+ if '↵' in stripped and 'send' in stripped.lower():
201
+ continue
202
+ # Check for prompt with either regular space or non-breaking space (\xa0)
203
+ is_user_input = (
204
+ stripped.startswith('> ') or stripped.startswith('>\xa0') or
205
+ stripped.startswith('› ') or stripped.startswith('›\xa0') or
206
+ stripped.startswith('❯ ') or stripped.startswith('❯\xa0')
207
+ )
208
+ if is_user_input and len(stripped) > 2: # Has actual content after prompt
209
+ found_user_input = True
210
+ found_claude_response = False # Reset - look for response after this
211
+ elif stripped.startswith('⏺'): # Claude's response indicator (not ⏵⏵ status bar)
212
+ found_claude_response = True
213
+
214
+ if found_user_input and not found_claude_response:
215
+ return self.STATUS_WAITING_USER, "Stalled - no response to user input", content
216
+
217
+ # Check for common waiting patterns
218
+ if matches_any(last_few, self.patterns.waiting_patterns):
219
+ return self.STATUS_WAITING_USER, self._extract_question(last_lines), content
220
+
221
+ # Default: if no standing instructions, it's yellow
222
+ if not session.standing_instructions:
223
+ return self.STATUS_NO_INSTRUCTIONS, self._extract_last_activity(last_lines), content
224
+
225
+ # Otherwise, assume running
226
+ return self.STATUS_RUNNING, self._extract_last_activity(last_lines), content
227
+
228
+ def _extract_permission_request(self, lines: list) -> str:
229
+ """Extract the permission request text from lines before the prompt"""
230
+ # Look for lines before "Enter to confirm" that contain the request
231
+ relevant_lines = []
232
+ for line in reversed(lines):
233
+ line_lower = line.lower()
234
+ # Stop when we hit the confirmation line
235
+ if "enter to confirm" in line_lower or "esc to reject" in line_lower:
236
+ continue
237
+ # Stop at empty lines
238
+ if not line.strip():
239
+ break
240
+ # Collect meaningful lines
241
+ clean = self._clean_line(line)
242
+ if len(clean) > 5:
243
+ relevant_lines.insert(0, clean)
244
+ # Don't go too far back
245
+ if len(relevant_lines) >= 3:
246
+ break
247
+
248
+ if relevant_lines:
249
+ # Join and truncate
250
+ request = " ".join(relevant_lines)
251
+ if len(request) > 100:
252
+ request = request[:97] + "..."
253
+ return request
254
+ return "approval required"
255
+
256
+ def _extract_question(self, lines: list) -> str:
257
+ """Extract a question from recent output"""
258
+ for line in reversed(lines):
259
+ if '?' in line:
260
+ return self._clean_line(line)
261
+ return self._clean_line(lines[-1])
262
+
263
+ def _extract_last_activity(self, lines: list) -> str:
264
+ """Extract the most recent activity description"""
265
+ # Look for lines that look like activity descriptions
266
+ # Skip status bar lines (they contain UI chrome, not actual activity)
267
+ for line in reversed(lines):
268
+ # Skip status bar lines
269
+ if is_status_bar_line(line, self.patterns):
270
+ continue
271
+ cleaned = clean_line(line, self.patterns)
272
+ if len(cleaned) > 10 and not cleaned.startswith('›'):
273
+ return cleaned
274
+ return "Idle"
275
+
276
+ def _clean_line(self, line: str) -> str:
277
+ """Clean a line for display"""
278
+ return clean_line(line, self.patterns)
279
+
280
+ def _filter_status_bar_for_hash(self, content: str) -> str:
281
+ """Filter out status bar lines before computing content hash.
282
+
283
+ The Claude Code status bar contains dynamic elements (token counts,
284
+ elapsed time, etc.) that change even when Claude is idle. Including
285
+ these in the hash causes false "content changed" detection.
286
+
287
+ Args:
288
+ content: Raw pane content
289
+
290
+ Returns:
291
+ Content with status bar lines removed
292
+ """
293
+ lines = content.split('\n')
294
+ filtered = [
295
+ line for line in lines
296
+ if not is_status_bar_line(line, self.patterns)
297
+ ]
298
+ return '\n'.join(filtered)
299
+
300
+ def _is_shell_prompt(self, lines: list) -> bool:
301
+ """Detect if we're at a shell prompt (Claude Code has exited).
302
+
303
+ Shell prompts typically:
304
+ - End with $ or % (bash/zsh)
305
+ - Have username@hostname pattern
306
+ - Don't have Claude Code UI elements (>, ⏺, status bar chars)
307
+
308
+ Returns True if this looks like a shell prompt, not Claude Code.
309
+ """
310
+ import re
311
+
312
+ if not lines:
313
+ return False
314
+
315
+ # Get last non-empty line
316
+ last_line = lines[-1].strip()
317
+
318
+ # Common shell prompt patterns:
319
+ # - user@host path $
320
+ # - user@host path %
321
+ # - [user@host path]$
322
+ # - path $
323
+ shell_prompt_patterns = [
324
+ r'\w+@\w+.*[%$]\s*$', # user@hostname ... $ or %
325
+ r'\[.*\][%$#]\s*$', # [prompt]$ or [prompt]%
326
+ r'^[~\/].*[%$]\s*$', # /path/to/dir $ or ~/dir %
327
+ ]
328
+
329
+ for pattern in shell_prompt_patterns:
330
+ if re.search(pattern, last_line):
331
+ # Verify there's no Claude Code UI in recent lines
332
+ claude_ui_indicators = ['⏺', '›', '? for shortcuts', '⎿', '⏵']
333
+ recent_text = ' '.join(lines[-5:])
334
+ has_claude_ui = any(indicator in recent_text for indicator in claude_ui_indicators)
335
+
336
+ if not has_claude_ui:
337
+ return True
338
+
339
+ return False
@@ -0,0 +1,164 @@
1
+ """
2
+ Agent status history tracking.
3
+
4
+ Provides functions to log and read agent status history for timeline visualization.
5
+ """
6
+
7
+ import csv
8
+ from datetime import datetime, timedelta
9
+ from pathlib import Path
10
+ from typing import List, Optional, Tuple
11
+
12
+ from .settings import PATHS
13
+
14
+
15
+ def log_agent_status(
16
+ agent_name: str,
17
+ status: str,
18
+ activity: str = "",
19
+ history_file: Optional[Path] = None
20
+ ) -> None:
21
+ """Log agent status to history CSV file.
22
+
23
+ Called by daemon each loop to track agent status over time.
24
+ Used by TUI for timeline visualization.
25
+
26
+ Args:
27
+ agent_name: Name of the agent
28
+ status: Current status string
29
+ activity: Optional activity description
30
+ history_file: Optional path override (for testing)
31
+ """
32
+ path = history_file or PATHS.agent_history
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+
35
+ # Check if file exists (to write header)
36
+ write_header = not path.exists()
37
+
38
+ with open(path, 'a', newline='') as f:
39
+ writer = csv.writer(f)
40
+ if write_header:
41
+ writer.writerow(['timestamp', 'agent', 'status', 'activity'])
42
+ writer.writerow([
43
+ datetime.now().isoformat(),
44
+ agent_name,
45
+ status,
46
+ activity[:100] if activity else ""
47
+ ])
48
+
49
+
50
+ def read_agent_status_history(
51
+ hours: float = 3.0,
52
+ agent_name: Optional[str] = None,
53
+ history_file: Optional[Path] = None
54
+ ) -> List[Tuple[datetime, str, str, str]]:
55
+ """Read agent status history from CSV file.
56
+
57
+ Args:
58
+ hours: How many hours of history to read (default 3)
59
+ agent_name: Optional - filter to specific agent
60
+ history_file: Optional path override (for testing)
61
+
62
+ Returns:
63
+ List of (timestamp, agent, status, activity) tuples, oldest first
64
+ """
65
+ path = history_file or PATHS.agent_history
66
+
67
+ if not path.exists():
68
+ return []
69
+
70
+ cutoff = datetime.now() - timedelta(hours=hours)
71
+ history: List[Tuple[datetime, str, str, str]] = []
72
+
73
+ try:
74
+ with open(path, 'r', newline='') as f:
75
+ reader = csv.DictReader(f)
76
+ for row in reader:
77
+ try:
78
+ ts = datetime.fromisoformat(row['timestamp'])
79
+ if ts >= cutoff:
80
+ agent = row['agent']
81
+ if agent_name is None or agent == agent_name:
82
+ history.append((
83
+ ts,
84
+ agent,
85
+ row['status'],
86
+ row.get('activity', '')
87
+ ))
88
+ except (ValueError, KeyError):
89
+ continue
90
+ except (OSError, IOError):
91
+ pass
92
+
93
+ return history
94
+
95
+
96
+ def get_agent_timeline(
97
+ agent_name: str,
98
+ hours: float = 3.0,
99
+ history_file: Optional[Path] = None
100
+ ) -> List[Tuple[datetime, str]]:
101
+ """Get simplified timeline for a specific agent.
102
+
103
+ Args:
104
+ agent_name: Name of the agent
105
+ hours: How many hours of history (default 3)
106
+ history_file: Optional path override (for testing)
107
+
108
+ Returns:
109
+ List of (timestamp, status) tuples for the agent
110
+ """
111
+ history = read_agent_status_history(hours, agent_name, history_file)
112
+ return [(ts, status) for ts, _, status, _ in history]
113
+
114
+
115
+ def clear_old_history(
116
+ max_age_hours: float = 24.0,
117
+ history_file: Optional[Path] = None
118
+ ) -> int:
119
+ """Remove old entries from history file.
120
+
121
+ Args:
122
+ max_age_hours: Remove entries older than this (default 24 hours)
123
+ history_file: Optional path override (for testing)
124
+
125
+ Returns:
126
+ Number of entries removed
127
+ """
128
+ path = history_file or PATHS.agent_history
129
+
130
+ if not path.exists():
131
+ return 0
132
+
133
+ cutoff = datetime.now() - timedelta(hours=max_age_hours)
134
+ kept_entries: List[List[str]] = []
135
+ removed_count = 0
136
+
137
+ try:
138
+ with open(path, 'r', newline='') as f:
139
+ reader = csv.reader(f)
140
+ header = next(reader, None)
141
+ if header:
142
+ kept_entries.append(header)
143
+
144
+ for row in reader:
145
+ try:
146
+ ts = datetime.fromisoformat(row[0])
147
+ if ts >= cutoff:
148
+ kept_entries.append(row)
149
+ else:
150
+ removed_count += 1
151
+ except (ValueError, IndexError):
152
+ # Keep malformed entries
153
+ kept_entries.append(row)
154
+
155
+ # Only rewrite if we removed entries
156
+ if removed_count > 0:
157
+ with open(path, 'w', newline='') as f:
158
+ writer = csv.writer(f)
159
+ writer.writerows(kept_entries)
160
+
161
+ except (OSError, IOError):
162
+ pass
163
+
164
+ return removed_count