overcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. overcode/__init__.py +5 -0
  2. overcode/cli.py +812 -0
  3. overcode/config.py +72 -0
  4. overcode/daemon.py +1184 -0
  5. overcode/daemon_claude_skill.md +180 -0
  6. overcode/daemon_state.py +113 -0
  7. overcode/data_export.py +257 -0
  8. overcode/dependency_check.py +227 -0
  9. overcode/exceptions.py +219 -0
  10. overcode/history_reader.py +448 -0
  11. overcode/implementations.py +214 -0
  12. overcode/interfaces.py +49 -0
  13. overcode/launcher.py +434 -0
  14. overcode/logging_config.py +193 -0
  15. overcode/mocks.py +152 -0
  16. overcode/monitor_daemon.py +808 -0
  17. overcode/monitor_daemon_state.py +358 -0
  18. overcode/pid_utils.py +225 -0
  19. overcode/presence_logger.py +454 -0
  20. overcode/protocols.py +143 -0
  21. overcode/session_manager.py +606 -0
  22. overcode/settings.py +412 -0
  23. overcode/standing_instructions.py +276 -0
  24. overcode/status_constants.py +190 -0
  25. overcode/status_detector.py +339 -0
  26. overcode/status_history.py +164 -0
  27. overcode/status_patterns.py +264 -0
  28. overcode/summarizer_client.py +136 -0
  29. overcode/summarizer_component.py +312 -0
  30. overcode/supervisor_daemon.py +1000 -0
  31. overcode/supervisor_layout.sh +50 -0
  32. overcode/tmux_manager.py +228 -0
  33. overcode/tui.py +2549 -0
  34. overcode/tui_helpers.py +495 -0
  35. overcode/web_api.py +279 -0
  36. overcode/web_server.py +138 -0
  37. overcode/web_templates.py +563 -0
  38. overcode-0.1.0.dist-info/METADATA +87 -0
  39. overcode-0.1.0.dist-info/RECORD +43 -0
  40. overcode-0.1.0.dist-info/WHEEL +5 -0
  41. overcode-0.1.0.dist-info/entry_points.txt +2 -0
  42. overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. overcode-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1000 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Supervisor Daemon - Claude orchestration for Overcode.
