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
overcode/daemon.py
ADDED
|
@@ -0,0 +1,1184 @@
|
|
|
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()
|