overcode 0.1.1__py3-none-any.whl → 0.1.2__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 CHANGED
@@ -2,4 +2,4 @@
2
2
  Overcode - A supervisor for managing multiple Claude Code instances.
3
3
  """
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.1.2"
@@ -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()
overcode/tui.py CHANGED
@@ -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
- sessions = self.monitor_state.sessions
175
- total_agents = len(sessions)
176
- green_now = self.monitor_state.green_sessions
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 sessions:
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(sessions)
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 all agent history and group by agent
285
- all_history = read_agent_status_history(hours=self.TIMELINE_HOURS)
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 = "not installed - pip install overcode[presence]"
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
- self.detected_status = status
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:.0f}%", style=f"bold orange1{bg}")
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
- # Header with session name
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, 60 - len(header)), style="dim")
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[:200] if len(line) > 200 else 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
- focused = self.focused
2225
- if isinstance(focused, SessionSummary):
2226
- preview.update_from_widget(focused)
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
overcode/web_api.py CHANGED
@@ -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
- all_history = read_agent_status_history(hours=hours)
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
- all_history = read_agent_status_history(hours=hours)
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: list of (timestamp, agent_name, status, activity)
585
- agent_history = read_agent_status_history(hours=hours)
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)
overcode/web_server.py CHANGED
@@ -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.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
+ ![Overcode split-screen with tmux sync](docs/images/overcode-split-screen.png)
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
+ ![Overcode preview mode](docs/images/overcode-preview-mode.png)
57
+
46
58
  ## Installation
47
59
 
48
60
  ```bash
@@ -1,4 +1,4 @@
1
- overcode/__init__.py,sha256=Khx1HKL81_NJYG9NMD-4uvzAFsJr4Te7jevga6MJ_Sk,100
1
+ overcode/__init__.py,sha256=ZruEjk2uKTCuffSqw8aToEfRa2SKr7VA37PhYVQZNRo,100
2
2
  overcode/cli.py,sha256=w3GZrhGqLaUM_zR71rG8g3D1D0oD0JgPeZeKDcg9C5Q,29922
3
3
  overcode/config.py,sha256=LiuG4HyOZB5GnkYfQmqkEIsIzNeIyUi0PIZc0dctds0,3447
4
4
  overcode/daemon_claude_skill.md,sha256=74OoUARhconOTmfWQaHaDbWEPc7wZeywVNb6fIIuELE,5177
@@ -13,33 +13,33 @@ overcode/interfaces.py,sha256=txVkMHHhQl7dpsBS2jx7sR-BHwKiqlAj-YRw7pFkZdg,1101
13
13
  overcode/launcher.py,sha256=OUR3OZc0ZA9sbk1wZ_NzQ7aokI1M3j_oKtNaws1QskM,16127
14
14
  overcode/logging_config.py,sha256=BuioELKEdfx_amx35sVIdR1tyf2nbhfmOPLp5778lcs,5755
15
15
  overcode/mocks.py,sha256=7eAAdI3yPkModUbpaOp3Ul2nO7pJ6Tlz1L1qQH7nG_0,5289
16
- overcode/monitor_daemon.py,sha256=OAwjTVgsIW1UYzBNuwDiNl-ApdBcCY8myVMDfXh53Tk,26908
16
+ overcode/monitor_daemon.py,sha256=tFeelz-fSI8g4z0uud6j6Hnj6Shqw8XqBlMGvxCLfEM,26965
17
17
  overcode/monitor_daemon_state.py,sha256=wD7f2BuS6d7BUS66BtGBevhv_IU3fp9x_4omhV78arE,14856
18
18
  overcode/pid_utils.py,sha256=CYgG1RBa_0E8DJGzAWYapzpMflEc6MQ0LM4-ttt9YBE,6867
19
19
  overcode/presence_logger.py,sha256=0jIUPrhbeU0CDL9W2J7CbDPbAc-WPD4n4LFJJbLZ7Tc,13338
20
20
  overcode/protocols.py,sha256=NfSrm9F4h608eEFvs438KxFBg5r8E0Ni5etBWsS9IeY,4268
21
- overcode/session_manager.py,sha256=bTqi9jX7FfEK96SmPGTpyp-pDUr2mw1-Nc8SBXd7O0A,23057
21
+ overcode/session_manager.py,sha256=Bgp-eiPtyR2GF6i9OoIPsowC_5iGPggwvQYLnOBPEvo,23145
22
22
  overcode/settings.py,sha256=NUCv1xkFxYvzck2KXQEWn0a98QEEtIG7HuSfPsW10N0,13710
23
23
  overcode/standing_instructions.py,sha256=F9zYuGY0A7V90-SL2e-kLySfJACJvVjlsDZpq3BEI9k,10034
24
- overcode/status_constants.py,sha256=f24L3pEyI32ntH_d-p_MMdN1SHsYj2IhFrTjTLVWIVc,5817
25
- overcode/status_detector.py,sha256=ga1MPnWGOsu73EV1lDzIMwZnuc7qKfvd8DiMHwX6hY0,14589
24
+ overcode/status_constants.py,sha256=iBV1ZJABwAam1Rn0a4nRA1iRSE3MOx5MBONmkzAgwqo,6294
25
+ overcode/status_detector.py,sha256=1E3qAu_ALIJq2vwvBls0b19kswWKRNaw64gLlz1V0Ig,16306
26
26
  overcode/status_history.py,sha256=NzhVuTqHkSqgTTdwNfQ8wudCggYjhelZWxpZh6AmzCM,4778
27
- overcode/status_patterns.py,sha256=5bqqlzaEwImRQvvrXA-Qaqng0F7hLUNxxQtBMniY9jQ,8182
27
+ overcode/status_patterns.py,sha256=6OA8mladX-CHtwr0N7c6qJC_ylGQxqjIz36wnAMcS1A,8722
28
28
  overcode/summarizer_client.py,sha256=RBRx6FCaKzLGfnLb3fKOK1XxmYlqclHvt6O8pSSQFv0,4322
29
29
  overcode/summarizer_component.py,sha256=IVLM-l2eOfiTAFQ_MX5jDm5lKBbe0nAVGkb0gMlxqhU,9797
30
30
  overcode/supervisor_daemon.py,sha256=5uDpSsNb8Xv_H99A5hcwnZuH-absdlBzJYcOAEo6i8I,32746
31
31
  overcode/supervisor_layout.sh,sha256=s0Fm4Fj7adv5IOsMx1JuA6OAXCoQx9OUnS1CmV0FOwI,1652
32
32
  overcode/tmux_manager.py,sha256=SNqgQN-MLCIlpg1u4Zf0k6OX2uYryKpIZ3PPnF4Jwb0,7624
33
- overcode/tui.py,sha256=8XwVhgM7Ylt4o0pE3oXLynzFRdjIDw4Kb0NaZyjxlEU,119297
33
+ overcode/tui.py,sha256=M4UBZEKdw-pfU1sMpRJnBAFn117KwGTbQN5sy256VVA,123634
34
34
  overcode/tui_helpers.py,sha256=U_Po2DEpvBt-4keZgiIld8WMcKWqSeaa-a8-hjP47ec,14812
35
- overcode/web_api.py,sha256=XjIxyEpH89R_GKqUiwP7ylgg7IxBEouijL1wcJLup8c,25288
35
+ overcode/web_api.py,sha256=L4shmXWpnPZA6DCeYFiVeLobxqFCSYlqbFG8fGupwqI,25815
36
36
  overcode/web_chartjs.py,sha256=8wxfa9K0dXrcBOcPF7UkQFsCybNHDXEj9pEtnCDe-6g,205608
37
- overcode/web_server.py,sha256=6S7df9cwiVVBSXM4Jgeyk2hpzqjh1eVel9nDln19i5Y,16354
37
+ overcode/web_server.py,sha256=YFifXCx2MIJ6ca2c6bi5zKDlTjVgQmNBpkZ9o87kpp0,16674
38
38
  overcode/web_server_runner.py,sha256=cRGeLAzsAF8B89SnK8MeFZ7vZCufjAcAiWhRjAtJ63A,3333
39
39
  overcode/web_templates.py,sha256=GNqEyabj5-mqlNvw4lVHcNh18ofBLsKwxCUYS4JaKao,58882
40
- overcode-0.1.1.dist-info/licenses/LICENSE,sha256=6C9I9dhq8QTa4P6LckWXugYJ3xm2ufZKrLymqOqRZFs,1066
41
- overcode-0.1.1.dist-info/METADATA,sha256=bO_mOUhufkxKlb-a_e1BVVCDND-hgHhoTj1M8RBkXdQ,2635
42
- overcode-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
43
- overcode-0.1.1.dist-info/entry_points.txt,sha256=EveN-QDOji1HFbJAsIH-djI_s0E4KQC6N144arLeRb0,47
44
- overcode-0.1.1.dist-info/top_level.txt,sha256=5cfXcNbSNvigE7coZgIRPCl_NDWHu_5UIBmWi9Xiupo,9
45
- overcode-0.1.1.dist-info/RECORD,,
40
+ overcode-0.1.2.dist-info/licenses/LICENSE,sha256=6C9I9dhq8QTa4P6LckWXugYJ3xm2ufZKrLymqOqRZFs,1066
41
+ overcode-0.1.2.dist-info/METADATA,sha256=EjDJkG0qvN-wLl1dUeslsvAOK-OBdmAUwRB7-wBvKSs,3312
42
+ overcode-0.1.2.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
43
+ overcode-0.1.2.dist-info/entry_points.txt,sha256=EveN-QDOji1HFbJAsIH-djI_s0E4KQC6N144arLeRb0,47
44
+ overcode-0.1.2.dist-info/top_level.txt,sha256=5cfXcNbSNvigE7coZgIRPCl_NDWHu_5UIBmWi9Xiupo,9
45
+ overcode-0.1.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5