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/__init__.py
CHANGED
overcode/cli.py
CHANGED
|
@@ -264,7 +264,7 @@ def instruct(
|
|
|
264
264
|
] = None,
|
|
265
265
|
instructions: Annotated[
|
|
266
266
|
Optional[List[str]],
|
|
267
|
-
typer.Argument(help="Instructions or preset name (e.g.,
|
|
267
|
+
typer.Argument(help="Instructions or preset name (e.g., DO_NOTHING, STANDARD, CODING)"),
|
|
268
268
|
] = None,
|
|
269
269
|
clear: Annotated[
|
|
270
270
|
bool, typer.Option("--clear", "-c", help="Clear standing instructions")
|
|
@@ -276,7 +276,7 @@ def instruct(
|
|
|
276
276
|
):
|
|
277
277
|
"""Set standing instructions for an agent.
|
|
278
278
|
|
|
279
|
-
Use a preset name (
|
|
279
|
+
Use a preset name (DO_NOTHING, STANDARD, CODING, etc.) or provide custom instructions.
|
|
280
280
|
Use --list to see all available presets.
|
|
281
281
|
"""
|
|
282
282
|
from .session_manager import SessionManager
|
|
@@ -285,7 +285,7 @@ def instruct(
|
|
|
285
285
|
if list_presets:
|
|
286
286
|
presets_dict = load_presets()
|
|
287
287
|
rprint("\n[bold]Standing Instruction Presets:[/bold]\n")
|
|
288
|
-
for preset_name in sorted(presets_dict.keys(), key=lambda x: (x != "
|
|
288
|
+
for preset_name in sorted(presets_dict.keys(), key=lambda x: (x != "DO_NOTHING", x)):
|
|
289
289
|
preset = presets_dict[preset_name]
|
|
290
290
|
rprint(f" [cyan]{preset_name:12}[/cyan] {preset.description}")
|
|
291
291
|
rprint("\n[dim]Usage: overcode instruct <agent> <PRESET>[/dim]")
|
|
@@ -405,6 +405,45 @@ def serve(
|
|
|
405
405
|
run_server(host=host, port=port, tmux_session=session)
|
|
406
406
|
|
|
407
407
|
|
|
408
|
+
@app.command()
|
|
409
|
+
def web(
|
|
410
|
+
host: Annotated[
|
|
411
|
+
str, typer.Option("--host", "-h", help="Host to bind to")
|
|
412
|
+
] = "127.0.0.1",
|
|
413
|
+
port: Annotated[
|
|
414
|
+
int, typer.Option("--port", "-p", help="Port to listen on")
|
|
415
|
+
] = 8080,
|
|
416
|
+
):
|
|
417
|
+
"""Launch analytics web dashboard for browsing historical data.
|
|
418
|
+
|
|
419
|
+
A lightweight web app for exploring session history, timeline
|
|
420
|
+
visualization, and efficiency metrics. Uses Chart.js for
|
|
421
|
+
interactive charts with dark theme matching the TUI.
|
|
422
|
+
|
|
423
|
+
Features:
|
|
424
|
+
- Dashboard with summary stats and daily activity charts
|
|
425
|
+
- Session browser with sortable table
|
|
426
|
+
- Timeline view with agent status and user presence
|
|
427
|
+
- Efficiency metrics with cost analysis
|
|
428
|
+
|
|
429
|
+
Time range presets can be configured in ~/.overcode/config.yaml:
|
|
430
|
+
|
|
431
|
+
web:
|
|
432
|
+
time_presets:
|
|
433
|
+
- name: "Morning"
|
|
434
|
+
start: "09:00"
|
|
435
|
+
end: "12:00"
|
|
436
|
+
|
|
437
|
+
Examples:
|
|
438
|
+
overcode web # Start on localhost:8080
|
|
439
|
+
overcode web --port 3000 # Custom port
|
|
440
|
+
overcode web --host 0.0.0.0 # Listen on all interfaces
|
|
441
|
+
"""
|
|
442
|
+
from .web_server import run_analytics_server
|
|
443
|
+
|
|
444
|
+
run_analytics_server(host=host, port=port)
|
|
445
|
+
|
|
446
|
+
|
|
408
447
|
|
|
409
448
|
|
|
410
449
|
@app.command()
|
overcode/config.py
CHANGED
|
@@ -70,3 +70,52 @@ def get_relay_config() -> Optional[dict]:
|
|
|
70
70
|
"api_key": api_key,
|
|
71
71
|
"interval": relay.get("interval", 30),
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_web_time_presets() -> list:
|
|
76
|
+
"""Get time presets for the web analytics dashboard.
|
|
77
|
+
|
|
78
|
+
Returns list of preset dictionaries with name, start, end times.
|
|
79
|
+
Falls back to defaults if not configured.
|
|
80
|
+
|
|
81
|
+
Config format in ~/.overcode/config.yaml:
|
|
82
|
+
web:
|
|
83
|
+
time_presets:
|
|
84
|
+
- name: "Morning"
|
|
85
|
+
start: "09:00"
|
|
86
|
+
end: "12:00"
|
|
87
|
+
- name: "Full Day"
|
|
88
|
+
start: "09:00"
|
|
89
|
+
end: "17:00"
|
|
90
|
+
- name: "Night Owl"
|
|
91
|
+
start: "22:00"
|
|
92
|
+
end: "02:00"
|
|
93
|
+
"""
|
|
94
|
+
config = load_config()
|
|
95
|
+
web_config = config.get("web", {})
|
|
96
|
+
presets = web_config.get("time_presets", None)
|
|
97
|
+
|
|
98
|
+
if presets and isinstance(presets, list):
|
|
99
|
+
# Validate and normalize presets
|
|
100
|
+
valid_presets = []
|
|
101
|
+
for p in presets:
|
|
102
|
+
if isinstance(p, dict) and "name" in p:
|
|
103
|
+
valid_presets.append({
|
|
104
|
+
"name": p.get("name", ""),
|
|
105
|
+
"start": p.get("start"),
|
|
106
|
+
"end": p.get("end"),
|
|
107
|
+
})
|
|
108
|
+
if valid_presets:
|
|
109
|
+
# Always add "All Time" at the end
|
|
110
|
+
if not any(p["name"] == "All Time" for p in valid_presets):
|
|
111
|
+
valid_presets.append({"name": "All Time", "start": None, "end": None})
|
|
112
|
+
return valid_presets
|
|
113
|
+
|
|
114
|
+
# Default presets
|
|
115
|
+
return [
|
|
116
|
+
{"name": "Morning", "start": "09:00", "end": "12:00"},
|
|
117
|
+
{"name": "Afternoon", "start": "13:00", "end": "17:00"},
|
|
118
|
+
{"name": "Full Day", "start": "09:00", "end": "17:00"},
|
|
119
|
+
{"name": "Evening", "start": "18:00", "end": "22:00"},
|
|
120
|
+
{"name": "All Time", "start": None, "end": None},
|
|
121
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared logging utilities for Overcode daemons.
|
|
3
|
+
|
|
4
|
+
Provides base logger class with common functionality for both
|
|
5
|
+
monitor_daemon and supervisor_daemon.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich.theme import Theme
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Shared theme for daemon logs
|
|
18
|
+
DAEMON_THEME = Theme({
|
|
19
|
+
"info": "cyan",
|
|
20
|
+
"warn": "yellow",
|
|
21
|
+
"error": "bold red",
|
|
22
|
+
"success": "bold green",
|
|
23
|
+
"daemon_claude": "magenta",
|
|
24
|
+
"dim": "dim white",
|
|
25
|
+
"highlight": "bold white",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BaseDaemonLogger:
|
|
30
|
+
"""Base logger for daemons with common logging methods."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, log_file: Path, theme: Theme = None):
|
|
33
|
+
"""Initialize the logger.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
log_file: Path to the log file
|
|
37
|
+
theme: Optional Rich theme (defaults to DAEMON_THEME)
|
|
38
|
+
"""
|
|
39
|
+
self.log_file = log_file
|
|
40
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
self.console = Console(theme=theme or DAEMON_THEME, force_terminal=True)
|
|
42
|
+
|
|
43
|
+
def _write_to_file(self, message: str, level: str = "INFO"):
|
|
44
|
+
"""Write plain text to log file."""
|
|
45
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
46
|
+
line = f"[{timestamp}] [{level}] {message}"
|
|
47
|
+
try:
|
|
48
|
+
with open(self.log_file, 'a') as f:
|
|
49
|
+
f.write(line + '\n')
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def _log(self, style: str, prefix: str, message: str, level: str = "INFO"):
|
|
54
|
+
"""Log a message with style to both console and file."""
|
|
55
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
56
|
+
text = Text()
|
|
57
|
+
text.append(f"[{timestamp}] ", style="dim")
|
|
58
|
+
text.append(f"{prefix} ", style=style)
|
|
59
|
+
text.append(message)
|
|
60
|
+
self.console.print(text)
|
|
61
|
+
self._write_to_file(message, level)
|
|
62
|
+
|
|
63
|
+
def info(self, message: str):
|
|
64
|
+
"""Log info message."""
|
|
65
|
+
self._log("info", "●", message, "INFO")
|
|
66
|
+
|
|
67
|
+
def warn(self, message: str):
|
|
68
|
+
"""Log warning message."""
|
|
69
|
+
self._log("warn", "⚠", message, "WARN")
|
|
70
|
+
|
|
71
|
+
def error(self, message: str):
|
|
72
|
+
"""Log error message."""
|
|
73
|
+
self._log("error", "✗", message, "ERROR")
|
|
74
|
+
|
|
75
|
+
def success(self, message: str):
|
|
76
|
+
"""Log success message."""
|
|
77
|
+
self._log("success", "✓", message, "INFO")
|
|
78
|
+
|
|
79
|
+
def debug(self, message: str):
|
|
80
|
+
"""Log a debug message (only to file, not console)."""
|
|
81
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
82
|
+
try:
|
|
83
|
+
with open(self.log_file, 'a') as f:
|
|
84
|
+
f.write(f"[{timestamp}] DEBUG {message}\n")
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
def section(self, title: str):
|
|
89
|
+
"""Print a section header."""
|
|
90
|
+
self._write_to_file(f"=== {title} ===", "INFO")
|
|
91
|
+
self.console.print()
|
|
92
|
+
self.console.rule(f"[bold cyan]{title}[/]")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SupervisorDaemonLogger(BaseDaemonLogger):
|
|
96
|
+
"""Logger for supervisor daemon with additional methods."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, log_file: Path):
|
|
99
|
+
super().__init__(log_file)
|
|
100
|
+
self._seen_daemon_claude_lines: set = set()
|
|
101
|
+
|
|
102
|
+
def daemon_claude_output(self, lines: List[str]):
|
|
103
|
+
"""Log daemon claude output, showing only new lines."""
|
|
104
|
+
new_lines = []
|
|
105
|
+
|
|
106
|
+
for line in lines:
|
|
107
|
+
stripped = line.strip()
|
|
108
|
+
if not stripped:
|
|
109
|
+
continue
|
|
110
|
+
if stripped not in self._seen_daemon_claude_lines:
|
|
111
|
+
new_lines.append(stripped)
|
|
112
|
+
self._seen_daemon_claude_lines.add(stripped)
|
|
113
|
+
|
|
114
|
+
# Limit set size
|
|
115
|
+
if len(self._seen_daemon_claude_lines) > 500:
|
|
116
|
+
current_lines = {line.strip() for line in lines if line.strip()}
|
|
117
|
+
self._seen_daemon_claude_lines = current_lines
|
|
118
|
+
|
|
119
|
+
if new_lines:
|
|
120
|
+
for line in new_lines:
|
|
121
|
+
self._write_to_file(f"[DAEMON_CLAUDE] {line}", "INFO")
|
|
122
|
+
if line.startswith('✓') or 'success' in line.lower():
|
|
123
|
+
self.console.print(f" [success]│[/success] {line}")
|
|
124
|
+
elif line.startswith('✗') or 'error' in line.lower() or 'fail' in line.lower():
|
|
125
|
+
self.console.print(f" [error]│[/error] {line}")
|
|
126
|
+
elif line.startswith('>') or line.startswith('$'):
|
|
127
|
+
self.console.print(f" [highlight]│[/highlight] {line}")
|
|
128
|
+
else:
|
|
129
|
+
self.console.print(f" [daemon_claude]│[/daemon_claude] {line}")
|
|
130
|
+
|
|
131
|
+
def status_summary(self, total: int, green: int, non_green: int, loop: int):
|
|
132
|
+
"""Print a status summary line."""
|
|
133
|
+
status_text = Text()
|
|
134
|
+
status_text.append(f"Loop #{loop}: ", style="dim")
|
|
135
|
+
status_text.append(f"{total} agents ", style="highlight")
|
|
136
|
+
status_text.append("(", style="dim")
|
|
137
|
+
status_text.append(f"{green} green", style="success")
|
|
138
|
+
status_text.append(", ", style="dim")
|
|
139
|
+
status_text.append(f"{non_green} non-green", style="warn" if non_green else "dim")
|
|
140
|
+
status_text.append(")", style="dim")
|
|
141
|
+
|
|
142
|
+
self._write_to_file(f"Loop #{loop}: {total} agents ({green} green, {non_green} non-green)", "INFO")
|
|
143
|
+
self.console.print(f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim] ", end="")
|
|
144
|
+
self.console.print(status_text)
|
overcode/daemon_utils.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared utilities for Overcode daemons.
|
|
3
|
+
|
|
4
|
+
Provides factory functions for creating daemon PID management helpers,
|
|
5
|
+
avoiding code duplication between monitor_daemon and supervisor_daemon.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from .pid_utils import (
|
|
14
|
+
get_process_pid,
|
|
15
|
+
is_process_running,
|
|
16
|
+
remove_pid_file,
|
|
17
|
+
)
|
|
18
|
+
from .settings import DAEMON
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_daemon_helpers(
|
|
22
|
+
get_pid_path: Callable[[str], Path],
|
|
23
|
+
daemon_name: str,
|
|
24
|
+
) -> Tuple[
|
|
25
|
+
Callable[[Optional[str]], bool],
|
|
26
|
+
Callable[[Optional[str]], Optional[int]],
|
|
27
|
+
Callable[[Optional[str]], bool],
|
|
28
|
+
]:
|
|
29
|
+
"""Factory to create is_*_running, get_*_pid, stop_* functions for a daemon.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
get_pid_path: Function that takes session name and returns PID file path
|
|
33
|
+
daemon_name: Human-readable name for error messages
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tuple of (is_running_fn, get_pid_fn, stop_fn)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def is_running(session: str = None) -> bool:
|
|
40
|
+
"""Check if the daemon process is currently running for a session.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
session: tmux session name (default: from config)
|
|
44
|
+
"""
|
|
45
|
+
if session is None:
|
|
46
|
+
session = DAEMON.default_tmux_session
|
|
47
|
+
return is_process_running(get_pid_path(session))
|
|
48
|
+
|
|
49
|
+
def get_pid(session: str = None) -> Optional[int]:
|
|
50
|
+
"""Get the daemon PID if running, None otherwise.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
session: tmux session name (default: from config)
|
|
54
|
+
"""
|
|
55
|
+
if session is None:
|
|
56
|
+
session = DAEMON.default_tmux_session
|
|
57
|
+
return get_process_pid(get_pid_path(session))
|
|
58
|
+
|
|
59
|
+
def stop(session: str = None) -> bool:
|
|
60
|
+
"""Stop the daemon process if running.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
session: tmux session name (default: from config)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if daemon was stopped, False if it wasn't running.
|
|
67
|
+
"""
|
|
68
|
+
if session is None:
|
|
69
|
+
session = DAEMON.default_tmux_session
|
|
70
|
+
pid_path = get_pid_path(session)
|
|
71
|
+
pid = get_process_pid(pid_path)
|
|
72
|
+
if pid is None:
|
|
73
|
+
remove_pid_file(pid_path)
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
os.kill(pid, signal.SIGTERM)
|
|
78
|
+
remove_pid_file(pid_path)
|
|
79
|
+
return True
|
|
80
|
+
except (OSError, ProcessLookupError):
|
|
81
|
+
remove_pid_file(pid_path)
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
return is_running, get_pid, stop
|
overcode/history_reader.py
CHANGED
|
@@ -39,6 +39,7 @@ class ClaudeSessionStats:
|
|
|
39
39
|
cache_creation_tokens: int
|
|
40
40
|
cache_read_tokens: int
|
|
41
41
|
work_times: List[float] # seconds per work cycle (prompt to next prompt)
|
|
42
|
+
current_context_tokens: int = 0 # Most recent input_tokens (current context size)
|
|
42
43
|
|
|
43
44
|
@property
|
|
44
45
|
def total_tokens(self) -> int:
|
|
@@ -249,13 +250,15 @@ def read_token_usage_from_session_file(
|
|
|
249
250
|
since: Only count tokens from messages after this time
|
|
250
251
|
|
|
251
252
|
Returns:
|
|
252
|
-
Dict with input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens
|
|
253
|
+
Dict with input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
|
|
254
|
+
and current_context_tokens (most recent input_tokens value)
|
|
253
255
|
"""
|
|
254
256
|
totals = {
|
|
255
257
|
"input_tokens": 0,
|
|
256
258
|
"output_tokens": 0,
|
|
257
259
|
"cache_creation_tokens": 0,
|
|
258
260
|
"cache_read_tokens": 0,
|
|
261
|
+
"current_context_tokens": 0, # Most recent input_tokens
|
|
259
262
|
}
|
|
260
263
|
|
|
261
264
|
if not session_file.exists():
|
|
@@ -288,14 +291,18 @@ def read_token_usage_from_session_file(
|
|
|
288
291
|
message = data.get("message", {})
|
|
289
292
|
usage = message.get("usage", {})
|
|
290
293
|
if usage:
|
|
291
|
-
|
|
294
|
+
input_tokens = usage.get("input_tokens", 0)
|
|
295
|
+
cache_read = usage.get("cache_read_input_tokens", 0)
|
|
296
|
+
totals["input_tokens"] += input_tokens
|
|
292
297
|
totals["output_tokens"] += usage.get("output_tokens", 0)
|
|
293
298
|
totals["cache_creation_tokens"] += usage.get(
|
|
294
299
|
"cache_creation_input_tokens", 0
|
|
295
300
|
)
|
|
296
|
-
totals["cache_read_tokens"] +=
|
|
297
|
-
|
|
298
|
-
|
|
301
|
+
totals["cache_read_tokens"] += cache_read
|
|
302
|
+
# Track most recent context size (input + cached context)
|
|
303
|
+
context_size = input_tokens + cache_read
|
|
304
|
+
if context_size > 0:
|
|
305
|
+
totals["current_context_tokens"] = context_size
|
|
299
306
|
except (json.JSONDecodeError, KeyError, TypeError):
|
|
300
307
|
continue
|
|
301
308
|
except IOError:
|
|
@@ -422,6 +429,7 @@ def get_session_stats(
|
|
|
422
429
|
total_output = 0
|
|
423
430
|
total_cache_creation = 0
|
|
424
431
|
total_cache_read = 0
|
|
432
|
+
current_context = 0 # Track most recent context size
|
|
425
433
|
all_work_times: List[float] = []
|
|
426
434
|
|
|
427
435
|
for sid in session_ids:
|
|
@@ -433,6 +441,9 @@ def get_session_stats(
|
|
|
433
441
|
total_output += usage["output_tokens"]
|
|
434
442
|
total_cache_creation += usage["cache_creation_tokens"]
|
|
435
443
|
total_cache_read += usage["cache_read_tokens"]
|
|
444
|
+
# Keep the largest current context (most recent across all session files)
|
|
445
|
+
if usage["current_context_tokens"] > current_context:
|
|
446
|
+
current_context = usage["current_context_tokens"]
|
|
436
447
|
|
|
437
448
|
# Collect work times from this session file
|
|
438
449
|
work_times = read_work_times_from_session_file(session_file, since=session_start)
|
|
@@ -445,4 +456,5 @@ def get_session_stats(
|
|
|
445
456
|
cache_creation_tokens=total_cache_creation,
|
|
446
457
|
cache_read_tokens=total_cache_read,
|
|
447
458
|
work_times=all_work_times,
|
|
459
|
+
current_context_tokens=current_context,
|
|
448
460
|
)
|
overcode/implementations.py
CHANGED
|
@@ -142,6 +142,17 @@ class RealTmux:
|
|
|
142
142
|
def attach(self, session: str) -> None:
|
|
143
143
|
os.execlp("tmux", "tmux", "attach-session", "-t", session)
|
|
144
144
|
|
|
145
|
+
def select_window(self, session: str, window: int) -> bool:
|
|
146
|
+
"""Select a window in a tmux session (for external pane sync)."""
|
|
147
|
+
try:
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
["tmux", "select-window", "-t", f"{session}:{window}"],
|
|
150
|
+
capture_output=True, timeout=5
|
|
151
|
+
)
|
|
152
|
+
return result.returncode == 0
|
|
153
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
154
|
+
return False
|
|
155
|
+
|
|
145
156
|
|
|
146
157
|
class RealFileSystem:
|
|
147
158
|
"""Production implementation of FileSystemInterface"""
|
overcode/launcher.py
CHANGED
|
@@ -4,6 +4,9 @@ Launcher for interactive Claude Code sessions in tmux windows.
|
|
|
4
4
|
All Claude sessions launched by overcode are interactive - users can
|
|
5
5
|
take over at any time. Initial prompts are sent as keystrokes after
|
|
6
6
|
Claude starts, not as CLI arguments.
|
|
7
|
+
|
|
8
|
+
TODO: Extract _send_prompt_to_window to a shared tmux utilities module
|
|
9
|
+
(duplicated in supervisor_daemon.py)
|
|
7
10
|
"""
|
|
8
11
|
|
|
9
12
|
import time
|
overcode/mocks.py
CHANGED
|
@@ -77,6 +77,10 @@ class MockTmux:
|
|
|
77
77
|
def attach(self, session: str) -> None:
|
|
78
78
|
pass # No-op in tests
|
|
79
79
|
|
|
80
|
+
def select_window(self, session: str, window: int) -> bool:
|
|
81
|
+
"""Select a window - no-op in tests, just return True."""
|
|
82
|
+
return session in self.sessions
|
|
83
|
+
|
|
80
84
|
|
|
81
85
|
class MockFileSystem:
|
|
82
86
|
"""Mock implementation of FileSystemInterface for testing"""
|
overcode/monitor_daemon.py
CHANGED
|
@@ -16,6 +16,8 @@ This separation ensures:
|
|
|
16
16
|
- No duplicate time tracking between TUI and daemon
|
|
17
17
|
- Clean interface contract via MonitorDaemonState
|
|
18
18
|
- Platform-agnostic core (presence is optional)
|
|
19
|
+
|
|
20
|
+
TODO: Add unit tests (currently 0% coverage)
|
|
19
21
|
"""
|
|
20
22
|
|
|
21
23
|
import os
|
|
@@ -26,10 +28,8 @@ from datetime import datetime
|
|
|
26
28
|
from pathlib import Path
|
|
27
29
|
from typing import Dict, List, Optional
|
|
28
30
|
|
|
29
|
-
from
|
|
30
|
-
from
|
|
31
|
-
from rich.theme import Theme
|
|
32
|
-
|
|
31
|
+
from .daemon_logging import BaseDaemonLogger
|
|
32
|
+
from .daemon_utils import create_daemon_helpers
|
|
33
33
|
from .history_reader import get_session_stats
|
|
34
34
|
from .monitor_daemon_state import (
|
|
35
35
|
MonitorDaemonState,
|
|
@@ -38,10 +38,7 @@ from .monitor_daemon_state import (
|
|
|
38
38
|
)
|
|
39
39
|
from .pid_utils import (
|
|
40
40
|
acquire_daemon_lock,
|
|
41
|
-
get_process_pid,
|
|
42
|
-
is_process_running,
|
|
43
41
|
remove_pid_file,
|
|
44
|
-
write_pid_file,
|
|
45
42
|
)
|
|
46
43
|
from .session_manager import SessionManager
|
|
47
44
|
from .settings import (
|
|
@@ -83,51 +80,12 @@ INTERVAL_SLOW = DAEMON.interval_slow # When all agents need user input
|
|
|
83
80
|
INTERVAL_IDLE = DAEMON.interval_idle # When no agents at all
|
|
84
81
|
|
|
85
82
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if session is None:
|
|
93
|
-
session = DAEMON.default_tmux_session
|
|
94
|
-
return is_process_running(get_monitor_daemon_pid_path(session))
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def get_monitor_daemon_pid(session: str = None) -> Optional[int]:
|
|
98
|
-
"""Get the monitor daemon PID if running, None otherwise.
|
|
99
|
-
|
|
100
|
-
Args:
|
|
101
|
-
session: tmux session name (default: from config)
|
|
102
|
-
"""
|
|
103
|
-
if session is None:
|
|
104
|
-
session = DAEMON.default_tmux_session
|
|
105
|
-
return get_process_pid(get_monitor_daemon_pid_path(session))
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def stop_monitor_daemon(session: str = None) -> bool:
|
|
109
|
-
"""Stop the monitor daemon process if running.
|
|
110
|
-
|
|
111
|
-
Args:
|
|
112
|
-
session: tmux session name (default: from config)
|
|
113
|
-
|
|
114
|
-
Returns True if daemon was stopped, False if it wasn't running.
|
|
115
|
-
"""
|
|
116
|
-
if session is None:
|
|
117
|
-
session = DAEMON.default_tmux_session
|
|
118
|
-
pid_path = get_monitor_daemon_pid_path(session)
|
|
119
|
-
pid = get_process_pid(pid_path)
|
|
120
|
-
if pid is None:
|
|
121
|
-
remove_pid_file(pid_path)
|
|
122
|
-
return False
|
|
123
|
-
|
|
124
|
-
try:
|
|
125
|
-
os.kill(pid, signal.SIGTERM)
|
|
126
|
-
remove_pid_file(pid_path)
|
|
127
|
-
return True
|
|
128
|
-
except (OSError, ProcessLookupError):
|
|
129
|
-
remove_pid_file(pid_path)
|
|
130
|
-
return False
|
|
83
|
+
# Create PID helper functions using factory
|
|
84
|
+
(
|
|
85
|
+
is_monitor_daemon_running,
|
|
86
|
+
get_monitor_daemon_pid,
|
|
87
|
+
stop_monitor_daemon,
|
|
88
|
+
) = create_daemon_helpers(get_monitor_daemon_pid_path, "monitor")
|
|
131
89
|
|
|
132
90
|
|
|
133
91
|
def check_activity_signal(session: str = None) -> bool:
|
|
@@ -151,71 +109,12 @@ def check_activity_signal(session: str = None) -> bool:
|
|
|
151
109
|
return False
|
|
152
110
|
|
|
153
111
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
"dim": "dim white",
|
|
161
|
-
"highlight": "bold white",
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
class MonitorDaemonLogger:
|
|
166
|
-
"""Simple logger for monitor daemon."""
|
|
167
|
-
|
|
168
|
-
def __init__(self, session: str = "agents", log_file: Optional[Path] = None):
|
|
169
|
-
self.console = Console(theme=MONITOR_THEME, force_terminal=True)
|
|
170
|
-
# Use session-specific log file
|
|
171
|
-
if log_file:
|
|
172
|
-
self.log_file = log_file
|
|
173
|
-
else:
|
|
174
|
-
session_dir = ensure_session_dir(session)
|
|
175
|
-
self.log_file = session_dir / "monitor_daemon.log"
|
|
176
|
-
self._logged_messages: set = set()
|
|
177
|
-
|
|
178
|
-
def _log(self, style: str, prefix: str, message: str):
|
|
179
|
-
"""Log a message with style."""
|
|
180
|
-
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
181
|
-
text = Text()
|
|
182
|
-
text.append(f"[{timestamp}] ", style="dim")
|
|
183
|
-
text.append(f"{prefix} ", style=style)
|
|
184
|
-
text.append(message)
|
|
185
|
-
self.console.print(text)
|
|
186
|
-
|
|
187
|
-
# Also log to file
|
|
188
|
-
try:
|
|
189
|
-
with open(self.log_file, 'a') as f:
|
|
190
|
-
f.write(f"[{timestamp}] {prefix} {message}\n")
|
|
191
|
-
except OSError:
|
|
192
|
-
pass
|
|
193
|
-
|
|
194
|
-
def info(self, message: str):
|
|
195
|
-
self._log("info", "●", message)
|
|
196
|
-
|
|
197
|
-
def warn(self, message: str):
|
|
198
|
-
self._log("warn", "⚠", message)
|
|
199
|
-
|
|
200
|
-
def error(self, message: str):
|
|
201
|
-
self._log("error", "✗", message)
|
|
202
|
-
|
|
203
|
-
def success(self, message: str):
|
|
204
|
-
self._log("success", "✓", message)
|
|
205
|
-
|
|
206
|
-
def debug(self, message: str):
|
|
207
|
-
"""Log a debug message (only to file, not console)."""
|
|
208
|
-
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
209
|
-
try:
|
|
210
|
-
with open(self.log_file, 'a') as f:
|
|
211
|
-
f.write(f"[{timestamp}] DEBUG {message}\n")
|
|
212
|
-
except OSError:
|
|
213
|
-
pass
|
|
214
|
-
|
|
215
|
-
def section(self, title: str):
|
|
216
|
-
"""Print a section header."""
|
|
217
|
-
self.console.print()
|
|
218
|
-
self.console.rule(f"[bold cyan]{title}[/]")
|
|
112
|
+
def _create_monitor_logger(session: str = "agents", log_file: Optional[Path] = None) -> BaseDaemonLogger:
|
|
113
|
+
"""Create a logger for the monitor daemon."""
|
|
114
|
+
if log_file is None:
|
|
115
|
+
session_dir = ensure_session_dir(session)
|
|
116
|
+
log_file = session_dir / "monitor_daemon.log"
|
|
117
|
+
return BaseDaemonLogger(log_file)
|
|
219
118
|
|
|
220
119
|
|
|
221
120
|
class PresenceComponent:
|
|
@@ -295,7 +194,7 @@ class MonitorDaemon:
|
|
|
295
194
|
)
|
|
296
195
|
|
|
297
196
|
# Logging - session-specific log file
|
|
298
|
-
self.log =
|
|
197
|
+
self.log = _create_monitor_logger(session=tmux_session)
|
|
299
198
|
|
|
300
199
|
# State tracking
|
|
301
200
|
self.state = MonitorDaemonState(
|
|
@@ -527,13 +426,13 @@ class MonitorDaemon:
|
|
|
527
426
|
return sorted_times[n // 2]
|
|
528
427
|
|
|
529
428
|
def calculate_interval(self, sessions: list, all_waiting_user: bool) -> int:
|
|
530
|
-
"""Calculate appropriate loop interval.
|
|
531
|
-
if not sessions:
|
|
532
|
-
return INTERVAL_IDLE
|
|
533
|
-
|
|
534
|
-
if all_waiting_user:
|
|
535
|
-
return INTERVAL_SLOW
|
|
429
|
+
"""Calculate appropriate loop interval.
|
|
536
430
|
|
|
431
|
+
The monitor daemon always uses a fixed 10s interval to maintain
|
|
432
|
+
high-resolution monitoring data. Variable frequency logic is only
|
|
433
|
+
used by the supervisor daemon.
|
|
434
|
+
"""
|
|
435
|
+
# Always use fast interval for consistent monitoring resolution
|
|
537
436
|
return INTERVAL_FAST
|
|
538
437
|
|
|
539
438
|
def _interruptible_sleep(self, total_seconds: int) -> None:
|
|
@@ -715,8 +614,8 @@ class MonitorDaemon:
|
|
|
715
614
|
session_state.current_activity = activity
|
|
716
615
|
session_states.append(session_state)
|
|
717
616
|
|
|
718
|
-
# Log status history
|
|
719
|
-
log_agent_status(session.name, status, activity)
|
|
617
|
+
# Log status history to session-specific file
|
|
618
|
+
log_agent_status(session.name, status, activity, history_file=self.history_path)
|
|
720
619
|
|
|
721
620
|
# Track if any session is not waiting for user
|
|
722
621
|
if status != "waiting_user":
|