overcode 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overcode/__init__.py +1 -1
- overcode/cli.py +42 -3
- overcode/config.py +49 -0
- overcode/daemon_logging.py +144 -0
- overcode/daemon_utils.py +84 -0
- overcode/history_reader.py +17 -5
- overcode/implementations.py +11 -0
- overcode/launcher.py +3 -0
- overcode/mocks.py +4 -0
- overcode/monitor_daemon.py +25 -126
- overcode/pid_utils.py +10 -3
- overcode/protocols.py +12 -0
- overcode/session_manager.py +3 -0
- overcode/settings.py +20 -1
- overcode/standing_instructions.py +15 -6
- overcode/status_constants.py +11 -0
- overcode/status_detector.py +38 -0
- overcode/status_patterns.py +12 -0
- overcode/supervisor_daemon.py +40 -171
- overcode/tui.py +326 -39
- overcode/tui_helpers.py +18 -0
- overcode/web_api.py +486 -2
- overcode/web_chartjs.py +32 -0
- overcode/web_server.py +355 -3
- overcode/web_server_runner.py +104 -0
- overcode/web_templates.py +1093 -0
- {overcode-0.1.0.dist-info → overcode-0.1.2.dist-info}/METADATA +13 -1
- overcode-0.1.2.dist-info/RECORD +45 -0
- {overcode-0.1.0.dist-info → overcode-0.1.2.dist-info}/WHEEL +1 -1
- overcode/daemon.py +0 -1184
- overcode/daemon_state.py +0 -113
- overcode-0.1.0.dist-info/RECORD +0 -43
- {overcode-0.1.0.dist-info → overcode-0.1.2.dist-info}/entry_points.txt +0 -0
- {overcode-0.1.0.dist-info → overcode-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {overcode-0.1.0.dist-info → overcode-0.1.2.dist-info}/top_level.txt +0 -0
overcode/pid_utils.py
CHANGED
|
@@ -150,23 +150,30 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
150
150
|
return False, None
|
|
151
151
|
|
|
152
152
|
|
|
153
|
-
def count_daemon_processes(pattern: str = "monitor_daemon") -> int:
|
|
153
|
+
def count_daemon_processes(pattern: str = "monitor_daemon", session: str = None) -> int:
|
|
154
154
|
"""Count running daemon processes matching the pattern.
|
|
155
155
|
|
|
156
156
|
Uses pgrep to find processes matching the pattern.
|
|
157
157
|
|
|
158
158
|
Args:
|
|
159
159
|
pattern: Pattern to search for in process names/args
|
|
160
|
+
session: If provided, only count daemons for this specific session
|
|
160
161
|
|
|
161
162
|
Returns:
|
|
162
|
-
Number of matching processes
|
|
163
|
+
Number of matching processes
|
|
163
164
|
"""
|
|
164
165
|
import subprocess
|
|
165
166
|
|
|
167
|
+
# Build pattern - if session provided, make it session-specific
|
|
168
|
+
if session:
|
|
169
|
+
search_pattern = f"{pattern} --session {session}"
|
|
170
|
+
else:
|
|
171
|
+
search_pattern = pattern
|
|
172
|
+
|
|
166
173
|
try:
|
|
167
174
|
# Use pgrep to find matching processes
|
|
168
175
|
result = subprocess.run(
|
|
169
|
-
["pgrep", "-f",
|
|
176
|
+
["pgrep", "-f", search_pattern],
|
|
170
177
|
capture_output=True,
|
|
171
178
|
text=True,
|
|
172
179
|
timeout=5.0,
|
overcode/protocols.py
CHANGED
|
@@ -78,6 +78,18 @@ class TmuxInterface(Protocol):
|
|
|
78
78
|
"""Attach to a tmux session (replaces current process)."""
|
|
79
79
|
...
|
|
80
80
|
|
|
81
|
+
def select_window(self, session: str, window: int) -> bool:
|
|
82
|
+
"""Select a window in a tmux session.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
session: tmux session name
|
|
86
|
+
window: window number to select
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True if successful, False otherwise
|
|
90
|
+
"""
|
|
91
|
+
...
|
|
92
|
+
|
|
81
93
|
|
|
82
94
|
@runtime_checkable
|
|
83
95
|
class FileSystemInterface(Protocol):
|
overcode/session_manager.py
CHANGED
|
@@ -88,6 +88,9 @@ class Session:
|
|
|
88
88
|
# Statistics
|
|
89
89
|
stats: SessionStats = field(default_factory=SessionStats)
|
|
90
90
|
|
|
91
|
+
# Sleep mode - agent is paused and excluded from stats
|
|
92
|
+
is_asleep: bool = False
|
|
93
|
+
|
|
91
94
|
def to_dict(self) -> dict:
|
|
92
95
|
data = asdict(self)
|
|
93
96
|
# Convert stats to dict
|
overcode/settings.py
CHANGED
|
@@ -8,6 +8,8 @@ Configuration hierarchy:
|
|
|
8
8
|
1. Environment variables (highest priority)
|
|
9
9
|
2. Config file (~/.overcode/config.yaml)
|
|
10
10
|
3. Default values (lowest priority)
|
|
11
|
+
|
|
12
|
+
TODO: Make INTERVAL_FAST/SLOW/IDLE configurable via config.yaml
|
|
11
13
|
"""
|
|
12
14
|
|
|
13
15
|
import os
|
|
@@ -19,7 +21,7 @@ import os
|
|
|
19
21
|
DAEMON_VERSION = 2 # Increment when daemon behavior changes
|
|
20
22
|
from dataclasses import dataclass, field
|
|
21
23
|
from pathlib import Path
|
|
22
|
-
from typing import Optional
|
|
24
|
+
from typing import Optional, Set
|
|
23
25
|
|
|
24
26
|
import yaml
|
|
25
27
|
|
|
@@ -328,6 +330,16 @@ def get_supervisor_log_path(session: str) -> Path:
|
|
|
328
330
|
return get_session_dir(session) / "supervisor.log"
|
|
329
331
|
|
|
330
332
|
|
|
333
|
+
def get_web_server_pid_path(session: str) -> Path:
|
|
334
|
+
"""Get web server PID file path for a specific session."""
|
|
335
|
+
return get_session_dir(session) / "web_server.pid"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_web_server_port_path(session: str) -> Path:
|
|
339
|
+
"""Get web server port file path for a specific session."""
|
|
340
|
+
return get_session_dir(session) / "web_server.port"
|
|
341
|
+
|
|
342
|
+
|
|
331
343
|
def ensure_session_dir(session: str) -> Path:
|
|
332
344
|
"""Ensure session directory exists and return it."""
|
|
333
345
|
session_dir = get_session_dir(session)
|
|
@@ -367,6 +379,9 @@ class TUIPreferences:
|
|
|
367
379
|
timeline_visible: bool = True
|
|
368
380
|
daemon_panel_visible: bool = False
|
|
369
381
|
view_mode: str = "tree" # tree, list_preview
|
|
382
|
+
tmux_sync: bool = False # sync navigation to external tmux pane
|
|
383
|
+
# Session IDs of stalled agents that have been visited by the user
|
|
384
|
+
visited_stalled_agents: Set[str] = field(default_factory=set)
|
|
370
385
|
|
|
371
386
|
@classmethod
|
|
372
387
|
def load(cls, session: str) -> "TUIPreferences":
|
|
@@ -389,6 +404,8 @@ class TUIPreferences:
|
|
|
389
404
|
timeline_visible=data.get("timeline_visible", True),
|
|
390
405
|
daemon_panel_visible=data.get("daemon_panel_visible", False),
|
|
391
406
|
view_mode=data.get("view_mode", "tree"),
|
|
407
|
+
tmux_sync=data.get("tmux_sync", False),
|
|
408
|
+
visited_stalled_agents=set(data.get("visited_stalled_agents", [])),
|
|
392
409
|
)
|
|
393
410
|
except (json.JSONDecodeError, IOError):
|
|
394
411
|
return cls()
|
|
@@ -407,6 +424,8 @@ class TUIPreferences:
|
|
|
407
424
|
"timeline_visible": self.timeline_visible,
|
|
408
425
|
"daemon_panel_visible": self.daemon_panel_visible,
|
|
409
426
|
"view_mode": self.view_mode,
|
|
427
|
+
"tmux_sync": self.tmux_sync,
|
|
428
|
+
"visited_stalled_agents": list(self.visited_stalled_agents),
|
|
410
429
|
}, f, indent=2)
|
|
411
430
|
except (IOError, OSError):
|
|
412
431
|
pass # Best effort
|
|
@@ -24,8 +24,17 @@ class InstructionPreset:
|
|
|
24
24
|
|
|
25
25
|
# Default presets - used to generate initial presets.json
|
|
26
26
|
DEFAULT_PRESETS: Dict[str, InstructionPreset] = {
|
|
27
|
-
"
|
|
28
|
-
name="
|
|
27
|
+
"DO_NOTHING": InstructionPreset(
|
|
28
|
+
name="DO_NOTHING",
|
|
29
|
+
description="Supervisor ignores this agent (default)",
|
|
30
|
+
instructions=(
|
|
31
|
+
"Do not interact with this agent at all. Do not approve or reject any prompts. "
|
|
32
|
+
"Do not send any input. Leave the agent completely alone and let it wait for "
|
|
33
|
+
"the human user. This agent is not under supervisor control."
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
"STANDARD": InstructionPreset(
|
|
37
|
+
name="STANDARD",
|
|
29
38
|
description="General-purpose safe automation",
|
|
30
39
|
instructions=(
|
|
31
40
|
"Approve safe operations within the working directory: file reads/writes/edits, "
|
|
@@ -208,11 +217,11 @@ def get_preset_names() -> List[str]:
|
|
|
208
217
|
List of preset names
|
|
209
218
|
"""
|
|
210
219
|
presets = load_presets()
|
|
211
|
-
# Return in a consistent order (
|
|
220
|
+
# Return in a consistent order (DO_NOTHING first, then alphabetical)
|
|
212
221
|
names = list(presets.keys())
|
|
213
|
-
if "
|
|
214
|
-
names.remove("
|
|
215
|
-
names = ["
|
|
222
|
+
if "DO_NOTHING" in names:
|
|
223
|
+
names.remove("DO_NOTHING")
|
|
224
|
+
names = ["DO_NOTHING"] + sorted(names)
|
|
216
225
|
else:
|
|
217
226
|
names = sorted(names)
|
|
218
227
|
return names
|
overcode/status_constants.py
CHANGED
|
@@ -17,6 +17,7 @@ STATUS_NO_INSTRUCTIONS = "no_instructions"
|
|
|
17
17
|
STATUS_WAITING_SUPERVISOR = "waiting_supervisor"
|
|
18
18
|
STATUS_WAITING_USER = "waiting_user"
|
|
19
19
|
STATUS_TERMINATED = "terminated" # Claude Code exited, shell prompt showing
|
|
20
|
+
STATUS_ASLEEP = "asleep" # Human marked agent as paused/snoozed (excluded from stats)
|
|
20
21
|
|
|
21
22
|
# All valid agent status values
|
|
22
23
|
ALL_STATUSES = [
|
|
@@ -25,6 +26,7 @@ ALL_STATUSES = [
|
|
|
25
26
|
STATUS_WAITING_SUPERVISOR,
|
|
26
27
|
STATUS_WAITING_USER,
|
|
27
28
|
STATUS_TERMINATED,
|
|
29
|
+
STATUS_ASLEEP,
|
|
28
30
|
]
|
|
29
31
|
|
|
30
32
|
|
|
@@ -60,6 +62,7 @@ STATUS_EMOJIS = {
|
|
|
60
62
|
STATUS_WAITING_SUPERVISOR: "🟠",
|
|
61
63
|
STATUS_WAITING_USER: "🔴",
|
|
62
64
|
STATUS_TERMINATED: "⚫", # Black circle - Claude exited
|
|
65
|
+
STATUS_ASLEEP: "💤", # Sleeping/snoozed - human marked as paused
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
|
|
@@ -78,6 +81,7 @@ STATUS_COLORS = {
|
|
|
78
81
|
STATUS_WAITING_SUPERVISOR: "orange1",
|
|
79
82
|
STATUS_WAITING_USER: "red",
|
|
80
83
|
STATUS_TERMINATED: "dim", # Grey for terminated
|
|
84
|
+
STATUS_ASLEEP: "dim", # Grey for sleeping
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
|
|
@@ -96,6 +100,7 @@ STATUS_SYMBOLS = {
|
|
|
96
100
|
STATUS_WAITING_SUPERVISOR: ("🟠", "orange1"),
|
|
97
101
|
STATUS_WAITING_USER: ("🔴", "red"),
|
|
98
102
|
STATUS_TERMINATED: ("⚫", "dim"),
|
|
103
|
+
STATUS_ASLEEP: ("💤", "dim"), # Sleeping/snoozed
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
|
|
@@ -114,6 +119,7 @@ AGENT_TIMELINE_CHARS = {
|
|
|
114
119
|
STATUS_WAITING_SUPERVISOR: "▒",
|
|
115
120
|
STATUS_WAITING_USER: "░",
|
|
116
121
|
STATUS_TERMINATED: "×", # Small X - terminated
|
|
122
|
+
STATUS_ASLEEP: "z", # Lowercase z for sleeping
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
|
|
@@ -188,3 +194,8 @@ def is_waiting_status(status: str) -> bool:
|
|
|
188
194
|
def is_user_blocked(status: str) -> bool:
|
|
189
195
|
"""Check if status indicates user intervention is required."""
|
|
190
196
|
return status == STATUS_WAITING_USER
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def is_asleep(status: str) -> bool:
|
|
200
|
+
"""Check if status indicates agent is asleep (paused by human)."""
|
|
201
|
+
return status == STATUS_ASLEEP
|
overcode/status_detector.py
CHANGED
|
@@ -116,6 +116,13 @@ class StatusDetector:
|
|
|
116
116
|
|
|
117
117
|
last_line = last_lines[-1]
|
|
118
118
|
|
|
119
|
+
# Check for spawn failure FIRST (command not found, etc.)
|
|
120
|
+
# This should be detected before shell prompt check because the error
|
|
121
|
+
# message appears before the shell prompt returns
|
|
122
|
+
spawn_error = self._detect_spawn_failure(lines)
|
|
123
|
+
if spawn_error:
|
|
124
|
+
return self.STATUS_WAITING_USER, spawn_error, content
|
|
125
|
+
|
|
119
126
|
# Check for shell prompt (Claude Code has terminated)
|
|
120
127
|
# Shell prompts typically end with $ or % and have username@hostname pattern
|
|
121
128
|
# Also check for absence of Claude Code UI elements
|
|
@@ -297,6 +304,37 @@ class StatusDetector:
|
|
|
297
304
|
]
|
|
298
305
|
return '\n'.join(filtered)
|
|
299
306
|
|
|
307
|
+
def _detect_spawn_failure(self, lines: list) -> str | None:
|
|
308
|
+
"""Detect if the claude command failed to spawn.
|
|
309
|
+
|
|
310
|
+
Checks for common error messages like "command not found" that indicate
|
|
311
|
+
the claude CLI is not installed or not in PATH.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
lines: All lines from the pane content
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Error message string if spawn failure detected, None otherwise
|
|
318
|
+
"""
|
|
319
|
+
# Check recent lines for spawn failure patterns
|
|
320
|
+
# We check the last 20 lines to catch the error message
|
|
321
|
+
recent_lines = lines[-20:] if len(lines) > 20 else lines
|
|
322
|
+
recent_text = ' '.join(recent_lines).lower()
|
|
323
|
+
|
|
324
|
+
if matches_any(recent_text, self.patterns.spawn_failure_patterns):
|
|
325
|
+
# Find the specific error line for a better message
|
|
326
|
+
for line in reversed(recent_lines):
|
|
327
|
+
line_lower = line.lower()
|
|
328
|
+
if any(p.lower() in line_lower for p in self.patterns.spawn_failure_patterns):
|
|
329
|
+
# Extract just the error part, clean it up
|
|
330
|
+
error_msg = line.strip()
|
|
331
|
+
if len(error_msg) > 80:
|
|
332
|
+
error_msg = error_msg[:77] + "..."
|
|
333
|
+
return f"Spawn failed: {error_msg}"
|
|
334
|
+
return "Spawn failed: claude command not found - is Claude CLI installed?"
|
|
335
|
+
|
|
336
|
+
return None
|
|
337
|
+
|
|
300
338
|
def _is_shell_prompt(self, lines: list) -> bool:
|
|
301
339
|
"""Detect if we're at a shell prompt (Claude Code has exited).
|
|
302
340
|
|
overcode/status_patterns.py
CHANGED
|
@@ -115,6 +115,18 @@ class StatusPatterns:
|
|
|
115
115
|
# Format: " /command-name Description text"
|
|
116
116
|
command_menu_pattern: str = r"^\s*/[\w-]+\s{2,}\S"
|
|
117
117
|
|
|
118
|
+
# Spawn failure patterns - when the claude command fails to start
|
|
119
|
+
# These indicate the command was not found or failed to execute
|
|
120
|
+
# Checked against pane content to detect failed spawns
|
|
121
|
+
spawn_failure_patterns: List[str] = field(default_factory=lambda: [
|
|
122
|
+
"command not found",
|
|
123
|
+
"not found:", # zsh style: "zsh: command not found: claude"
|
|
124
|
+
"no such file or directory",
|
|
125
|
+
"permission denied",
|
|
126
|
+
"cannot execute",
|
|
127
|
+
"is not recognized", # Windows-style (for future compatibility)
|
|
128
|
+
])
|
|
129
|
+
|
|
118
130
|
|
|
119
131
|
# Default patterns instance
|
|
120
132
|
DEFAULT_PATTERNS = StatusPatterns()
|
overcode/supervisor_daemon.py
CHANGED
|
@@ -15,10 +15,15 @@ Prerequisites:
|
|
|
15
15
|
|
|
16
16
|
Architecture:
|
|
17
17
|
Monitor Daemon (metrics) → monitor_daemon_state.json → Supervisor Daemon (claude)
|
|
18
|
+
|
|
19
|
+
TODO: Add unit tests (currently 0% coverage)
|
|
20
|
+
TODO: Extract _send_prompt_to_window to a shared tmux utilities module
|
|
21
|
+
(duplicated in launcher.py)
|
|
18
22
|
"""
|
|
19
23
|
|
|
20
24
|
import json
|
|
21
25
|
import os
|
|
26
|
+
import signal
|
|
22
27
|
import subprocess
|
|
23
28
|
import sys
|
|
24
29
|
import tempfile
|
|
@@ -28,20 +33,16 @@ from datetime import datetime
|
|
|
28
33
|
from pathlib import Path
|
|
29
34
|
from typing import Dict, List, Optional
|
|
30
35
|
|
|
31
|
-
from
|
|
32
|
-
from
|
|
33
|
-
from rich.theme import Theme
|
|
34
|
-
|
|
36
|
+
from .daemon_logging import SupervisorDaemonLogger
|
|
37
|
+
from .daemon_utils import create_daemon_helpers
|
|
35
38
|
from .monitor_daemon_state import (
|
|
36
39
|
MonitorDaemonState,
|
|
37
40
|
SessionDaemonState,
|
|
38
41
|
get_monitor_daemon_state,
|
|
39
42
|
)
|
|
40
43
|
from .pid_utils import (
|
|
41
|
-
|
|
42
|
-
is_process_running,
|
|
44
|
+
acquire_daemon_lock,
|
|
43
45
|
remove_pid_file,
|
|
44
|
-
write_pid_file,
|
|
45
46
|
)
|
|
46
47
|
from .session_manager import SessionManager
|
|
47
48
|
from .settings import (
|
|
@@ -131,105 +132,6 @@ class SupervisorStats:
|
|
|
131
132
|
return cls()
|
|
132
133
|
|
|
133
134
|
|
|
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
135
|
class SupervisorDaemon:
|
|
234
136
|
"""Background daemon that orchestrates daemon claude for non-green sessions.
|
|
235
137
|
|
|
@@ -244,7 +146,7 @@ class SupervisorDaemon:
|
|
|
244
146
|
tmux_session: str = None,
|
|
245
147
|
session_manager: SessionManager = None,
|
|
246
148
|
tmux_manager: TmuxManager = None,
|
|
247
|
-
logger:
|
|
149
|
+
logger: SupervisorDaemonLogger = None,
|
|
248
150
|
):
|
|
249
151
|
"""Initialize the supervisor daemon.
|
|
250
152
|
|
|
@@ -252,7 +154,7 @@ class SupervisorDaemon:
|
|
|
252
154
|
tmux_session: Name of the tmux session to manage
|
|
253
155
|
session_manager: Optional SessionManager for dependency injection
|
|
254
156
|
tmux_manager: Optional TmuxManager for dependency injection
|
|
255
|
-
logger: Optional
|
|
157
|
+
logger: Optional SupervisorDaemonLogger for dependency injection
|
|
256
158
|
"""
|
|
257
159
|
self.tmux_session = tmux_session or DAEMON.default_tmux_session
|
|
258
160
|
self.session_manager = session_manager or SessionManager()
|
|
@@ -267,7 +169,7 @@ class SupervisorDaemon:
|
|
|
267
169
|
self.log_path = get_supervisor_log_path(self.tmux_session)
|
|
268
170
|
|
|
269
171
|
# Logger with session-specific log file
|
|
270
|
-
self.log = logger or
|
|
172
|
+
self.log = logger or SupervisorDaemonLogger(log_file=self.log_path)
|
|
271
173
|
|
|
272
174
|
# Load persistent supervisor stats
|
|
273
175
|
self.supervisor_stats = SupervisorStats.load(self.stats_path)
|
|
@@ -808,20 +710,30 @@ class SupervisorDaemon:
|
|
|
808
710
|
"""
|
|
809
711
|
check_interval = check_interval or DAEMON.interval_fast
|
|
810
712
|
|
|
811
|
-
#
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
713
|
+
# Atomically check if already running and acquire lock
|
|
714
|
+
# This prevents TOCTOU race conditions that could cause multiple daemons
|
|
715
|
+
acquired, existing_pid = acquire_daemon_lock(self.pid_path)
|
|
716
|
+
if not acquired:
|
|
717
|
+
if existing_pid:
|
|
718
|
+
self.log.error(f"Supervisor daemon already running (PID {existing_pid})")
|
|
719
|
+
else:
|
|
720
|
+
self.log.error("Could not acquire daemon lock (another daemon may be starting)")
|
|
815
721
|
sys.exit(1)
|
|
816
722
|
|
|
817
|
-
# Write PID file
|
|
818
|
-
write_pid_file(self.pid_path)
|
|
819
|
-
|
|
820
723
|
self.log.section("Supervisor Daemon")
|
|
821
724
|
self.log.info(f"PID: {os.getpid()}")
|
|
822
725
|
self.log.info(f"Tmux session: {self.tmux_session}")
|
|
823
726
|
self.log.info(f"Check interval: {check_interval}s")
|
|
824
727
|
|
|
728
|
+
# Setup signal handlers for graceful shutdown
|
|
729
|
+
def handle_shutdown(signum, frame):
|
|
730
|
+
self.log.info("Shutdown signal received")
|
|
731
|
+
self._shutdown = True
|
|
732
|
+
|
|
733
|
+
signal.signal(signal.SIGTERM, handle_shutdown)
|
|
734
|
+
signal.signal(signal.SIGINT, handle_shutdown)
|
|
735
|
+
self._shutdown = False
|
|
736
|
+
|
|
825
737
|
# Wait for monitor daemon
|
|
826
738
|
self.log.info("Waiting for Monitor Daemon...")
|
|
827
739
|
if not self.wait_for_monitor_daemon():
|
|
@@ -834,7 +746,7 @@ class SupervisorDaemon:
|
|
|
834
746
|
self.status = "active"
|
|
835
747
|
|
|
836
748
|
try:
|
|
837
|
-
while
|
|
749
|
+
while not self._shutdown:
|
|
838
750
|
self.loop_count += 1
|
|
839
751
|
|
|
840
752
|
# Cleanup orphaned daemon claudes
|
|
@@ -914,64 +826,21 @@ class SupervisorDaemon:
|
|
|
914
826
|
|
|
915
827
|
time.sleep(check_interval)
|
|
916
828
|
|
|
917
|
-
except
|
|
918
|
-
self.log.
|
|
919
|
-
|
|
920
|
-
remove_pid_file(self.pid_path)
|
|
921
|
-
self.log.info("Supervisor daemon stopped")
|
|
922
|
-
sys.exit(0)
|
|
829
|
+
except Exception as e:
|
|
830
|
+
self.log.error(f"Supervisor daemon error: {e}")
|
|
831
|
+
raise
|
|
923
832
|
finally:
|
|
833
|
+
self.log.info("Supervisor daemon shutting down")
|
|
834
|
+
self.status = "stopped"
|
|
924
835
|
remove_pid_file(self.pid_path)
|
|
925
836
|
|
|
926
837
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
|
838
|
+
# Create PID helper functions using factory
|
|
839
|
+
(
|
|
840
|
+
is_supervisor_daemon_running,
|
|
841
|
+
get_supervisor_daemon_pid,
|
|
842
|
+
stop_supervisor_daemon,
|
|
843
|
+
) = create_daemon_helpers(get_supervisor_daemon_pid_path, "supervisor")
|
|
975
844
|
|
|
976
845
|
|
|
977
846
|
def main():
|