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
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
presence_logger.py
|
|
3
|
+
|
|
4
|
+
Mac-only presence logger that records user presence/absence stats.
|
|
5
|
+
|
|
6
|
+
Records once per SAMPLE_INTERVAL:
|
|
7
|
+
- timestamp (ISO8601 local time)
|
|
8
|
+
- state (1=locked/sleep, 2=screen on inactive, 3=screen on active)
|
|
9
|
+
- idle_seconds
|
|
10
|
+
- locked (0/1)
|
|
11
|
+
- inferred_sleep (0/1)
|
|
12
|
+
|
|
13
|
+
Data is appended to:
|
|
14
|
+
~/.overcode/presence_log.csv
|
|
15
|
+
|
|
16
|
+
Usage from another Python app:
|
|
17
|
+
|
|
18
|
+
from overcode.presence_logger import start_background_logger
|
|
19
|
+
|
|
20
|
+
logger = start_background_logger()
|
|
21
|
+
# ... your app runs ...
|
|
22
|
+
logger.stop() # optional, logs until process exits anyway
|
|
23
|
+
|
|
24
|
+
CLI usage:
|
|
25
|
+
|
|
26
|
+
overcode presence
|
|
27
|
+
|
|
28
|
+
to run it in the foreground.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import csv
|
|
32
|
+
import datetime as dt
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Optional
|
|
40
|
+
|
|
41
|
+
from .pid_utils import is_process_running, get_process_pid, write_pid_file, remove_pid_file
|
|
42
|
+
|
|
43
|
+
# Check for macOS-specific dependencies
|
|
44
|
+
try:
|
|
45
|
+
from Quartz import (
|
|
46
|
+
CGEventSourceSecondsSinceLastEventType,
|
|
47
|
+
kCGEventSourceStateCombinedSessionState,
|
|
48
|
+
kCGAnyInputEventType,
|
|
49
|
+
)
|
|
50
|
+
from ApplicationServices import CGSessionCopyCurrentDictionary
|
|
51
|
+
MACOS_APIS_AVAILABLE = True
|
|
52
|
+
except ImportError:
|
|
53
|
+
MACOS_APIS_AVAILABLE = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---- config -----------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
OVERCODE_DIR = Path.home() / ".overcode"
|
|
59
|
+
PRESENCE_PID_FILE = OVERCODE_DIR / "presence.pid"
|
|
60
|
+
DEFAULT_SAMPLE_INTERVAL = 60 # seconds
|
|
61
|
+
DEFAULT_IDLE_THRESHOLD = 60 # seconds
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_presence_running() -> bool:
|
|
65
|
+
"""Check if the presence logger process is currently running.
|
|
66
|
+
|
|
67
|
+
Returns True if PID file exists and process is alive.
|
|
68
|
+
"""
|
|
69
|
+
return is_process_running(PRESENCE_PID_FILE)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_presence_pid() -> Optional[int]:
|
|
73
|
+
"""Get the presence logger PID if running, None otherwise."""
|
|
74
|
+
return get_process_pid(PRESENCE_PID_FILE)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _write_pid_file() -> None:
|
|
78
|
+
"""Write current PID to file."""
|
|
79
|
+
write_pid_file(PRESENCE_PID_FILE)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _remove_pid_file() -> None:
|
|
83
|
+
"""Remove PID file."""
|
|
84
|
+
remove_pid_file(PRESENCE_PID_FILE)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def default_log_path() -> str:
|
|
88
|
+
"""Return default CSV path under ~/.overcode/."""
|
|
89
|
+
OVERCODE_DIR.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
return str(OVERCODE_DIR / "presence_log.csv")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class PresenceLoggerConfig:
|
|
95
|
+
sample_interval: int = DEFAULT_SAMPLE_INTERVAL
|
|
96
|
+
idle_threshold: int = DEFAULT_IDLE_THRESHOLD
|
|
97
|
+
log_path: str = ""
|
|
98
|
+
|
|
99
|
+
def __post_init__(self):
|
|
100
|
+
if not self.log_path:
|
|
101
|
+
self.log_path = default_log_path()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---- low-level state helpers -----------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_idle_seconds() -> float:
|
|
108
|
+
"""Seconds since last user input (mouse/keyboard) in current session."""
|
|
109
|
+
if not MACOS_APIS_AVAILABLE:
|
|
110
|
+
return 0.0
|
|
111
|
+
return CGEventSourceSecondsSinceLastEventType(
|
|
112
|
+
kCGEventSourceStateCombinedSessionState,
|
|
113
|
+
kCGAnyInputEventType,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_screen_locked() -> bool:
|
|
118
|
+
"""
|
|
119
|
+
Try to detect if the screen/session is locked.
|
|
120
|
+
|
|
121
|
+
This relies on keys in CGSessionCopyCurrentDictionary; may vary by macOS
|
|
122
|
+
version but works on most modern versions.
|
|
123
|
+
"""
|
|
124
|
+
if not MACOS_APIS_AVAILABLE:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
session_info = CGSessionCopyCurrentDictionary()
|
|
128
|
+
if not session_info:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
# Common key; default to 0 if missing
|
|
132
|
+
if session_info.get("CGSSessionScreenIsLocked", 0):
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
# Fallback heuristic: if there is an explicit "kCGSessionOnConsoleKey"
|
|
136
|
+
# and it's false, treat as locked. This is more conservative.
|
|
137
|
+
on_console = session_info.get("kCGSessionOnConsoleKey")
|
|
138
|
+
if isinstance(on_console, bool) and not on_console:
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def infer_sleep(last_ts: Optional[dt.datetime],
|
|
145
|
+
now: dt.datetime,
|
|
146
|
+
sample_interval: int) -> bool:
|
|
147
|
+
"""
|
|
148
|
+
Infer whether the machine likely slept between last_ts and now, based on
|
|
149
|
+
a gap larger than ~2x the sample interval.
|
|
150
|
+
"""
|
|
151
|
+
if last_ts is None:
|
|
152
|
+
return False
|
|
153
|
+
gap = (now - last_ts).total_seconds()
|
|
154
|
+
return gap > 2 * sample_interval
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def classify_state(locked: bool,
|
|
158
|
+
idle_seconds: float,
|
|
159
|
+
slept: bool,
|
|
160
|
+
idle_threshold: int) -> int:
|
|
161
|
+
"""
|
|
162
|
+
Map low-level measures to 3 presence states:
|
|
163
|
+
|
|
164
|
+
1: screen locked/hibernating
|
|
165
|
+
2: screen on, inactive (idle > idle_threshold)
|
|
166
|
+
3: screen on, active
|
|
167
|
+
"""
|
|
168
|
+
if locked or slept:
|
|
169
|
+
return 1
|
|
170
|
+
elif idle_seconds > idle_threshold:
|
|
171
|
+
return 2
|
|
172
|
+
else:
|
|
173
|
+
return 3
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def state_to_name(state: int) -> str:
|
|
177
|
+
"""Convert state number to human-readable name."""
|
|
178
|
+
return {
|
|
179
|
+
1: "locked/sleep",
|
|
180
|
+
2: "inactive",
|
|
181
|
+
3: "active",
|
|
182
|
+
}.get(state, "unknown")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---- main logger class ------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class PresenceLogger:
|
|
189
|
+
"""
|
|
190
|
+
Background presence logger.
|
|
191
|
+
|
|
192
|
+
- Call .start() to spin up a daemon thread that logs continuously.
|
|
193
|
+
- Call .stop() to ask it to shut down cleanly.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(self, config: Optional[PresenceLoggerConfig] = None):
|
|
197
|
+
self.config = config or PresenceLoggerConfig()
|
|
198
|
+
self._thread: Optional[threading.Thread] = None
|
|
199
|
+
self._stop_event = threading.Event()
|
|
200
|
+
self._lock = threading.Lock() # protect start/stop
|
|
201
|
+
self._last_state: Optional[int] = None
|
|
202
|
+
|
|
203
|
+
def start(self) -> None:
|
|
204
|
+
"""Start the background logging thread (idempotent)."""
|
|
205
|
+
with self._lock:
|
|
206
|
+
if self._thread is not None and self._thread.is_alive():
|
|
207
|
+
return
|
|
208
|
+
self._stop_event.clear()
|
|
209
|
+
self._thread = threading.Thread(
|
|
210
|
+
target=self._run, name="PresenceLoggerThread", daemon=True
|
|
211
|
+
)
|
|
212
|
+
self._thread.start()
|
|
213
|
+
|
|
214
|
+
def stop(self, timeout: Optional[float] = None) -> None:
|
|
215
|
+
"""Request the logger to stop and optionally wait for it."""
|
|
216
|
+
with self._lock:
|
|
217
|
+
if self._thread is None:
|
|
218
|
+
return
|
|
219
|
+
self._stop_event.set()
|
|
220
|
+
self._thread.join(timeout=timeout)
|
|
221
|
+
# Don't reuse threads
|
|
222
|
+
self._thread = None
|
|
223
|
+
|
|
224
|
+
def get_current_state(self) -> tuple[int, float, bool]:
|
|
225
|
+
"""Get current presence state without logging.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Tuple of (state, idle_seconds, is_locked)
|
|
229
|
+
"""
|
|
230
|
+
idle = get_idle_seconds()
|
|
231
|
+
locked = is_screen_locked()
|
|
232
|
+
state = classify_state(
|
|
233
|
+
locked=locked,
|
|
234
|
+
idle_seconds=idle,
|
|
235
|
+
slept=False, # Can't infer sleep without history
|
|
236
|
+
idle_threshold=self.config.idle_threshold,
|
|
237
|
+
)
|
|
238
|
+
return state, idle, locked
|
|
239
|
+
|
|
240
|
+
def _run(self) -> None:
|
|
241
|
+
"""
|
|
242
|
+
Main loop: every sample_interval seconds, log one row to CSV.
|
|
243
|
+
|
|
244
|
+
CSV columns:
|
|
245
|
+
timestamp_iso, state, idle_seconds, locked, inferred_sleep
|
|
246
|
+
"""
|
|
247
|
+
cfg = self.config
|
|
248
|
+
last_ts: Optional[dt.datetime] = None
|
|
249
|
+
|
|
250
|
+
# Ensure directory exists
|
|
251
|
+
os.makedirs(os.path.dirname(cfg.log_path), exist_ok=True)
|
|
252
|
+
|
|
253
|
+
# Open in append mode; keep file handle for the process lifetime
|
|
254
|
+
with open(cfg.log_path, "a", newline="") as f:
|
|
255
|
+
writer = csv.writer(f)
|
|
256
|
+
|
|
257
|
+
# Add header if file is empty
|
|
258
|
+
try:
|
|
259
|
+
if f.tell() == 0:
|
|
260
|
+
writer.writerow(
|
|
261
|
+
[
|
|
262
|
+
"timestamp",
|
|
263
|
+
"state",
|
|
264
|
+
"idle_seconds",
|
|
265
|
+
"locked",
|
|
266
|
+
"inferred_sleep",
|
|
267
|
+
]
|
|
268
|
+
)
|
|
269
|
+
f.flush()
|
|
270
|
+
except (OSError, IOError):
|
|
271
|
+
# File write failed - header is not critical, continue
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
while not self._stop_event.is_set():
|
|
275
|
+
now = dt.datetime.now()
|
|
276
|
+
slept = infer_sleep(last_ts, now, cfg.sample_interval)
|
|
277
|
+
idle = get_idle_seconds()
|
|
278
|
+
locked = is_screen_locked()
|
|
279
|
+
state = classify_state(
|
|
280
|
+
locked=locked,
|
|
281
|
+
idle_seconds=idle,
|
|
282
|
+
slept=slept,
|
|
283
|
+
idle_threshold=cfg.idle_threshold,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
self._last_state = state
|
|
287
|
+
|
|
288
|
+
writer.writerow(
|
|
289
|
+
[
|
|
290
|
+
now.isoformat(),
|
|
291
|
+
state,
|
|
292
|
+
f"{idle:.1f}",
|
|
293
|
+
int(locked),
|
|
294
|
+
int(slept),
|
|
295
|
+
]
|
|
296
|
+
)
|
|
297
|
+
f.flush()
|
|
298
|
+
last_ts = now
|
|
299
|
+
|
|
300
|
+
# Sleep in small chunks so stop() is responsive
|
|
301
|
+
remaining = cfg.sample_interval
|
|
302
|
+
while remaining > 0 and not self._stop_event.is_set():
|
|
303
|
+
step = min(1.0, remaining)
|
|
304
|
+
time.sleep(step)
|
|
305
|
+
remaining -= step
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---- simple singleton helper for "just start it" ---------------------------
|
|
309
|
+
|
|
310
|
+
_singleton_logger: Optional[PresenceLogger] = None
|
|
311
|
+
_singleton_lock = threading.Lock()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def start_background_logger(
|
|
315
|
+
sample_interval: int = DEFAULT_SAMPLE_INTERVAL,
|
|
316
|
+
idle_threshold: int = DEFAULT_IDLE_THRESHOLD,
|
|
317
|
+
log_path: Optional[str] = None,
|
|
318
|
+
) -> PresenceLogger:
|
|
319
|
+
"""
|
|
320
|
+
Create (if needed) and start a global background PresenceLogger.
|
|
321
|
+
|
|
322
|
+
Returns the logger instance, so you can call .stop() later if desired.
|
|
323
|
+
|
|
324
|
+
Example:
|
|
325
|
+
|
|
326
|
+
from overcode.presence_logger import start_background_logger
|
|
327
|
+
|
|
328
|
+
logger = start_background_logger()
|
|
329
|
+
# ... do stuff ...
|
|
330
|
+
# logger.stop()
|
|
331
|
+
"""
|
|
332
|
+
global _singleton_logger
|
|
333
|
+
with _singleton_lock:
|
|
334
|
+
if _singleton_logger is None:
|
|
335
|
+
cfg = PresenceLoggerConfig(
|
|
336
|
+
sample_interval=sample_interval,
|
|
337
|
+
idle_threshold=idle_threshold,
|
|
338
|
+
log_path=log_path or default_log_path(),
|
|
339
|
+
)
|
|
340
|
+
_singleton_logger = PresenceLogger(cfg)
|
|
341
|
+
_singleton_logger.start()
|
|
342
|
+
else:
|
|
343
|
+
# Optionally you could update config here; for simplicity, we don't.
|
|
344
|
+
_singleton_logger.start()
|
|
345
|
+
return _singleton_logger
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def get_singleton_logger() -> Optional[PresenceLogger]:
|
|
349
|
+
"""Get the singleton logger instance if it exists."""
|
|
350
|
+
return _singleton_logger
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def get_current_presence_state() -> tuple[int, float, bool]:
|
|
354
|
+
"""Get current presence state without needing a logger instance.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Tuple of (state, idle_seconds, is_locked)
|
|
358
|
+
state: 1=locked/sleep, 2=inactive, 3=active
|
|
359
|
+
"""
|
|
360
|
+
idle = get_idle_seconds()
|
|
361
|
+
locked = is_screen_locked()
|
|
362
|
+
state = classify_state(
|
|
363
|
+
locked=locked,
|
|
364
|
+
idle_seconds=idle,
|
|
365
|
+
slept=False,
|
|
366
|
+
idle_threshold=DEFAULT_IDLE_THRESHOLD,
|
|
367
|
+
)
|
|
368
|
+
return state, idle, locked
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def read_presence_history(hours: float = 3.0) -> list[tuple[dt.datetime, int]]:
|
|
372
|
+
"""Read presence history from CSV file.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
hours: How many hours of history to read (default 3)
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
List of (timestamp, state) tuples, oldest first
|
|
379
|
+
"""
|
|
380
|
+
log_path = default_log_path()
|
|
381
|
+
if not Path(log_path).exists():
|
|
382
|
+
return []
|
|
383
|
+
|
|
384
|
+
cutoff = dt.datetime.now() - dt.timedelta(hours=hours)
|
|
385
|
+
history = []
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
with open(log_path, 'r', newline='') as f:
|
|
389
|
+
reader = csv.DictReader(f)
|
|
390
|
+
for row in reader:
|
|
391
|
+
try:
|
|
392
|
+
ts = dt.datetime.fromisoformat(row['timestamp'])
|
|
393
|
+
if ts >= cutoff:
|
|
394
|
+
state = int(row['state'])
|
|
395
|
+
history.append((ts, state))
|
|
396
|
+
except (ValueError, KeyError):
|
|
397
|
+
continue
|
|
398
|
+
except (OSError, IOError):
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
return history
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ---- CLI entrypoint ---------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def main() -> int:
|
|
408
|
+
"""
|
|
409
|
+
Run the logger in the foreground; blocks until interrupted (Ctrl+C).
|
|
410
|
+
|
|
411
|
+
Useful if you just want a standalone process:
|
|
412
|
+
|
|
413
|
+
overcode presence
|
|
414
|
+
"""
|
|
415
|
+
if not MACOS_APIS_AVAILABLE:
|
|
416
|
+
print("Error: macOS APIs not available.")
|
|
417
|
+
print("Install dependencies: pip install pyobjc-framework-Quartz pyobjc-framework-ApplicationServices")
|
|
418
|
+
return 1
|
|
419
|
+
|
|
420
|
+
# Check if already running
|
|
421
|
+
if is_presence_running():
|
|
422
|
+
pid = get_presence_pid()
|
|
423
|
+
print(f"Presence logger already running (PID: {pid})")
|
|
424
|
+
return 1
|
|
425
|
+
|
|
426
|
+
# Write PID file
|
|
427
|
+
_write_pid_file()
|
|
428
|
+
|
|
429
|
+
logger = PresenceLogger()
|
|
430
|
+
logger.start()
|
|
431
|
+
print(f"Presence logger running (PID: {os.getpid()})")
|
|
432
|
+
print(f"Writing to: {logger.config.log_path}")
|
|
433
|
+
print(f"Sample interval: {logger.config.sample_interval}s, idle threshold: {logger.config.idle_threshold}s")
|
|
434
|
+
print("Press Ctrl+C to stop.")
|
|
435
|
+
print()
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
while True:
|
|
439
|
+
state, idle, locked = logger.get_current_state()
|
|
440
|
+
state_name = state_to_name(state)
|
|
441
|
+
status = f"State: {state} ({state_name}), Idle: {idle:.0f}s, Locked: {locked}"
|
|
442
|
+
print(f"\r{status:<60}", end="", flush=True)
|
|
443
|
+
time.sleep(1.0)
|
|
444
|
+
except KeyboardInterrupt:
|
|
445
|
+
print("\nStopping logger...")
|
|
446
|
+
logger.stop()
|
|
447
|
+
finally:
|
|
448
|
+
_remove_pid_file()
|
|
449
|
+
|
|
450
|
+
return 0
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
if __name__ == "__main__":
|
|
454
|
+
sys.exit(main())
|
overcode/protocols.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Protocol definitions for external dependencies.
|
|
3
|
+
|
|
4
|
+
These interfaces allow dependency injection for testing, enabling us to
|
|
5
|
+
swap real implementations (subprocess calls to tmux, file I/O) with
|
|
6
|
+
mock implementations in tests.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Protocol, Optional, List, Dict, Any, runtime_checkable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class TmuxInterface(Protocol):
|
|
15
|
+
"""Interface for tmux operations"""
|
|
16
|
+
|
|
17
|
+
def capture_pane(self, session: str, window: int, lines: int = 100) -> Optional[str]:
|
|
18
|
+
"""Capture content from a tmux pane.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
session: tmux session name
|
|
22
|
+
window: window number
|
|
23
|
+
lines: number of lines to capture from scrollback
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Pane content as string, or None on failure
|
|
27
|
+
"""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def send_keys(self, session: str, window: int, keys: str, enter: bool = True) -> bool:
|
|
31
|
+
"""Send keys to a tmux pane.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
session: tmux session name
|
|
35
|
+
window: window number
|
|
36
|
+
keys: keys/text to send
|
|
37
|
+
enter: whether to send Enter after keys
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
True if successful, False otherwise
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def has_session(self, session: str) -> bool:
|
|
45
|
+
"""Check if a tmux session exists."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def new_session(self, session: str) -> bool:
|
|
49
|
+
"""Create a new tmux session."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def new_window(self, session: str, name: str, command: Optional[List[str]] = None,
|
|
53
|
+
cwd: Optional[str] = None) -> Optional[int]:
|
|
54
|
+
"""Create a new window in a session.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Window number if successful, None otherwise
|
|
58
|
+
"""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def kill_window(self, session: str, window: int) -> bool:
|
|
62
|
+
"""Kill a tmux window."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def kill_session(self, session: str) -> bool:
|
|
66
|
+
"""Kill an entire tmux session."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def list_windows(self, session: str) -> List[Dict[str, Any]]:
|
|
70
|
+
"""List windows in a session.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of window info dicts with 'index', 'name', etc.
|
|
74
|
+
"""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
def attach(self, session: str) -> None:
|
|
78
|
+
"""Attach to a tmux session (replaces current process)."""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@runtime_checkable
|
|
83
|
+
class FileSystemInterface(Protocol):
|
|
84
|
+
"""Interface for file system operations"""
|
|
85
|
+
|
|
86
|
+
def read_json(self, path: Path) -> Optional[Dict[str, Any]]:
|
|
87
|
+
"""Read and parse a JSON file.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Parsed JSON data, or None if file doesn't exist/is invalid
|
|
91
|
+
"""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
def write_json(self, path: Path, data: Dict[str, Any]) -> bool:
|
|
95
|
+
"""Write data to a JSON file atomically.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
True if successful, False otherwise
|
|
99
|
+
"""
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
def exists(self, path: Path) -> bool:
|
|
103
|
+
"""Check if a path exists."""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
def mkdir(self, path: Path, parents: bool = True) -> bool:
|
|
107
|
+
"""Create a directory."""
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
def read_text(self, path: Path) -> Optional[str]:
|
|
111
|
+
"""Read text from a file."""
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
def write_text(self, path: Path, content: str) -> bool:
|
|
115
|
+
"""Write text to a file."""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@runtime_checkable
|
|
120
|
+
class SubprocessInterface(Protocol):
|
|
121
|
+
"""Interface for subprocess operations (non-tmux)"""
|
|
122
|
+
|
|
123
|
+
def run(self, cmd: List[str], timeout: Optional[int] = None,
|
|
124
|
+
capture_output: bool = True) -> Optional[Dict[str, Any]]:
|
|
125
|
+
"""Run a subprocess command.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
cmd: command and arguments
|
|
129
|
+
timeout: timeout in seconds
|
|
130
|
+
capture_output: whether to capture stdout/stderr
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dict with 'returncode', 'stdout', 'stderr', or None on failure
|
|
134
|
+
"""
|
|
135
|
+
...
|
|
136
|
+
|
|
137
|
+
def popen(self, cmd: List[str], cwd: Optional[str] = None) -> Any:
|
|
138
|
+
"""Start a subprocess without waiting.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Process handle or None on failure
|
|
142
|
+
"""
|
|
143
|
+
...
|