personal memory agent
0
fork

Configure Feed

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

extract standalone tmux observer, simplify linux observer to 2-mode

New `sol tmux-observer` service captures all active tmux sessions
independently of the linux observer. Linux observer simplified from
3-mode (SCREENCAST/TMUX/IDLE) to 2-mode (SCREENCAST/IDLE) by removing
all tmux code.

+316 -169
+15 -169
observe/linux/observer.py
··· 5 5 """ 6 6 Unified observer for audio and screencast capture. 7 7 8 - Continuously captures audio and manages screencast/tmux recording based on activity. 8 + Continuously captures audio and manages screencast recording based on activity. 9 9 Creates 5-minute windows, saving audio if voice activity detected and recording 10 - screencasts during active segments. When screen is idle but tmux sessions are active, 11 - captures tmux terminal content instead. 10 + screencasts during active segments. 12 11 13 12 State machine: 14 13 SCREENCAST: Screen is active, recording video 15 - TMUX: Screen is idle but tmux has recent activity 16 - IDLE: Both screen and tmux are inactive 14 + IDLE: Screen is inactive 17 15 """ 18 16 19 17 import argparse ··· 41 39 from observe.hear import AudioRecorder 42 40 from observe.linux.audio import is_sink_muted 43 41 from observe.linux.screencast import Screencaster, StreamInfo 44 - from observe.tmux.capture import TmuxCapture, write_captures_jsonl 45 42 from observe.utils import create_draft_folder, get_timestamp_parts 46 43 from think.callosum import CallosumConnection 47 44 from think.streams import stream_name, update_stream, write_segment_stream 48 - from think.utils import day_path, get_config, get_journal, get_rev, setup_cli 45 + from think.utils import day_path, get_journal, get_rev, setup_cli 49 46 50 47 logger = logging.getLogger(__name__) 51 48 ··· 58 55 RMS_THRESHOLD = 0.01 59 56 MIN_HITS_FOR_SAVE = 3 60 57 CHUNK_DURATION = 5 # seconds 61 - TMUX_ACTIVITY_THRESHOLD = 5 # seconds - fixed window for activity detection 62 - 63 - 64 58 # Exit codes 65 59 EXIT_TEMPFAIL = 75 # EX_TEMPFAIL: session not ready, retry later 66 60 67 61 # Capture modes 68 62 MODE_IDLE = "idle" 69 63 MODE_SCREENCAST = "screencast" 70 - MODE_TMUX = "tmux" 71 64 72 65 # Audio detection retry 73 66 DETECT_RETRIES = 3 ··· 75 68 76 69 77 70 class Observer: 78 - """Unified audio and screencast/tmux observer.""" 71 + """Unified audio and screencast observer.""" 79 72 80 73 def __init__(self, interval: int = 300): 81 74 self.interval = interval 82 75 self.audio_recorder = AudioRecorder() 83 76 self.screencaster = Screencaster() 84 - self.tmux_capture = TmuxCapture() 85 77 self.bus: MessageBus | None = None 86 78 self.running = True 87 79 self.stream = stream_name(host=HOST) ··· 105 97 # Multi-file screencast tracking 106 98 self.current_streams: list[StreamInfo] = [] 107 99 108 - # Tmux capture tracking 109 - self.tmux_captures: list[dict] = [] 110 - self.tmux_capture_id = 0 111 - self.tmux_sessions_seen: set[str] = set() 112 - self.last_tmux_capture_time: float = 0 # For capture interval timing 113 - self.in_tmux_segment: bool = False # True while tmux segment is open 114 - 115 100 # Activity status cache (updated each loop) 116 101 self.cached_is_active = False 117 102 self.cached_idle_time_ms = 0 ··· 122 107 # Mute state at segment start (determines save format) 123 108 self.segment_is_muted = False 124 109 125 - # Tmux configuration (loaded from journal config) 126 - self.tmux_enabled = True 127 - self.tmux_capture_interval = ( 128 - CHUNK_DURATION # User-configurable capture frequency 129 - ) 130 - 131 - def _load_tmux_config(self): 132 - """Load tmux settings from journal config.""" 133 - try: 134 - config = get_config() 135 - observe_config = config.get("observe", {}) 136 - tmux_config = observe_config.get("tmux", {}) 137 - 138 - self.tmux_enabled = tmux_config.get("enabled", True) 139 - self.tmux_capture_interval = tmux_config.get( 140 - "capture_interval", CHUNK_DURATION 141 - ) 142 - 143 - if not self.tmux_enabled: 144 - logger.info("Tmux capture disabled in config") 145 - elif self.tmux_capture_interval != CHUNK_DURATION: 146 - logger.info(f"Tmux capture interval: {self.tmux_capture_interval}s") 147 - except Exception as e: 148 - logger.warning(f"Failed to load tmux config, using defaults: {e}") 149 - 150 110 async def setup(self): 151 111 """Initialize audio devices and DBus connection.""" 152 112 # Detect audio devices with retry (devices may still be initializing) ··· 180 140 return False 181 141 logger.info("Screencast portal connected") 182 142 183 - # Load tmux configuration from journal 184 - self._load_tmux_config() 185 - 186 - # Check tmux availability (only if enabled) 187 - if self.tmux_enabled: 188 - if self.tmux_capture.is_available(): 189 - logger.info("Tmux available for fallback capture") 190 - else: 191 - logger.info("Tmux not available (will only use screencast)") 192 - 193 143 # Start Callosum connection for events 194 144 self._callosum = CallosumConnection(defaults={"rev": get_rev()}) 195 145 self._callosum.start() ··· 203 153 Check system activity status and determine capture mode. 204 154 205 155 Returns: 206 - Capture mode: MODE_SCREENCAST, MODE_TMUX, or MODE_IDLE 156 + Capture mode: MODE_SCREENCAST or MODE_IDLE 207 157 """ 208 158 idle_time = await get_idle_time_ms(self.bus) 209 159 screen_locked = await is_screen_locked(self.bus) ··· 220 170 screen_idle = (idle_time > IDLE_THRESHOLD_MS) or screen_locked or power_save 221 171 screen_active = not screen_idle 222 172 223 - # Check tmux activity (only if screen is idle and tmux is enabled) 224 - # Uses fixed threshold for mode detection, not capture interval 225 - if screen_active or not self.tmux_enabled: 226 - tmux_active = False 227 - else: 228 - tmux_active = self.tmux_capture.is_active( 229 - poll_interval=TMUX_ACTIVITY_THRESHOLD 230 - ) 231 - 232 - # Determine mode with priority: screen > tmux > idle 173 + # Determine mode from screen activity 233 174 if screen_active: 234 175 mode = MODE_SCREENCAST 235 - elif tmux_active: 236 - mode = MODE_TMUX 237 176 else: 238 177 mode = MODE_IDLE 239 178 240 179 # Cache legacy is_active for audio threshold logic 241 180 has_audio_activity = self.threshold_hits >= MIN_HITS_FOR_SAVE 242 - self.cached_is_active = screen_active or tmux_active or has_audio_activity 181 + self.cached_is_active = screen_active or has_audio_activity 243 182 244 183 return mode 245 184 ··· 351 290 self.accumulated_audio_buffer = np.array([], dtype=np.float32).reshape(0, 2) 352 291 self.threshold_hits = 0 353 292 354 - # Handle tmux capture save (to draft dir) 355 - # Save if we have captures, regardless of current mode (mode may have 356 - # changed to IDLE within the segment without triggering a boundary) 357 - tmux_files: list[str] = [] 358 - if self.tmux_captures and self.draft_dir: 359 - # write_captures_jsonl expects a Path and creates it if needed 360 - # Draft dir already exists 361 - tmux_files = write_captures_jsonl(self.tmux_captures, Path(self.draft_dir)) 362 - 363 - # Reset tmux state 364 - self.tmux_captures = [] 365 - self.tmux_capture_id = 0 366 - self.tmux_sessions_seen = set() 367 - self.tmux_capture.reset_hashes() 368 - self.last_tmux_capture_time = 0 # Allow immediate capture in new segment 369 - self.in_tmux_segment = new_mode == MODE_TMUX # Track if new segment is tmux 370 - 371 293 # Collect all files saved in this segment 372 - files = audio_files + screen_files + tmux_files 294 + files = audio_files + screen_files 373 295 segment_key = f"{time_part}_{duration}" 374 296 375 297 # Rename draft folder to final segment name (atomic handoff) ··· 429 351 # Start new capture based on mode (creates new draft folder) 430 352 if new_mode == MODE_SCREENCAST and not self.cached_screen_locked: 431 353 await self.initialize_screencast() 432 - elif new_mode == MODE_TMUX or new_mode == MODE_IDLE: 433 - # Create draft folder for audio/tmux even without screencast 354 + elif new_mode == MODE_IDLE: 434 355 self._create_draft_folder() 435 - # MODE_TMUX doesn't need initialization, captures happen in main loop 436 356 437 357 logger.info(f"Mode transition: {old_mode} -> {new_mode}") 438 358 ··· 490 410 logger.info(f" {stream.position} ({stream.connector}): {stream.file_path}") 491 411 492 412 return True 493 - 494 - def capture_tmux(self): 495 - """Poll tmux and accumulate captures based on capture interval. 496 - 497 - Only captures if: 498 - 1. Enough time has passed since last capture (capture_interval) 499 - 2. There was activity since the last capture 500 - """ 501 - now = time.time() 502 - time_since_capture = now - self.last_tmux_capture_time 503 - 504 - # Check if capture interval has elapsed 505 - if time_since_capture < self.tmux_capture_interval: 506 - return 507 - 508 - # Get sessions with activity since last capture 509 - active_sessions = self.tmux_capture.get_active_sessions(time_since_capture) 510 - if not active_sessions: 511 - return 512 - 513 - # Update capture time before capturing 514 - self.last_tmux_capture_time = now 515 - 516 - for session_info in active_sessions: 517 - session = session_info["session"] 518 - self.tmux_sessions_seen.add(session) 519 - 520 - result = self.tmux_capture.capture_changed(session) 521 - if not result: 522 - continue 523 - 524 - self.tmux_capture_id += 1 525 - relative_ts = now - self.start_at 526 - capture_dict = self.tmux_capture.result_to_dict( 527 - result, self.tmux_capture_id, relative_ts 528 - ) 529 - self.tmux_captures.append(capture_dict) 530 - logger.debug(f"Captured tmux session {session}: {len(result.panes)} panes") 531 413 532 414 def emit_status(self): 533 415 """Emit observe.status event with current state.""" ··· 562 444 else: 563 445 screencast_info = {"recording": False} 564 446 565 - # Calculate tmux info (show capturing=true while tmux segment is open) 566 - if self.in_tmux_segment: 567 - tmux_info = { 568 - "capturing": True, 569 - "captures": len(self.tmux_captures), 570 - "sessions": sorted(self.tmux_sessions_seen), 571 - "window_elapsed_seconds": elapsed, 572 - } 573 - else: 574 - tmux_info = {"capturing": False} 575 - 576 447 # Audio info 577 448 audio_info = { 578 449 "threshold_hits": self.threshold_hits, ··· 589 460 } 590 461 591 462 # Determine reported mode (segment type, not instantaneous state) 592 - # Screencast takes priority, then tmux segment, then idle 593 463 if self.current_mode == MODE_SCREENCAST: 594 464 reported_mode = MODE_SCREENCAST 595 - elif self.in_tmux_segment: 596 - reported_mode = MODE_TMUX 597 465 else: 598 466 reported_mode = MODE_IDLE 599 467 ··· 603 471 "status", 604 472 mode=reported_mode, 605 473 screencast=screencast_info, 606 - tmux=tmux_info, 607 474 audio=audio_info, 608 475 activity=activity_info, 609 476 host=HOST, ··· 619 486 new_mode = await self.check_activity_status() 620 487 self.segment_is_muted = self.cached_is_muted # Sync initial mute state 621 488 self.current_mode = new_mode 622 - self.in_tmux_segment = new_mode == MODE_TMUX 623 489 624 490 # Start initial capture based on mode (creates draft folder) 625 491 if new_mode == MODE_SCREENCAST and not self.cached_screen_locked: ··· 630 496 self.running = False 631 497 return 632 498 else: 633 - # Create draft folder for audio/tmux even without screencast 499 + # Create draft folder for audio even without screencast 634 500 self._create_draft_folder() 635 501 636 502 logger.info(f"Initial mode: {self.current_mode}") ··· 660 526 if mode_changed: 661 527 logger.info(f"Mode changing: {self.current_mode} -> {new_mode}") 662 528 663 - # Only trigger segment boundary on screencast transitions (not tmux<->idle) 664 - # This allows tmux segments to run full 5min windows like screencast 529 + # Only trigger segment boundary on screencast transitions 665 530 screencast_transition = mode_changed and ( 666 531 self.current_mode == MODE_SCREENCAST or new_mode == MODE_SCREENCAST 667 532 ) ··· 696 561 else: 697 562 logger.debug("No audio data in chunk") 698 563 699 - # Capture tmux if in tmux mode 700 - if self.current_mode == MODE_TMUX: 701 - self.capture_tmux() 702 - 703 564 # Check for window boundary (use monotonic to avoid DST/clock jumps) 704 565 now_mono = time.monotonic() 705 566 elapsed = now_mono - self.start_at_mono ··· 714 575 f"hits={self.threshold_hits}/{MIN_HITS_FOR_SAVE}" 715 576 ) 716 577 await self.handle_boundary(new_mode) 717 - elif mode_changed: 718 - # Update mode without boundary (tmux<->idle transitions) 719 - self.current_mode = new_mode 720 - # Mark tmux segment active when entering TMUX mode 721 - if new_mode == MODE_TMUX: 722 - self.in_tmux_segment = True 723 578 724 579 # Emit status event 725 580 self.emit_status() ··· 752 607 if audio_files: 753 608 logger.info(f"Saved final audio: {len(audio_files)} file(s)") 754 609 755 - # Save tmux captures (to draft dir) 756 - # Save if we have captures, regardless of current mode (mode may have 757 - # changed to IDLE within the segment without triggering a boundary) 758 - tmux_files: list[str] = [] 759 - if self.tmux_captures and self.draft_dir: 760 - tmux_files = write_captures_jsonl(self.tmux_captures, Path(self.draft_dir)) 761 - if tmux_files: 762 - logger.info(f"Saved final tmux captures: {len(tmux_files)} file(s)") 763 - 764 610 # Collect all files and finalize segment 765 611 screen_files = [stream.filename for stream in stopped_streams] 766 - files = audio_files + screen_files + tmux_files 612 + files = audio_files + screen_files 767 613 segment_key = f"{time_part}_{duration}" 768 614 769 615 if self.draft_dir and files: ··· 834 680 835 681 On GNOME Wayland, gnome-shell pushes DISPLAY, WAYLAND_DISPLAY, and 836 682 DBUS_SESSION_BUS_ADDRESS into the systemd user environment on startup. 837 - When the observer is launched from SSH or tmux, these vars may be missing 683 + When the observer is launched from a non-desktop shell, these vars may be missing 838 684 from the inherited environment — but systemctl --user show-environment 839 685 has them. 840 686 """ ··· 943 789 def main(): 944 790 """CLI entry point.""" 945 791 parser = argparse.ArgumentParser( 946 - description="Unified audio, screencast, and tmux observer for journaling." 792 + description="Unified audio and screencast observer for journaling." 947 793 ) 948 794 parser.add_argument( 949 795 "--interval",
+299
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 with draft-to-final atomic rename. 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.tmux.capture import TmuxCapture, write_captures_jsonl 27 + from observe.utils import create_draft_folder, get_timestamp_parts 28 + from think.callosum import CallosumConnection 29 + from think.streams import stream_name, update_stream, write_segment_stream 30 + from think.utils import day_path, get_config, get_rev, 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._callosum: CallosumConnection | 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 Callosum.""" 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._callosum = CallosumConnection(defaults={"rev": get_rev()}) 80 + self._callosum.start() 81 + logger.info("Callosum connection started") 82 + return True 83 + 84 + def capture(self): 85 + """Poll tmux and accumulate captures based on capture interval.""" 86 + now = time.time() 87 + time_since_capture = now - self.last_capture_time 88 + 89 + if time_since_capture < self.capture_interval: 90 + return 91 + 92 + active_sessions = self.tmux_capture.get_active_sessions(time_since_capture) 93 + if not active_sessions: 94 + return 95 + 96 + self.last_capture_time = now 97 + 98 + for session_info in active_sessions: 99 + session = session_info["session"] 100 + self.sessions_seen.add(session) 101 + 102 + result = self.tmux_capture.capture_changed(session) 103 + if not result: 104 + continue 105 + 106 + self.capture_id += 1 107 + relative_ts = now - self.start_at 108 + capture_dict = self.tmux_capture.result_to_dict( 109 + result, self.capture_id, relative_ts 110 + ) 111 + self.captures.append(capture_dict) 112 + logger.debug(f"Captured tmux session {session}: {len(result.panes)} panes") 113 + 114 + def _reset_capture_state(self): 115 + """Reset per-segment capture tracking.""" 116 + self.captures = [] 117 + self.capture_id = 0 118 + self.sessions_seen = set() 119 + self.tmux_capture.reset_hashes() 120 + self.last_capture_time = 0 121 + 122 + def _remove_empty_draft(self): 123 + """Remove an empty draft folder, ignoring errors.""" 124 + if self.draft_dir: 125 + try: 126 + os.rmdir(self.draft_dir) 127 + except OSError: 128 + pass 129 + 130 + def finalize_segment(self) -> list[str]: 131 + """Write captures to disk, finalize the segment, and emit observing.""" 132 + if not self.captures or not self.draft_dir: 133 + self._remove_empty_draft() 134 + self._reset_capture_state() 135 + return [] 136 + 137 + tmux_files = write_captures_jsonl(self.captures, Path(self.draft_dir)) 138 + if not tmux_files: 139 + self._remove_empty_draft() 140 + self._reset_capture_state() 141 + return [] 142 + 143 + date_part, time_part = get_timestamp_parts(self.start_at) 144 + duration = int(time.time() - self.start_at) 145 + day_dir = day_path(date_part) 146 + segment_key = f"{time_part}_{duration}" 147 + final_segment_dir = str(day_dir / self.stream / segment_key) 148 + 149 + try: 150 + os.rename(self.draft_dir, final_segment_dir) 151 + logger.info(f"Segment finalized: {self.draft_dir} -> {final_segment_dir}") 152 + except OSError as e: 153 + logger.error(f"Failed to rename draft folder: {e}") 154 + tmux_files = [] 155 + 156 + if tmux_files: 157 + try: 158 + result = update_stream( 159 + self.stream, 160 + date_part, 161 + segment_key, 162 + type="observer", 163 + host=HOST, 164 + platform=PLATFORM, 165 + ) 166 + write_segment_stream( 167 + final_segment_dir, 168 + self.stream, 169 + result["prev_day"], 170 + result["prev_segment"], 171 + result["seq"], 172 + ) 173 + except Exception as e: 174 + logger.warning(f"Failed to write stream identity: {e}") 175 + 176 + if self._callosum: 177 + self._callosum.emit( 178 + "observe", 179 + "observing", 180 + day=date_part, 181 + segment=segment_key, 182 + files=tmux_files, 183 + host=HOST, 184 + platform=PLATFORM, 185 + stream=self.stream, 186 + ) 187 + 188 + self._reset_capture_state() 189 + return tmux_files 190 + 191 + def _start_segment(self): 192 + """Start a new draft segment.""" 193 + self.start_at = time.time() 194 + self.start_at_mono = time.monotonic() 195 + self.draft_dir = create_draft_folder(self.start_at, self.stream) 196 + 197 + def emit_status(self): 198 + """Emit observe.status with current tmux capture state.""" 199 + if not self._callosum: 200 + return 201 + 202 + elapsed = int(time.monotonic() - self.start_at_mono) 203 + tmux_info = { 204 + "capturing": True, 205 + "captures": len(self.captures), 206 + "sessions": sorted(self.sessions_seen), 207 + "window_elapsed_seconds": elapsed, 208 + } 209 + self._callosum.emit( 210 + "observe", 211 + "status", 212 + mode="tmux", 213 + tmux=tmux_info, 214 + host=HOST, 215 + platform=PLATFORM, 216 + stream=self.stream, 217 + ) 218 + 219 + async def main_loop(self): 220 + """Run the polling loop until shutdown.""" 221 + self._start_segment() 222 + 223 + while self.running: 224 + await asyncio.sleep(1) # Short sleep for responsive shutdown 225 + self.capture() 226 + 227 + elapsed = time.monotonic() - self.start_at_mono 228 + if elapsed >= self.interval: 229 + self.finalize_segment() 230 + self._start_segment() 231 + 232 + self.emit_status() 233 + 234 + await self.shutdown() 235 + 236 + async def shutdown(self): 237 + """Finalize the current segment and stop Callosum.""" 238 + self.finalize_segment() 239 + self.draft_dir = None 240 + if self._callosum: 241 + self._callosum.stop() 242 + self._callosum = None 243 + 244 + 245 + async def async_main(args): 246 + """Async entry point.""" 247 + observer = TmuxObserver(interval=args.interval) 248 + 249 + loop = asyncio.get_running_loop() 250 + 251 + def signal_handler(): 252 + logger.info("Received shutdown signal") 253 + observer.running = False 254 + 255 + for sig in (signal.SIGINT, signal.SIGTERM): 256 + loop.add_signal_handler(sig, signal_handler) 257 + 258 + if not observer.setup(): 259 + logger.error("Tmux observer setup failed") 260 + return 1 261 + 262 + try: 263 + await observer.main_loop() 264 + except RuntimeError as e: 265 + logger.error(f"Tmux observer runtime error: {e}") 266 + return 1 267 + except Exception as e: 268 + logger.error(f"Tmux observer error: {e}", exc_info=True) 269 + return 1 270 + 271 + return 0 272 + 273 + 274 + def main(): 275 + """CLI entry point.""" 276 + parser = argparse.ArgumentParser( 277 + description="Standalone tmux terminal capture observer." 278 + ) 279 + parser.add_argument( 280 + "--interval", 281 + type=int, 282 + default=300, 283 + help="Duration per tmux segment in seconds (default: 300 = 5 minutes).", 284 + ) 285 + args = setup_cli(parser) 286 + 287 + try: 288 + rc = asyncio.run(async_main(args)) 289 + sys.exit(rc) 290 + except KeyboardInterrupt: 291 + logger.info("Interrupted by user") 292 + sys.exit(0) 293 + except Exception as e: 294 + logger.error(f"Fatal error: {e}", exc_info=True) 295 + sys.exit(1) 296 + 297 + 298 + if __name__ == "__main__": 299 + main()
+2
sol.py
··· 63 63 "remote": "observe.remote_cli", 64 64 "observe-linux": "observe.linux.observer", 65 65 "observe-macos": "observe.macos.observer", 66 + "tmux-observer": "observe.tmux.observer", 66 67 # AI agents (formerly muse package) 67 68 "agents": "think.agents", 68 69 "cortex": "think.cortex", ··· 115 116 "transfer", 116 117 "observer", 117 118 "remote", 119 + "tmux-observer", 118 120 ], 119 121 "Muse (AI agents)": [ 120 122 "agents",