personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

remove built-in tmux observer (migrated to solstone-tmux external package)

The standalone tmux observer (observe/tmux/) has been extracted to the
solstone-tmux package which runs as its own systemd user service.

- Delete observe/tmux/ directory (observer.py, capture.py, __init__.py)
- Remove tmux-observer from CLI registry and help groups in sol.py
- Remove tmux-observer launch/shutdown blocks from supervisor
- Update --observers flag: default "linux" (was "linux,tmux"), remove tmux from valid set
- Simplify shutdown filters to string equality (single observer)
- Update tests to match single-observer behavior
- Note in docs/OBSERVE.md that standalone tmux capture uses solstone-tmux

+20 -779
+2 -1
docs/OBSERVE.md
··· 60 60 - **linux/observer.py** - Linux capture using native APIs 61 61 - **linux/screencast.py** - XDG Portal screencast with PipeWire + GStreamer 62 62 - **gnome/activity.py** - GNOME-specific activity detection (idle, lock, power save) 63 - - **tmux/capture.py** - Tmux capture library (integrated into Linux observer for fallback capture) 64 63 - **sense.py** - File watcher that dispatches transcription and description jobs 65 64 - **transcribe.py** - Audio transcription with faster-whisper and sentence-level embeddings 66 65 - **describe.py** - Vision analysis with Gemini, category-based prompts 67 66 - **categories/** - Category-specific prompts for screen content (see [SCREEN_CATEGORIES.md](SCREEN_CATEGORIES.md)) 67 + 68 + Standalone tmux capture is handled by the external `solstone-tmux` package, which runs as its own systemd user service. 68 69 69 70 ## Output Formats 70 71
-22
observe/tmux/__init__.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Tmux terminal capture for observe package.""" 5 - 6 - from observe.tmux.capture import ( 7 - CaptureResult, 8 - PaneInfo, 9 - TmuxCapture, 10 - WindowInfo, 11 - run_tmux_command, 12 - write_captures_jsonl, 13 - ) 14 - 15 - __all__ = [ 16 - "TmuxCapture", 17 - "CaptureResult", 18 - "PaneInfo", 19 - "WindowInfo", 20 - "run_tmux_command", 21 - "write_captures_jsonl", 22 - ]
-407
observe/tmux/capture.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Tmux terminal capture library. 5 - 6 - Provides functions for capturing tmux session content, designed for use 7 - by the Linux observer for fallback capture when screen is idle. 8 - """ 9 - 10 - import hashlib 11 - import json 12 - import logging 13 - import subprocess 14 - import time 15 - from dataclasses import dataclass 16 - from pathlib import Path 17 - 18 - logger = logging.getLogger(__name__) 19 - 20 - 21 - @dataclass 22 - class PaneInfo: 23 - """Information about a tmux pane.""" 24 - 25 - id: str 26 - index: int 27 - left: int 28 - top: int 29 - width: int 30 - height: int 31 - active: bool 32 - content: str = "" 33 - 34 - 35 - @dataclass 36 - class WindowInfo: 37 - """Information about a tmux window.""" 38 - 39 - id: str 40 - index: int 41 - name: str 42 - active: bool 43 - 44 - 45 - @dataclass 46 - class CaptureResult: 47 - """Result of capturing a session's active window.""" 48 - 49 - session: str 50 - window: WindowInfo 51 - windows: list[WindowInfo] 52 - panes: list[PaneInfo] 53 - 54 - 55 - def run_tmux_command(args: list[str]) -> str | None: 56 - """Run a tmux command and return stdout, or None on error.""" 57 - try: 58 - result = subprocess.run( 59 - ["tmux"] + args, 60 - capture_output=True, 61 - text=True, 62 - timeout=5, 63 - ) 64 - if result.returncode != 0: 65 - return None 66 - return result.stdout 67 - except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: 68 - logger.debug(f"tmux command failed: {e}") 69 - return None 70 - 71 - 72 - class TmuxCapture: 73 - """Tmux terminal capture with deduplication.""" 74 - 75 - def __init__(self): 76 - # Deduplication: session -> hash of last capture 77 - self.last_hash: dict[str, str] = {} 78 - 79 - def reset_hashes(self): 80 - """Reset deduplication hashes (call at segment boundary).""" 81 - self.last_hash.clear() 82 - 83 - def is_available(self) -> bool: 84 - """Check if tmux is available on this system.""" 85 - return run_tmux_command(["list-sessions"]) is not None 86 - 87 - def is_active(self, poll_interval: float = 5.0) -> bool: 88 - """Check if any tmux sessions have recent activity. 89 - 90 - Args: 91 - poll_interval: Consider sessions active if client activity 92 - within this many seconds. 93 - 94 - Returns: 95 - True if any active sessions found. 96 - """ 97 - return len(self.get_active_sessions(poll_interval)) > 0 98 - 99 - def get_active_sessions(self, poll_interval: float = 5.0) -> list[dict]: 100 - """Get sessions with recent client activity. 101 - 102 - Args: 103 - poll_interval: Consider sessions active if client activity 104 - within this many seconds. 105 - 106 - Returns: 107 - List of dicts with 'session' and 'activity' keys. 108 - """ 109 - output = run_tmux_command( 110 - [ 111 - "list-clients", 112 - "-F", 113 - "#{client_session} #{client_activity}", 114 - ] 115 - ) 116 - if not output: 117 - return [] 118 - 119 - now = time.time() 120 - active = [] 121 - seen_sessions = set() 122 - 123 - for line in output.strip().split("\n"): 124 - if not line: 125 - continue 126 - parts = line.split(" ", 1) 127 - if len(parts) != 2: 128 - continue 129 - 130 - session, activity_str = parts 131 - try: 132 - activity = int(activity_str) 133 - except ValueError: 134 - continue 135 - 136 - # Check if active within poll interval 137 - if now - activity <= poll_interval and session not in seen_sessions: 138 - active.append({"session": session, "activity": activity}) 139 - seen_sessions.add(session) 140 - 141 - return active 142 - 143 - def get_windows(self, session: str) -> list[WindowInfo]: 144 - """Get all windows for a session.""" 145 - output = run_tmux_command( 146 - [ 147 - "list-windows", 148 - "-t", 149 - session, 150 - "-F", 151 - "#{window_active} #{window_id} #{window_index} #{window_name}", 152 - ] 153 - ) 154 - if not output: 155 - return [] 156 - 157 - windows = [] 158 - for line in output.strip().split("\n"): 159 - if not line: 160 - continue 161 - parts = line.split(" ", 3) 162 - if len(parts) < 4: 163 - continue 164 - 165 - active_str, window_id, index_str, name = parts 166 - try: 167 - windows.append( 168 - WindowInfo( 169 - id=window_id, 170 - index=int(index_str), 171 - name=name, 172 - active=(active_str == "1"), 173 - ) 174 - ) 175 - except ValueError: 176 - continue 177 - 178 - return windows 179 - 180 - def get_panes(self, window_id: str) -> list[PaneInfo]: 181 - """Get all panes for a window with layout info.""" 182 - output = run_tmux_command( 183 - [ 184 - "list-panes", 185 - "-t", 186 - window_id, 187 - "-F", 188 - "#{pane_id} #{pane_index} #{pane_left} #{pane_top} #{pane_width} #{pane_height} #{pane_active}", 189 - ] 190 - ) 191 - if not output: 192 - return [] 193 - 194 - panes = [] 195 - for line in output.strip().split("\n"): 196 - if not line: 197 - continue 198 - parts = line.split(" ") 199 - if len(parts) != 7: 200 - continue 201 - 202 - try: 203 - panes.append( 204 - PaneInfo( 205 - id=parts[0], 206 - index=int(parts[1]), 207 - left=int(parts[2]), 208 - top=int(parts[3]), 209 - width=int(parts[4]), 210 - height=int(parts[5]), 211 - active=(parts[6] == "1"), 212 - ) 213 - ) 214 - except ValueError: 215 - continue 216 - 217 - return panes 218 - 219 - def capture_pane(self, pane_id: str) -> str: 220 - """Capture visible pane content with ANSI escape codes.""" 221 - output = run_tmux_command( 222 - [ 223 - "capture-pane", 224 - "-p", # Print to stdout 225 - "-e", # Include escape sequences (ANSI codes) 226 - "-t", 227 - pane_id, 228 - ] 229 - ) 230 - return output if output else "" 231 - 232 - def capture_session(self, session: str) -> CaptureResult | None: 233 - """Capture the active window of a session with all its panes. 234 - 235 - Returns None if session doesn't exist or has no active window. 236 - """ 237 - windows = self.get_windows(session) 238 - if not windows: 239 - return None 240 - 241 - # Find active window 242 - active_window = next((w for w in windows if w.active), None) 243 - if not active_window: 244 - return None 245 - 246 - # Get panes for active window 247 - panes = self.get_panes(active_window.id) 248 - if not panes: 249 - return None 250 - 251 - # Capture content for each pane 252 - for pane in panes: 253 - pane.content = self.capture_pane(pane.id) 254 - 255 - return CaptureResult( 256 - session=session, 257 - window=active_window, 258 - windows=windows, 259 - panes=panes, 260 - ) 261 - 262 - def compute_hash(self, result: CaptureResult) -> str: 263 - """Compute hash of capture for deduplication.""" 264 - # Hash window id + all pane contents 265 - parts = [result.window.id] 266 - for pane in sorted(result.panes, key=lambda p: p.id): 267 - parts.append(pane.content) 268 - content = "\n".join(parts) 269 - return hashlib.md5(content.encode()).hexdigest() 270 - 271 - def capture_changed(self, session: str) -> CaptureResult | None: 272 - """Capture session if content changed since last capture. 273 - 274 - Uses internal hash tracking for deduplication. 275 - 276 - Returns: 277 - CaptureResult if content changed, None if unchanged or error. 278 - """ 279 - result = self.capture_session(session) 280 - if not result: 281 - return None 282 - 283 - content_hash = self.compute_hash(result) 284 - if self.last_hash.get(session) == content_hash: 285 - logger.debug(f"Session {session} unchanged, skipping") 286 - return None 287 - 288 - self.last_hash[session] = content_hash 289 - return result 290 - 291 - def result_to_dict( 292 - self, result: CaptureResult, capture_id: int, relative_ts: float 293 - ) -> dict: 294 - """Convert CaptureResult to JSON-serializable dict. 295 - 296 - Output format matches screen.jsonl structure from sol describe: 297 - - frame_id: Capture sequence number 298 - - timestamp: Seconds since segment start 299 - - requests: Empty list (no AI processing) 300 - - analysis: Category info with templated visual_description 301 - - tmux: All terminal-specific data 302 - 303 - Args: 304 - result: Capture result to convert. 305 - capture_id: Sequential frame ID for this capture. 306 - relative_ts: Timestamp relative to segment start. 307 - 308 - Returns: 309 - Dict ready for JSON serialization. 310 - """ 311 - # Build visual description from tmux info 312 - pane_count = len(result.panes) 313 - pane_word = "pane" if pane_count == 1 else "panes" 314 - visual_description = ( 315 - f"Terminal session '{result.session}' with {pane_count} {pane_word} " 316 - f"in window '{result.window.name}'" 317 - ) 318 - 319 - return { 320 - "frame_id": capture_id, 321 - "timestamp": relative_ts, 322 - "requests": [], 323 - "analysis": { 324 - "visual_description": visual_description, 325 - "primary": "tmux", 326 - "secondary": "none", 327 - "overlap": False, 328 - }, 329 - "content": { 330 - "tmux": { 331 - "session": result.session, 332 - "window": { 333 - "id": result.window.id, 334 - "index": result.window.index, 335 - "name": result.window.name, 336 - }, 337 - "windows": [ 338 - { 339 - "id": w.id, 340 - "index": w.index, 341 - "name": w.name, 342 - "active": w.active, 343 - } 344 - for w in result.windows 345 - ], 346 - "panes": [ 347 - { 348 - "id": p.id, 349 - "index": p.index, 350 - "left": p.left, 351 - "top": p.top, 352 - "width": p.width, 353 - "height": p.height, 354 - "active": p.active, 355 - "content": p.content, 356 - } 357 - for p in result.panes 358 - ], 359 - }, 360 - }, 361 - } 362 - 363 - 364 - def write_captures_jsonl(captures: list[dict], segment_dir: Path) -> list[str]: 365 - """Write tmux captures to JSONL files, grouped by session. 366 - 367 - Creates one file per session: tmux_{session}_screen.jsonl 368 - Frame entries match screen.jsonl format for unified formatting/indexing. 369 - No header line since tmux captures have no raw media file. 370 - 371 - Args: 372 - captures: List of capture dicts from result_to_dict() 373 - segment_dir: Directory to write files to (created if needed) 374 - 375 - Returns: 376 - List of filenames written (empty if no captures) 377 - """ 378 - if not captures: 379 - return [] 380 - 381 - segment_dir.mkdir(parents=True, exist_ok=True) 382 - 383 - # Group captures by session 384 - by_session: dict[str, list[dict]] = {} 385 - for capture in captures: 386 - session = capture.get("content", {}).get("tmux", {}).get("session", "unknown") 387 - if session not in by_session: 388 - by_session[session] = [] 389 - by_session[session].append(capture) 390 - 391 - # Write one file per session 392 - files_written = [] 393 - for session, session_captures in by_session.items(): 394 - # Sanitize session name for filename 395 - safe_session = session.replace("/", "_").replace(" ", "_") 396 - filename = f"tmux_{safe_session}_screen.jsonl" 397 - output_path = segment_dir / filename 398 - 399 - with open(output_path, "w") as f: 400 - # No header - tmux captures have no raw media file 401 - for capture in session_captures: 402 - f.write(json.dumps(capture) + "\n") 403 - 404 - files_written.append(filename) 405 - logger.info(f"Wrote {len(session_captures)} tmux captures to {output_path}") 406 - 407 - return files_written
-282
observe/tmux/observer.py
··· 1 - #!/usr/bin/env python3 2 - # SPDX-License-Identifier: AGPL-3.0-only 3 - # Copyright (c) 2026 sol pbc 4 - 5 - """ 6 - Standalone tmux terminal capture observer. 7 - 8 - Continuously polls all active tmux sessions and captures terminal content, 9 - creating 5-minute segments uploaded via HTTP to the ingest server. 10 - 11 - Always-on: no idle detection, no screen activity checks. Just captures 12 - whatever tmux sessions exist on the configurable interval. 13 - """ 14 - 15 - import argparse 16 - import asyncio 17 - import logging 18 - import os 19 - import platform 20 - import signal 21 - import socket 22 - import sys 23 - import time 24 - from pathlib import Path 25 - 26 - from observe.remote_client import ObserverClient, cleanup_draft, finalize_draft 27 - from observe.tmux.capture import TmuxCapture, write_captures_jsonl 28 - from observe.utils import create_draft_folder, get_timestamp_parts 29 - from think.streams import stream_name 30 - from think.utils import get_config, setup_cli 31 - 32 - logger = logging.getLogger(__name__) 33 - 34 - HOST = socket.gethostname() 35 - PLATFORM = platform.system().lower() 36 - 37 - 38 - class TmuxObserver: 39 - def __init__(self, interval: int = 300): 40 - self.interval = interval 41 - self.tmux_capture = TmuxCapture() 42 - self.running = True 43 - self.stream = stream_name(host=HOST, qualifier="tmux") 44 - self._client: ObserverClient | None = None 45 - self.start_at = time.time() 46 - self.start_at_mono = time.monotonic() 47 - self.draft_dir: str | None = None 48 - self.captures: list[dict] = [] 49 - self.capture_id = 0 50 - self.sessions_seen: set[str] = set() 51 - self.last_capture_time: float = 0 52 - self.capture_interval = 5 53 - 54 - def _load_config(self) -> tuple[bool, int]: 55 - """Load tmux settings from journal config.""" 56 - enabled = True 57 - capture_interval = 5 58 - try: 59 - config = get_config() 60 - observe_config = config.get("observe", {}) 61 - tmux_config = observe_config.get("tmux", {}) 62 - enabled = tmux_config.get("enabled", True) 63 - capture_interval = tmux_config.get("capture_interval", 5) 64 - except Exception as e: 65 - logger.warning(f"Failed to load tmux config, using defaults: {e}") 66 - return enabled, capture_interval 67 - 68 - def setup(self) -> bool: 69 - """Initialize config, tmux availability, and remote client.""" 70 - enabled, self.capture_interval = self._load_config() 71 - if not enabled: 72 - logger.info("Tmux capture disabled in config") 73 - return False 74 - 75 - if not self.tmux_capture.is_available(): 76 - logger.error("Tmux not available") 77 - return False 78 - 79 - self._client = ObserverClient(self.stream) 80 - logger.info("Remote client initialized") 81 - return True 82 - 83 - def capture(self): 84 - """Poll tmux and accumulate captures based on capture interval.""" 85 - now = time.time() 86 - time_since_capture = now - self.last_capture_time 87 - 88 - if time_since_capture < self.capture_interval: 89 - return 90 - 91 - active_sessions = self.tmux_capture.get_active_sessions(time_since_capture) 92 - if not active_sessions: 93 - return 94 - 95 - self.last_capture_time = now 96 - 97 - for session_info in active_sessions: 98 - session = session_info["session"] 99 - self.sessions_seen.add(session) 100 - 101 - result = self.tmux_capture.capture_changed(session) 102 - if not result: 103 - continue 104 - 105 - self.capture_id += 1 106 - relative_ts = now - self.start_at 107 - capture_dict = self.tmux_capture.result_to_dict( 108 - result, self.capture_id, relative_ts 109 - ) 110 - self.captures.append(capture_dict) 111 - logger.debug(f"Captured tmux session {session}: {len(result.panes)} panes") 112 - 113 - def _reset_capture_state(self): 114 - """Reset per-segment capture tracking.""" 115 - self.captures = [] 116 - self.capture_id = 0 117 - self.sessions_seen = set() 118 - self.tmux_capture.reset_hashes() 119 - self.last_capture_time = 0 120 - 121 - def _remove_empty_draft(self): 122 - """Remove an empty draft folder, ignoring errors.""" 123 - if self.draft_dir: 124 - try: 125 - os.rmdir(self.draft_dir) 126 - except OSError: 127 - pass 128 - 129 - def finalize_segment(self) -> list[str]: 130 - """Write captures to disk, upload the segment, and clean up draft state.""" 131 - if not self.captures or not self.draft_dir: 132 - self._remove_empty_draft() 133 - self._reset_capture_state() 134 - return [] 135 - 136 - tmux_files = write_captures_jsonl(self.captures, Path(self.draft_dir)) 137 - if not tmux_files: 138 - self._remove_empty_draft() 139 - self._reset_capture_state() 140 - return [] 141 - 142 - date_part, time_part = get_timestamp_parts(self.start_at) 143 - duration = int(time.time() - self.start_at) 144 - segment_key = f"{time_part}_{duration}" 145 - 146 - # Upload from draft directory 147 - draft_path = Path(self.draft_dir) 148 - draft_files = [ 149 - draft_path / f 150 - for f in os.listdir(self.draft_dir) 151 - if (draft_path / f).is_file() 152 - ] 153 - uploaded = False 154 - if draft_files and self._client: 155 - meta = {"host": HOST, "platform": PLATFORM, "stream": self.stream} 156 - result = self._client.upload_segment( 157 - date_part, segment_key, draft_files, meta 158 - ) 159 - if result.success: 160 - logger.info( 161 - f"Segment uploaded: {segment_key} ({len(draft_files)} files)" 162 - ) 163 - uploaded = True 164 - else: 165 - logger.error(f"Segment upload failed: {segment_key}") 166 - if uploaded: 167 - cleanup_draft(self.draft_dir) 168 - else: 169 - finalize_draft(self.draft_dir, segment_key) 170 - 171 - self._reset_capture_state() 172 - return tmux_files 173 - 174 - def _start_segment(self): 175 - """Start a new draft segment.""" 176 - self.start_at = time.time() 177 - self.start_at_mono = time.monotonic() 178 - self.draft_dir = create_draft_folder(self.start_at, self.stream) 179 - 180 - def emit_status(self): 181 - """Emit observe.status with current tmux capture state.""" 182 - if not self._client: 183 - return 184 - 185 - elapsed = int(time.monotonic() - self.start_at_mono) 186 - tmux_info = { 187 - "capturing": True, 188 - "captures": len(self.captures), 189 - "sessions": sorted(self.sessions_seen), 190 - "window_elapsed_seconds": elapsed, 191 - } 192 - self._client.relay_event( 193 - "observe", 194 - "status", 195 - mode="tmux", 196 - tmux=tmux_info, 197 - host=HOST, 198 - platform=PLATFORM, 199 - stream=self.stream, 200 - ) 201 - 202 - async def main_loop(self): 203 - """Run the polling loop until shutdown.""" 204 - self._start_segment() 205 - 206 - while self.running: 207 - await asyncio.sleep(1) # Short sleep for responsive shutdown 208 - self.capture() 209 - 210 - elapsed = time.monotonic() - self.start_at_mono 211 - if elapsed >= self.interval: 212 - self.finalize_segment() 213 - self._start_segment() 214 - 215 - self.emit_status() 216 - 217 - await self.shutdown() 218 - 219 - async def shutdown(self): 220 - """Finalize the current segment and stop the remote client.""" 221 - self.finalize_segment() 222 - self.draft_dir = None 223 - if self._client: 224 - self._client.stop() 225 - self._client = None 226 - 227 - 228 - async def async_main(args): 229 - """Async entry point.""" 230 - observer = TmuxObserver(interval=args.interval) 231 - 232 - loop = asyncio.get_running_loop() 233 - 234 - def signal_handler(): 235 - logger.info("Received shutdown signal") 236 - observer.running = False 237 - 238 - for sig in (signal.SIGINT, signal.SIGTERM): 239 - loop.add_signal_handler(sig, signal_handler) 240 - 241 - if not observer.setup(): 242 - logger.error("Tmux observer setup failed") 243 - return 1 244 - 245 - try: 246 - await observer.main_loop() 247 - except RuntimeError as e: 248 - logger.error(f"Tmux observer runtime error: {e}") 249 - return 1 250 - except Exception as e: 251 - logger.error(f"Tmux observer error: {e}", exc_info=True) 252 - return 1 253 - 254 - return 0 255 - 256 - 257 - def main(): 258 - """CLI entry point.""" 259 - parser = argparse.ArgumentParser( 260 - description="Standalone tmux terminal capture observer." 261 - ) 262 - parser.add_argument( 263 - "--interval", 264 - type=int, 265 - default=300, 266 - help="Duration per tmux segment in seconds (default: 300 = 5 minutes).", 267 - ) 268 - args = setup_cli(parser) 269 - 270 - try: 271 - rc = asyncio.run(async_main(args)) 272 - sys.exit(rc) 273 - except KeyboardInterrupt: 274 - logger.info("Interrupted by user") 275 - sys.exit(0) 276 - except Exception as e: 277 - logger.error(f"Fatal error: {e}", exc_info=True) 278 - sys.exit(1) 279 - 280 - 281 - if __name__ == "__main__": 282 - main()
-2
sol.py
··· 62 62 "observer": "observe.observer", 63 63 "remote": "observe.remote_cli", 64 64 "observe-linux": "observe.linux.observer", 65 - "tmux-observer": "observe.tmux.observer", 66 65 # AI agents (formerly muse package) 67 66 "agents": "think.agents", 68 67 "cortex": "think.cortex", ··· 119 118 "transfer", 120 119 "observer", 121 120 "remote", 122 - "tmux-observer", 123 121 ], 124 122 "Muse (AI agents)": [ 125 123 "agents",
+11 -42
tests/test_supervisor.py
··· 17 17 """Test per-observer health checking based on observe.status event freshness.""" 18 18 mod = importlib.import_module("think.supervisor") 19 19 20 - mod._enabled_observers = {"linux-observer", "tmux-observer"} 20 + mod._enabled_observers = {"linux-observer"} 21 21 mod._observer_health = {} 22 22 23 23 # No status events yet - grace period, returns empty ··· 34 34 stale = mod.check_health(threshold=60) 35 35 assert stale == ["archon"] 36 36 37 - # Add tmux observer - fresh 38 - mod._observer_health["archon.tmux"] = { 39 - "last_ts": time.time(), 40 - "ever_received": True, 41 - } 42 - stale = mod.check_health(threshold=60) 43 - assert stale == ["archon"] 44 - 45 - # Both stale 46 - mod._observer_health["archon.tmux"]["last_ts"] = time.time() - 100 47 - stale = mod.check_health(threshold=60) 48 - assert sorted(stale) == ["archon", "archon.tmux"] 49 - 50 - # Both fresh 37 + # Fresh linux observer clears stale health 51 38 mod._observer_health["archon"]["last_ts"] = time.time() 52 - mod._observer_health["archon.tmux"]["last_ts"] = time.time() 53 39 stale = mod.check_health(threshold=60) 54 40 assert stale == [] 55 41 ··· 155 141 156 142 157 143 def test_start_observers_and_sense(tmp_path, mock_callosum, monkeypatch): 158 - """Test that linux-observer, tmux-observer, and sense launch correctly.""" 144 + """Test that linux-observer and sense launch correctly.""" 159 145 mod = importlib.import_module("think.supervisor") 160 146 161 147 started = [] ··· 194 180 ) 195 181 assert linux_proc is not None 196 182 assert any(cmd == ["sol", "observer", "-v"] for cmd, _, _ in started) 197 - 198 - # Test tmux-observer 199 - tmux_proc = mod._launch_process( 200 - "tmux-observer", ["sol", "tmux-observer", "-v"], restart=True 201 - ) 202 - assert tmux_proc is not None 203 - assert any(cmd == ["sol", "tmux-observer", "-v"] for cmd, _, _ in started) 204 183 205 184 # Test start_sense() 206 185 sense_proc = mod.start_sense() ··· 261 240 262 241 def test_parse_args_remote_flag(): 263 242 """Test that parse_args includes --remote flag.""" 264 - mod = importlib.import_module("think.supervisor") 243 + mod = importlib.reload(importlib.import_module("think.supervisor")) 265 244 266 245 parser = mod.parse_args() 267 246 args = parser.parse_args(["--remote", "https://server/ingest/key"]) ··· 271 250 272 251 def test_parse_args_remote_flag_optional(): 273 252 """Test that --remote is optional.""" 274 - mod = importlib.import_module("think.supervisor") 253 + mod = importlib.reload(importlib.import_module("think.supervisor")) 275 254 276 255 parser = mod.parse_args() 277 256 args = parser.parse_args([]) ··· 281 260 282 261 def test_parse_args_observers_flag(): 283 262 """Test --observers flag parsing.""" 284 - mod = importlib.import_module("think.supervisor") 263 + mod = importlib.reload(importlib.import_module("think.supervisor")) 285 264 286 265 parser = mod.parse_args() 287 266 288 267 args = parser.parse_args([]) 289 - assert args.observers == "linux,tmux" 268 + assert args.observers == "linux" 290 269 291 270 args = parser.parse_args(["--observers", "linux"]) 292 271 assert args.observers == "linux" ··· 333 312 MockManaged("convey"), 334 313 MockManaged("sense"), 335 314 MockManaged("linux-observer"), 336 - MockManaged("tmux-observer"), 337 315 MockManaged("cortex"), 338 316 ] 339 317 ··· 345 323 346 324 monkeypatch.setattr(mod.time, "sleep", fake_sleep) 347 325 348 - observer_procs = [p for p in procs if p.name in ("linux-observer", "tmux-observer")] 349 - other_procs = [ 350 - p for p in procs if p.name not in ("linux-observer", "tmux-observer") 351 - ] 326 + observer_procs = [p for p in procs if p.name == "linux-observer"] 327 + other_procs = [p for p in procs if p.name != "linux-observer"] 352 328 353 329 for managed in observer_procs: 354 330 proc = managed.process ··· 381 357 ("terminate", "linux-observer"), 382 358 ("wait", "linux-observer"), 383 359 ("cleanup", "linux-observer"), 384 - ("terminate", "tmux-observer"), 385 - ("wait", "tmux-observer"), 386 - ("cleanup", "tmux-observer"), 387 360 ("sleep", 2), 388 361 ("terminate", "cortex"), 389 362 ("wait", "cortex"), ··· 400 373 operations.clear() 401 374 sleep_calls.clear() 402 375 procs_no_obs = [MockManaged("sense"), MockManaged("cortex")] 403 - observer_procs = [ 404 - p for p in procs_no_obs if p.name in ("linux-observer", "tmux-observer") 405 - ] 406 - other_procs = [ 407 - p for p in procs_no_obs if p.name not in ("linux-observer", "tmux-observer") 408 - ] 376 + observer_procs = [p for p in procs_no_obs if p.name == "linux-observer"] 377 + other_procs = [p for p in procs_no_obs if p.name != "linux-observer"] 409 378 410 379 for managed in observer_procs: 411 380 managed.process.terminate()
+7 -23
think/supervisor.py
··· 426 426 # Populated when observe.status events arrive with a stream field. 427 427 _observer_health: dict[str, dict] = {} 428 428 429 - # Set of enabled observer process names (e.g. {"linux-observer", "tmux-observer"}). 429 + # Set of enabled observer process names (e.g. {"linux-observer"}). 430 430 # Empty set means no observers. Used to gate health checks. 431 431 _enabled_observers: set[str] = set() 432 432 ··· 1478 1478 parser.add_argument( 1479 1479 "--observers", 1480 1480 type=str, 1481 - default="linux,tmux", 1482 - help="Comma-separated observers to start: linux, tmux, none (default: linux,tmux)", 1481 + default="linux", 1482 + help="Comma-separated observers to start: linux, none (default: linux)", 1483 1483 ) 1484 1484 parser.add_argument( 1485 1485 "--no-daily", ··· 1602 1602 if "none" in obs_names: 1603 1603 _enabled_observers = set() 1604 1604 else: 1605 - valid = {"linux", "tmux"} 1605 + valid = {"linux"} 1606 1606 for name in obs_names: 1607 1607 if name not in valid: 1608 1608 parser.error( 1609 - f"Invalid observer: {name}. Choose from: linux, tmux, none" 1609 + f"Invalid observer: {name}. Choose from: linux, none" 1610 1610 ) 1611 1611 _enabled_observers = {f"{name}-observer" for name in obs_names} 1612 1612 ··· 1660 1660 procs.append( 1661 1661 _launch_process( 1662 1662 "linux-observer", ["sol", "observer", "-v"], restart=True 1663 - ) 1664 - ) 1665 - if "tmux-observer" in _enabled_observers: 1666 - procs.append( 1667 - _launch_process( 1668 - "tmux-observer", ["sol", "tmux-observer", "-v"], restart=True 1669 1663 ) 1670 1664 ) 1671 1665 else: ··· 1684 1678 "linux-observer", ["sol", "observer", "-v"], restart=True 1685 1679 ) 1686 1680 ) 1687 - if "tmux-observer" in _enabled_observers: 1688 - procs.append( 1689 - _launch_process( 1690 - "tmux-observer", ["sol", "tmux-observer", "-v"], restart=True 1691 - ) 1692 - ) 1693 1681 # Cortex after observers 1694 1682 if not args.no_cortex: 1695 1683 procs.append(start_cortex_server()) ··· 1731 1719 finally: 1732 1720 logging.info("Stopping all processes...") 1733 1721 print("\nShutting down gracefully (this may take a moment)...", flush=True) 1734 - observer_procs = [ 1735 - p for p in procs if p.name in ("linux-observer", "tmux-observer") 1736 - ] 1737 - other_procs = [ 1738 - p for p in procs if p.name not in ("linux-observer", "tmux-observer") 1739 - ] 1722 + observer_procs = [p for p in procs if p.name == "linux-observer"] 1723 + other_procs = [p for p in procs if p.name != "linux-observer"] 1740 1724 1741 1725 def _stop_process(managed: ManagedProcess) -> None: 1742 1726 name = managed.name