4
+
5
+ This daemon handles:
6
+ - Launching daemon claude when sessions need attention
7
+ - Waiting for daemon claude to complete
8
+ - Tracking interventions and steers
9
+
10
+ The Supervisor Daemon reads session status from the Monitor Daemon's
11
+ published state (MonitorDaemonState) rather than detecting status directly.
12
+
13
+ Prerequisites:
14
+ - Monitor Daemon must be running (publishes session state)
15
+
16
+ Architecture:
17
+ Monitor Daemon (metrics) → monitor_daemon_state.json → Supervisor Daemon (claude)
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from typing import Dict, List, Optional
30
+
31
+ from rich.console import Console
32
+ from rich.text import Text
33
+ from rich.theme import Theme
34
+
35
+ from .monitor_daemon_state import (
36
+ MonitorDaemonState,
37
+ SessionDaemonState,
38
+ get_monitor_daemon_state,
39
+ )
40
+ from .pid_utils import (
41
+ get_process_pid,
42
+ is_process_running,
43
+ remove_pid_file,
44
+ write_pid_file,
45
+ )
46
+ from .session_manager import SessionManager
47
+ from .settings import (
48
+ DAEMON,
49
+ PATHS,
50
+ ensure_session_dir,
51
+ get_supervisor_daemon_pid_path,
52
+ get_supervisor_log_path,
53
+ get_supervisor_stats_path,
54
+ )
55
+ from .status_constants import (
56
+ STATUS_RUNNING,
57
+ STATUS_WAITING_USER,
58
+ get_status_emoji,
59
+ )
60
+ from .tmux_manager import TmuxManager
61
+ from .history_reader import encode_project_path, read_token_usage_from_session_file
62
+
63
+
64
+ @dataclass
65
+ class SupervisorStats:
66
+ """Persistent stats for supervisor daemon token tracking.
67
+
68
+ Tracks cumulative tokens used by daemon claude across restarts.
69
+ """
70
+
71
+ supervisor_launches: int = 0
72
+ supervisor_tokens: int = 0 # input + output
73
+ supervisor_input_tokens: int = 0
74
+ supervisor_output_tokens: int = 0
75
+ supervisor_cache_tokens: int = 0 # creation + read
76
+ last_sync_time: Optional[str] = None
77
+ seen_session_ids: List[str] = field(default_factory=list)
78
+
79
+ # Daemon Claude run tracking
80
+ supervisor_claude_running: bool = False
81
+ supervisor_claude_started_at: Optional[str] = None # ISO timestamp
82
+ supervisor_claude_total_run_seconds: float = 0.0 # Cumulative run time
83
+
84
+ def to_dict(self) -> dict:
85
+ """Convert to dictionary for JSON serialization."""
86
+ return {
87
+ "supervisor_launches": self.supervisor_launches,
88
+ "supervisor_tokens": self.supervisor_tokens,
89
+ "supervisor_input_tokens": self.supervisor_input_tokens,
90
+ "supervisor_output_tokens": self.supervisor_output_tokens,
91
+ "supervisor_cache_tokens": self.supervisor_cache_tokens,
92
+ "last_sync_time": self.last_sync_time,
93
+ "seen_session_ids": self.seen_session_ids,
94
+ "supervisor_claude_running": self.supervisor_claude_running,
95
+ "supervisor_claude_started_at": self.supervisor_claude_started_at,
96
+ "supervisor_claude_total_run_seconds": self.supervisor_claude_total_run_seconds,
97
+ }
98
+
99
+ @classmethod
100
+ def from_dict(cls, data: dict) -> "SupervisorStats":
101
+ """Create from dictionary."""
102
+ return cls(
103
+ supervisor_launches=data.get("supervisor_launches", 0),
104
+ supervisor_tokens=data.get("supervisor_tokens", 0),
105
+ supervisor_input_tokens=data.get("supervisor_input_tokens", 0),
106
+ supervisor_output_tokens=data.get("supervisor_output_tokens", 0),
107
+ supervisor_cache_tokens=data.get("supervisor_cache_tokens", 0),
108
+ last_sync_time=data.get("last_sync_time"),
109
+ seen_session_ids=data.get("seen_session_ids", []),
110
+ supervisor_claude_running=data.get("supervisor_claude_running", False),
111
+ supervisor_claude_started_at=data.get("supervisor_claude_started_at"),
112
+ supervisor_claude_total_run_seconds=data.get("supervisor_claude_total_run_seconds", 0.0),
113
+ )
114
+
115
+ def save(self, path: Path) -> None:
116
+ """Save stats to file."""
117
+ path.parent.mkdir(parents=True, exist_ok=True)
118
+ with open(path, 'w') as f:
119
+ json.dump(self.to_dict(), f, indent=2)
120
+
121
+ @classmethod
122
+ def load(cls, path: Path) -> "SupervisorStats":
123
+ """Load stats from file, returns empty stats if file doesn't exist."""
124
+ if not path.exists():
125
+ return cls()
126
+ try:
127
+ with open(path) as f:
128
+ data = json.load(f)
129
+ return cls.from_dict(data)
130
+ except (json.JSONDecodeError, KeyError, ValueError, TypeError):
131
+ return cls()
132
+
133
+
134
+ # Rich theme for supervisor logs
135
+ SUPERVISOR_THEME = Theme({
136
+ "info": "cyan",
137
+ "warn": "yellow",
138
+ "error": "bold red",
139
+ "success": "bold green",
140
+ "daemon_claude": "magenta",
141
+ "dim": "dim white",
142
+ "highlight": "bold white",
143
+ })
144
+
145
+
146
+ class SupervisorLogger:
147
+ """Rich-based logger for supervisor daemon."""
148
+
149
+ def __init__(self, log_file: Path = None):
150
+ self.log_file = log_file or PATHS.supervisor_log
151
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
152
+ self.console = Console(theme=SUPERVISOR_THEME, force_terminal=True)
153
+ self._seen_daemon_claude_lines: set = set()
154
+
155
+ def _write_to_file(self, message: str, level: str):
156
+ """Write plain text to log file."""
157
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
158
+ line = f"[{timestamp}] [{level}] {message}"
159
+ with open(self.log_file, 'a') as f:
160
+ f.write(line + '\n')
161
+
162
+ def info(self, message: str):
163
+ """Log info message."""
164
+ self._write_to_file(message, "INFO")
165
+ self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] [info]INFO[/info] {message}")
166
+
167
+ def warn(self, message: str):
168
+ """Log warning message."""
169
+ self._write_to_file(message, "WARN")
170
+ self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] [warn]WARN[/warn] {message}")
171
+
172
+ def error(self, message: str):
173
+ """Log error message."""
174
+ self._write_to_file(message, "ERROR")
175
+ self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] [error]ERROR[/error] {message}")
176
+
177
+ def success(self, message: str):
178
+ """Log success message."""
179
+ self._write_to_file(message, "INFO")
180
+ self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] [success]OK[/success] {message}")
181
+
182
+ def daemon_claude_output(self, lines: List[str]):
183
+ """Log daemon claude output, showing only new lines."""
184
+ new_lines = []
185
+
186
+ for line in lines:
187
+ stripped = line.strip()
188
+ if not stripped:
189
+ continue
190
+ if stripped not in self._seen_daemon_claude_lines:
191
+ new_lines.append(stripped)
192
+ self._seen_daemon_claude_lines.add(stripped)
193
+
194
+ # Limit set size
195
+ if len(self._seen_daemon_claude_lines) > 500:
196
+ current_lines = {line.strip() for line in lines if line.strip()}
197
+ self._seen_daemon_claude_lines = current_lines
198
+
199
+ if new_lines:
200
+ for line in new_lines:
201
+ self._write_to_file(f"[DAEMON_CLAUDE] {line}", "INFO")
202
+ if line.startswith('✓') or 'success' in line.lower():
203
+ self.console.print(f" [success]│[/success] {line}")
204
+ elif line.startswith('✗') or 'error' in line.lower() or 'fail' in line.lower():
205
+ self.console.print(f" [error]│[/error] {line}")
206
+ elif line.startswith('>') or line.startswith('$'):
207
+ self.console.print(f" [highlight]│[/highlight] {line}")
208
+ else:
209
+ self.console.print(f" [daemon_claude]│[/daemon_claude] {line}")
210
+
211
+ def section(self, title: str):
212
+ """Print a section divider."""
213
+ self._write_to_file(f"=== {title} ===", "INFO")
214
+ self.console.print()
215
+ self.console.rule(f"[bold]{title}[/bold]", style="dim")
216
+
217
+ def status_summary(self, total: int, green: int, non_green: int, loop: int):
218
+ """Print a status summary line."""
219
+ status_text = Text()
220
+ status_text.append(f"Loop #{loop}: ", style="dim")
221
+ status_text.append(f"{total} agents ", style="highlight")
222
+ status_text.append("(", style="dim")
223
+ status_text.append(f"{green} green", style="success")
224
+ status_text.append(", ", style="dim")
225
+ status_text.append(f"{non_green} non-green", style="warn" if non_green else "dim")
226
+ status_text.append(")", style="dim")
227
+
228
+ self._write_to_file(f"Loop #{loop}: {total} agents ({green} green, {non_green} non-green)", "INFO")
229
+ self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] ", end="")
230
+ self.console.print(status_text)
231
+
232
+
233
+ class SupervisorDaemon:
234
+ """Background daemon that orchestrates daemon claude for non-green sessions.
235
+
236
+ The Supervisor Daemon reads session state from the Monitor Daemon's published
237
+ interface (MonitorDaemonState) and launches daemon claude when needed.
238
+ """
239
+
240
+ DAEMON_CLAUDE_WINDOW_NAME = "_daemon_claude"
241
+
242
+ def __init__(
243
+ self,
244
+ tmux_session: str = None,
245
+ session_manager: SessionManager = None,
246
+ tmux_manager: TmuxManager = None,
247
+ logger: SupervisorLogger = None,
248
+ ):
249
+ """Initialize the supervisor daemon.
250
+
251
+ Args:
252
+ tmux_session: Name of the tmux session to manage
253
+ session_manager: Optional SessionManager for dependency injection
254
+ tmux_manager: Optional TmuxManager for dependency injection
255
+ logger: Optional SupervisorLogger for dependency injection
256
+ """
257
+ self.tmux_session = tmux_session or DAEMON.default_tmux_session
258
+ self.session_manager = session_manager or SessionManager()
259
+ self.tmux = tmux_manager or TmuxManager(self.tmux_session)
260
+
261
+ # Ensure session directory exists
262
+ ensure_session_dir(self.tmux_session)
263
+
264
+ # Session-specific paths
265
+ self.pid_path = get_supervisor_daemon_pid_path(self.tmux_session)
266
+ self.stats_path = get_supervisor_stats_path(self.tmux_session)
267
+ self.log_path = get_supervisor_log_path(self.tmux_session)
268
+
269
+ # Logger with session-specific log file
270
+ self.log = logger or SupervisorLogger(log_file=self.log_path)
271
+
272
+ # Load persistent supervisor stats
273
+ self.supervisor_stats = SupervisorStats.load(self.stats_path)
274
+
275
+ # Daemon claude tracking
276
+ self.daemon_claude_window: Optional[int] = None
277
+ self.daemon_claude_launch_time: Optional[datetime] = None
278
+
279
+ # State tracking
280
+ self.loop_count = 0
281
+ self.daemon_claude_launches = 0
282
+ self.status = "starting"
283
+ self.started_at: Optional[datetime] = None
284
+
285
+ # =========================================================================
286
+ # Daemon Claude Management
287
+ # =========================================================================
288
+
289
+ def is_daemon_claude_running(self) -> bool:
290
+ """Check if daemon claude is still running."""
291
+ if self.daemon_claude_window is None:
292
+ return False
293
+ return self.tmux.window_exists(self.daemon_claude_window)
294
+
295
+ def is_daemon_claude_done(self) -> bool:
296
+ """Check if daemon claude has finished its task.
297
+
298
+ Returns True if:
299
+ - Window doesn't exist (closed/crashed)
300
+ - Window shows empty prompt AND no active work indicators
301
+ """
302
+ if not self.is_daemon_claude_running():
303
+ return True
304
+
305
+ try:
306
+ result = subprocess.run(
307
+ [
308
+ "tmux", "capture-pane",
309
+ "-t", f"{self.tmux_session}:{self.daemon_claude_window}",
310
+ "-p",
311
+ "-S", "-30",
312
+ ],
313
+ capture_output=True,
314
+ text=True,
315
+ timeout=5
316
+ )
317
+
318
+ if result.returncode != 0:
319
+ return True
320
+
321
+ content = result.stdout
322
+
323
+ # Active work indicators
324
+ active_indicators = [
325
+ '· ',
326
+ 'Running…',
327
+ '(esc to interrupt',
328
+ '✽',
329
+ ]
330
+ for indicator in active_indicators:
331
+ if indicator in content:
332
+ return False
333
+
334
+ # Check for tool calls without results
335
+ lines = content.split('\n')
336
+ for i, line in enumerate(lines):
337
+ if '⏺' in line and '(' in line:
338
+ remaining = '\n'.join(lines[i+1:])
339
+ if '⎿' not in remaining:
340
+ return False
341
+
342
+ # Check for empty prompt
343
+ last_lines = [l.strip() for l in lines[-8:] if l.strip()]
344
+ for line in last_lines:
345
+ if line == '>' or line == '›':
346
+ return True
347
+
348
+ return False
349
+
350
+ except subprocess.TimeoutExpired:
351
+ return False
352
+ except subprocess.SubprocessError:
353
+ return False
354
+
355
+ def _has_daemon_claude_started(self) -> bool:
356
+ """Check if daemon claude has started working."""
357
+ if not self.is_daemon_claude_running():
358
+ return False
359
+
360
+ try:
361
+ result = subprocess.run(
362
+ [
363
+ "tmux", "capture-pane",
364
+ "-t", f"{self.tmux_session}:{self.daemon_claude_window}",
365
+ "-p",
366
+ "-S", "-30",
367
+ ],
368
+ capture_output=True,
369
+ text=True,
370
+ timeout=5
371
+ )
372
+
373
+ if result.returncode != 0:
374
+ return False
375
+
376
+ content = result.stdout
377
+ activity_indicators = ['⏺', 'Read(', 'Write(', 'Edit(', 'Bash(', 'Grep(', 'Glob(']
378
+ for indicator in activity_indicators:
379
+ if indicator in content:
380
+ return True
381
+
382
+ return False
383
+
384
+ except subprocess.SubprocessError:
385
+ return False
386
+
387
+ def wait_for_daemon_claude(
388
+ self,
389
+ timeout: int = None,
390
+ poll_interval: int = None
391
+ ) -> bool:
392
+ """Wait for daemon claude to complete its task.
393
+
394
+ Args:
395
+ timeout: Max seconds to wait (default from settings)
396
+ poll_interval: Seconds between checks (default from settings)
397
+
398
+ Returns:
399
+ True if daemon claude completed, False if timed out
400
+ """
401
+ timeout = timeout or DAEMON.daemon_claude_timeout
402
+ poll_interval = poll_interval or DAEMON.daemon_claude_poll
403
+
404
+ if not self.is_daemon_claude_running():
405
+ return True
406
+
407
+ self.log.info(f"Waiting for daemon claude to complete (timeout {timeout}s)...")
408
+ start_time = time.time()
409
+ has_seen_activity = False
410
+
411
+ while time.time() - start_time < timeout:
412
+ self.capture_daemon_claude_output()
413
+
414
+ if not has_seen_activity:
415
+ has_seen_activity = self._has_daemon_claude_started()
416
+ if has_seen_activity:
417
+ self.log.info("Daemon claude started working...")
418
+
419
+ if has_seen_activity and self.is_daemon_claude_done():
420
+ elapsed = int(time.time() - start_time)
421
+ self.log.success(f"Daemon claude completed in {elapsed}s")
422
+ return True
423
+
424
+ time.sleep(poll_interval)
425
+
426
+ self.log.warn(f"Daemon claude timed out after {timeout}s")
427
+ return False
428
+
429
+ def kill_daemon_claude(self) -> None:
430
+ """Kill daemon claude window if it exists."""
431
+ if self.daemon_claude_window is not None and self.tmux.window_exists(self.daemon_claude_window):
432
+ self.log.info(f"Killing daemon claude window {self.daemon_claude_window}")
433
+ self.tmux.kill_window(self.daemon_claude_window)
434
+ self.daemon_claude_window = None
435
+
436
+ def cleanup_stale_daemon_claudes(self) -> None:
437
+ """Clean up any orphaned daemon claude windows."""
438
+ if self.daemon_claude_window is not None and not self.tmux.window_exists(self.daemon_claude_window):
439
+ self.log.info(f"Daemon claude window {self.daemon_claude_window} no longer exists")
440
+ self.daemon_claude_window = None
441
+
442
+ windows = self.tmux.list_windows()
443
+ for window in windows:
444
+ if window['name'] == self.DAEMON_CLAUDE_WINDOW_NAME:
445
+ window_idx = int(window['index'])
446
+ if self.daemon_claude_window != window_idx:
447
+ self.log.info(f"Killing orphaned daemon claude window {window_idx}")
448
+ self.tmux.kill_window(window_idx)
449
+
450
+ def capture_daemon_claude_output(self) -> None:
451
+ """Capture and log output from daemon claude window."""
452
+ if not self.is_daemon_claude_running():
453
+ return
454
+
455
+ try:
456
+ result = subprocess.run(
457
+ [
458
+ "tmux", "capture-pane",
459
+ "-t", f"{self.tmux_session}:{self.daemon_claude_window}",
460
+ "-p",
461
+ "-S", "-50",
462
+ ],
463
+ capture_output=True,
464
+ text=True,
465
+ timeout=5
466
+ )
467
+
468
+ if result.returncode == 0:
469
+ lines = [line for line in result.stdout.split('\n') if line.strip()]
470
+ if lines:
471
+ self.log.daemon_claude_output(lines)
472
+
473
+ except subprocess.SubprocessError:
474
+ pass
475
+
476
+ # =========================================================================
477
+ # Intervention Tracking
478
+ # =========================================================================
479
+
480
+ def count_interventions_from_log(self, session_names: List[str]) -> Dict[str, int]:
481
+ """Count interventions per session from supervisor log since daemon claude launch.
482
+
483
+ Args:
484
+ session_names: List of session names to check for
485
+
486
+ Returns:
487
+ Dict mapping session name to intervention count
488
+ """
489
+ if not self.daemon_claude_launch_time:
490
+ return {}
491
+
492
+ log_path = self.log_path
493
+ if not log_path.exists():
494
+ return {}
495
+
496
+ counts: Dict[str, int] = {}
497
+ session_set = set(session_names)
498
+
499
+ action_phrases = [
500
+ "approved",
501
+ "rejected",
502
+ "sent ",
503
+ "provided",
504
+ "unblocked",
505
+ ]
506
+
507
+ no_action_phrases = [
508
+ "no intervention needed",
509
+ "no action needed",
510
+ ]
511
+
512
+ try:
513
+ with open(log_path, 'r') as f:
514
+ for line in f:
515
+ line = line.strip()
516
+ if not line:
517
+ continue
518
+
519
+ try:
520
+ if ": " not in line:
521
+ continue
522
+ timestamp_part = line.split(": ")[0]
523
+ entry_time = None
524
+ for fmt in ["%a %d %b %Y %H:%M:%S %Z", "%a %d %b %Y %H:%M:%S %Z"]:
525
+ try:
526
+ entry_time = datetime.strptime(timestamp_part.strip(), fmt)
527
+ break
528
+ except ValueError:
529
+ continue
530
+ if entry_time is None:
531
+ continue
532
+ except (ValueError, IndexError):
533
+ continue
534
+
535
+ if entry_time < self.daemon_claude_launch_time:
536
+ continue
537
+
538
+ for name in session_set:
539
+ if f"{name} - " in line:
540
+ line_lower = line.lower()
541
+ if any(phrase in line_lower for phrase in no_action_phrases):
542
+ break
543
+ if any(phrase in line_lower for phrase in action_phrases):
544
+ counts[name] = counts.get(name, 0) + 1
545
+ break
546
+
547
+ except IOError:
548
+ pass
549
+
550
+ return counts
551
+
552
+ def update_intervention_counts(self, session_names: List[str]) -> None:
553
+ """Update steers_count for sessions based on supervisor log interventions."""
554
+ counts = self.count_interventions_from_log(session_names)
555
+ if not counts:
556
+ return
557
+
558
+ sessions = self.session_manager.list_sessions()
559
+ session_by_name = {s.name: s for s in sessions}
560
+
561
+ for name, intervention_count in counts.items():
562
+ if name in session_by_name:
563
+ session = session_by_name[name]
564
+ current_stats = session.stats
565
+ self.session_manager.update_stats(
566
+ session.id,
567
+ steers_count=current_stats.steers_count + intervention_count,
568
+ )
569
+ self.log.info(f"[{name}] +{intervention_count} daemon interventions")
570
+
571
+ def _mark_daemon_claude_stopped(self) -> None:
572
+ """Mark daemon claude as stopped and accumulate run time."""
573
+ if self.supervisor_stats.supervisor_claude_running:
574
+ # Calculate run duration
575
+ if self.supervisor_stats.supervisor_claude_started_at:
576
+ try:
577
+ started_at = datetime.fromisoformat(self.supervisor_stats.supervisor_claude_started_at)
578
+ run_seconds = (datetime.now() - started_at).total_seconds()
579
+ self.supervisor_stats.supervisor_claude_total_run_seconds += run_seconds
580
+ except (ValueError, TypeError):
581
+ pass
582
+
583
+ self.supervisor_stats.supervisor_claude_running = False
584
+ self.supervisor_stats.supervisor_claude_started_at = None
585
+ self.supervisor_stats.save(self.stats_path)
586
+
587
+ def _sync_daemon_claude_tokens(self) -> None:
588
+ """Sync token usage from daemon claude's Claude Code history.
589
+
590
+ Reads token usage from Claude Code's session files for the ~/.overcode/
591
+ working directory and updates the persistent supervisor stats.
592
+ """
593
+ overcode_dir = Path.home() / ".overcode"
594
+ encoded_path = encode_project_path(str(overcode_dir))
595
+ projects_dir = Path.home() / ".claude" / "projects" / encoded_path
596
+
597
+ if not projects_dir.exists():
598
+ return
599
+
600
+ now = datetime.now()
601
+
602
+ # Find all session files
603
+ try:
604
+ session_files = list(projects_dir.glob("*.jsonl"))
605
+ except OSError:
606
+ return
607
+
608
+ new_tokens = 0
609
+ new_input = 0
610
+ new_output = 0
611
+ new_cache = 0
612
+ new_sessions = []
613
+
614
+ for session_file in session_files:
615
+ session_id = session_file.stem
616
+ if session_id in self.supervisor_stats.seen_session_ids:
617
+ continue
618
+
619
+ # Read tokens from this new session
620
+ try:
621
+ usage = read_token_usage_from_session_file(session_file)
622
+ input_tokens = usage.get("input_tokens", 0)
623
+ output_tokens = usage.get("output_tokens", 0)
624
+ cache_creation = usage.get("cache_creation_tokens", 0)
625
+ cache_read = usage.get("cache_read_tokens", 0)
626
+
627
+ new_input += input_tokens
628
+ new_output += output_tokens
629
+ new_cache += cache_creation + cache_read
630
+ new_tokens += input_tokens + output_tokens
631
+ new_sessions.append(session_id)
632
+
633
+ except (OSError, IOError, ValueError):
634
+ continue
635
+
636
+ if new_tokens > 0:
637
+ self.supervisor_stats.supervisor_input_tokens += new_input
638
+ self.supervisor_stats.supervisor_output_tokens += new_output
639
+ self.supervisor_stats.supervisor_cache_tokens += new_cache
640
+ self.supervisor_stats.supervisor_tokens += new_tokens
641
+ self.supervisor_stats.seen_session_ids.extend(new_sessions)
642
+ self.supervisor_stats.last_sync_time = now.isoformat()
643
+ self.supervisor_stats.save(self.stats_path)
644
+
645
+ self.log.info(f"Daemon claude tokens: +{new_tokens} ({new_input} in, {new_output} out)")
646
+
647
+ # =========================================================================
648
+ # Daemon Claude Launch
649
+ # =========================================================================
650
+
651
+ def build_daemon_claude_context(
652
+ self,
653
+ non_green_sessions: List[SessionDaemonState]
654
+ ) -> str:
655
+ """Build initial context for daemon claude."""
656
+ context_parts = []
657
+
658
+ context_parts.append("You are the Overcode daemon claude agent.")
659
+ context_parts.append("Your mission: Make all RED/YELLOW/ORANGE sessions GREEN.")
660
+ context_parts.append("")
661
+ context_parts.append(f"TMUX SESSION: {self.tmux_session}")
662
+ context_parts.append(f"Sessions needing attention: {len(non_green_sessions)}")
663
+ context_parts.append("")
664
+
665
+ for session in non_green_sessions:
666
+ emoji = get_status_emoji(session.current_status)
667
+ context_parts.append(f"{emoji} {session.name} (window {session.tmux_window})")
668
+ if session.standing_instructions:
669
+ context_parts.append(f" Autopilot: {session.standing_instructions}")
670
+ else:
671
+ context_parts.append(f" No autopilot instructions set")
672
+ if session.repo_name:
673
+ context_parts.append(f" Repo: {session.repo_name}")
674
+ context_parts.append("")
675
+
676
+ context_parts.append("Read the daemon claude skill for how to control sessions via tmux.")
677
+ context_parts.append("Start by reading ~/.overcode/sessions/sessions.json to see full state.")
678
+ context_parts.append("Then check each non-green session and help them make progress.")
679
+
680
+ return "\n".join(context_parts)
681
+
682
+ def _send_prompt_to_window(self, window_index: int, prompt: str) -> bool:
683
+ """Send a large prompt to a tmux window via load-buffer/paste-buffer."""
684
+ lines = prompt.split('\n')
685
+ batch_size = 10
686
+
687
+ for i in range(0, len(lines), batch_size):
688
+ batch = lines[i:i + batch_size]
689
+ text = '\n'.join(batch)
690
+ if i + batch_size < len(lines):
691
+ text += '\n'
692
+
693
+ try:
694
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
695
+ temp_path = f.name
696
+ f.write(text)
697
+
698
+ subprocess.run(['tmux', 'load-buffer', temp_path], timeout=5, check=True)
699
+ subprocess.run([
700
+ 'tmux', 'paste-buffer', '-t',
701
+ f"{self.tmux.session_name}:{window_index}"
702
+ ], timeout=5, check=True)
703
+ except subprocess.SubprocessError as e:
704
+ self.log.error(f"Failed to send prompt batch: {e}")
705
+ return False
706
+ finally:
707
+ try:
708
+ os.unlink(temp_path)
709
+ except OSError:
710
+ pass
711
+
712
+ time.sleep(0.1)
713
+
714
+ # Send Enter to submit
715
+ subprocess.run([
716
+ 'tmux', 'send-keys', '-t',
717
+ f"{self.tmux.session_name}:{window_index}",
718
+ '', 'Enter'
719
+ ])
720
+
721
+ return True
722
+
723
+ def launch_daemon_claude(self, non_green_sessions: List[SessionDaemonState]) -> bool:
724
+ """Launch daemon claude to handle non-green sessions.
725
+
726
+ Returns:
727
+ True if launched successfully, False otherwise
728
+ """
729
+ context = self.build_daemon_claude_context(non_green_sessions)
730
+
731
+ # Get skill content
732
+ skill_path = Path(__file__).parent / "daemon_claude_skill.md"
733
+ try:
734
+ with open(skill_path) as f:
735
+ skill_content = f.read()
736
+ except IOError as e:
737
+ self.log.error(f"Failed to read daemon claude skill: {e}")
738
+ return False
739
+
740
+ full_prompt = f"{skill_content}\n\n---\n\n{context}"
741
+
742
+ # Ensure tmux session exists
743
+ if not self.tmux.ensure_session():
744
+ self.log.error(f"Failed to create tmux session '{self.tmux.session_name}'")
745
+ return False
746
+
747
+ # Create window
748
+ window_index = self.tmux.create_window(
749
+ self.DAEMON_CLAUDE_WINDOW_NAME,
750
+ str(Path.home() / '.overcode')
751
+ )
752
+ if window_index is None:
753
+ self.log.error("Failed to create daemon claude window")
754
+ return False
755
+
756
+ self.daemon_claude_window = window_index
757
+ self.daemon_claude_launch_time = datetime.now()
758
+
759
+ # Start Claude with auto-permissions
760
+ claude_cmd = "claude code --dangerously-skip-permissions"
761
+ if not self.tmux.send_keys(window_index, claude_cmd, enter=True):
762
+ self.log.error("Failed to start Claude in daemon claude window")
763
+ return False
764
+
765
+ # Wait for Claude startup
766
+ time.sleep(3.0)
767
+
768
+ # Send prompt
769
+ return self._send_prompt_to_window(window_index, full_prompt)
770
+
771
+ # =========================================================================
772
+ # Main Loop
773
+ # =========================================================================
774
+
775
+ def get_non_green_sessions(
776
+ self,
777
+ monitor_state: MonitorDaemonState
778
+ ) -> List[SessionDaemonState]:
779
+ """Get sessions that are not in running state from monitor daemon state."""
780
+ return [
781
+ s for s in monitor_state.sessions
782
+ if s.current_status != STATUS_RUNNING and s.name != 'daemon_claude'
783
+ ]
784
+
785
+ def wait_for_monitor_daemon(self, timeout: int = 30, poll_interval: int = 2) -> bool:
786
+ """Wait for monitor daemon to be running.
787
+
788
+ Args:
789
+ timeout: Max seconds to wait
790
+ poll_interval: Seconds between checks
791
+
792
+ Returns:
793
+ True if monitor daemon is running, False if timed out
794
+ """
795
+ start_time = time.time()
796
+ while time.time() - start_time < timeout:
797
+ state = get_monitor_daemon_state(self.tmux_session)
798
+ if state is not None and not state.is_stale():
799
+ return True
800
+ time.sleep(poll_interval)
801
+ return False
802
+
803
+ def run(self, check_interval: int = None):
804
+ """Main supervisor daemon loop.
805
+
806
+ Args:
807
+ check_interval: Override check interval (default from settings)
808
+ """
809
+ check_interval = check_interval or DAEMON.interval_fast
810
+
811
+ # Check for existing supervisor daemon
812
+ existing_pid = get_process_pid(self.pid_path)
813
+ if existing_pid is not None and existing_pid != os.getpid():
814
+ self.log.error(f"Another supervisor daemon is running (PID {existing_pid})")
815
+ sys.exit(1)
816
+
817
+ # Write PID file
818
+ write_pid_file(self.pid_path)
819
+
820
+ self.log.section("Supervisor Daemon")
821
+ self.log.info(f"PID: {os.getpid()}")
822
+ self.log.info(f"Tmux session: {self.tmux_session}")
823
+ self.log.info(f"Check interval: {check_interval}s")
824
+
825
+ # Wait for monitor daemon
826
+ self.log.info("Waiting for Monitor Daemon...")
827
+ if not self.wait_for_monitor_daemon():
828
+ self.log.error("Monitor Daemon not running. Start it first with: overcode monitor-daemon start")
829
+ remove_pid_file(self.pid_path)
830
+ sys.exit(1)
831
+ self.log.success("Monitor Daemon connected")
832
+
833
+ self.started_at = datetime.now()
834
+ self.status = "active"
835
+
836
+ try:
837
+ while True:
838
+ self.loop_count += 1
839
+
840
+ # Cleanup orphaned daemon claudes
841
+ self.cleanup_stale_daemon_claudes()
842
+
843
+ # Read state from monitor daemon
844
+ monitor_state = get_monitor_daemon_state(self.tmux_session)
845
+ if monitor_state is None or monitor_state.is_stale():
846
+ self.log.warn("Monitor Daemon state stale, waiting...")
847
+ self.status = "waiting_monitor"
848
+ time.sleep(check_interval)
849
+ continue
850
+
851
+ # Get non-green sessions
852
+ non_green = self.get_non_green_sessions(monitor_state)
853
+ total = len(monitor_state.sessions)
854
+ green_count = total - len(non_green)
855
+
856
+ self.log.status_summary(
857
+ total=total,
858
+ green=green_count,
859
+ non_green=len(non_green),
860
+ loop=self.loop_count
861
+ )
862
+
863
+ # Check if all non-green are waiting for user
864
+ all_waiting_user = (
865
+ non_green and
866
+ all(s.current_status == STATUS_WAITING_USER for s in non_green)
867
+ )
868
+
869
+ # Check if any have standing instructions
870
+ any_has_instructions = any(
871
+ s.standing_instructions for s in non_green
872
+ )
873
+
874
+ if non_green:
875
+ if all_waiting_user and not any_has_instructions:
876
+ self.status = "waiting_user"
877
+ self.log.warn("All sessions waiting for user input (no instructions)")
878
+ else:
879
+ # Launch daemon claude if not running
880
+ if not self.is_daemon_claude_running():
881
+ reason = "with instructions" if any_has_instructions else "non-user-blocked"
882
+ self.log.info(f"Launching daemon claude for {len(non_green)} session(s) ({reason})...")
883
+ if self.launch_daemon_claude(non_green):
884
+ self.daemon_claude_launches += 1
885
+ self.supervisor_stats.supervisor_launches += 1
886
+ # Track daemon claude run start
887
+ self.supervisor_stats.supervisor_claude_running = True
888
+ self.supervisor_stats.supervisor_claude_started_at = datetime.now().isoformat()
889
+ self.supervisor_stats.save(self.stats_path)
890
+ self.status = "supervising"
891
+ self.log.success(f"Daemon claude launched in window {self.daemon_claude_window}")
892
+
893
+ # Wait for daemon claude
894
+ if self.is_daemon_claude_running():
895
+ completed = self.wait_for_daemon_claude()
896
+ self.capture_daemon_claude_output()
897
+
898
+ # Track daemon claude run end
899
+ self._mark_daemon_claude_stopped()
900
+
901
+ if completed:
902
+ self.kill_daemon_claude()
903
+ session_names = [s.name for s in non_green]
904
+ self.update_intervention_counts(session_names)
905
+ self._sync_daemon_claude_tokens()
906
+ else:
907
+ self.log.warn("Daemon claude still working, continuing...")
908
+ else:
909
+ if total > 0:
910
+ self.status = "idle"
911
+ self.log.success("All sessions GREEN")
912
+ else:
913
+ self.status = "no_agents"
914
+
915
+ time.sleep(check_interval)
916
+
917
+ except KeyboardInterrupt:
918
+ self.log.section("Shutting Down")
919
+ self.status = "stopped"
920
+ remove_pid_file(self.pid_path)
921
+ self.log.info("Supervisor daemon stopped")
922
+ sys.exit(0)
923
+ finally:
924
+ remove_pid_file(self.pid_path)
925
+
926
+
927
+ def is_supervisor_daemon_running(session: str = None) -> bool:
928
+ """Check if the supervisor daemon is running for a session.
929
+
930
+ Args:
931
+ session: tmux session name (default: from config)
932
+ """
933
+ if session is None:
934
+ session = DAEMON.default_tmux_session
935
+ return is_process_running(get_supervisor_daemon_pid_path(session))
936
+
937
+
938
+ def get_supervisor_daemon_pid(session: str = None) -> Optional[int]:
939
+ """Get the supervisor daemon PID if running.
940
+
941
+ Args:
942
+ session: tmux session name (default: from config)
943
+ """
944
+ if session is None:
945
+ session = DAEMON.default_tmux_session
946
+ return get_process_pid(get_supervisor_daemon_pid_path(session))
947
+
948
+
949
+ def stop_supervisor_daemon(session: str = None) -> bool:
950
+ """Stop the supervisor daemon if running.
951
+
952
+ Args:
953
+ session: tmux session name (default: from config)
954
+
955
+ Returns:
956
+ True if daemon was stopped, False if it wasn't running.
957
+ """
958
+ import signal
959
+
960
+ if session is None:
961
+ session = DAEMON.default_tmux_session
962
+ pid_path = get_supervisor_daemon_pid_path(session)
963
+ pid = get_process_pid(pid_path)
964
+ if pid is None:
965
+ remove_pid_file(pid_path)
966
+ return False
967
+
968
+ try:
969
+ os.kill(pid, signal.SIGTERM)
970
+ remove_pid_file(pid_path)
971
+ return True
972
+ except (OSError, ProcessLookupError):
973
+ remove_pid_file(pid_path)
974
+ return False
975
+
976
+
977
+ def main():
978
+ import argparse
979
+
980
+ parser = argparse.ArgumentParser(description="Overcode Supervisor Daemon")
981
+ parser.add_argument(
982
+ "--session",
983
+ default=None,
984
+ help=f"Tmux session to manage (default: {DAEMON.default_tmux_session})"
985
+ )
986
+ parser.add_argument(
987
+ "--interval",
988
+ type=int,
989
+ default=None,
990
+ help=f"Check interval in seconds (default: {DAEMON.interval_fast})"
991
+ )
992
+
993
+ args = parser.parse_args()
994
+
995
+ daemon = SupervisorDaemon(tmux_session=args.session)
996
+ daemon.run(check_interval=args.interval)
997
+
998
+
999
+ if __name__ == "__main__":
1000
+ main()