overcode 0.1.0__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/daemon.py DELETED
@@ -1,1184 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- DEPRECATED: Legacy Overcode Daemon
4
-
5
- This module is deprecated. Use instead:
6
- - overcode.monitor_daemon - For metrics tracking and status monitoring
7
- - overcode.supervisor_daemon - For autonomous Claude orchestration
8
-
9
- The CLI commands 'overcode daemon start/stop/status' are also deprecated.
10
- Use 'overcode monitor-daemon' and 'overcode supervisor-daemon' instead.
11
-
12
- This file is kept for backwards compatibility with existing CLI commands
13
- and will be removed in a future version.
14
- """
15
-
16
- import os
17
- import tempfile
18
- import time
19
- import subprocess
20
- import sys
21
- from datetime import datetime
22
- from pathlib import Path
23
- from typing import Optional, List, Dict
24
- from rich.console import Console
25
- from rich.text import Text
26
- from rich.theme import Theme
27
- from .session_manager import SessionManager
28
- from .status_detector import StatusDetector
29
- from .tmux_manager import TmuxManager
30
- from .pid_utils import is_process_running, get_process_pid, write_pid_file, remove_pid_file
31
- from .status_constants import (
32
- STATUS_RUNNING,
33
- STATUS_WAITING_USER,
34
- get_status_color,
35
- get_status_emoji,
36
- )
37
- from .daemon_state import DaemonState, MODE_MONITOR, MODE_SUPERVISE
38
- from .status_history import log_agent_status, read_agent_status_history
39
- from .history_reader import get_session_stats
40
- from .settings import DAEMON, get_activity_signal_path
41
-
42
-
43
- # Interval settings (in seconds)
44
- INTERVAL_FAST = 10 # When active or agents working
45
- INTERVAL_SLOW = 300 # When all agents need user input (5 min)
46
- INTERVAL_IDLE = 3600 # When no agents at all (1 hour)
47
-
48
- # File locations
49
- OVERCODE_DIR = Path.home() / '.overcode'
50
- DAEMON_STATE_FILE = OVERCODE_DIR / 'daemon_state.json'
51
- DAEMON_LOG_FILE = OVERCODE_DIR / 'daemon.log'
52
- DAEMON_PID_FILE = OVERCODE_DIR / 'daemon.pid'
53
- ACTIVITY_SIGNAL_FILE = OVERCODE_DIR / 'activity_signal'
54
- AGENT_STATUS_HISTORY_FILE = OVERCODE_DIR / 'agent_status_history.csv'
55
-
56
-
57
- def is_daemon_running() -> bool:
58
- """Check if the daemon process is currently running.
59
-
60
- Returns True if PID file exists and process is alive.
61
- Can be called from other modules (e.g., TUI).
62
- """
63
- return is_process_running(DAEMON_PID_FILE)
64
-
65
-
66
- def get_daemon_pid() -> Optional[int]:
67
- """Get the daemon PID if running, None otherwise."""
68
- return get_process_pid(DAEMON_PID_FILE)
69
-
70
-
71
- def stop_daemon() -> bool:
72
- """Stop the daemon process if running.
73
-
74
- Returns True if daemon was stopped, False if it wasn't running.
75
- Also cleans up stale PID files.
76
-
77
- Note: Uses quick stop (just SIGTERM, no wait) for responsive CLI.
78
- """
79
- import signal
80
-
81
- pid = get_process_pid(DAEMON_PID_FILE)
82
- if pid is None:
83
- # Clean up stale PID file if exists
84
- remove_pid_file(DAEMON_PID_FILE)
85
- return False
86
-
87
- try:
88
- os.kill(pid, signal.SIGTERM)
89
- remove_pid_file(DAEMON_PID_FILE)
90
- return True
91
- except (OSError, ProcessLookupError):
92
- remove_pid_file(DAEMON_PID_FILE)
93
- return False
94
-
95
-
96
- def signal_activity(session: str = None) -> None:
97
- """Signal user activity to the daemon (called by TUI on keypress).
98
-
99
- Creates a signal file that the daemon checks each loop.
100
-
101
- Args:
102
- session: tmux session name (default: from config)
103
- """
104
- if session is None:
105
- session = DAEMON.default_tmux_session
106
- signal_path = get_activity_signal_path(session)
107
- try:
108
- signal_path.parent.mkdir(parents=True, exist_ok=True)
109
- signal_path.touch()
110
- except OSError:
111
- pass # Best effort
112
-
113
-
114
- def check_activity_signal() -> bool:
115
- """Check for and consume the activity signal (called by daemon).
116
-
117
- Returns True if signal was present (and deletes the file).
118
- """
119
- if ACTIVITY_SIGNAL_FILE.exists():
120
- try:
121
- ACTIVITY_SIGNAL_FILE.unlink()
122
- return True
123
- except OSError:
124
- pass
125
- return False
126
-
127
-
128
- def _write_pid_file() -> None:
129
- """Write current PID to file."""
130
- write_pid_file(DAEMON_PID_FILE)
131
-
132
-
133
- def _remove_pid_file() -> None:
134
- """Remove PID file."""
135
- remove_pid_file(DAEMON_PID_FILE)
136
-
137
-
138
- # Rich theme for daemon logs
139
- DAEMON_THEME = Theme({
140
- "info": "cyan",
141
- "warn": "yellow",
142
- "error": "bold red",
143
- "success": "bold green",
144
- "daemon_claude": "magenta",
145
- "dim": "dim white",
146
- "highlight": "bold white",
147
- })
148
-
149
-
150
- class DaemonLogger:
151
- """Rich-based logger for daemon with pretty console output and file logging."""
152
-
153
- def __init__(self, log_file: Path = DAEMON_LOG_FILE):
154
- self.log_file = log_file
155
- self.log_file.parent.mkdir(parents=True, exist_ok=True)
156
- self.console = Console(theme=DAEMON_THEME, force_terminal=True)
157
- # Track seen lines by content to avoid duplicates when output scrolls
158
- self._seen_daemon_claude_lines: set = set()
159
-
160
- def _write_to_file(self, message: str, level: str):
161
- """Write plain text to log file."""
162
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
163
- line = f"[{timestamp}] [{level}] {message}"
164
- with open(self.log_file, 'a') as f:
165
- f.write(line + '\n')
166
-
167
- def info(self, message: str):
168
- """Log info message."""
169
- self._write_to_file(message, "INFO")
170
- self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] [info]INFO[/info] {message}")
171
-
172
- def warn(self, message: str):
173
- """Log warning message."""
174
- self._write_to_file(message, "WARN")
175
- self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] [warn]WARN[/warn] {message}")
176
-
177
- def error(self, message: str):
178
- """Log error message."""
179
- self._write_to_file(message, "ERROR")
180
- self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] [error]ERROR[/error] {message}")
181
-
182
- def success(self, message: str):
183
- """Log success message."""
184
- self._write_to_file(message, "INFO")
185
- self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] [success]OK[/success] {message}")
186
-
187
- def daemon_claude_output(self, lines: List[str]):
188
- """Log daemon claude output, showing only new lines.
189
-
190
- Uses content-based tracking (set) instead of positional comparison,
191
- which correctly handles terminal scrolling where lines shift up.
192
- """
193
- new_lines = []
194
-
195
- for line in lines:
196
- stripped = line.strip()
197
- if not stripped:
198
- continue
199
-
200
- # Check if we've seen this line before
201
- if stripped not in self._seen_daemon_claude_lines:
202
- new_lines.append(stripped)
203
- self._seen_daemon_claude_lines.add(stripped)
204
-
205
- # Limit the set size to prevent unbounded memory growth
206
- # Keep only the most recent lines by clearing old entries periodically
207
- if len(self._seen_daemon_claude_lines) > 500:
208
- # Keep lines from current capture to maintain context
209
- current_lines = {line.strip() for line in lines if line.strip()}
210
- self._seen_daemon_claude_lines = current_lines
211
-
212
- if new_lines:
213
- # Log new output with daemon claude styling
214
- for line in new_lines:
215
- self._write_to_file(f"[DAEMON_CLAUDE] {line}", "INFO")
216
- # Style the output based on content
217
- if line.startswith('✓') or 'success' in line.lower():
218
- self.console.print(f" [success]│[/success] {line}")
219
- elif line.startswith('✗') or 'error' in line.lower() or 'fail' in line.lower():
220
- self.console.print(f" [error]│[/error] {line}")
221
- elif line.startswith('>') or line.startswith('$'):
222
- self.console.print(f" [highlight]│[/highlight] {line}")
223
- else:
224
- self.console.print(f" [daemon_claude]│[/daemon_claude] {line}")
225
-
226
- def section(self, title: str):
227
- """Print a section divider."""
228
- self._write_to_file(f"=== {title} ===", "INFO")
229
- self.console.print()
230
- self.console.rule(f"[bold]{title}[/bold]", style="dim")
231
-
232
- def status_summary(self, total: int, green: int, non_green: int, loop: int):
233
- """Print a status summary line."""
234
- status_text = Text()
235
- status_text.append(f"Loop #{loop}: ", style="dim")
236
- status_text.append(f"{total} agents ", style="highlight")
237
- status_text.append("(", style="dim")
238
- status_text.append(f"{green} green", style="success")
239
- status_text.append(", ", style="dim")
240
- status_text.append(f"{non_green} non-green", style="warn" if non_green else "dim")
241
- status_text.append(")", style="dim")
242
-
243
- self._write_to_file(f"Loop #{loop}: {total} agents ({green} green, {non_green} non-green)", "INFO")
244
- self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] ", end="")
245
- self.console.print(status_text)
246
-
247
- def agent_status(self, name: str, status: str, activity: str):
248
- """Log individual agent status."""
249
- style = get_status_color(status)
250
-
251
- self._write_to_file(f" {name}: {status} - {activity[:60]}", "INFO")
252
- self.console.print(f" [{style}]●[/{style}] [bold]{name}[/bold]: [{style}]{status}[/{style}] - [dim]{activity[:60]}[/dim]")
253
-
254
-
255
- class Daemon:
256
- """Background daemon that auto-launches daemon claude when needed"""
257
-
258
- # Special window name for daemon's claude (not tracked in sessions.json)
259
- DAEMON_CLAUDE_WINDOW_NAME = "_daemon_claude"
260
-
261
- # Rough cost estimates per interaction (USD)
262
- # Based on typical Claude Code interaction costs
263
- COST_PER_INTERACTION = {
264
- "opus": 0.05, # ~$0.05 per interaction for Opus
265
- "sonnet": 0.01, # ~$0.01 per interaction for Sonnet
266
- "default": 0.03 # Default estimate
267
- }
268
-
269
- def __init__(
270
- self,
271
- tmux_session: str = "agents",
272
- session_manager: SessionManager = None,
273
- status_detector: StatusDetector = None,
274
- tmux_manager: TmuxManager = None,
275
- logger: "DaemonLogger" = None,
276
- mode: str = MODE_SUPERVISE,
277
- ):
278
- """Initialize the daemon.
279
-
280
- Args:
281
- tmux_session: Name of the tmux session to manage
282
- session_manager: Optional SessionManager for dependency injection (testing)
283
- status_detector: Optional StatusDetector for dependency injection (testing)
284
- tmux_manager: Optional TmuxManager for dependency injection (testing)
285
- logger: Optional DaemonLogger for dependency injection (testing)
286
- mode: Daemon mode - MODE_MONITOR (stats only) or MODE_SUPERVISE (full)
287
- """
288
- self.tmux_session = tmux_session
289
- self.session_manager = session_manager if session_manager else SessionManager()
290
- self.status_detector = status_detector if status_detector else StatusDetector(tmux_session)
291
- self.tmux = tmux_manager if tmux_manager else TmuxManager(tmux_session)
292
- self.daemon_claude_window: Optional[int] = None
293
- self.state = DaemonState()
294
- self.state.mode = mode
295
- self.last_controller_check: Optional[datetime] = None
296
- self.controller_line_count = 0
297
- self.log = logger if logger else DaemonLogger()
298
- # Track previous states to detect transitions
299
- self.previous_states: Dict[str, str] = {} # session_id -> last known status
300
- # Track when operations started (for timing)
301
- self.operation_start_times: Dict[str, datetime] = {} # session_id -> when went non-running
302
- # Track when daemon claude was launched (for counting interventions)
303
- self.daemon_claude_launch_time: Optional[datetime] = None
304
- # Track last state change time per session for state time tracking
305
- self.last_state_times: Dict[str, datetime] = {} # session_id -> last state change time
306
-
307
- def track_session_stats(self, session, status: str) -> None:
308
- """Track session state transitions and update stats.
309
-
310
- Called for each session on each daemon loop to detect:
311
- - Transitions from non-running to running (= 1 interaction completed)
312
- - Operation duration (time spent waiting)
313
- - State time accumulation (green_time_seconds, non_green_time_seconds)
314
- """
315
- session_id = session.id
316
- now = datetime.now()
317
-
318
- # Get previous status (default to current if first time seeing this session)
319
- prev_status = self.previous_states.get(session_id, status)
320
-
321
- # Track state time accumulation
322
- self._update_state_time(session, status, now)
323
-
324
- # Detect state transitions
325
- was_running = prev_status == STATUS_RUNNING
326
- is_running = status == STATUS_RUNNING
327
-
328
- # Session went from running to waiting (operation started)
329
- if was_running and not is_running:
330
- self.operation_start_times[session_id] = now
331
-
332
- # Session went from waiting back to running (operation completed)
333
- if not was_running and is_running:
334
- # Calculate operation time if we have a start time
335
- op_duration = None
336
- if session_id in self.operation_start_times:
337
- start_time = self.operation_start_times[session_id]
338
- op_duration = (now - start_time).total_seconds()
339
- del self.operation_start_times[session_id]
340
-
341
- # Update operation times for latency tracking (keep last 100)
342
- current_stats = session.stats
343
- op_times = list(current_stats.operation_times)
344
- if op_duration is not None and op_duration > 0:
345
- op_times.append(op_duration)
346
- op_times = op_times[-100:] # Keep last 100
347
-
348
- # Save updated operation times
349
- self.session_manager.update_stats(
350
- session_id,
351
- operation_times=op_times,
352
- last_activity=now.isoformat()
353
- )
354
-
355
- self.log.info(f"[{session.name}] Operation completed ({op_duration:.1f}s)")
356
-
357
- # Update previous state for next check
358
- self.previous_states[session_id] = status
359
-
360
- def _update_state_time(self, session, status: str, now: datetime) -> None:
361
- """Update green_time_seconds and non_green_time_seconds for a session.
362
-
363
- Called each loop to accumulate time in current state.
364
- """
365
- session_id = session.id
366
- current_stats = session.stats
367
-
368
- # Get last recorded time for this session
369
- last_time = self.last_state_times.get(session_id)
370
- if last_time is None:
371
- # First time seeing this session, initialize from state_since if available
372
- if current_stats.state_since:
373
- try:
374
- last_time = datetime.fromisoformat(current_stats.state_since)
375
- except ValueError:
376
- last_time = now
377
- else:
378
- last_time = now
379
- self.last_state_times[session_id] = last_time
380
- return # Don't accumulate on first observation
381
-
382
- # Calculate time elapsed since last check
383
- elapsed = (now - last_time).total_seconds()
384
- if elapsed <= 0:
385
- return
386
-
387
- # Accumulate time based on current state
388
- green_time = current_stats.green_time_seconds
389
- non_green_time = current_stats.non_green_time_seconds
390
-
391
- if status == STATUS_RUNNING:
392
- green_time += elapsed
393
- else:
394
- non_green_time += elapsed
395
-
396
- # Update state tracking if state changed
397
- prev_status = self.previous_states.get(session_id, status)
398
- state_since = current_stats.state_since
399
- if prev_status != status:
400
- state_since = now.isoformat()
401
- elif not state_since:
402
- # Initialize state_since if never set (e.g., new session)
403
- state_since = now.isoformat()
404
-
405
- # Save updated times
406
- self.session_manager.update_stats(
407
- session_id,
408
- current_state=status,
409
- state_since=state_since,
410
- green_time_seconds=green_time,
411
- non_green_time_seconds=non_green_time,
412
- )
413
-
414
- # Update tracking time
415
- self.last_state_times[session_id] = now
416
-
417
- def sync_claude_code_stats(self, session) -> None:
418
- """Sync token/interaction stats from Claude Code history files.
419
-
420
- Reads from ~/.claude/projects/ to get actual token usage and
421
- persists to SessionStats for historical reference.
422
- """
423
- try:
424
- stats = get_session_stats(session)
425
- if stats is None:
426
- return
427
-
428
- now = datetime.now()
429
-
430
- # Calculate total tokens
431
- total_tokens = (
432
- stats.input_tokens +
433
- stats.output_tokens +
434
- stats.cache_creation_tokens +
435
- stats.cache_read_tokens
436
- )
437
-
438
- # Estimate cost (rough approximation)
439
- # Using ~$3/1M input, ~$15/1M output for Claude
440
- cost_estimate = (
441
- (stats.input_tokens / 1_000_000) * 3.0 +
442
- (stats.output_tokens / 1_000_000) * 15.0 +
443
- (stats.cache_creation_tokens / 1_000_000) * 3.75 +
444
- (stats.cache_read_tokens / 1_000_000) * 0.30
445
- )
446
-
447
- self.session_manager.update_stats(
448
- session.id,
449
- interaction_count=stats.interaction_count,
450
- total_tokens=total_tokens,
451
- input_tokens=stats.input_tokens,
452
- output_tokens=stats.output_tokens,
453
- cache_creation_tokens=stats.cache_creation_tokens,
454
- cache_read_tokens=stats.cache_read_tokens,
455
- estimated_cost_usd=round(cost_estimate, 4),
456
- last_stats_update=now.isoformat(),
457
- )
458
- except Exception as e:
459
- # Don't fail the loop if stats sync fails
460
- self.log.warn(f"Failed to sync stats for {session.name}: {e}")
461
-
462
- def is_daemon_claude_running(self) -> bool:
463
- """Check if daemon claude is still running"""
464
- if self.daemon_claude_window is None:
465
- return False
466
-
467
- # Check if our tracked window still exists
468
- return self.tmux.window_exists(self.daemon_claude_window)
469
-
470
- def is_daemon_claude_done(self) -> bool:
471
- """Check if daemon claude has finished its task (at empty prompt or gone).
472
-
473
- Returns True if:
474
- - Window doesn't exist (closed/crashed)
475
- - Window shows empty prompt AND no active work indicators
476
- """
477
- if not self.is_daemon_claude_running():
478
- return True
479
-
480
- # Capture pane content to check status
481
- try:
482
- result = subprocess.run(
483
- [
484
- "tmux", "capture-pane",
485
- "-t", f"{self.tmux_session}:{self.daemon_claude_window}",
486
- "-p",
487
- "-S", "-30", # Last 30 lines for better context
488
- ],
489
- capture_output=True,
490
- text=True,
491
- timeout=5
492
- )
493
-
494
- if result.returncode != 0:
495
- return True # Can't capture = assume done
496
-
497
- content = result.stdout
498
-
499
- # If Claude is actively working, NOT done
500
- # These indicators show Claude is thinking or running tools
501
- active_indicators = [
502
- '· ', # Thinking indicator (· Thinking…, · Combobulating…)
503
- 'Running…', # Tool is running
504
- '(esc to interrupt', # Active work message
505
- '✽', # Another thinking indicator
506
- ]
507
- for indicator in active_indicators:
508
- if indicator in content:
509
- return False # Still working
510
-
511
- # Also check for tool calls that just started (no result yet)
512
- # Pattern: ⏺ ToolName(...) without ⎿ result after
513
- lines = content.split('\n')
514
- for i, line in enumerate(lines):
515
- if '⏺' in line and '(' in line:
516
- # Found a tool call - check if there's a result after it
517
- remaining = '\n'.join(lines[i+1:])
518
- if '⎿' not in remaining:
519
- return False # Tool call without result = still working
520
-
521
- # Check for empty prompt in last few lines
522
- last_lines = [l.strip() for l in lines[-8:] if l.strip()]
523
- for line in last_lines:
524
- # Empty Claude Code prompt (just > or › with nothing after)
525
- if line == '>' or line == '›':
526
- return True
527
-
528
- return False
529
-
530
- except subprocess.TimeoutExpired:
531
- # Timeout checking status != daemon claude is done
532
- # Return False to keep waiting - it may still be working
533
- return False
534
- except subprocess.SubprocessError:
535
- # Other subprocess errors - can't determine status
536
- # Return False to be safe (keep waiting rather than kill)
537
- return False
538
-
539
- def wait_for_daemon_claude(self, timeout: int = 300, poll_interval: int = 5) -> bool:
540
- """Wait for daemon claude to complete its task.
541
-
542
- Args:
543
- timeout: Max seconds to wait (default 5 minutes)
544
- poll_interval: Seconds between checks (default 5s)
545
-
546
- Returns:
547
- True if daemon claude completed, False if timed out
548
- """
549
- if not self.is_daemon_claude_running():
550
- return True
551
-
552
- self.log.info(f"Waiting for daemon claude to complete (timeout {timeout}s)...")
553
- start_time = time.time()
554
- has_seen_activity = False
555
-
556
- while time.time() - start_time < timeout:
557
- # Capture and log output while waiting
558
- self.capture_daemon_claude_output()
559
-
560
- # Check if daemon claude has started working (shows tool use indicator)
561
- if not has_seen_activity:
562
- has_seen_activity = self._has_daemon_claude_started()
563
- if has_seen_activity:
564
- self.log.info("Daemon claude started working...")
565
-
566
- # Only check for completion after we've seen activity
567
- if has_seen_activity and self.is_daemon_claude_done():
568
- elapsed = int(time.time() - start_time)
569
- self.log.success(f"Daemon claude completed in {elapsed}s")
570
- return True
571
-
572
- time.sleep(poll_interval)
573
-
574
- self.log.warn(f"Daemon claude timed out after {timeout}s")
575
- return False
576
-
577
- def _has_daemon_claude_started(self) -> bool:
578
- """Check if daemon claude has started working (shows activity indicators)."""
579
- if not self.is_daemon_claude_running():
580
- return False
581
-
582
- try:
583
- result = subprocess.run(
584
- [
585
- "tmux", "capture-pane",
586
- "-t", f"{self.tmux_session}:{self.daemon_claude_window}",
587
- "-p",
588
- "-S", "-30",
589
- ],
590
- capture_output=True,
591
- text=True,
592
- timeout=5
593
- )
594
-
595
- if result.returncode != 0:
596
- return False
597
-
598
- content = result.stdout
599
-
600
- # Look for signs Claude has started working:
601
- # - Tool use indicator (⏺)
602
- # - Thinking/processing
603
- # - Any substantial output beyond just the prompt
604
- activity_indicators = ['⏺', 'Read(', 'Write(', 'Edit(', 'Bash(', 'Grep(', 'Glob(']
605
- for indicator in activity_indicators:
606
- if indicator in content:
607
- return True
608
-
609
- return False
610
-
611
- except subprocess.SubprocessError:
612
- return False
613
-
614
- def kill_daemon_claude(self) -> None:
615
- """Kill daemon claude window if it exists"""
616
- if self.daemon_claude_window is not None and self.tmux.window_exists(self.daemon_claude_window):
617
- self.log.info(f"Killing daemon claude window {self.daemon_claude_window}")
618
- self.tmux.kill_window(self.daemon_claude_window)
619
- self.daemon_claude_window = None
620
-
621
- def cleanup_stale_daemon_claudes(self) -> None:
622
- """Clean up any orphaned daemon claude windows.
623
-
624
- This handles:
625
- 1. Our tracked daemon claude window that no longer exists
626
- 2. Orphaned windows with the daemon claude name from previous daemon runs
627
- """
628
- # Clear our reference if the window is gone
629
- if self.daemon_claude_window is not None and not self.tmux.window_exists(self.daemon_claude_window):
630
- self.log.info(f"Daemon claude window {self.daemon_claude_window} no longer exists")
631
- self.daemon_claude_window = None
632
-
633
- # Also kill any orphaned daemon claude windows (from previous daemon runs)
634
- windows = self.tmux.list_windows()
635
- for window in windows:
636
- if window['name'] == self.DAEMON_CLAUDE_WINDOW_NAME:
637
- window_idx = int(window['index'])
638
- # If we're not tracking this window, it's orphaned
639
- if self.daemon_claude_window != window_idx:
640
- self.log.info(f"Killing orphaned daemon claude window {window_idx}")
641
- self.tmux.kill_window(window_idx)
642
-
643
- def capture_daemon_claude_output(self) -> None:
644
- """Capture and log output from daemon claude window."""
645
- if not self.is_daemon_claude_running():
646
- return
647
-
648
- try:
649
- # Capture the pane content
650
- result = subprocess.run(
651
- [
652
- "tmux", "capture-pane",
653
- "-t", f"{self.tmux_session}:{self.daemon_claude_window}",
654
- "-p", # Print to stdout
655
- "-S", "-50", # Start from 50 lines before current position
656
- ],
657
- capture_output=True,
658
- text=True,
659
- timeout=5
660
- )
661
-
662
- if result.returncode == 0:
663
- lines = [line for line in result.stdout.split('\n') if line.strip()]
664
- if lines:
665
- self.log.daemon_claude_output(lines)
666
-
667
- except subprocess.SubprocessError:
668
- pass # Silently ignore capture errors
669
-
670
- def count_interventions_from_log(self, sessions: list) -> Dict[str, int]:
671
- """Count interventions per session from supervisor log since daemon claude launch.
672
-
673
- Parses ~/.overcode/supervisor.log for entries after daemon_claude_launch_time
674
- and counts actual interventions (not "No intervention needed" entries).
675
-
676
- Args:
677
- sessions: List of sessions to check for
678
-
679
- Returns:
680
- Dict mapping session name to intervention count
681
- """
682
- if not self.daemon_claude_launch_time:
683
- return {}
684
-
685
- log_path = Path.home() / ".overcode" / "supervisor.log"
686
- if not log_path.exists():
687
- return {}
688
-
689
- counts: Dict[str, int] = {}
690
- session_names = {s.name for s in sessions}
691
-
692
- # Phrases that indicate an action WAS taken
693
- action_phrases = [
694
- "approved",
695
- "rejected",
696
- "sent ", # "sent /exit", "sent feedback"
697
- "provided",
698
- "unblocked",
699
- ]
700
-
701
- # Phrases that indicate NO action was taken (overrides action phrases)
702
- no_action_phrases = [
703
- "no intervention needed",
704
- "no action needed",
705
- ]
706
-
707
- try:
708
- with open(log_path, 'r') as f:
709
- for line in f:
710
- line = line.strip()
711
- if not line:
712
- continue
713
-
714
- # Parse timestamp - format: "Fri 2 Jan 2026 07:11:36 GMT: ..."
715
- # Also handles "Fri 2 Jan..." (double space)
716
- try:
717
- if ": " not in line:
718
- continue
719
- timestamp_part = line.split(": ")[0]
720
- # Try both single and double space after weekday
721
- entry_time = None
722
- for fmt in ["%a %d %b %Y %H:%M:%S %Z", "%a %d %b %Y %H:%M:%S %Z"]:
723
- try:
724
- entry_time = datetime.strptime(timestamp_part.strip(), fmt)
725
- break
726
- except ValueError:
727
- continue
728
- if entry_time is None:
729
- continue
730
- except (ValueError, IndexError):
731
- continue
732
-
733
- # Skip entries before daemon claude launch
734
- if entry_time < self.daemon_claude_launch_time:
735
- continue
736
-
737
- # Check if this entry is about one of our sessions
738
- for name in session_names:
739
- if f"{name} - " in line:
740
- line_lower = line.lower()
741
- # First check if explicitly says "no action"
742
- if any(phrase in line_lower for phrase in no_action_phrases):
743
- break
744
- # Then check if an action was taken
745
- if any(phrase in line_lower for phrase in action_phrases):
746
- counts[name] = counts.get(name, 0) + 1
747
- break
748
-
749
- except IOError:
750
- pass
751
-
752
- return counts
753
-
754
- def update_intervention_counts(self, sessions: list) -> None:
755
- """Update steers_count for sessions based on supervisor log interventions.
756
-
757
- Called after daemon claude completes to track actual interventions.
758
- """
759
- counts = self.count_interventions_from_log(sessions)
760
- if not counts:
761
- return
762
-
763
- for session in sessions:
764
- if session.name in counts:
765
- intervention_count = counts[session.name]
766
- current_stats = session.stats
767
- self.session_manager.update_stats(
768
- session.id,
769
- steers_count=current_stats.steers_count + intervention_count,
770
- )
771
- self.log.info(f"[{session.name}] +{intervention_count} daemon interventions")
772
-
773
- def get_non_green_sessions(self) -> list:
774
- """Get all sessions that are not in running state"""
775
- sessions = self.session_manager.list_sessions()
776
- non_green = []
777
-
778
- for session in sessions:
779
- # Skip the daemon claude itself (shouldn't appear, but just in case)
780
- if session.name == 'daemon_claude':
781
- continue
782
-
783
- status, _, _ = self.status_detector.detect_status(session)
784
- if status != STATUS_RUNNING:
785
- non_green.append((session, status))
786
-
787
- return non_green
788
-
789
- def build_daemon_claude_context(self, non_green_sessions: list) -> str:
790
- """Build initial context for daemon claude"""
791
- context_parts = []
792
-
793
- context_parts.append("You are the Overcode daemon claude agent.")
794
- context_parts.append("Your mission: Make all RED/YELLOW/ORANGE sessions GREEN.")
795
- context_parts.append("")
796
- context_parts.append(f"TMUX SESSION: {self.tmux_session}")
797
- context_parts.append(f"Sessions needing attention: {len(non_green_sessions)}")
798
- context_parts.append("")
799
-
800
- for session, status in non_green_sessions:
801
- emoji = get_status_emoji(status)
802
- context_parts.append(f"{emoji} {session.name} (window {session.tmux_window})")
803
- if session.standing_instructions:
804
- context_parts.append(f" Autopilot: {session.standing_instructions}")
805
- else:
806
- context_parts.append(f" No autopilot instructions set")
807
- context_parts.append(f" Working dir: {session.start_directory or 'unknown'}")
808
- context_parts.append("")
809
-
810
- context_parts.append("Read the daemon claude skill for how to control sessions via tmux.")
811
- context_parts.append("Start by reading ~/.overcode/sessions/sessions.json to see full state.")
812
- context_parts.append("Then check each non-green session and help them make progress.")
813
-
814
- return "\n".join(context_parts)
815
-
816
- def launch_daemon_claude(self, non_green_sessions: list):
817
- """Launch daemon claude to handle non-green sessions.
818
-
819
- This creates a non-interactive daemon claude directly in a tmux window
820
- WITHOUT registering it in sessions.json. It's a background worker, not a
821
- user-facing agent.
822
- """
823
- # Build context message
824
- context = self.build_daemon_claude_context(non_green_sessions)
825
-
826
- # Get the daemon claude skill path
827
- skill_path = Path(__file__).parent / "daemon_claude_skill.md"
828
-
829
- # Read the daemon claude skill content
830
- with open(skill_path) as f:
831
- skill_content = f.read()
832
-
833
- # Build full prompt with skill + context
834
- full_prompt = f"{skill_content}\n\n---\n\n{context}"
835
-
836
- # Ensure tmux session exists
837
- if not self.tmux.ensure_session():
838
- self.log.error(f"Failed to create tmux session '{self.tmux.session_name}'")
839
- return
840
-
841
- # Create window for daemon claude (uses special name prefix)
842
- window_index = self.tmux.create_window(
843
- self.DAEMON_CLAUDE_WINDOW_NAME,
844
- str(Path.home() / '.overcode')
845
- )
846
- if window_index is None:
847
- self.log.error("Failed to create daemon claude window")
848
- return
849
-
850
- self.daemon_claude_window = window_index
851
- self.daemon_claude_launch_time = datetime.now()
852
-
853
- # Start Claude with auto-permissions
854
- # Use dangerously-skip-permissions so daemon claude can run tmux commands
855
- # without prompting. This is safe because daemon claude only operates on
856
- # the agent sessions within the monitored tmux session.
857
- claude_cmd = "claude code --dangerously-skip-permissions"
858
- if not self.tmux.send_keys(window_index, claude_cmd, enter=True):
859
- self.log.error("Failed to start Claude in daemon claude window")
860
- return
861
-
862
- # Wait for Claude to start up
863
- time.sleep(3.0)
864
-
865
- # Send the prompt via tmux load-buffer/paste-buffer for large text
866
- self._send_prompt_to_window(window_index, full_prompt)
867
-
868
- def _send_prompt_to_window(self, window_index: int, prompt: str) -> bool:
869
- """Send a large prompt to a tmux window via load-buffer/paste-buffer."""
870
- import os
871
-
872
- lines = prompt.split('\n')
873
- batch_size = 10
874
-
875
- for i in range(0, len(lines), batch_size):
876
- batch = lines[i:i + batch_size]
877
- text = '\n'.join(batch)
878
- if i + batch_size < len(lines):
879
- text += '\n' # Add newline between batches
880
-
881
- try:
882
- with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
883
- temp_path = f.name
884
- f.write(text)
885
-
886
- subprocess.run(['tmux', 'load-buffer', temp_path], timeout=5, check=True)
887
- subprocess.run([
888
- 'tmux', 'paste-buffer', '-t',
889
- f"{self.tmux.session_name}:{window_index}"
890
- ], timeout=5, check=True)
891
- except subprocess.SubprocessError as e:
892
- self.log.error(f"Failed to send prompt batch: {e}")
893
- return False
894
- finally:
895
- try:
896
- os.unlink(temp_path)
897
- except OSError:
898
- pass
899
-
900
- time.sleep(0.1)
901
-
902
- # Send Enter to submit the prompt
903
- subprocess.run([
904
- 'tmux', 'send-keys', '-t',
905
- f"{self.tmux.session_name}:{window_index}",
906
- '', 'Enter'
907
- ])
908
-
909
- return True
910
-
911
- def _interruptible_sleep(self, total_seconds: int) -> None:
912
- """Sleep for total_seconds, but check for activity signal every 10s.
913
-
914
- This allows the daemon to wake up quickly when the user becomes active,
915
- even if we're in a long sleep interval (e.g., 1 hour idle).
916
- """
917
- chunk_size = 10 # Check every 10 seconds
918
- elapsed = 0
919
-
920
- while elapsed < total_seconds:
921
- remaining = total_seconds - elapsed
922
- sleep_time = min(chunk_size, remaining)
923
- time.sleep(sleep_time)
924
- elapsed += sleep_time
925
-
926
- # Check for activity signal
927
- if check_activity_signal():
928
- self.log.info("User activity detected → waking up")
929
- self.state.current_interval = INTERVAL_FAST
930
- self.state.last_activity = datetime.now()
931
- self.state.save()
932
- return # Exit sleep early
933
-
934
- def is_controller_active(self) -> bool:
935
- """Check if user is actively using the controller (by monitoring output changes)"""
936
- try:
937
- # Check the overcode-controller session's bottom pane
938
- result = subprocess.run(
939
- ["tmux", "capture-pane", "-t", "overcode-controller:0.1", "-p"],
940
- capture_output=True,
941
- text=True,
942
- timeout=5
943
- )
944
- if result.returncode != 0:
945
- return False
946
-
947
- # Count non-empty lines
948
- lines = [l for l in result.stdout.split('\n') if l.strip()]
949
- current_count = len(lines)
950
-
951
- # Compare with last check
952
- now = datetime.now()
953
- if self.last_controller_check:
954
- time_since_check = (now - self.last_controller_check).total_seconds()
955
- # If output changed recently, user is active
956
- if current_count != self.controller_line_count and time_since_check < 60:
957
- self.state.last_activity = now
958
- self.controller_line_count = current_count
959
- self.last_controller_check = now
960
- return True
961
-
962
- self.controller_line_count = current_count
963
- self.last_controller_check = now
964
-
965
- # Also consider "recently active" if activity was in last 2 minutes
966
- if self.state.last_activity:
967
- since_activity = (now - self.state.last_activity).total_seconds()
968
- if since_activity < 120:
969
- return True
970
-
971
- return False
972
- except subprocess.SubprocessError:
973
- # tmux command failed (timeout, not found, etc.) - assume not active
974
- return False
975
- except (OSError, IOError) as e:
976
- # File/process errors - log and assume not active
977
- self.log.warn(f"Error checking controller activity: {e}")
978
- return False
979
-
980
- def calculate_interval(self, sessions: list, non_green: list, all_waiting_user: bool) -> int:
981
- """Calculate appropriate loop interval based on current state"""
982
- # If user is active, stay fast
983
- if self.is_controller_active():
984
- return INTERVAL_FAST
985
-
986
- # No sessions at all - go idle
987
- if not sessions:
988
- return INTERVAL_IDLE
989
-
990
- # All sessions waiting for user - slow down
991
- if all_waiting_user and not self.is_daemon_claude_running():
992
- return INTERVAL_SLOW
993
-
994
- # Agents are working or daemon claude is active - stay fast
995
- return INTERVAL_FAST
996
-
997
- def run(self, check_interval: int = 10):
998
- """Main daemon loop with adaptive speed"""
999
- # Check if another daemon is already running (via PID file)
1000
- existing_pid = get_daemon_pid()
1001
- if existing_pid is not None and existing_pid != os.getpid():
1002
- self.log.error(f"Another daemon is already running (PID {existing_pid})")
1003
- self.log.info(f"Kill it with: kill {existing_pid}")
1004
- sys.exit(1)
1005
-
1006
- # Also check for orphaned daemon processes (not tracked in PID file)
1007
- try:
1008
- result = subprocess.run(
1009
- ["pgrep", "-f", "overcode.*daemon|overcode\\.daemon"],
1010
- capture_output=True, text=True, timeout=5
1011
- )
1012
- if result.returncode == 0:
1013
- other_pids = [int(p) for p in result.stdout.strip().split('\n') if p]
1014
- other_pids = [p for p in other_pids if p != os.getpid()]
1015
- if other_pids:
1016
- self.log.warn(f"Found orphaned daemon process(es): {other_pids}")
1017
- self.log.info(f"Kill them with: kill {' '.join(map(str, other_pids))}")
1018
- # Don't exit - just warn. Let user decide to kill orphans.
1019
- except (subprocess.SubprocessError, ValueError):
1020
- pass # pgrep failed, proceed anyway
1021
-
1022
- # Write PID file for process detection
1023
- _write_pid_file()
1024
- self.log.section("Overcode Daemon")
1025
- self.log.info(f"PID: {os.getpid()}")
1026
- self.log.info(f"Mode: {self.state.mode}")
1027
- self.log.info(f"Monitoring tmux session: {self.tmux_session}")
1028
- self.log.info(f"Base interval: {check_interval}s (adaptive)")
1029
-
1030
- self.state.started_at = datetime.now()
1031
- self.state.current_interval = check_interval
1032
- self.state.status = "active"
1033
- self.state.save()
1034
-
1035
- try:
1036
- while True:
1037
- self.state.loop_count += 1
1038
- self.state.last_loop_time = datetime.now()
1039
-
1040
- # Check for user activity signal from TUI
1041
- if check_activity_signal():
1042
- if self.state.current_interval != INTERVAL_FAST:
1043
- self.log.info("User activity detected → fast interval")
1044
- self.state.current_interval = INTERVAL_FAST
1045
- self.state.last_activity = datetime.now()
1046
-
1047
- # Cleanup any orphaned daemon claude windows from previous daemon runs
1048
- self.cleanup_stale_daemon_claudes()
1049
-
1050
- # Get all sessions and their statuses
1051
- sessions = self.session_manager.list_sessions()
1052
- non_green = self.get_non_green_sessions()
1053
-
1054
- # Log loop status with pretty formatting
1055
- green_count = len(sessions) - len(non_green)
1056
- self.log.status_summary(
1057
- total=len(sessions),
1058
- green=green_count,
1059
- non_green=len(non_green),
1060
- loop=self.state.loop_count
1061
- )
1062
-
1063
- # Log each agent's individual status and track stats
1064
- for session in sessions:
1065
- status, activity, _ = self.status_detector.detect_status(session)
1066
- self.log.agent_status(session.name, status, activity)
1067
- # Track state transitions and update interaction stats
1068
- self.track_session_stats(session, status)
1069
- # Also log to history file for timeline visualization
1070
- log_agent_status(session.name, status, activity)
1071
-
1072
- # Sync Claude Code stats periodically (every 6 loops = ~1 minute at fast interval)
1073
- if self.state.loop_count % 6 == 0:
1074
- for session in sessions:
1075
- self.sync_claude_code_stats(session)
1076
-
1077
- # Check if ALL non-green sessions are waiting for user
1078
- all_waiting_user = (
1079
- non_green and
1080
- all(status == STATUS_WAITING_USER for _, status in non_green)
1081
- )
1082
-
1083
- # Check if any session has standing instructions (daemon claude can help)
1084
- any_has_instructions = any(
1085
- session.standing_instructions
1086
- for session, _ in non_green
1087
- )
1088
-
1089
- if non_green:
1090
- # Skip daemon claude only if ALL waiting_user AND none have instructions
1091
- if all_waiting_user and not any_has_instructions:
1092
- self.state.status = "waiting"
1093
- self.log.warn("All sessions waiting for user input (no instructions set)")
1094
- elif self.state.mode == MODE_MONITOR:
1095
- # Monitor mode: track stats but never launch daemon claude
1096
- self.state.status = "monitoring"
1097
- else:
1098
- # Supervise mode: Launch daemon claude if not already running
1099
- if not self.is_daemon_claude_running():
1100
- reason = "with instructions" if any_has_instructions else "non-user-blocked"
1101
- self.log.info(f"Launching daemon claude for {len(non_green)} session(s) ({reason})...")
1102
- self.launch_daemon_claude(non_green)
1103
- self.state.daemon_claude_launches += 1
1104
- self.state.status = "supervising"
1105
- self.log.success(f"Daemon claude launched in window {self.daemon_claude_window}")
1106
-
1107
- # Wait for daemon claude to complete its task
1108
- if self.is_daemon_claude_running():
1109
- completed = self.wait_for_daemon_claude(timeout=300)
1110
-
1111
- # Capture final output
1112
- self.capture_daemon_claude_output()
1113
-
1114
- if completed:
1115
- # Only kill on successful completion
1116
- self.kill_daemon_claude()
1117
-
1118
- # Update intervention counts from supervisor log
1119
- sessions_handled = [session for session, _ in non_green]
1120
- self.update_intervention_counts(sessions_handled)
1121
- else:
1122
- # Timeout - let daemon claude keep working
1123
- # Don't kill it, just log and continue to next loop
1124
- self.log.warn("Daemon claude still working after 5 min, continuing...")
1125
- else:
1126
- if sessions:
1127
- self.state.status = "idle"
1128
- self.log.success("All sessions GREEN")
1129
- else:
1130
- self.state.status = "no_agents"
1131
-
1132
- # Calculate next interval
1133
- new_interval = self.calculate_interval(sessions, non_green, all_waiting_user)
1134
- if new_interval != self.state.current_interval:
1135
- interval_names = {
1136
- INTERVAL_FAST: "fast (10s)",
1137
- INTERVAL_SLOW: "slow (5m)",
1138
- INTERVAL_IDLE: "idle (1h)"
1139
- }
1140
- self.log.info(f"Loop speed → {interval_names.get(new_interval, f'{new_interval}s')}")
1141
- self.state.current_interval = new_interval
1142
-
1143
- # Save state for TUI
1144
- self.state.save()
1145
-
1146
- # Sleep in chunks, checking for activity signal periodically
1147
- self._interruptible_sleep(self.state.current_interval)
1148
-
1149
- except KeyboardInterrupt:
1150
- self.log.section("Shutting Down")
1151
- self.state.status = "stopped"
1152
- self.state.save()
1153
- _remove_pid_file()
1154
- self.log.info("Daemon stopped")
1155
- sys.exit(0)
1156
- finally:
1157
- # Ensure PID file is removed even on unexpected exit
1158
- _remove_pid_file()
1159
-
1160
-
1161
- def main():
1162
- import argparse
1163
-
1164
- parser = argparse.ArgumentParser(description="Overcode daemon")
1165
- parser.add_argument(
1166
- "--session",
1167
- default="agents",
1168
- help="Tmux session to monitor (default: agents)"
1169
- )
1170
- parser.add_argument(
1171
- "--interval",
1172
- type=int,
1173
- default=10,
1174
- help="Check interval in seconds (default: 10)"
1175
- )
1176
-
1177
- args = parser.parse_args()
1178
-
1179
- daemon = Daemon(args.session)
1180
- daemon.run(args.interval)
1181
-
1182
-
1183
- if __name__ == "__main__":
1184
- main()