overcode 0.1.1__tar.gz → 0.1.2__tar.gz
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-0.1.1/src/overcode.egg-info → overcode-0.1.2}/PKG-INFO +13 -1
- {overcode-0.1.1 → overcode-0.1.2}/README.md +12 -0
- {overcode-0.1.1 → overcode-0.1.2}/pyproject.toml +1 -1
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/__init__.py +1 -1
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/monitor_daemon.py +2 -2
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/session_manager.py +3 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/status_constants.py +11 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/status_detector.py +38 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/status_patterns.py +12 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/tui.py +112 -27
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/web_api.py +17 -7
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/web_server.py +10 -2
- {overcode-0.1.1 → overcode-0.1.2/src/overcode.egg-info}/PKG-INFO +13 -1
- {overcode-0.1.1 → overcode-0.1.2}/LICENSE +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/MANIFEST.in +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/setup.cfg +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/cli.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/config.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/daemon_claude_skill.md +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/daemon_logging.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/daemon_utils.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/data_export.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/dependency_check.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/exceptions.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/history_reader.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/implementations.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/interfaces.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/launcher.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/logging_config.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/mocks.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/monitor_daemon_state.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/pid_utils.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/presence_logger.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/protocols.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/settings.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/standing_instructions.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/status_history.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/summarizer_client.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/summarizer_component.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/supervisor_daemon.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/supervisor_layout.sh +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/tmux_manager.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/tui_helpers.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/web_chartjs.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/web_server_runner.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode/web_templates.py +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode.egg-info/SOURCES.txt +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode.egg-info/dependency_links.txt +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode.egg-info/entry_points.txt +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode.egg-info/requires.txt +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/src/overcode.egg-info/top_level.txt +0 -0
- {overcode-0.1.1 → overcode-0.1.2}/tests/test_e2e_multi_agent_jokes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: overcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A supervisor for managing multiple Claude Code instances in tmux
|
|
5
5
|
Author: Mike Bond
|
|
6
6
|
Project-URL: Homepage, https://github.com/mkb23/overcode
|
|
@@ -43,6 +43,18 @@ A TUI supervisor for managing multiple Claude Code agents in tmux.
|
|
|
43
43
|
|
|
44
44
|
Monitor status, costs, and activity across all your agents from a single dashboard.
|
|
45
45
|
|
|
46
|
+
## Screenshots
|
|
47
|
+
|
|
48
|
+
**Split-screen with tmux sync** - Monitor agents in the top pane while viewing live agent output below. Press `p` to enable pane sync, then navigate with `j/k` to switch the bottom pane to the selected agent's window.
|
|
49
|
+
|
|
50
|
+

|
|
51
|
+
|
|
52
|
+
> **iTerm2 setup**: Use `Cmd+Shift+D` to split horizontally. Run `overcode monitor` in the top pane and `tmux attach -t agents` in the bottom pane.
|
|
53
|
+
|
|
54
|
+
**Preview mode** - Press `m` to toggle List+Preview mode. Shows collapsed agent list with detailed terminal output preview for the selected agent.
|
|
55
|
+
|
|
56
|
+

