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/launcher.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Launcher for interactive Claude Code sessions in tmux windows.
|
|
3
|
+
|
|
4
|
+
All Claude sessions launched by overcode are interactive - users can
|
|
5
|
+
take over at any time. Initial prompts are sent as keystrokes after
|
|
6
|
+
Claude starts, not as CLI arguments.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import os
|
|
13
|
+
from typing import List, Optional, TYPE_CHECKING
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from .tmux_manager import TmuxManager
|
|
19
|
+
from .session_manager import SessionManager, Session
|
|
20
|
+
from .config import get_default_standing_instructions
|
|
21
|
+
from .dependency_check import require_tmux, require_claude
|
|
22
|
+
from .exceptions import TmuxNotFoundError, ClaudeNotFoundError, InvalidSessionNameError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Valid session name pattern
|
|
26
|
+
SESSION_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_session_name(name: str) -> None:
|
|
30
|
+
"""Validate session name format.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
name: Session name to validate
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
InvalidSessionNameError: If name is invalid
|
|
37
|
+
"""
|
|
38
|
+
if not name:
|
|
39
|
+
raise InvalidSessionNameError(name, "name cannot be empty")
|
|
40
|
+
if not SESSION_NAME_PATTERN.match(name):
|
|
41
|
+
raise InvalidSessionNameError(name)
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
pass # For future type hints
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ClaudeLauncher:
|
|
48
|
+
"""Launches interactive Claude Code sessions in tmux windows.
|
|
49
|
+
|
|
50
|
+
All sessions are interactive - this is the only supported mode.
|
|
51
|
+
Users can take over any session at any time via tmux.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
tmux_session: str = "agents",
|
|
57
|
+
tmux_manager: TmuxManager = None,
|
|
58
|
+
session_manager: SessionManager = None,
|
|
59
|
+
):
|
|
60
|
+
"""Initialize the launcher.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
tmux_session: Name of the tmux session to use
|
|
64
|
+
tmux_manager: Optional TmuxManager for dependency injection (testing)
|
|
65
|
+
session_manager: Optional SessionManager for dependency injection (testing)
|
|
66
|
+
"""
|
|
67
|
+
self.tmux = tmux_manager if tmux_manager else TmuxManager(tmux_session)
|
|
68
|
+
self.sessions = session_manager if session_manager else SessionManager()
|
|
69
|
+
|
|
70
|
+
def launch(
|
|
71
|
+
self,
|
|
72
|
+
name: str,
|
|
73
|
+
start_directory: Optional[str] = None,
|
|
74
|
+
initial_prompt: Optional[str] = None,
|
|
75
|
+
skip_permissions: bool = False,
|
|
76
|
+
dangerously_skip_permissions: bool = False,
|
|
77
|
+
) -> Optional[Session]:
|
|
78
|
+
"""
|
|
79
|
+
Launch an interactive Claude Code session in a tmux window.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
name: Name for this Claude session
|
|
83
|
+
start_directory: Starting directory for the session
|
|
84
|
+
initial_prompt: Optional initial prompt to send after Claude starts
|
|
85
|
+
skip_permissions: If True, use --permission-mode dontAsk
|
|
86
|
+
dangerously_skip_permissions: If True, use --dangerously-skip-permissions
|
|
87
|
+
(for testing only - bypasses folder trust dialog)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Session object if successful, None otherwise
|
|
91
|
+
"""
|
|
92
|
+
# Validate session name
|
|
93
|
+
try:
|
|
94
|
+
validate_session_name(name)
|
|
95
|
+
except InvalidSessionNameError as e:
|
|
96
|
+
print(f"Cannot launch: {e}")
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
# Check dependencies before attempting to launch
|
|
100
|
+
try:
|
|
101
|
+
require_tmux()
|
|
102
|
+
require_claude()
|
|
103
|
+
except (TmuxNotFoundError, ClaudeNotFoundError) as e:
|
|
104
|
+
print(f"Cannot launch: {e}")
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# Check if a session with this name already exists
|
|
108
|
+
existing = self.sessions.get_session_by_name(name)
|
|
109
|
+
if existing:
|
|
110
|
+
# Check if its tmux window still exists
|
|
111
|
+
if self.tmux.window_exists(existing.tmux_window):
|
|
112
|
+
print(f"Session '{name}' already exists in window {existing.tmux_window}")
|
|
113
|
+
return existing
|
|
114
|
+
else:
|
|
115
|
+
# Window is gone, clean up the stale session
|
|
116
|
+
self.sessions.delete_session(existing.id)
|
|
117
|
+
|
|
118
|
+
# Ensure tmux session exists
|
|
119
|
+
if not self.tmux.ensure_session():
|
|
120
|
+
print(f"Failed to create tmux session '{self.tmux.session_name}'")
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# Create window
|
|
124
|
+
window_index = self.tmux.create_window(name, start_directory)
|
|
125
|
+
if window_index is None:
|
|
126
|
+
print(f"Failed to create tmux window '{name}'")
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
# Build the claude command - always interactive
|
|
130
|
+
# Support CLAUDE_COMMAND env var for testing with mock
|
|
131
|
+
claude_command = os.environ.get("CLAUDE_COMMAND", "claude")
|
|
132
|
+
claude_cmd = [claude_command, "code"] if claude_command == "claude" else [claude_command]
|
|
133
|
+
if dangerously_skip_permissions:
|
|
134
|
+
claude_cmd.append("--dangerously-skip-permissions")
|
|
135
|
+
elif skip_permissions:
|
|
136
|
+
claude_cmd.extend(["--permission-mode", "dontAsk"])
|
|
137
|
+
|
|
138
|
+
# If MOCK_SCENARIO is set, prepend it to the command for testing
|
|
139
|
+
mock_scenario = os.environ.get("MOCK_SCENARIO")
|
|
140
|
+
if mock_scenario:
|
|
141
|
+
cmd_str = f"MOCK_SCENARIO={mock_scenario} python {' '.join(claude_cmd)}"
|
|
142
|
+
else:
|
|
143
|
+
cmd_str = " ".join(claude_cmd)
|
|
144
|
+
|
|
145
|
+
# Send command to window to start interactive Claude
|
|
146
|
+
if not self.tmux.send_keys(window_index, cmd_str, enter=True):
|
|
147
|
+
print(f"Failed to send command to window {window_index}")
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
# Determine permissiveness mode based on flags
|
|
151
|
+
if dangerously_skip_permissions:
|
|
152
|
+
perm_mode = "bypass"
|
|
153
|
+
elif skip_permissions:
|
|
154
|
+
perm_mode = "permissive"
|
|
155
|
+
else:
|
|
156
|
+
perm_mode = "normal"
|
|
157
|
+
|
|
158
|
+
# Register session with default standing instructions from config
|
|
159
|
+
default_instructions = get_default_standing_instructions()
|
|
160
|
+
session = self.sessions.create_session(
|
|
161
|
+
name=name,
|
|
162
|
+
tmux_session=self.tmux.session_name,
|
|
163
|
+
tmux_window=window_index,
|
|
164
|
+
command=claude_cmd,
|
|
165
|
+
start_directory=start_directory,
|
|
166
|
+
standing_instructions=default_instructions,
|
|
167
|
+
permissiveness_mode=perm_mode
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
print(f"✓ Launched '{name}' in tmux window {window_index}")
|
|
171
|
+
|
|
172
|
+
# Send initial prompt if provided (after Claude starts)
|
|
173
|
+
if initial_prompt:
|
|
174
|
+
self._send_prompt_to_window(window_index, initial_prompt)
|
|
175
|
+
|
|
176
|
+
return session
|
|
177
|
+
|
|
178
|
+
def _send_prompt_to_window(
|
|
179
|
+
self,
|
|
180
|
+
window_index: int,
|
|
181
|
+
prompt: str,
|
|
182
|
+
startup_delay: float = 3.0,
|
|
183
|
+
) -> bool:
|
|
184
|
+
"""
|
|
185
|
+
Send a prompt to a Claude session via tmux keystrokes.
|
|
186
|
+
|
|
187
|
+
This sends the prompt as if the user typed it, so the session
|
|
188
|
+
remains fully interactive - the user can take over at any time.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
window_index: The tmux window index
|
|
192
|
+
prompt: The prompt text to send
|
|
193
|
+
startup_delay: Seconds to wait for Claude to start (default: 3)
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
True if successful, False otherwise
|
|
197
|
+
"""
|
|
198
|
+
# Wait for Claude to start up
|
|
199
|
+
time.sleep(startup_delay)
|
|
200
|
+
|
|
201
|
+
# For large prompts, use tmux load-buffer/paste-buffer
|
|
202
|
+
# to avoid escaping issues and line length limits
|
|
203
|
+
lines = prompt.split('\n')
|
|
204
|
+
batch_size = 10
|
|
205
|
+
|
|
206
|
+
for i in range(0, len(lines), batch_size):
|
|
207
|
+
batch = lines[i:i + batch_size]
|
|
208
|
+
text = '\n'.join(batch)
|
|
209
|
+
if i + batch_size < len(lines):
|
|
210
|
+
text += '\n' # Add newline between batches
|
|
211
|
+
|
|
212
|
+
# Use tempfile for the buffer
|
|
213
|
+
temp_path = None
|
|
214
|
+
try:
|
|
215
|
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
|
|
216
|
+
temp_path = f.name
|
|
217
|
+
f.write(text)
|
|
218
|
+
|
|
219
|
+
subprocess.run(['tmux', 'load-buffer', temp_path], timeout=5, check=True)
|
|
220
|
+
subprocess.run([
|
|
221
|
+
'tmux', 'paste-buffer', '-t',
|
|
222
|
+
f"{self.tmux.session_name}:{window_index}"
|
|
223
|
+
], timeout=5, check=True)
|
|
224
|
+
except subprocess.SubprocessError as e:
|
|
225
|
+
print(f"Failed to send prompt batch: {e}")
|
|
226
|
+
return False
|
|
227
|
+
finally:
|
|
228
|
+
if temp_path:
|
|
229
|
+
try:
|
|
230
|
+
os.unlink(temp_path)
|
|
231
|
+
except OSError:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
time.sleep(0.1)
|
|
235
|
+
|
|
236
|
+
# Send Enter to submit the prompt
|
|
237
|
+
subprocess.run([
|
|
238
|
+
'tmux', 'send-keys', '-t',
|
|
239
|
+
f"{self.tmux.session_name}:{window_index}",
|
|
240
|
+
'', 'Enter'
|
|
241
|
+
])
|
|
242
|
+
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
def attach(self):
|
|
246
|
+
"""Attach to the tmux session"""
|
|
247
|
+
if not self.tmux.session_exists():
|
|
248
|
+
print(f"Error: tmux session '{self.tmux.session_name}' does not exist")
|
|
249
|
+
print("No active sessions to attach to. Launch a session first with 'overcode launch'")
|
|
250
|
+
return
|
|
251
|
+
self.tmux.attach_session()
|
|
252
|
+
|
|
253
|
+
def list_sessions(self, detect_terminated: bool = True, kill_untracked: bool = False) -> List[Session]:
|
|
254
|
+
"""
|
|
255
|
+
List all registered sessions, detecting terminated ones.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
detect_terminated: If True (default), check tmux and mark sessions as
|
|
259
|
+
"terminated" if their window no longer exists
|
|
260
|
+
kill_untracked: If True, kill tmux windows that aren't tracked in sessions.json
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
List of all Session objects (including terminated ones)
|
|
264
|
+
"""
|
|
265
|
+
all_sessions = self.sessions.list_sessions()
|
|
266
|
+
|
|
267
|
+
# Filter to only sessions belonging to this tmux session
|
|
268
|
+
my_sessions = [s for s in all_sessions if s.tmux_session == self.tmux.session_name]
|
|
269
|
+
other_sessions = [s for s in all_sessions if s.tmux_session != self.tmux.session_name]
|
|
270
|
+
|
|
271
|
+
# Detect terminated sessions (tmux window gone but session still tracked)
|
|
272
|
+
if detect_terminated:
|
|
273
|
+
newly_terminated = []
|
|
274
|
+
for session in my_sessions:
|
|
275
|
+
# Only check non-terminated sessions
|
|
276
|
+
if session.status != "terminated":
|
|
277
|
+
if not self.tmux.window_exists(session.tmux_window):
|
|
278
|
+
# Mark as terminated in state file
|
|
279
|
+
self.sessions.update_session_status(session.id, "terminated")
|
|
280
|
+
session.status = "terminated" # Update local object too
|
|
281
|
+
newly_terminated.append(session.name)
|
|
282
|
+
|
|
283
|
+
if newly_terminated:
|
|
284
|
+
print(f"Detected {len(newly_terminated)} terminated session(s): {', '.join(newly_terminated)}")
|
|
285
|
+
|
|
286
|
+
# Kill untracked windows (tmux windows exist but not tracked)
|
|
287
|
+
if kill_untracked and self.tmux.session_exists():
|
|
288
|
+
active_sessions = [s for s in my_sessions if s.status != "terminated"]
|
|
289
|
+
tracked_windows = {s.tmux_window for s in active_sessions}
|
|
290
|
+
tmux_windows = self.tmux.list_windows()
|
|
291
|
+
|
|
292
|
+
untracked_count = 0
|
|
293
|
+
for window_info in tmux_windows:
|
|
294
|
+
window_idx = int(window_info['index'])
|
|
295
|
+
# Don't kill window 0 (default shell) or tracked windows
|
|
296
|
+
if window_idx != 0 and window_idx not in tracked_windows:
|
|
297
|
+
window_name = window_info['name']
|
|
298
|
+
print(f"Killing untracked window {window_idx}: {window_name}")
|
|
299
|
+
self.tmux.kill_window(window_idx)
|
|
300
|
+
untracked_count += 1
|
|
301
|
+
|
|
302
|
+
if untracked_count > 0:
|
|
303
|
+
print(f"Killed {untracked_count} untracked window(s)")
|
|
304
|
+
|
|
305
|
+
return my_sessions + other_sessions
|
|
306
|
+
|
|
307
|
+
def cleanup_terminated_sessions(self) -> int:
|
|
308
|
+
"""Remove all terminated sessions from state.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Number of sessions cleaned up
|
|
312
|
+
"""
|
|
313
|
+
all_sessions = self.sessions.list_sessions()
|
|
314
|
+
terminated = [s for s in all_sessions if s.status == "terminated"]
|
|
315
|
+
|
|
316
|
+
for session in terminated:
|
|
317
|
+
self.sessions.delete_session(session.id)
|
|
318
|
+
|
|
319
|
+
return len(terminated)
|
|
320
|
+
|
|
321
|
+
def kill_session(self, name: str) -> bool:
|
|
322
|
+
"""Kill a session by name.
|
|
323
|
+
|
|
324
|
+
Handles both active sessions and stale sessions (where tmux window/session
|
|
325
|
+
no longer exists, e.g., after a machine reboot).
|
|
326
|
+
"""
|
|
327
|
+
session = self.sessions.get_session_by_name(name)
|
|
328
|
+
if session is None:
|
|
329
|
+
print(f"Session '{name}' not found")
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
# Check if the tmux window/session still exists
|
|
333
|
+
window_exists = self.tmux.window_exists(session.tmux_window)
|
|
334
|
+
|
|
335
|
+
if window_exists:
|
|
336
|
+
# Active session - try to kill the tmux window
|
|
337
|
+
if self.tmux.kill_window(session.tmux_window):
|
|
338
|
+
self.sessions.delete_session(session.id)
|
|
339
|
+
print(f"✓ Killed session '{name}'")
|
|
340
|
+
return True
|
|
341
|
+
else:
|
|
342
|
+
print(f"Failed to kill tmux window for '{name}'")
|
|
343
|
+
return False
|
|
344
|
+
else:
|
|
345
|
+
# Stale session - tmux window/session is already gone (e.g., after reboot)
|
|
346
|
+
# Just clean up the state file
|
|
347
|
+
self.sessions.delete_session(session.id)
|
|
348
|
+
print(f"✓ Cleaned up stale session '{name}' (tmux window no longer exists)")
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
def send_to_session(self, name: str, text: str, enter: bool = True) -> bool:
|
|
352
|
+
"""Send text/keys to a session by name.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
name: Name of the session
|
|
356
|
+
text: Text to send (or special key like "Enter", "Escape")
|
|
357
|
+
enter: Whether to press Enter after the text (default: True)
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
True if successful, False otherwise
|
|
361
|
+
"""
|
|
362
|
+
session = self.sessions.get_session_by_name(name)
|
|
363
|
+
if session is None:
|
|
364
|
+
print(f"Session '{name}' not found")
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
# Handle special keys
|
|
368
|
+
special_keys = {
|
|
369
|
+
"enter": "", # Empty string + Enter = just press Enter
|
|
370
|
+
"escape": "Escape",
|
|
371
|
+
"esc": "Escape",
|
|
372
|
+
"tab": "Tab",
|
|
373
|
+
"up": "Up",
|
|
374
|
+
"down": "Down",
|
|
375
|
+
"left": "Left",
|
|
376
|
+
"right": "Right",
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# Check if it's a special key
|
|
380
|
+
text_lower = text.lower().strip()
|
|
381
|
+
success = False
|
|
382
|
+
if text_lower in special_keys:
|
|
383
|
+
key = special_keys[text_lower]
|
|
384
|
+
if key == "":
|
|
385
|
+
# Just press Enter
|
|
386
|
+
success = self.tmux.send_keys(session.tmux_window, "", enter=True)
|
|
387
|
+
else:
|
|
388
|
+
# Send special key without Enter
|
|
389
|
+
success = self.tmux.send_keys(session.tmux_window, key, enter=False)
|
|
390
|
+
else:
|
|
391
|
+
# Regular text
|
|
392
|
+
success = self.tmux.send_keys(session.tmux_window, text, enter=enter)
|
|
393
|
+
|
|
394
|
+
# Update last activity on success (steers_count is tracked via supervisor log parsing)
|
|
395
|
+
if success:
|
|
396
|
+
self.sessions.update_stats(
|
|
397
|
+
session.id,
|
|
398
|
+
last_activity=time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return success
|
|
402
|
+
|
|
403
|
+
def get_session_output(self, name: str, lines: int = 50) -> Optional[str]:
|
|
404
|
+
"""Get recent output from a session.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
name: Name of the session
|
|
408
|
+
lines: Number of lines to capture (default: 50)
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
The captured output, or None if session not found
|
|
412
|
+
"""
|
|
413
|
+
session = self.sessions.get_session_by_name(name)
|
|
414
|
+
if session is None:
|
|
415
|
+
print(f"Session '{name}' not found")
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
result = subprocess.run(
|
|
420
|
+
[
|
|
421
|
+
"tmux", "capture-pane",
|
|
422
|
+
"-t", f"{self.tmux.session_name}:{session.tmux_window}",
|
|
423
|
+
"-p", # Print to stdout
|
|
424
|
+
"-S", f"-{lines}", # Capture last N lines
|
|
425
|
+
],
|
|
426
|
+
capture_output=True,
|
|
427
|
+
text=True,
|
|
428
|
+
timeout=5
|
|
429
|
+
)
|
|
430
|
+
if result.returncode == 0:
|
|
431
|
+
return result.stdout.rstrip()
|
|
432
|
+
return None
|
|
433
|
+
except subprocess.SubprocessError:
|
|
434
|
+
return None
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logging configuration for Overcode.
|
|
3
|
+
|
|
4
|
+
Provides centralized logging configuration with support for:
|
|
5
|
+
- Console output with Rich formatting (optional)
|
|
6
|
+
- File output for persistent logs
|
|
7
|
+
- Different log levels per component
|
|
8
|
+
- Structured log messages
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Default log directory
|
|
19
|
+
DEFAULT_LOG_DIR = Path.home() / ".overcode" / "logs"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_logger(name: str) -> logging.Logger:
|
|
23
|
+
"""Get a logger for the specified component.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
name: Component name (e.g., 'daemon', 'launcher', 'tui')
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Configured logger instance
|
|
30
|
+
"""
|
|
31
|
+
return logging.getLogger(f"overcode.{name}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def setup_logging(
|
|
35
|
+
level: int = logging.INFO,
|
|
36
|
+
log_file: Optional[Path] = None,
|
|
37
|
+
console: bool = True,
|
|
38
|
+
rich_console: bool = False,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Configure logging for the application.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
level: Logging level (default: INFO)
|
|
44
|
+
log_file: Optional path to log file
|
|
45
|
+
console: Whether to log to console (default: True)
|
|
46
|
+
rich_console: Whether to use Rich for console output (default: False)
|
|
47
|
+
"""
|
|
48
|
+
root_logger = logging.getLogger("overcode")
|
|
49
|
+
root_logger.setLevel(level)
|
|
50
|
+
|
|
51
|
+
# Clear existing handlers
|
|
52
|
+
root_logger.handlers.clear()
|
|
53
|
+
|
|
54
|
+
# Log format
|
|
55
|
+
fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
56
|
+
date_fmt = "%Y-%m-%d %H:%M:%S"
|
|
57
|
+
|
|
58
|
+
# Console handler
|
|
59
|
+
if console:
|
|
60
|
+
if rich_console:
|
|
61
|
+
try:
|
|
62
|
+
from rich.logging import RichHandler
|
|
63
|
+
|
|
64
|
+
console_handler = RichHandler(
|
|
65
|
+
show_time=True,
|
|
66
|
+
show_path=False,
|
|
67
|
+
markup=True,
|
|
68
|
+
rich_tracebacks=True,
|
|
69
|
+
)
|
|
70
|
+
console_handler.setLevel(level)
|
|
71
|
+
root_logger.addHandler(console_handler)
|
|
72
|
+
except ImportError:
|
|
73
|
+
# Fall back to standard console handler
|
|
74
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
75
|
+
console_handler.setLevel(level)
|
|
76
|
+
console_handler.setFormatter(logging.Formatter(fmt, date_fmt))
|
|
77
|
+
root_logger.addHandler(console_handler)
|
|
78
|
+
else:
|
|
79
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
80
|
+
console_handler.setLevel(level)
|
|
81
|
+
console_handler.setFormatter(logging.Formatter(fmt, date_fmt))
|
|
82
|
+
root_logger.addHandler(console_handler)
|
|
83
|
+
|
|
84
|
+
# File handler
|
|
85
|
+
if log_file:
|
|
86
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
file_handler = logging.FileHandler(log_file)
|
|
88
|
+
file_handler.setLevel(level)
|
|
89
|
+
file_handler.setFormatter(logging.Formatter(fmt, date_fmt))
|
|
90
|
+
root_logger.addHandler(file_handler)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def setup_daemon_logging(log_file: Optional[Path] = None) -> logging.Logger:
|
|
94
|
+
"""Configure logging specifically for the daemon.
|
|
95
|
+
|
|
96
|
+
Uses file logging by default to the daemon log directory.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
log_file: Optional custom log file path
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Configured daemon logger
|
|
103
|
+
"""
|
|
104
|
+
if log_file is None:
|
|
105
|
+
DEFAULT_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
log_file = DEFAULT_LOG_DIR / "daemon.log"
|
|
107
|
+
|
|
108
|
+
setup_logging(
|
|
109
|
+
level=logging.INFO,
|
|
110
|
+
log_file=log_file,
|
|
111
|
+
console=True,
|
|
112
|
+
rich_console=True,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return get_logger("daemon")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def setup_cli_logging() -> logging.Logger:
|
|
119
|
+
"""Configure logging for CLI commands.
|
|
120
|
+
|
|
121
|
+
Uses minimal console output since CLI uses Rich for user feedback.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Configured CLI logger
|
|
125
|
+
"""
|
|
126
|
+
setup_logging(
|
|
127
|
+
level=logging.WARNING, # Only warnings and errors
|
|
128
|
+
console=True,
|
|
129
|
+
rich_console=False,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return get_logger("cli")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class StructuredLogger:
|
|
136
|
+
"""Logger that supports structured log messages with context."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, logger: logging.Logger):
|
|
139
|
+
self._logger = logger
|
|
140
|
+
self._context: dict = {}
|
|
141
|
+
|
|
142
|
+
def with_context(self, **kwargs) -> "StructuredLogger":
|
|
143
|
+
"""Create a new logger with additional context.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
**kwargs: Key-value pairs to add to log context
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
New StructuredLogger with merged context
|
|
150
|
+
"""
|
|
151
|
+
new_logger = StructuredLogger(self._logger)
|
|
152
|
+
new_logger._context = {**self._context, **kwargs}
|
|
153
|
+
return new_logger
|
|
154
|
+
|
|
155
|
+
def _format_message(self, message: str, **kwargs) -> str:
|
|
156
|
+
"""Format message with context."""
|
|
157
|
+
context = {**self._context, **kwargs}
|
|
158
|
+
if context:
|
|
159
|
+
context_str = " ".join(f"{k}={v}" for k, v in context.items())
|
|
160
|
+
return f"{message} [{context_str}]"
|
|
161
|
+
return message
|
|
162
|
+
|
|
163
|
+
def debug(self, message: str, **kwargs) -> None:
|
|
164
|
+
"""Log debug message."""
|
|
165
|
+
self._logger.debug(self._format_message(message, **kwargs))
|
|
166
|
+
|
|
167
|
+
def info(self, message: str, **kwargs) -> None:
|
|
168
|
+
"""Log info message."""
|
|
169
|
+
self._logger.info(self._format_message(message, **kwargs))
|
|
170
|
+
|
|
171
|
+
def warning(self, message: str, **kwargs) -> None:
|
|
172
|
+
"""Log warning message."""
|
|
173
|
+
self._logger.warning(self._format_message(message, **kwargs))
|
|
174
|
+
|
|
175
|
+
def error(self, message: str, **kwargs) -> None:
|
|
176
|
+
"""Log error message."""
|
|
177
|
+
self._logger.error(self._format_message(message, **kwargs))
|
|
178
|
+
|
|
179
|
+
def exception(self, message: str, **kwargs) -> None:
|
|
180
|
+
"""Log exception with traceback."""
|
|
181
|
+
self._logger.exception(self._format_message(message, **kwargs))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_structured_logger(name: str) -> StructuredLogger:
|
|
185
|
+
"""Get a structured logger for the specified component.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
name: Component name
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
StructuredLogger instance
|
|
192
|
+
"""
|
|
193
|
+
return StructuredLogger(get_logger(name))
|