overcode 0.1.2__py3-none-any.whl → 0.1.3__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 +147 -49
- overcode/config.py +66 -0
- overcode/daemon_claude_skill.md +36 -33
- overcode/history_reader.py +69 -8
- overcode/implementations.py +109 -84
- overcode/monitor_daemon.py +33 -38
- overcode/monitor_daemon_state.py +17 -15
- overcode/pid_utils.py +17 -3
- overcode/session_manager.py +53 -0
- overcode/settings.py +12 -0
- overcode/status_constants.py +1 -1
- overcode/status_detector.py +8 -2
- overcode/status_patterns.py +19 -0
- overcode/summarizer_client.py +72 -27
- overcode/summarizer_component.py +87 -107
- overcode/supervisor_daemon.py +21 -5
- overcode/tmux_manager.py +101 -91
- overcode/tui.py +829 -133
- overcode/tui_helpers.py +4 -3
- {overcode-0.1.2.dist-info → overcode-0.1.3.dist-info}/METADATA +2 -1
- overcode-0.1.3.dist-info/RECORD +45 -0
- {overcode-0.1.2.dist-info → overcode-0.1.3.dist-info}/WHEEL +1 -1
- overcode-0.1.2.dist-info/RECORD +0 -45
- {overcode-0.1.2.dist-info → overcode-0.1.3.dist-info}/entry_points.txt +0 -0
- {overcode-0.1.2.dist-info → overcode-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {overcode-0.1.2.dist-info → overcode-0.1.3.dist-info}/top_level.txt +0 -0
overcode/implementations.py
CHANGED
|
@@ -1,142 +1,167 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Real implementations of protocol interfaces.
|
|
3
3
|
|
|
4
|
-
These are production implementations that
|
|
5
|
-
|
|
4
|
+
These are production implementations that use libtmux for tmux operations
|
|
5
|
+
and perform real file I/O.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
-
import subprocess
|
|
10
9
|
import os
|
|
11
10
|
import time
|
|
12
11
|
from pathlib import Path
|
|
13
12
|
from typing import Optional, List, Dict, Any
|
|
14
13
|
|
|
14
|
+
import libtmux
|
|
15
|
+
from libtmux.exc import LibTmuxException
|
|
16
|
+
from libtmux._internal.query_list import ObjectDoesNotExist
|
|
17
|
+
|
|
15
18
|
|
|
16
19
|
class RealTmux:
|
|
17
|
-
"""Production implementation of TmuxInterface using
|
|
20
|
+
"""Production implementation of TmuxInterface using libtmux"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, socket_name: Optional[str] = None):
|
|
23
|
+
"""Initialize with optional socket name for test isolation.
|
|
24
|
+
|
|
25
|
+
If no socket_name is provided, checks OVERCODE_TMUX_SOCKET env var.
|
|
26
|
+
"""
|
|
27
|
+
# Support OVERCODE_TMUX_SOCKET env var for testing
|
|
28
|
+
self._socket_name = socket_name or os.environ.get("OVERCODE_TMUX_SOCKET")
|
|
29
|
+
self._server: Optional[libtmux.Server] = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def server(self) -> libtmux.Server:
|
|
33
|
+
"""Lazy-load the tmux server connection."""
|
|
34
|
+
if self._server is None:
|
|
35
|
+
if self._socket_name:
|
|
36
|
+
self._server = libtmux.Server(socket_name=self._socket_name)
|
|
37
|
+
else:
|
|
38
|
+
self._server = libtmux.Server()
|
|
39
|
+
return self._server
|
|
40
|
+
|
|
41
|
+
def _get_session(self, session: str) -> Optional[libtmux.Session]:
|
|
42
|
+
"""Get a session by name, or None if it doesn't exist."""
|
|
43
|
+
try:
|
|
44
|
+
return self.server.sessions.get(session_name=session)
|
|
45
|
+
except (LibTmuxException, ObjectDoesNotExist):
|
|
46
|
+
return None
|
|
18
47
|
|
|
19
|
-
def
|
|
48
|
+
def _get_window(self, session: str, window: int) -> Optional[libtmux.Window]:
|
|
49
|
+
"""Get a window by session name and window index."""
|
|
50
|
+
sess = self._get_session(session)
|
|
51
|
+
if sess is None:
|
|
52
|
+
return None
|
|
20
53
|
try:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"-p", "-S", f"-{lines}"],
|
|
24
|
-
capture_output=True, text=True, timeout=5
|
|
25
|
-
)
|
|
26
|
-
if result.returncode == 0:
|
|
27
|
-
return result.stdout
|
|
54
|
+
return sess.windows.get(window_index=str(window))
|
|
55
|
+
except (LibTmuxException, ObjectDoesNotExist):
|
|
28
56
|
return None
|
|
29
|
-
|
|
57
|
+
|
|
58
|
+
def _get_pane(self, session: str, window: int) -> Optional[libtmux.Pane]:
|
|
59
|
+
"""Get the first pane of a window."""
|
|
60
|
+
win = self._get_window(session, window)
|
|
61
|
+
if win is None or not win.panes:
|
|
62
|
+
return None
|
|
63
|
+
return win.panes[0]
|
|
64
|
+
|
|
65
|
+
def capture_pane(self, session: str, window: int, lines: int = 100) -> Optional[str]:
|
|
66
|
+
try:
|
|
67
|
+
pane = self._get_pane(session, window)
|
|
68
|
+
if pane is None:
|
|
69
|
+
return None
|
|
70
|
+
# capture_pane returns list of lines
|
|
71
|
+
# escape_sequences=True preserves ANSI color codes for TUI rendering
|
|
72
|
+
captured = pane.capture_pane(start=-lines, escape_sequences=True)
|
|
73
|
+
if isinstance(captured, list):
|
|
74
|
+
return '\n'.join(captured)
|
|
75
|
+
return captured
|
|
76
|
+
except LibTmuxException:
|
|
30
77
|
return None
|
|
31
78
|
|
|
32
79
|
def send_keys(self, session: str, window: int, keys: str, enter: bool = True) -> bool:
|
|
33
80
|
try:
|
|
81
|
+
pane = self._get_pane(session, window)
|
|
82
|
+
if pane is None:
|
|
83
|
+
return False
|
|
84
|
+
|
|
34
85
|
# For Claude Code: text and Enter must be sent as SEPARATE commands
|
|
35
86
|
# with a small delay, otherwise Claude Code doesn't process the Enter.
|
|
36
|
-
target = f"{session}:{window}"
|
|
37
|
-
|
|
38
|
-
# Send text first (if any)
|
|
39
87
|
if keys:
|
|
40
|
-
|
|
41
|
-
["tmux", "send-keys", "-t", target, keys],
|
|
42
|
-
timeout=5, capture_output=True
|
|
43
|
-
)
|
|
44
|
-
if result.returncode != 0:
|
|
45
|
-
return False
|
|
88
|
+
pane.send_keys(keys, enter=False)
|
|
46
89
|
# Small delay for Claude Code to process text
|
|
47
90
|
time.sleep(0.1)
|
|
48
91
|
|
|
49
|
-
# Send Enter separately
|
|
50
92
|
if enter:
|
|
51
|
-
|
|
52
|
-
["tmux", "send-keys", "-t", target, "Enter"],
|
|
53
|
-
timeout=5, capture_output=True
|
|
54
|
-
)
|
|
55
|
-
if result.returncode != 0:
|
|
56
|
-
return False
|
|
93
|
+
pane.send_keys('', enter=True)
|
|
57
94
|
|
|
58
95
|
return True
|
|
59
|
-
except
|
|
96
|
+
except LibTmuxException:
|
|
60
97
|
return False
|
|
61
98
|
|
|
62
99
|
def has_session(self, session: str) -> bool:
|
|
63
100
|
try:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
capture_output=True, timeout=5
|
|
67
|
-
)
|
|
68
|
-
return result.returncode == 0
|
|
69
|
-
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
101
|
+
return self.server.has_session(session)
|
|
102
|
+
except LibTmuxException:
|
|
70
103
|
return False
|
|
71
104
|
|
|
72
105
|
def new_session(self, session: str) -> bool:
|
|
73
106
|
try:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
)
|
|
78
|
-
return result.returncode == 0
|
|
79
|
-
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
107
|
+
self.server.new_session(session_name=session, attach=False)
|
|
108
|
+
return True
|
|
109
|
+
except LibTmuxException:
|
|
80
110
|
return False
|
|
81
111
|
|
|
82
112
|
def new_window(self, session: str, name: str, command: Optional[List[str]] = None,
|
|
83
113
|
cwd: Optional[str] = None) -> Optional[int]:
|
|
84
114
|
try:
|
|
85
|
-
|
|
115
|
+
sess = self._get_session(session)
|
|
116
|
+
if sess is None:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
kwargs: Dict[str, Any] = {'window_name': name, 'attach': False}
|
|
86
120
|
if cwd:
|
|
87
|
-
|
|
121
|
+
kwargs['start_directory'] = cwd
|
|
88
122
|
if command:
|
|
89
|
-
|
|
123
|
+
kwargs['window_shell'] = ' '.join(command)
|
|
90
124
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return None
|
|
95
|
-
except (subprocess.TimeoutExpired, subprocess.SubprocessError, ValueError):
|
|
125
|
+
window = sess.new_window(**kwargs)
|
|
126
|
+
return int(window.window_index)
|
|
127
|
+
except (LibTmuxException, ValueError):
|
|
96
128
|
return None
|
|
97
129
|
|
|
98
130
|
def kill_window(self, session: str, window: int) -> bool:
|
|
99
131
|
try:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
return
|
|
105
|
-
except
|
|
132
|
+
win = self._get_window(session, window)
|
|
133
|
+
if win is None:
|
|
134
|
+
return False
|
|
135
|
+
win.kill()
|
|
136
|
+
return True
|
|
137
|
+
except LibTmuxException:
|
|
106
138
|
return False
|
|
107
139
|
|
|
108
140
|
def kill_session(self, session: str) -> bool:
|
|
109
141
|
try:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
)
|
|
114
|
-
return
|
|
115
|
-
except
|
|
142
|
+
sess = self._get_session(session)
|
|
143
|
+
if sess is None:
|
|
144
|
+
return False
|
|
145
|
+
sess.kill()
|
|
146
|
+
return True
|
|
147
|
+
except LibTmuxException:
|
|
116
148
|
return False
|
|
117
149
|
|
|
118
150
|
def list_windows(self, session: str) -> List[Dict[str, Any]]:
|
|
119
151
|
try:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"#{window_index}:#{window_name}:#{window_active}"],
|
|
123
|
-
capture_output=True, text=True, timeout=5
|
|
124
|
-
)
|
|
125
|
-
if result.returncode != 0:
|
|
152
|
+
sess = self._get_session(session)
|
|
153
|
+
if sess is None:
|
|
126
154
|
return []
|
|
127
155
|
|
|
128
156
|
windows = []
|
|
129
|
-
for
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
'name': parts[1],
|
|
136
|
-
'active': parts[2] == '1'
|
|
137
|
-
})
|
|
157
|
+
for win in sess.windows:
|
|
158
|
+
windows.append({
|
|
159
|
+
'index': int(win.window_index),
|
|
160
|
+
'name': win.window_name,
|
|
161
|
+
'active': win.window_active == '1'
|
|
162
|
+
})
|
|
138
163
|
return windows
|
|
139
|
-
except
|
|
164
|
+
except LibTmuxException:
|
|
140
165
|
return []
|
|
141
166
|
|
|
142
167
|
def attach(self, session: str) -> None:
|
|
@@ -145,12 +170,12 @@ class RealTmux:
|
|
|
145
170
|
def select_window(self, session: str, window: int) -> bool:
|
|
146
171
|
"""Select a window in a tmux session (for external pane sync)."""
|
|
147
172
|
try:
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
)
|
|
152
|
-
return
|
|
153
|
-
except
|
|
173
|
+
win = self._get_window(session, window)
|
|
174
|
+
if win is None:
|
|
175
|
+
return False
|
|
176
|
+
win.select()
|
|
177
|
+
return True
|
|
178
|
+
except LibTmuxException:
|
|
154
179
|
return False
|
|
155
180
|
|
|
156
181
|
|
overcode/monitor_daemon.py
CHANGED
|
@@ -30,7 +30,7 @@ from typing import Dict, List, Optional
|
|
|
30
30
|
|
|
31
31
|
from .daemon_logging import BaseDaemonLogger
|
|
32
32
|
from .daemon_utils import create_daemon_helpers
|
|
33
|
-
from .history_reader import get_session_stats
|
|
33
|
+
from .history_reader import get_session_stats, get_current_session_id_for_directory
|
|
34
34
|
from .monitor_daemon_state import (
|
|
35
35
|
MonitorDaemonState,
|
|
36
36
|
SessionDaemonState,
|
|
@@ -53,10 +53,9 @@ from .settings import (
|
|
|
53
53
|
get_supervisor_stats_path,
|
|
54
54
|
)
|
|
55
55
|
from .config import get_relay_config
|
|
56
|
-
from .status_constants import STATUS_RUNNING, STATUS_TERMINATED
|
|
56
|
+
from .status_constants import STATUS_ASLEEP, STATUS_RUNNING, STATUS_TERMINATED
|
|
57
57
|
from .status_detector import StatusDetector
|
|
58
58
|
from .status_history import log_agent_status
|
|
59
|
-
from .summarizer_component import SummarizerComponent, SummarizerConfig
|
|
60
59
|
|
|
61
60
|
|
|
62
61
|
# Check for macOS presence APIs (optional)
|
|
@@ -187,12 +186,6 @@ class MonitorDaemon:
|
|
|
187
186
|
# Presence tracking (graceful degradation)
|
|
188
187
|
self.presence = PresenceComponent()
|
|
189
188
|
|
|
190
|
-
# Summarizer component (graceful degradation if no API key)
|
|
191
|
-
self.summarizer = SummarizerComponent(
|
|
192
|
-
tmux_session=tmux_session,
|
|
193
|
-
config=SummarizerConfig(enabled=False), # Off by default, enable via CLI
|
|
194
|
-
)
|
|
195
|
-
|
|
196
189
|
# Logging - session-specific log file
|
|
197
190
|
self.log = _create_monitor_logger(session=tmux_session)
|
|
198
191
|
|
|
@@ -209,8 +202,8 @@ class MonitorDaemon:
|
|
|
209
202
|
self.last_state_times: Dict[str, datetime] = {}
|
|
210
203
|
self.operation_start_times: Dict[str, datetime] = {}
|
|
211
204
|
|
|
212
|
-
# Stats sync throttling
|
|
213
|
-
self._last_stats_sync = datetime.
|
|
205
|
+
# Stats sync throttling - start with min time to force immediate sync on first loop
|
|
206
|
+
self._last_stats_sync = datetime.min
|
|
214
207
|
self._stats_sync_interval = 60 # seconds
|
|
215
208
|
|
|
216
209
|
# Relay configuration (for pushing state to cloud)
|
|
@@ -293,6 +286,8 @@ class MonitorDaemon:
|
|
|
293
286
|
start_time=session.start_time,
|
|
294
287
|
permissiveness_mode=session.permissiveness_mode,
|
|
295
288
|
start_directory=session.start_directory,
|
|
289
|
+
is_asleep=session.is_asleep,
|
|
290
|
+
agent_value=session.agent_value,
|
|
296
291
|
)
|
|
297
292
|
|
|
298
293
|
def _update_state_time(self, session, status: str, now: datetime) -> None:
|
|
@@ -332,10 +327,10 @@ class MonitorDaemon:
|
|
|
332
327
|
|
|
333
328
|
if status == STATUS_RUNNING:
|
|
334
329
|
green_time += elapsed
|
|
335
|
-
elif status
|
|
336
|
-
# Only count non-green time for non-terminated states
|
|
330
|
+
elif status not in (STATUS_TERMINATED, STATUS_ASLEEP):
|
|
331
|
+
# Only count non-green time for non-terminated/non-asleep states (#68)
|
|
337
332
|
non_green_time += elapsed
|
|
338
|
-
# else: terminated - don't accumulate time
|
|
333
|
+
# else: terminated or asleep - don't accumulate time
|
|
339
334
|
|
|
340
335
|
# INVARIANT CHECK: accumulated time should never exceed uptime
|
|
341
336
|
# This catches bugs like multiple daemons running simultaneously
|
|
@@ -381,6 +376,19 @@ class MonitorDaemon:
|
|
|
381
376
|
def sync_claude_code_stats(self, session) -> None:
|
|
382
377
|
"""Sync token/interaction stats from Claude Code history files."""
|
|
383
378
|
try:
|
|
379
|
+
# Capture current Claude sessionId if not already tracked (#119)
|
|
380
|
+
# This ensures accurate context window calculation for this agent
|
|
381
|
+
if session.start_directory:
|
|
382
|
+
try:
|
|
383
|
+
session_start = datetime.fromisoformat(session.start_time)
|
|
384
|
+
current_id = get_current_session_id_for_directory(
|
|
385
|
+
session.start_directory, session_start
|
|
386
|
+
)
|
|
387
|
+
if current_id:
|
|
388
|
+
self.session_manager.add_claude_session_id(session.id, current_id)
|
|
389
|
+
except (ValueError, TypeError):
|
|
390
|
+
pass
|
|
391
|
+
|
|
384
392
|
stats = get_session_stats(session)
|
|
385
393
|
if stats is None:
|
|
386
394
|
return
|
|
@@ -481,13 +489,6 @@ class MonitorDaemon:
|
|
|
481
489
|
except (json.JSONDecodeError, OSError):
|
|
482
490
|
pass
|
|
483
491
|
|
|
484
|
-
# Update summarizer stats
|
|
485
|
-
self.state.summarizer_available = self.summarizer.available
|
|
486
|
-
self.state.summarizer_enabled = self.summarizer.enabled
|
|
487
|
-
self.state.summarizer_calls = self.summarizer.total_calls
|
|
488
|
-
# Estimate cost: ~$0.0007 per call (4K input tokens + 150 output tokens)
|
|
489
|
-
self.state.summarizer_cost_usd = round(self.summarizer.total_calls * 0.0007, 4)
|
|
490
|
-
|
|
491
492
|
self.state.save(self.state_path)
|
|
492
493
|
|
|
493
494
|
# Push to relay if configured and interval elapsed
|
|
@@ -587,6 +588,13 @@ class MonitorDaemon:
|
|
|
587
588
|
# Get all sessions
|
|
588
589
|
sessions = self.session_manager.list_sessions()
|
|
589
590
|
|
|
591
|
+
# Sync Claude Code stats BEFORE building session_states so token counts are fresh
|
|
592
|
+
# This ensures the first loop has accurate data (fixes #103)
|
|
593
|
+
if (now - self._last_stats_sync).total_seconds() >= self._stats_sync_interval:
|
|
594
|
+
for session in sessions:
|
|
595
|
+
self.sync_claude_code_stats(session)
|
|
596
|
+
self._last_stats_sync = now
|
|
597
|
+
|
|
590
598
|
# Detect status and track stats for each session
|
|
591
599
|
session_states = []
|
|
592
600
|
all_waiting_user = True
|
|
@@ -610,12 +618,14 @@ class MonitorDaemon:
|
|
|
610
618
|
continue
|
|
611
619
|
|
|
612
620
|
# Track stats and build state
|
|
613
|
-
|
|
621
|
+
# Use "asleep" status if session is marked as sleeping (#68)
|
|
622
|
+
effective_status = STATUS_ASLEEP if session.is_asleep else status
|
|
623
|
+
session_state = self.track_session_stats(session, effective_status)
|
|
614
624
|
session_state.current_activity = activity
|
|
615
625
|
session_states.append(session_state)
|
|
616
626
|
|
|
617
627
|
# Log status history to session-specific file
|
|
618
|
-
log_agent_status(session.name,
|
|
628
|
+
log_agent_status(session.name, effective_status, activity, history_file=self.history_path)
|
|
619
629
|
|
|
620
630
|
# Track if any session is not waiting for user
|
|
621
631
|
if status != "waiting_user":
|
|
@@ -630,20 +640,6 @@ class MonitorDaemon:
|
|
|
630
640
|
for stale_id in stale_ids:
|
|
631
641
|
del self.previous_states[stale_id]
|
|
632
642
|
|
|
633
|
-
# Sync Claude Code stats periodically (git context is refreshed every loop above)
|
|
634
|
-
if (now - self._last_stats_sync).total_seconds() >= self._stats_sync_interval:
|
|
635
|
-
for session in sessions:
|
|
636
|
-
self.sync_claude_code_stats(session)
|
|
637
|
-
self._last_stats_sync = now
|
|
638
|
-
|
|
639
|
-
# Update summaries (if enabled)
|
|
640
|
-
summaries = self.summarizer.update(sessions)
|
|
641
|
-
for session_state in session_states:
|
|
642
|
-
summary = summaries.get(session_state.session_id)
|
|
643
|
-
if summary:
|
|
644
|
-
session_state.activity_summary = summary.text
|
|
645
|
-
session_state.activity_summary_updated = summary.updated_at
|
|
646
|
-
|
|
647
643
|
# Calculate interval
|
|
648
644
|
interval = self.calculate_interval(sessions, all_waiting_user)
|
|
649
645
|
self.state.current_interval = interval
|
|
@@ -673,7 +669,6 @@ class MonitorDaemon:
|
|
|
673
669
|
finally:
|
|
674
670
|
self.log.info("Monitor daemon shutting down")
|
|
675
671
|
self.presence.stop()
|
|
676
|
-
self.summarizer.stop()
|
|
677
672
|
self.state.status = "stopped"
|
|
678
673
|
self.state.save(self.state_path)
|
|
679
674
|
remove_pid_file(self.pid_path)
|
overcode/monitor_daemon_state.py
CHANGED
|
@@ -67,10 +67,18 @@ class SessionDaemonState:
|
|
|
67
67
|
start_time: Optional[str] = None # ISO timestamp when session started
|
|
68
68
|
permissiveness_mode: str = "normal" # normal, permissive, bypass
|
|
69
69
|
start_directory: Optional[str] = None # For git diff stats
|
|
70
|
+
is_asleep: bool = False # Agent is paused and excluded from stats (#70)
|
|
70
71
|
|
|
71
|
-
#
|
|
72
|
+
# Agent priority value (#61)
|
|
73
|
+
agent_value: int = 1000 # Default 1000, higher = more important
|
|
74
|
+
|
|
75
|
+
# Activity summaries (from SummarizerComponent)
|
|
76
|
+
# Short: current activity - what's happening right now (~50 chars)
|
|
72
77
|
activity_summary: str = ""
|
|
73
78
|
activity_summary_updated: Optional[str] = None # ISO timestamp
|
|
79
|
+
# Context: wider context - what's being worked on overall (~80 chars)
|
|
80
|
+
activity_summary_context: str = ""
|
|
81
|
+
activity_summary_context_updated: Optional[str] = None # ISO timestamp
|
|
74
82
|
|
|
75
83
|
def to_dict(self) -> dict:
|
|
76
84
|
"""Convert to dictionary for JSON serialization."""
|
|
@@ -98,8 +106,12 @@ class SessionDaemonState:
|
|
|
98
106
|
"start_time": self.start_time,
|
|
99
107
|
"permissiveness_mode": self.permissiveness_mode,
|
|
100
108
|
"start_directory": self.start_directory,
|
|
109
|
+
"is_asleep": self.is_asleep,
|
|
110
|
+
"agent_value": self.agent_value,
|
|
101
111
|
"activity_summary": self.activity_summary,
|
|
102
112
|
"activity_summary_updated": self.activity_summary_updated,
|
|
113
|
+
"activity_summary_context": self.activity_summary_context,
|
|
114
|
+
"activity_summary_context_updated": self.activity_summary_context_updated,
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
@classmethod
|
|
@@ -129,8 +141,12 @@ class SessionDaemonState:
|
|
|
129
141
|
start_time=data.get("start_time"),
|
|
130
142
|
permissiveness_mode=data.get("permissiveness_mode", "normal"),
|
|
131
143
|
start_directory=data.get("start_directory"),
|
|
144
|
+
is_asleep=data.get("is_asleep", False),
|
|
145
|
+
agent_value=data.get("agent_value", 1000),
|
|
132
146
|
activity_summary=data.get("activity_summary", ""),
|
|
133
147
|
activity_summary_updated=data.get("activity_summary_updated"),
|
|
148
|
+
activity_summary_context=data.get("activity_summary_context", ""),
|
|
149
|
+
activity_summary_context_updated=data.get("activity_summary_context_updated"),
|
|
134
150
|
)
|
|
135
151
|
|
|
136
152
|
|
|
@@ -180,12 +196,6 @@ class MonitorDaemonState:
|
|
|
180
196
|
relay_last_push: Optional[str] = None # ISO timestamp of last successful push
|
|
181
197
|
relay_last_status: str = "disabled" # "ok", "error", "disabled"
|
|
182
198
|
|
|
183
|
-
# Summarizer status
|
|
184
|
-
summarizer_enabled: bool = False
|
|
185
|
-
summarizer_available: bool = False # True if OPENAI_API_KEY is set
|
|
186
|
-
summarizer_calls: int = 0
|
|
187
|
-
summarizer_cost_usd: float = 0.0
|
|
188
|
-
|
|
189
199
|
def to_dict(self) -> dict:
|
|
190
200
|
"""Convert to dictionary for JSON serialization."""
|
|
191
201
|
return {
|
|
@@ -213,10 +223,6 @@ class MonitorDaemonState:
|
|
|
213
223
|
"relay_enabled": self.relay_enabled,
|
|
214
224
|
"relay_last_push": self.relay_last_push,
|
|
215
225
|
"relay_last_status": self.relay_last_status,
|
|
216
|
-
"summarizer_enabled": self.summarizer_enabled,
|
|
217
|
-
"summarizer_available": self.summarizer_available,
|
|
218
|
-
"summarizer_calls": self.summarizer_calls,
|
|
219
|
-
"summarizer_cost_usd": self.summarizer_cost_usd,
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
@classmethod
|
|
@@ -252,10 +258,6 @@ class MonitorDaemonState:
|
|
|
252
258
|
relay_enabled=data.get("relay_enabled", False),
|
|
253
259
|
relay_last_push=data.get("relay_last_push"),
|
|
254
260
|
relay_last_status=data.get("relay_last_status", "disabled"),
|
|
255
|
-
summarizer_enabled=data.get("summarizer_enabled", False),
|
|
256
|
-
summarizer_available=data.get("summarizer_available", False),
|
|
257
|
-
summarizer_calls=data.get("summarizer_calls", 0),
|
|
258
|
-
summarizer_cost_usd=data.get("summarizer_cost_usd", 0.0),
|
|
259
261
|
)
|
|
260
262
|
|
|
261
263
|
def update_summaries(self) -> None:
|
overcode/pid_utils.py
CHANGED
|
@@ -88,6 +88,10 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
88
88
|
Uses file locking to prevent TOCTOU race conditions when multiple
|
|
89
89
|
processes try to start the daemon simultaneously.
|
|
90
90
|
|
|
91
|
+
IMPORTANT: The lock is held for the daemon's entire lifetime. The lock
|
|
92
|
+
file descriptor is stored in a module-level variable and released
|
|
93
|
+
automatically when the process exits (normal or crash).
|
|
94
|
+
|
|
91
95
|
Args:
|
|
92
96
|
pid_file: Path to the PID file
|
|
93
97
|
|
|
@@ -96,6 +100,8 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
96
100
|
- (True, None) if lock was acquired and PID file written
|
|
97
101
|
- (False, existing_pid) if another daemon is already running
|
|
98
102
|
"""
|
|
103
|
+
global _held_lock_fd
|
|
104
|
+
|
|
99
105
|
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
100
106
|
|
|
101
107
|
# Use a separate lock file to avoid truncation issues
|
|
@@ -110,12 +116,14 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
110
116
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
111
117
|
|
|
112
118
|
# We have the lock - now check if another daemon is running
|
|
119
|
+
# (handles case where previous daemon crashed without releasing lock)
|
|
113
120
|
if pid_file.exists():
|
|
114
121
|
try:
|
|
115
122
|
existing_pid = int(pid_file.read_text().strip())
|
|
116
123
|
# Check if process is still alive
|
|
117
124
|
os.kill(existing_pid, 0)
|
|
118
125
|
# Process exists - another daemon is running
|
|
126
|
+
# This shouldn't happen if locking works, but check anyway
|
|
119
127
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
120
128
|
os.close(fd)
|
|
121
129
|
return False, existing_pid
|
|
@@ -127,9 +135,10 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
127
135
|
current_pid = os.getpid()
|
|
128
136
|
pid_file.write_text(str(current_pid))
|
|
129
137
|
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
# IMPORTANT: Keep the lock held for the daemon's entire lifetime!
|
|
139
|
+
# The OS will automatically release it when the process exits.
|
|
140
|
+
# Store fd in module-level variable to prevent garbage collection.
|
|
141
|
+
_held_lock_fd = fd
|
|
133
142
|
|
|
134
143
|
return True, None
|
|
135
144
|
|
|
@@ -150,6 +159,11 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
|
|
|
150
159
|
return False, None
|
|
151
160
|
|
|
152
161
|
|
|
162
|
+
# Module-level variable to hold the lock file descriptor.
|
|
163
|
+
# This prevents garbage collection from closing the fd and releasing the lock.
|
|
164
|
+
_held_lock_fd: Optional[int] = None
|
|
165
|
+
|
|
166
|
+
|
|
153
167
|
def count_daemon_processes(pattern: str = "monitor_daemon", session: str = None) -> int:
|
|
154
168
|
"""Count running daemon processes matching the pattern.
|
|
155
169
|
|
overcode/session_manager.py
CHANGED
|
@@ -91,6 +91,17 @@ class Session:
|
|
|
91
91
|
# Sleep mode - agent is paused and excluded from stats
|
|
92
92
|
is_asleep: bool = False
|
|
93
93
|
|
|
94
|
+
# Agent value - priority indicator for sorting/attention (#61)
|
|
95
|
+
# Default 1000, higher = more important
|
|
96
|
+
agent_value: int = 1000
|
|
97
|
+
|
|
98
|
+
# Human annotation - user's notes about this agent (#74)
|
|
99
|
+
human_annotation: str = ""
|
|
100
|
+
|
|
101
|
+
# Claude sessionIds owned by this overcode session (#119)
|
|
102
|
+
# Used to accurately calculate context window for this specific agent
|
|
103
|
+
claude_session_ids: List[str] = field(default_factory=list)
|
|
104
|
+
|
|
94
105
|
def to_dict(self) -> dict:
|
|
95
106
|
data = asdict(self)
|
|
96
107
|
# Convert stats to dict
|
|
@@ -607,3 +618,45 @@ class SessionManager:
|
|
|
607
618
|
def set_permissiveness(self, session_id: str, mode: str):
|
|
608
619
|
"""Set permissiveness mode (normal, permissive, strict)"""
|
|
609
620
|
self.update_session(session_id, permissiveness_mode=mode)
|
|
621
|
+
|
|
622
|
+
def set_agent_value(self, session_id: str, value: int):
|
|
623
|
+
"""Set agent value for priority sorting (#61).
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
session_id: The session ID
|
|
627
|
+
value: Priority value (default 1000, higher = more important)
|
|
628
|
+
"""
|
|
629
|
+
self.update_session(session_id, agent_value=value)
|
|
630
|
+
|
|
631
|
+
def set_human_annotation(self, session_id: str, annotation: str):
|
|
632
|
+
"""Set human annotation for a session (#74)."""
|
|
633
|
+
self.update_session(session_id, human_annotation=annotation)
|
|
634
|
+
|
|
635
|
+
def add_claude_session_id(self, session_id: str, claude_session_id: str) -> bool:
|
|
636
|
+
"""Add a Claude sessionId to a session's owned list if not already present.
|
|
637
|
+
|
|
638
|
+
This tracks which Claude sessionIds belong to this overcode agent,
|
|
639
|
+
enabling accurate context window calculation when multiple agents
|
|
640
|
+
run in the same directory (#119).
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
session_id: The overcode session ID
|
|
644
|
+
claude_session_id: The Claude Code sessionId to add
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
True if the sessionId was added, False if already present or session not found
|
|
648
|
+
"""
|
|
649
|
+
session = self.get_session(session_id)
|
|
650
|
+
if not session or claude_session_id in session.claude_session_ids:
|
|
651
|
+
return False
|
|
652
|
+
|
|
653
|
+
def do_update(state):
|
|
654
|
+
if session_id in state:
|
|
655
|
+
ids = state[session_id].get('claude_session_ids', [])
|
|
656
|
+
if claude_session_id not in ids:
|
|
657
|
+
ids.append(claude_session_id)
|
|
658
|
+
state[session_id]['claude_session_ids'] = ids
|
|
659
|
+
return state
|
|
660
|
+
|
|
661
|
+
self._atomic_update(do_update)
|
|
662
|
+
return True
|
overcode/settings.py
CHANGED
|
@@ -380,6 +380,10 @@ class TUIPreferences:
|
|
|
380
380
|
daemon_panel_visible: bool = False
|
|
381
381
|
view_mode: str = "tree" # tree, list_preview
|
|
382
382
|
tmux_sync: bool = False # sync navigation to external tmux pane
|
|
383
|
+
show_terminated: bool = False # keep killed sessions visible in timeline
|
|
384
|
+
hide_asleep: bool = False # hide sleeping agents from display
|
|
385
|
+
sort_mode: str = "alphabetical" # alphabetical, by_status, by_value (#61)
|
|
386
|
+
summary_content_mode: str = "ai_short" # ai_short, ai_long, orders, annotation (#98)
|
|
383
387
|
# Session IDs of stalled agents that have been visited by the user
|
|
384
388
|
visited_stalled_agents: Set[str] = field(default_factory=set)
|
|
385
389
|
|
|
@@ -405,6 +409,10 @@ class TUIPreferences:
|
|
|
405
409
|
daemon_panel_visible=data.get("daemon_panel_visible", False),
|
|
406
410
|
view_mode=data.get("view_mode", "tree"),
|
|
407
411
|
tmux_sync=data.get("tmux_sync", False),
|
|
412
|
+
show_terminated=data.get("show_terminated", False),
|
|
413
|
+
hide_asleep=data.get("hide_asleep", False),
|
|
414
|
+
sort_mode=data.get("sort_mode", "alphabetical"),
|
|
415
|
+
summary_content_mode=data.get("summary_content_mode", "ai_short"),
|
|
408
416
|
visited_stalled_agents=set(data.get("visited_stalled_agents", [])),
|
|
409
417
|
)
|
|
410
418
|
except (json.JSONDecodeError, IOError):
|
|
@@ -425,6 +433,10 @@ class TUIPreferences:
|
|
|
425
433
|
"daemon_panel_visible": self.daemon_panel_visible,
|
|
426
434
|
"view_mode": self.view_mode,
|
|
427
435
|
"tmux_sync": self.tmux_sync,
|
|
436
|
+
"show_terminated": self.show_terminated,
|
|
437
|
+
"hide_asleep": self.hide_asleep,
|
|
438
|
+
"sort_mode": self.sort_mode,
|
|
439
|
+
"summary_content_mode": self.summary_content_mode,
|
|
428
440
|
"visited_stalled_agents": list(self.visited_stalled_agents),
|
|
429
441
|
}, f, indent=2)
|
|
430
442
|
except (IOError, OSError):
|
overcode/status_constants.py
CHANGED
|
@@ -119,7 +119,7 @@ AGENT_TIMELINE_CHARS = {
|
|
|
119
119
|
STATUS_WAITING_SUPERVISOR: "▒",
|
|
120
120
|
STATUS_WAITING_USER: "░",
|
|
121
121
|
STATUS_TERMINATED: "×", # Small X - terminated
|
|
122
|
-
STATUS_ASLEEP: "
|
|
122
|
+
STATUS_ASLEEP: "░", # Light shade hatching (grey) - sleeping/paused
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
|