|
|
57
|
+
|
|
46
58
|
## Installation
|
|
47
59
|
|
|
48
60
|
```bash
|
|
@@ -4,6 +4,18 @@ A TUI supervisor for managing multiple Claude Code agents in tmux.
|
|
|
4
4
|
|
|
5
5
|
Monitor status, costs, and activity across all your agents from a single dashboard.
|
|
6
6
|
|
|
7
|
+
## Screenshots
|
|
8
|
+
|
|
9
|
+
**Split-screen with tmux sync** - Monitor agents in the top pane while viewing live agent output below. Press `p` to enable pane sync, then navigate with `j/k` to switch the bottom pane to the selected agent's window.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
> **iTerm2 setup**: Use `Cmd+Shift+D` to split horizontally. Run `overcode monitor` in the top pane and `tmux attach -t agents` in the bottom pane.
|
|
14
|
+
|
|
15
|
+
**Preview mode** - Press `m` to toggle List+Preview mode. Shows collapsed agent list with detailed terminal output preview for the selected agent.
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
7
19
|
## Installation
|
|
8
20
|
|
|
9
21
|
```bash
|
|
@@ -614,8 +614,8 @@ class MonitorDaemon:
|
|
|
614
614
|
session_state.current_activity = activity
|
|
615
615
|
session_states.append(session_state)
|
|
616
616
|
|
|
617
|
-
# Log status history
|
|
618
|
-
log_agent_status(session.name, status, activity)
|
|
617
|
+
# Log status history to session-specific file
|
|
618
|
+
log_agent_status(session.name, status, activity, history_file=self.history_path)
|
|
619
619
|
|
|
620
620
|
# Track if any session is not waiting for user
|
|
621
621
|
if status != "waiting_user":
|
|
@@ -88,6 +88,9 @@ class Session:
|
|
|
88
88
|
# Statistics
|
|
89
89
|
stats: SessionStats = field(default_factory=SessionStats)
|
|
90
90
|
|
|
91
|
+
# Sleep mode - agent is paused and excluded from stats
|
|
92
|
+
is_asleep: bool = False
|
|
93
|
+
|
|
91
94
|
def to_dict(self) -> dict:
|
|
92
95
|
data = asdict(self)
|
|
93
96
|
# Convert stats to dict
|
|
@@ -17,6 +17,7 @@ STATUS_NO_INSTRUCTIONS = "no_instructions"
|
|
|
17
17
|
STATUS_WAITING_SUPERVISOR = "waiting_supervisor"
|
|
18
18
|
STATUS_WAITING_USER = "waiting_user"
|
|
19
19
|
STATUS_TERMINATED = "terminated" # Claude Code exited, shell prompt showing
|
|
20
|
+
STATUS_ASLEEP = "asleep" # Human marked agent as paused/snoozed (excluded from stats)
|
|
20
21
|
|
|
21
22
|
# All valid agent status values
|
|
22
23
|
ALL_STATUSES = [
|
|
@@ -25,6 +26,7 @@ ALL_STATUSES = [
|
|
|
25
26
|
STATUS_WAITING_SUPERVISOR,
|
|
26
27
|
STATUS_WAITING_USER,
|
|
27
28
|
STATUS_TERMINATED,
|
|
29
|
+
STATUS_ASLEEP,
|
|
28
30
|
]
|
|
29
31
|
|
|
30
32
|
|
|
@@ -60,6 +62,7 @@ STATUS_EMOJIS = {
|
|
|
60
62
|
STATUS_WAITING_SUPERVISOR: "🟠",
|
|
61
63
|
STATUS_WAITING_USER: "🔴",
|
|
62
64
|
STATUS_TERMINATED: "⚫", # Black circle - Claude exited
|
|
65
|
+
STATUS_ASLEEP: "💤", # Sleeping/snoozed - human marked as paused
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
|
|
@@ -78,6 +81,7 @@ STATUS_COLORS = {
|
|
|
78
81
|
STATUS_WAITING_SUPERVISOR: "orange1",
|
|
79
82
|
STATUS_WAITING_USER: "red",
|
|
80
83
|
STATUS_TERMINATED: "dim", # Grey for terminated
|
|
84
|
+
STATUS_ASLEEP: "dim", # Grey for sleeping
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
|
|
@@ -96,6 +100,7 @@ STATUS_SYMBOLS = {
|
|
|
96
100
|
STATUS_WAITING_SUPERVISOR: ("🟠", "orange1"),
|
|
97
101
|
STATUS_WAITING_USER: ("🔴", "red"),
|
|
98
102
|
STATUS_TERMINATED: ("⚫", "dim"),
|
|
103
|
+
STATUS_ASLEEP: ("💤", "dim"), # Sleeping/snoozed
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
|
|
@@ -114,6 +119,7 @@ AGENT_TIMELINE_CHARS = {
|
|
|
114
119
|
STATUS_WAITING_SUPERVISOR: "▒",
|
|
115
120
|
STATUS_WAITING_USER: "░",
|
|
116
121
|
STATUS_TERMINATED: "×", # Small X - terminated
|
|
122
|
+
STATUS_ASLEEP: "z", # Lowercase z for sleeping
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
|
|
@@ -188,3 +194,8 @@ def is_waiting_status(status: str) -> bool:
|
|
|
188
194
|
def is_user_blocked(status: str) -> bool:
|
|
189
195
|
"""Check if status indicates user intervention is required."""
|
|
190
196
|
return status == STATUS_WAITING_USER
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def is_asleep(status: str) -> bool:
|
|
200
|
+
"""Check if status indicates agent is asleep (paused by human)."""
|
|
201
|
+
return status == STATUS_ASLEEP
|
|
@@ -116,6 +116,13 @@ class StatusDetector:
|
|
|
116
116
|
|
|
117
117
|
last_line = last_lines[-1]
|
|
118
118
|
|
|
119
|
+
# Check for spawn failure FIRST (command not found, etc.)
|
|
120
|
+
# This should be detected before shell prompt check because the error
|
|
121
|
+
# message appears before the shell prompt returns
|
|
122
|
+
spawn_error = self._detect_spawn_failure(lines)
|
|
123
|
+
if spawn_error:
|
|
124
|
+
return self.STATUS_WAITING_USER, spawn_error, content
|
|
125
|
+
|
|
119
126
|
# Check for shell prompt (Claude Code has terminated)
|
|
120
127
|
# Shell prompts typically end with $ or % and have username@hostname pattern
|
|
121
128
|
# Also check for absence of Claude Code UI elements
|
|
@@ -297,6 +304,37 @@ class StatusDetector:
|
|
|
297
304
|
]
|
|
298
305
|
return '\n'.join(filtered)
|
|
299
306
|
|
|
307
|
+
def _detect_spawn_failure(self, lines: list) -> str | None:
|
|
308
|
+
"""Detect if the claude command failed to spawn.
|
|
309
|
+
|
|
310
|
+
Checks for common error messages like "command not found" that indicate
|
|
311
|
+
the claude CLI is not installed or not in PATH.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
lines: All lines from the pane content
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Error message string if spawn failure detected, None otherwise
|
|
318
|
+
"""
|
|
319
|
+
# Check recent lines for spawn failure patterns
|
|
320
|
+
# We check the last 20 lines to catch the error message
|
|
321
|
+
recent_lines = lines[-20:] if len(lines) > 20 else lines
|
|
322
|
+
recent_text = ' '.join(recent_lines).lower()
|
|
323
|
+
|
|
324
|
+
if matches_any(recent_text, self.patterns.spawn_failure_patterns):
|
|
325
|
+
# Find the specific error line for a better message
|
|
326
|
+
for line in reversed(recent_lines):
|
|
327
|
+
line_lower = line.lower()
|
|
328
|
+
if any(p.lower() in line_lower for p in self.patterns.spawn_failure_patterns):
|
|
329
|
+
# Extract just the error part, clean it up
|
|
330
|
+
error_msg = line.strip()
|
|
331
|
+
if len(error_msg) > 80:
|
|
332
|
+
error_msg = error_msg[:77] + "..."
|
|
333
|
+
return f"Spawn failed: {error_msg}"
|
|
334
|
+
return "Spawn failed: claude command not found - is Claude CLI installed?"
|
|
335
|
+
|
|
336
|
+
return None
|
|
337
|
+
|
|
300
338
|
def _is_shell_prompt(self, lines: list) -> bool:
|
|
301
339
|
"""Detect if we're at a shell prompt (Claude Code has exited).
|
|
302
340
|
|
|
@@ -115,6 +115,18 @@ class StatusPatterns:
|
|
|
115
115
|
# Format: " /command-name Description text"
|
|
116
116
|
command_menu_pattern: str = r"^\s*/[\w-]+\s{2,}\S"
|
|
117
117
|
|
|
118
|
+
# Spawn failure patterns - when the claude command fails to start
|
|
119
|
+
# These indicate the command was not found or failed to execute
|
|
120
|
+
# Checked against pane content to detect failed spawns
|
|
121
|
+
spawn_failure_patterns: List[str] = field(default_factory=lambda: [
|
|
122
|
+
"command not found",
|
|
123
|
+
"not found:", # zsh style: "zsh: command not found: claude"
|
|
124
|
+
"no such file or directory",
|
|
125
|
+
"permission denied",
|
|
126
|
+
"cannot execute",
|
|
127
|
+
"is not recognized", # Windows-style (for future compatibility)
|
|
128
|
+
])
|
|
129
|
+
|
|
118
130
|
|
|
119
131
|
# Default patterns instance
|
|
120
132
|
DEFAULT_PATTERNS = StatusPatterns()
|
|
@@ -30,7 +30,7 @@ from .launcher import ClaudeLauncher
|
|
|
30
30
|
from .status_detector import StatusDetector
|
|
31
31
|
from .status_constants import STATUS_WAITING_USER
|
|
32
32
|
from .history_reader import get_session_stats, ClaudeSessionStats
|
|
33
|
-
from .settings import signal_activity, get_session_dir, TUIPreferences, DAEMON_VERSION # Activity signaling to daemon
|
|
33
|
+
from .settings import signal_activity, get_session_dir, get_agent_history_path, TUIPreferences, DAEMON_VERSION # Activity signaling to daemon
|
|
34
34
|
from .monitor_daemon_state import MonitorDaemonState, get_monitor_daemon_state
|
|
35
35
|
from .monitor_daemon import (
|
|
36
36
|
is_monitor_daemon_running,
|
|
@@ -99,14 +99,21 @@ class DaemonStatusBar(Static):
|
|
|
99
99
|
Presence is shown only when available (macOS with monitor daemon running).
|
|
100
100
|
"""
|
|
101
101
|
|
|
102
|
-
def __init__(self, tmux_session: str = "agents", *args, **kwargs):
|
|
102
|
+
def __init__(self, tmux_session: str = "agents", session_manager: Optional["SessionManager"] = None, *args, **kwargs):
|
|
103
103
|
super().__init__(*args, **kwargs)
|
|
104
104
|
self.tmux_session = tmux_session
|
|
105
105
|
self.monitor_state: Optional[MonitorDaemonState] = None
|
|
106
|
+
self._session_manager = session_manager
|
|
107
|
+
self._asleep_session_ids: set = set() # Cache of asleep session IDs
|
|
106
108
|
|
|
107
109
|
def update_status(self) -> None:
|
|
108
110
|
"""Refresh daemon state from file"""
|
|
109
111
|
self.monitor_state = get_monitor_daemon_state(self.tmux_session)
|
|
112
|
+
# Update cache of asleep session IDs from session manager
|
|
113
|
+
if self._session_manager:
|
|
114
|
+
self._asleep_session_ids = {
|
|
115
|
+
s.id for s in self._session_manager.list_sessions() if s.is_asleep
|
|
116
|
+
}
|
|
110
117
|
self.refresh()
|
|
111
118
|
|
|
112
119
|
def render(self) -> Text:
|
|
@@ -171,13 +178,18 @@ class DaemonStatusBar(Static):
|
|
|
171
178
|
# Spin rate stats (only when monitor running with sessions)
|
|
172
179
|
if monitor_running and self.monitor_state.sessions:
|
|
173
180
|
content.append(" │ ", style="dim")
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
# Filter out sleeping agents from stats
|
|
182
|
+
all_sessions = self.monitor_state.sessions
|
|
183
|
+
active_sessions = [s for s in all_sessions if s.session_id not in self._asleep_session_ids]
|
|
184
|
+
sleeping_count = len(all_sessions) - len(active_sessions)
|
|
185
|
+
|
|
186
|
+
total_agents = len(active_sessions)
|
|
187
|
+
# Recalculate green_now excluding sleeping agents
|
|
188
|
+
green_now = sum(1 for s in active_sessions if s.current_status == "running")
|
|
177
189
|
|
|
178
|
-
# Calculate mean spin rate from green_time percentages
|
|
190
|
+
# Calculate mean spin rate from green_time percentages (exclude sleeping)
|
|
179
191
|
mean_spin = 0.0
|
|
180
|
-
for s in
|
|
192
|
+
for s in active_sessions:
|
|
181
193
|
total_time = s.green_time_seconds + s.non_green_time_seconds
|
|
182
194
|
if total_time > 0:
|
|
183
195
|
mean_spin += s.green_time_seconds / total_time
|
|
@@ -185,11 +197,13 @@ class DaemonStatusBar(Static):
|
|
|
185
197
|
content.append("Spin: ", style="bold")
|
|
186
198
|
content.append(f"{green_now}", style="bold green" if green_now > 0 else "dim")
|
|
187
199
|
content.append(f"/{total_agents}", style="dim")
|
|
200
|
+
if sleeping_count > 0:
|
|
201
|
+
content.append(f" 💤{sleeping_count}", style="dim") # Show sleeping count
|
|
188
202
|
if mean_spin > 0:
|
|
189
203
|
content.append(f" μ{mean_spin:.1f}x", style="cyan")
|
|
190
204
|
|
|
191
|
-
# Safe break duration (time until 50%+ agents need attention)
|
|
192
|
-
safe_break = calculate_safe_break_duration(
|
|
205
|
+
# Safe break duration (time until 50%+ agents need attention) - exclude sleeping
|
|
206
|
+
safe_break = calculate_safe_break_duration(active_sessions)
|
|
193
207
|
if safe_break is not None:
|
|
194
208
|
content.append(" │ ", style="dim")
|
|
195
209
|
content.append("☕", style="bold")
|
|
@@ -252,9 +266,10 @@ class StatusTimeline(Static):
|
|
|
252
266
|
MIN_TIMELINE = 20 # Minimum timeline width
|
|
253
267
|
DEFAULT_TIMELINE = 60 # Fallback if can't detect width
|
|
254
268
|
|
|
255
|
-
def __init__(self, sessions: list, *args, **kwargs):
|
|
269
|
+
def __init__(self, sessions: list, tmux_session: str = "agents", *args, **kwargs):
|
|
256
270
|
super().__init__(*args, **kwargs)
|
|
257
271
|
self.sessions = sessions
|
|
272
|
+
self.tmux_session = tmux_session
|
|
258
273
|
self._presence_history = []
|
|
259
274
|
self._agent_histories = {}
|
|
260
275
|
|
|
@@ -281,8 +296,9 @@ class StatusTimeline(Static):
|
|
|
281
296
|
# Get agent names from sessions
|
|
282
297
|
agent_names = [s.name for s in sessions]
|
|
283
298
|
|
|
284
|
-
# Read
|
|
285
|
-
|
|
299
|
+
# Read agent history from session-specific file and group by agent
|
|
300
|
+
history_path = get_agent_history_path(self.tmux_session)
|
|
301
|
+
all_history = read_agent_status_history(hours=self.TIMELINE_HOURS, history_file=history_path)
|
|
286
302
|
for ts, agent, status, activity in all_history:
|
|
287
303
|
if agent not in self._agent_histories:
|
|
288
304
|
self._agent_histories[agent] = []
|
|
@@ -355,8 +371,8 @@ class StatusTimeline(Static):
|
|
|
355
371
|
else:
|
|
356
372
|
content.append("─", style="dim")
|
|
357
373
|
elif not MACOS_APIS_AVAILABLE:
|
|
358
|
-
# Show install instructions when presence deps not installed
|
|
359
|
-
msg = "
|
|
374
|
+
# Show install instructions when presence deps not installed (macOS only)
|
|
375
|
+
msg = "macOS only - pip install overcode[presence]"
|
|
360
376
|
content.append(msg[:width], style="dim italic")
|
|
361
377
|
else:
|
|
362
378
|
content.append("─" * width, style="dim")
|
|
@@ -661,9 +677,17 @@ class SessionSummary(Static, can_focus=True):
|
|
|
661
677
|
self.post_message(self.StalledAgentVisited(self.session.id))
|
|
662
678
|
|
|
663
679
|
def on_focus(self) -> None:
|
|
664
|
-
"""Handle focus event - mark stalled agent as visited"""
|
|
680
|
+
"""Handle focus event - mark stalled agent as visited and update selection"""
|
|
665
681
|
if self.is_unvisited_stalled:
|
|
666
682
|
self.post_message(self.StalledAgentVisited(self.session.id))
|
|
683
|
+
# Notify app to update selection highlighting
|
|
684
|
+
self.post_message(self.SessionSelected(self.session.id))
|
|
685
|
+
|
|
686
|
+
class SessionSelected(events.Message):
|
|
687
|
+
"""Message sent when a session is selected/focused"""
|
|
688
|
+
def __init__(self, session_id: str):
|
|
689
|
+
super().__init__()
|
|
690
|
+
self.session_id = session_id
|
|
667
691
|
|
|
668
692
|
class ExpandedChanged(events.Message):
|
|
669
693
|
"""Message sent when expanded state changes"""
|
|
@@ -738,7 +762,11 @@ class SessionSummary(Static, can_focus=True):
|
|
|
738
762
|
# Update detected status for display
|
|
739
763
|
# NOTE: Time tracking removed - Monitor Daemon is the single source of truth
|
|
740
764
|
# The session.stats values are read from what Monitor Daemon has persisted
|
|
741
|
-
|
|
765
|
+
# If session is asleep, keep the asleep status instead of the detected status
|
|
766
|
+
if self.session.is_asleep:
|
|
767
|
+
self.detected_status = "asleep"
|
|
768
|
+
else:
|
|
769
|
+
self.detected_status = status
|
|
742
770
|
|
|
743
771
|
# Use pre-fetched claude stats (no file I/O on main thread)
|
|
744
772
|
if claude_stats is not None:
|
|
@@ -844,15 +872,18 @@ class SessionSummary(Static, can_focus=True):
|
|
|
844
872
|
content.append(f" {pct:>3.0f}%", style=f"bold green{bg}" if pct >= 50 else f"bold red{bg}")
|
|
845
873
|
|
|
846
874
|
# Always show: token usage (from Claude Code)
|
|
875
|
+
# ALIGNMENT: context indicator is always 7 chars " c@NNN%" (or placeholder)
|
|
847
876
|
if self.claude_stats is not None:
|
|
848
877
|
content.append(f" Σ{format_tokens(self.claude_stats.total_tokens):>6}", style=f"bold orange1{bg}")
|
|
849
878
|
# Show current context window usage as percentage (assuming 200K max)
|
|
850
879
|
if self.claude_stats.current_context_tokens > 0:
|
|
851
880
|
max_context = 200_000 # Claude models have 200K context window
|
|
852
881
|
ctx_pct = min(100, self.claude_stats.current_context_tokens / max_context * 100)
|
|
853
|
-
content.append(f" c@{ctx_pct
|
|
882
|
+
content.append(f" c@{ctx_pct:>3.0f}%", style=f"bold orange1{bg}")
|
|
883
|
+
else:
|
|
884
|
+
content.append(" c@ -%", style=f"dim orange1{bg}")
|
|
854
885
|
else:
|
|
855
|
-
content.append(" -", style=f"dim orange1{bg}")
|
|
886
|
+
content.append(" - c@ -%", style=f"dim orange1{bg}")
|
|
856
887
|
|
|
857
888
|
# Git diff stats (outstanding changes since last commit)
|
|
858
889
|
# ALIGNMENT: Use fixed widths - low/med: 4 chars "Δnn ", full: 16 chars "Δnn +nnnn -nnnn"
|
|
@@ -995,10 +1026,13 @@ class PreviewPane(Static):
|
|
|
995
1026
|
|
|
996
1027
|
def render(self) -> Text:
|
|
997
1028
|
content = Text()
|
|
998
|
-
#
|
|
1029
|
+
# Use widget width for layout, with sensible fallback
|
|
1030
|
+
pane_width = self.size.width if self.size.width > 0 else 80
|
|
1031
|
+
|
|
1032
|
+
# Header with session name - pad to full pane width
|
|
999
1033
|
header = f"─── {self.session_name} " if self.session_name else "─── Preview "
|
|
1000
1034
|
content.append(header, style="bold cyan")
|
|
1001
|
-
content.append("─" * max(0,
|
|
1035
|
+
content.append("─" * max(0, pane_width - len(header)), style="dim")
|
|
1002
1036
|
content.append("\n")
|
|
1003
1037
|
|
|
1004
1038
|
if not self.content_lines:
|
|
@@ -1008,9 +1042,11 @@ class PreviewPane(Static):
|
|
|
1008
1042
|
# Reserve 2 lines for header and some padding
|
|
1009
1043
|
available_lines = max(10, self.size.height - 2) if self.size.height > 0 else 30
|
|
1010
1044
|
# Show last N lines of output - plain text, no decoration
|
|
1045
|
+
# Truncate lines to pane width to match tmux display
|
|
1046
|
+
max_line_len = max(pane_width - 1, 40) # Leave room for newline, minimum 40
|
|
1011
1047
|
for line in self.content_lines[-available_lines:]:
|
|
1012
|
-
# Truncate long lines
|
|
1013
|
-
display_line = line[:
|
|
1048
|
+
# Truncate long lines to pane width
|
|
1049
|
+
display_line = line[:max_line_len] if len(line) > max_line_len else line
|
|
1014
1050
|
content.append(display_line + "\n")
|
|
1015
1051
|
|
|
1016
1052
|
return content
|
|
@@ -1394,6 +1430,12 @@ class SupervisorTUI(App):
|
|
|
1394
1430
|
text-style: bold;
|
|
1395
1431
|
}
|
|
1396
1432
|
|
|
1433
|
+
/* .selected class preserves highlight when app loses focus */
|
|
1434
|
+
SessionSummary.selected {
|
|
1435
|
+
background: #2d4a5a;
|
|
1436
|
+
text-style: bold;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1397
1439
|
#help-text {
|
|
1398
1440
|
dock: bottom;
|
|
1399
1441
|
height: 1;
|
|
@@ -1555,6 +1597,8 @@ class SupervisorTUI(App):
|
|
|
1555
1597
|
("p", "toggle_tmux_sync", "Pane sync"),
|
|
1556
1598
|
# Web server toggle
|
|
1557
1599
|
("w", "toggle_web_server", "Web dashboard"),
|
|
1600
|
+
# Sleep mode toggle - mark agent as paused (excluded from stats)
|
|
1601
|
+
("z", "toggle_sleep", "Sleep mode"),
|
|
1558
1602
|
]
|
|
1559
1603
|
|
|
1560
1604
|
# Detail level cycles through 5, 10, 20, 50 lines
|
|
@@ -1617,8 +1661,8 @@ class SupervisorTUI(App):
|
|
|
1617
1661
|
def compose(self) -> ComposeResult:
|
|
1618
1662
|
"""Create child widgets"""
|
|
1619
1663
|
yield Header(show_clock=True)
|
|
1620
|
-
yield DaemonStatusBar(tmux_session=self.tmux_session, id="daemon-status")
|
|
1621
|
-
yield StatusTimeline([], id="timeline")
|
|
1664
|
+
yield DaemonStatusBar(tmux_session=self.tmux_session, session_manager=self.session_manager, id="daemon-status")
|
|
1665
|
+
yield StatusTimeline([], tmux_session=self.tmux_session, id="timeline")
|
|
1622
1666
|
yield DaemonPanel(tmux_session=self.tmux_session, id="daemon-panel")
|
|
1623
1667
|
yield ScrollableContainer(id="sessions-container")
|
|
1624
1668
|
yield PreviewPane(id="preview-pane")
|
|
@@ -2054,6 +2098,15 @@ class SupervisorTUI(App):
|
|
|
2054
2098
|
widget.refresh()
|
|
2055
2099
|
break
|
|
2056
2100
|
|
|
2101
|
+
def on_session_summary_session_selected(self, message: SessionSummary.SessionSelected) -> None:
|
|
2102
|
+
"""Handle session selection - update .selected class to preserve highlight when unfocused"""
|
|
2103
|
+
session_id = message.session_id
|
|
2104
|
+
for widget in self.query(SessionSummary):
|
|
2105
|
+
if widget.session.id == session_id:
|
|
2106
|
+
widget.add_class("selected")
|
|
2107
|
+
else:
|
|
2108
|
+
widget.remove_class("selected")
|
|
2109
|
+
|
|
2057
2110
|
def action_toggle_focused(self) -> None:
|
|
2058
2111
|
"""Toggle expansion of focused session (only in tree mode)"""
|
|
2059
2112
|
if self.view_mode == "list_preview":
|
|
@@ -2221,9 +2274,9 @@ class SupervisorTUI(App):
|
|
|
2221
2274
|
"""Update preview pane with focused session's content."""
|
|
2222
2275
|
try:
|
|
2223
2276
|
preview = self.query_one("#preview-pane", PreviewPane)
|
|
2224
|
-
|
|
2225
|
-
if
|
|
2226
|
-
preview.update_from_widget(
|
|
2277
|
+
widgets = self._get_widgets_in_session_order()
|
|
2278
|
+
if widgets and 0 <= self.focused_session_index < len(widgets):
|
|
2279
|
+
preview.update_from_widget(widgets[self.focused_session_index])
|
|
2227
2280
|
except NoMatches:
|
|
2228
2281
|
pass
|
|
2229
2282
|
|
|
@@ -2534,6 +2587,38 @@ class SupervisorTUI(App):
|
|
|
2534
2587
|
|
|
2535
2588
|
self.update_daemon_status()
|
|
2536
2589
|
|
|
2590
|
+
def action_toggle_sleep(self) -> None:
|
|
2591
|
+
"""Toggle sleep mode for the focused agent.
|
|
2592
|
+
|
|
2593
|
+
Sleep mode marks an agent as 'asleep' (human doesn't want it to do anything).
|
|
2594
|
+
Sleeping agents are excluded from stats calculations.
|
|
2595
|
+
Press z again to wake the agent.
|
|
2596
|
+
"""
|
|
2597
|
+
focused = self.focused
|
|
2598
|
+
if not isinstance(focused, SessionSummary):
|
|
2599
|
+
self.notify("No agent focused", severity="warning")
|
|
2600
|
+
return
|
|
2601
|
+
|
|
2602
|
+
session = focused.session
|
|
2603
|
+
new_asleep_state = not session.is_asleep
|
|
2604
|
+
|
|
2605
|
+
# Update the session in the session manager
|
|
2606
|
+
self.session_manager.update_session(session.id, is_asleep=new_asleep_state)
|
|
2607
|
+
|
|
2608
|
+
# Update the local session object
|
|
2609
|
+
session.is_asleep = new_asleep_state
|
|
2610
|
+
|
|
2611
|
+
# Update the widget's display status if sleeping
|
|
2612
|
+
if new_asleep_state:
|
|
2613
|
+
focused.detected_status = "asleep"
|
|
2614
|
+
self.notify(f"Agent '{session.name}' is now asleep (excluded from stats)", severity="information")
|
|
2615
|
+
else:
|
|
2616
|
+
# Wake up - status will be refreshed on next update cycle
|
|
2617
|
+
self.notify(f"Agent '{session.name}' is now awake", severity="information")
|
|
2618
|
+
|
|
2619
|
+
# Force a refresh
|
|
2620
|
+
focused.refresh()
|
|
2621
|
+
|
|
2537
2622
|
def action_kill_focused(self) -> None:
|
|
2538
2623
|
"""Kill the currently focused agent (requires confirmation)."""
|
|
2539
2624
|
focused = self.focused
|
|
@@ -12,6 +12,7 @@ from .monitor_daemon_state import (
|
|
|
12
12
|
MonitorDaemonState,
|
|
13
13
|
SessionDaemonState,
|
|
14
14
|
)
|
|
15
|
+
from .settings import get_agent_history_path
|
|
15
16
|
from .status_history import read_agent_status_history
|
|
16
17
|
from .tui_helpers import (
|
|
17
18
|
format_duration,
|
|
@@ -232,8 +233,9 @@ def get_timeline_data(tmux_session: str, hours: float = 3.0, slots: int = 60) ->
|
|
|
232
233
|
"status_colors": {k: get_web_color(get_status_color(k)) for k in AGENT_TIMELINE_CHARS},
|
|
233
234
|
}
|
|
234
235
|
|
|
235
|
-
# Get agent history
|
|
236
|
-
|
|
236
|
+
# Get agent history from session-specific file
|
|
237
|
+
history_path = get_agent_history_path(tmux_session)
|
|
238
|
+
all_history = read_agent_status_history(hours=hours, history_file=history_path)
|
|
237
239
|
|
|
238
240
|
# Group by agent
|
|
239
241
|
agent_histories: Dict[str, List] = {}
|
|
@@ -391,12 +393,14 @@ def _session_to_analytics_record(session, is_archived: bool) -> Dict[str, Any]:
|
|
|
391
393
|
|
|
392
394
|
|
|
393
395
|
def get_analytics_timeline(
|
|
396
|
+
tmux_session: str,
|
|
394
397
|
start: Optional[datetime] = None,
|
|
395
398
|
end: Optional[datetime] = None,
|
|
396
399
|
) -> Dict[str, Any]:
|
|
397
400
|
"""Get agent status timeline within a time range.
|
|
398
401
|
|
|
399
402
|
Args:
|
|
403
|
+
tmux_session: tmux session name
|
|
400
404
|
start: Start of time range
|
|
401
405
|
end: End of time range
|
|
402
406
|
|
|
@@ -413,8 +417,9 @@ def get_analytics_timeline(
|
|
|
413
417
|
|
|
414
418
|
hours = (end - start).total_seconds() / 3600.0
|
|
415
419
|
|
|
416
|
-
# Get agent status history
|
|
417
|
-
|
|
420
|
+
# Get agent status history from session-specific file
|
|
421
|
+
history_path = get_agent_history_path(tmux_session)
|
|
422
|
+
all_history = read_agent_status_history(hours=hours, history_file=history_path)
|
|
418
423
|
|
|
419
424
|
# Filter to time range and group by agent
|
|
420
425
|
agent_events: Dict[str, List[Dict[str, Any]]] = {}
|
|
@@ -458,12 +463,14 @@ def get_analytics_timeline(
|
|
|
458
463
|
|
|
459
464
|
|
|
460
465
|
def get_analytics_stats(
|
|
466
|
+
tmux_session: str,
|
|
461
467
|
start: Optional[datetime] = None,
|
|
462
468
|
end: Optional[datetime] = None,
|
|
463
469
|
) -> Dict[str, Any]:
|
|
464
470
|
"""Get aggregate statistics for a time range.
|
|
465
471
|
|
|
466
472
|
Args:
|
|
473
|
+
tmux_session: tmux session name
|
|
467
474
|
start: Start of time range
|
|
468
475
|
end: End of time range
|
|
469
476
|
|
|
@@ -498,7 +505,7 @@ def get_analytics_stats(
|
|
|
498
505
|
work_time_stats = _calculate_percentiles(all_work_times)
|
|
499
506
|
|
|
500
507
|
# Calculate presence-based efficiency metrics
|
|
501
|
-
presence_efficiency = _calculate_presence_efficiency(start, end)
|
|
508
|
+
presence_efficiency = _calculate_presence_efficiency(tmux_session, start, end)
|
|
502
509
|
|
|
503
510
|
return {
|
|
504
511
|
"time_range": {
|
|
@@ -552,6 +559,7 @@ def _calculate_percentiles(values: List[float]) -> Dict[str, float]:
|
|
|
552
559
|
|
|
553
560
|
|
|
554
561
|
def _calculate_presence_efficiency(
|
|
562
|
+
tmux_session: str,
|
|
555
563
|
start: Optional[datetime] = None,
|
|
556
564
|
end: Optional[datetime] = None,
|
|
557
565
|
sample_interval_seconds: int = 60,
|
|
@@ -564,6 +572,7 @@ def _calculate_presence_efficiency(
|
|
|
564
572
|
- AFK periods: user presence state = 1 (locked) or 2 (inactive)
|
|
565
573
|
|
|
566
574
|
Args:
|
|
575
|
+
tmux_session: tmux session name
|
|
567
576
|
start: Start of time range
|
|
568
577
|
end: End of time range
|
|
569
578
|
sample_interval_seconds: How often to sample (default 60s)
|
|
@@ -581,8 +590,9 @@ def _calculate_presence_efficiency(
|
|
|
581
590
|
|
|
582
591
|
hours = (end - start).total_seconds() / 3600.0
|
|
583
592
|
|
|
584
|
-
# Get agent status history
|
|
585
|
-
|
|
593
|
+
# Get agent status history from session-specific file
|
|
594
|
+
history_path = get_agent_history_path(tmux_session)
|
|
595
|
+
agent_history = read_agent_status_history(hours=hours, history_file=history_path)
|
|
586
596
|
|
|
587
597
|
# Get presence history: list of (timestamp, state)
|
|
588
598
|
presence_history = read_presence_history(hours=hours)
|
|
@@ -338,6 +338,9 @@ def toggle_web_server(session: str, port: int = 8080) -> Tuple[bool, str]:
|
|
|
338
338
|
class AnalyticsHandler(BaseHTTPRequestHandler):
|
|
339
339
|
"""HTTP request handler for analytics dashboard."""
|
|
340
340
|
|
|
341
|
+
# Set by run_analytics_server before starting
|
|
342
|
+
tmux_session: str = "agents"
|
|
343
|
+
|
|
341
344
|
def do_GET(self) -> None:
|
|
342
345
|
"""Handle GET requests."""
|
|
343
346
|
parsed = urlparse(self.path)
|
|
@@ -355,9 +358,9 @@ class AnalyticsHandler(BaseHTTPRequestHandler):
|
|
|
355
358
|
elif path == "/api/analytics/sessions":
|
|
356
359
|
self._serve_json(get_analytics_sessions(start, end))
|
|
357
360
|
elif path == "/api/analytics/timeline":
|
|
358
|
-
self._serve_json(get_analytics_timeline(start, end))
|
|
361
|
+
self._serve_json(get_analytics_timeline(self.tmux_session, start, end))
|
|
359
362
|
elif path == "/api/analytics/stats":
|
|
360
|
-
self._serve_json(get_analytics_stats(start, end))
|
|
363
|
+
self._serve_json(get_analytics_stats(self.tmux_session, start, end))
|
|
361
364
|
elif path == "/api/analytics/daily":
|
|
362
365
|
self._serve_json(get_analytics_daily(start, end))
|
|
363
366
|
elif path == "/api/analytics/presets":
|
|
@@ -434,13 +437,18 @@ class AnalyticsHandler(BaseHTTPRequestHandler):
|
|
|
434
437
|
def run_analytics_server(
|
|
435
438
|
host: str = "127.0.0.1",
|
|
436
439
|
port: int = 8080,
|
|
440
|
+
tmux_session: str = "agents",
|
|
437
441
|
) -> None:
|
|
438
442
|
"""Run the analytics web dashboard server.
|
|
439
443
|
|
|
440
444
|
Args:
|
|
441
445
|
host: Host to bind to (default: 127.0.0.1 for local only)
|
|
442
446
|
port: Port to listen on (default: 8080)
|
|
447
|
+
tmux_session: tmux session name for session-specific data
|
|
443
448
|
"""
|
|
449
|
+
# Set the tmux session on the handler class
|
|
450
|
+
AnalyticsHandler.tmux_session = tmux_session
|
|
451
|
+
|
|
444
452
|
server_address = (host, port)
|
|
445
453
|
|
|
446
454
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: overcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A supervisor for managing multiple Claude Code instances in tmux
|
|
5
5
|
Author: Mike Bond
|
|
6
6
|
Project-URL: Homepage, https://github.com/mkb23/overcode
|
|
@@ -43,6 +43,18 @@ A TUI supervisor for managing multiple Claude Code agents in tmux.
|
|
|
43
43
|
|
|
44
44
|
Monitor status, costs, and activity across all your agents from a single dashboard.
|
|
45
45
|
|
|
46
|
+
## Screenshots
|
|
47
|
+
|
|
48
|
+
**Split-screen with tmux sync** - Monitor agents in the top pane while viewing live agent output below. Press `p` to enable pane sync, then navigate with `j/k` to switch the bottom pane to the selected agent's window.
|
|
49
|
+
|
|
50
|
+

|
|
51
|
+
|
|
52
|
+
> **iTerm2 setup**: Use `Cmd+Shift+D` to split horizontally. Run `overcode monitor` in the top pane and `tmux attach -t agents` in the bottom pane.
|
|
53
|
+
|
|
54
|
+
**Preview mode** - Press `m` to toggle List+Preview mode. Shows collapsed agent list with detailed terminal output preview for the selected agent.
|
|
55
|
+
|
|
56
|
+

|
|
57
|
+
|
|
46
58
|
## Installation
|
|
47
59
|
|
|
48
60
|
```bash
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|