overcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overcode/__init__.py +5 -0
- overcode/cli.py +812 -0
- overcode/config.py +72 -0
- overcode/daemon.py +1184 -0
- overcode/daemon_claude_skill.md +180 -0
- overcode/daemon_state.py +113 -0
- overcode/data_export.py +257 -0
- overcode/dependency_check.py +227 -0
- overcode/exceptions.py +219 -0
- overcode/history_reader.py +448 -0
- overcode/implementations.py +214 -0
- overcode/interfaces.py +49 -0
- overcode/launcher.py +434 -0
- overcode/logging_config.py +193 -0
- overcode/mocks.py +152 -0
- overcode/monitor_daemon.py +808 -0
- overcode/monitor_daemon_state.py +358 -0
- overcode/pid_utils.py +225 -0
- overcode/presence_logger.py +454 -0
- overcode/protocols.py +143 -0
- overcode/session_manager.py +606 -0
- overcode/settings.py +412 -0
- overcode/standing_instructions.py +276 -0
- overcode/status_constants.py +190 -0
- overcode/status_detector.py +339 -0
- overcode/status_history.py +164 -0
- overcode/status_patterns.py +264 -0
- overcode/summarizer_client.py +136 -0
- overcode/summarizer_component.py +312 -0
- overcode/supervisor_daemon.py +1000 -0
- overcode/supervisor_layout.sh +50 -0
- overcode/tmux_manager.py +228 -0
- overcode/tui.py +2549 -0
- overcode/tui_helpers.py +495 -0
- overcode/web_api.py +279 -0
- overcode/web_server.py +138 -0
- overcode/web_templates.py +563 -0
- overcode-0.1.0.dist-info/METADATA +87 -0
- overcode-0.1.0.dist-info/RECORD +43 -0
- overcode-0.1.0.dist-info/WHEEL +5 -0
- overcode-0.1.0.dist-info/entry_points.txt +2 -0
- overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- overcode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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()
|