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.
- overcode/__init__.py +5 -0
- overcode/cli.py +812 -0
- overcode/config.py +72 -0
- overcode/daemon.py +1184 -0
- overcode/daemon_claude_skill.md +180 -0
- overcode/daemon_state.py +113 -0
- overcode/data_export.py +257 -0
- overcode/dependency_check.py +227 -0
- overcode/exceptions.py +219 -0
- overcode/history_reader.py +448 -0
- overcode/implementations.py +214 -0
- overcode/interfaces.py +49 -0
- overcode/launcher.py +434 -0
- overcode/logging_config.py +193 -0
- overcode/mocks.py +152 -0
- overcode/monitor_daemon.py +808 -0
- overcode/monitor_daemon_state.py +358 -0
- overcode/pid_utils.py +225 -0
- overcode/presence_logger.py +454 -0
- overcode/protocols.py +143 -0
- overcode/session_manager.py +606 -0
- overcode/settings.py +412 -0
- overcode/standing_instructions.py +276 -0
- overcode/status_constants.py +190 -0
- overcode/status_detector.py +339 -0
- overcode/status_history.py +164 -0
- overcode/status_patterns.py +264 -0
- overcode/summarizer_client.py +136 -0
- overcode/summarizer_component.py +312 -0
- overcode/supervisor_daemon.py +1000 -0
- overcode/supervisor_layout.sh +50 -0
- overcode/tmux_manager.py +228 -0
- overcode/tui.py +2549 -0
- overcode/tui_helpers.py +495 -0
- overcode/web_api.py +279 -0
- overcode/web_server.py +138 -0
- overcode/web_templates.py +563 -0
- overcode-0.1.0.dist-info/METADATA +87 -0
- overcode-0.1.0.dist-info/RECORD +43 -0
- overcode-0.1.0.dist-info/WHEEL +5 -0
- overcode-0.1.0.dist-info/entry_points.txt +2 -0
- overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|