tmux observer
0
fork

Configure Feed

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

Initial solstone-tmux: standalone tmux terminal observer

Extracted from solstone monorepo as part of observer decoupling phase 5a.
Standalone Python package that captures tmux terminal sessions to a local
cache directory and syncs them to a solstone server.

Key components:
- capture.py: tmux session capture with deduplication (from observe/tmux/capture.py)
- observer.py: main capture loop writing to local cache (from observe/tmux/observer.py)
- sync.py: background sync service modeled on solstone-macos SyncService.swift
- upload.py: HTTP upload client (from observe/remote_client.py)
- recovery.py: crash recovery for orphaned .incomplete segments
- config.py: config persistence at ~/.local/share/solstone-tmux/
- streams.py: stream naming (from think/streams.py)
- cli.py: subcommands — run, setup, install-service, status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Jer Miller f8169cdf

+2100
+17
LICENSE
··· 1 + GNU AFFERO GENERAL PUBLIC LICENSE 2 + Version 3, 19 November 2007 3 + 4 + Copyright (c) 2026 sol pbc 5 + 6 + This program is free software: you can redistribute it and/or modify 7 + it under the terms of the GNU Affero General Public License as published by 8 + the Free Software Foundation, either version 3 of the License, or 9 + (at your option) any later version. 10 + 11 + This program is distributed in the hope that it will be useful, 12 + but WITHOUT ANY WARRANTY; without even the implied warranty of 13 + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 + GNU Affero General Public License for more details. 15 + 16 + You should have received a copy of the GNU Affero General Public License 17 + along with this program. If not, see <https://www.gnu.org/licenses/>.
+40
README.md
··· 1 + # solstone-tmux 2 + 3 + Standalone tmux terminal observer for [solstone](https://solpbc.org). Captures tmux terminal sessions to a local cache and syncs them to a solstone server. 4 + 5 + ## Install 6 + 7 + ```bash 8 + pipx install solstone-tmux 9 + ``` 10 + 11 + ## Setup 12 + 13 + ```bash 14 + solstone-tmux setup 15 + ``` 16 + 17 + Prompts for your solstone server URL and auto-registers an observer key. 18 + 19 + ## Run 20 + 21 + ```bash 22 + # Run directly 23 + solstone-tmux run 24 + 25 + # Or install as a systemd user service 26 + solstone-tmux install-service 27 + systemctl --user status solstone-tmux 28 + ``` 29 + 30 + ## Status 31 + 32 + ```bash 33 + solstone-tmux status 34 + ``` 35 + 36 + Shows capture state, sync state, cache size, and last sync time. 37 + 38 + ## License 39 + 40 + AGPL-3.0-only. Copyright (c) 2026 sol pbc.
+14
contrib/solstone-tmux.service
··· 1 + [Unit] 2 + Description=Solstone Tmux Terminal Observer 3 + After=basic.target 4 + 5 + [Service] 6 + Type=simple 7 + ExecStart=%h/.local/bin/solstone-tmux run 8 + Restart=on-failure 9 + RestartSec=5 10 + StartLimitIntervalSec=300 11 + StartLimitBurst=5 12 + 13 + [Install] 14 + WantedBy=default.target
+17
pyproject.toml
··· 1 + [project] 2 + name = "solstone-tmux" 3 + version = "0.1.0" 4 + description = "Standalone tmux terminal observer for solstone" 5 + readme = "README.md" 6 + license = "AGPL-3.0-only" 7 + requires-python = ">=3.10" 8 + dependencies = [ 9 + "requests", 10 + ] 11 + 12 + [project.scripts] 13 + solstone-tmux = "solstone_tmux.cli:main" 14 + 15 + [build-system] 16 + requires = ["hatchling"] 17 + build-backend = "hatchling.build"
+6
src/solstone_tmux/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Standalone tmux terminal observer for solstone.""" 5 + 6 + __version__ = "0.1.0"
+333
src/solstone_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. Extracted from 7 + solstone's observe/tmux/capture.py — self-contained, uses only stdlib 8 + and subprocess for tmux CLI interaction. 9 + """ 10 + 11 + import hashlib 12 + import json 13 + import logging 14 + import subprocess 15 + import time 16 + from dataclasses import dataclass 17 + from pathlib import Path 18 + 19 + logger = logging.getLogger(__name__) 20 + 21 + 22 + @dataclass 23 + class PaneInfo: 24 + """Information about a tmux pane.""" 25 + 26 + id: str 27 + index: int 28 + left: int 29 + top: int 30 + width: int 31 + height: int 32 + active: bool 33 + content: str = "" 34 + 35 + 36 + @dataclass 37 + class WindowInfo: 38 + """Information about a tmux window.""" 39 + 40 + id: str 41 + index: int 42 + name: str 43 + active: bool 44 + 45 + 46 + @dataclass 47 + class CaptureResult: 48 + """Result of capturing a session's active window.""" 49 + 50 + session: str 51 + window: WindowInfo 52 + windows: list[WindowInfo] 53 + panes: list[PaneInfo] 54 + 55 + 56 + def run_tmux_command(args: list[str]) -> str | None: 57 + """Run a tmux command and return stdout, or None on error.""" 58 + try: 59 + result = subprocess.run( 60 + ["tmux"] + args, 61 + capture_output=True, 62 + text=True, 63 + timeout=5, 64 + ) 65 + if result.returncode != 0: 66 + return None 67 + return result.stdout 68 + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: 69 + logger.debug(f"tmux command failed: {e}") 70 + return None 71 + 72 + 73 + class TmuxCapture: 74 + """Tmux terminal capture with deduplication.""" 75 + 76 + def __init__(self): 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 + return len(self.get_active_sessions(poll_interval)) > 0 90 + 91 + def get_active_sessions(self, poll_interval: float = 5.0) -> list[dict]: 92 + """Get sessions with recent client activity.""" 93 + output = run_tmux_command( 94 + ["list-clients", "-F", "#{client_session} #{client_activity}"] 95 + ) 96 + if not output: 97 + return [] 98 + 99 + now = time.time() 100 + active = [] 101 + seen_sessions: set[str] = set() 102 + 103 + for line in output.strip().split("\n"): 104 + if not line: 105 + continue 106 + parts = line.split(" ", 1) 107 + if len(parts) != 2: 108 + continue 109 + 110 + session, activity_str = parts 111 + try: 112 + activity = int(activity_str) 113 + except ValueError: 114 + continue 115 + 116 + if now - activity <= poll_interval and session not in seen_sessions: 117 + active.append({"session": session, "activity": activity}) 118 + seen_sessions.add(session) 119 + 120 + return active 121 + 122 + def get_windows(self, session: str) -> list[WindowInfo]: 123 + """Get all windows for a session.""" 124 + output = run_tmux_command( 125 + [ 126 + "list-windows", "-t", session, "-F", 127 + "#{window_active} #{window_id} #{window_index} #{window_name}", 128 + ] 129 + ) 130 + if not output: 131 + return [] 132 + 133 + windows = [] 134 + for line in output.strip().split("\n"): 135 + if not line: 136 + continue 137 + parts = line.split(" ", 3) 138 + if len(parts) < 4: 139 + continue 140 + 141 + active_str, window_id, index_str, name = parts 142 + try: 143 + windows.append( 144 + WindowInfo( 145 + id=window_id, 146 + index=int(index_str), 147 + name=name, 148 + active=(active_str == "1"), 149 + ) 150 + ) 151 + except ValueError: 152 + continue 153 + 154 + return windows 155 + 156 + def get_panes(self, window_id: str) -> list[PaneInfo]: 157 + """Get all panes for a window with layout info.""" 158 + output = run_tmux_command( 159 + [ 160 + "list-panes", "-t", window_id, "-F", 161 + "#{pane_id} #{pane_index} #{pane_left} #{pane_top} #{pane_width} #{pane_height} #{pane_active}", 162 + ] 163 + ) 164 + if not output: 165 + return [] 166 + 167 + panes = [] 168 + for line in output.strip().split("\n"): 169 + if not line: 170 + continue 171 + parts = line.split(" ") 172 + if len(parts) != 7: 173 + continue 174 + 175 + try: 176 + panes.append( 177 + PaneInfo( 178 + id=parts[0], 179 + index=int(parts[1]), 180 + left=int(parts[2]), 181 + top=int(parts[3]), 182 + width=int(parts[4]), 183 + height=int(parts[5]), 184 + active=(parts[6] == "1"), 185 + ) 186 + ) 187 + except ValueError: 188 + continue 189 + 190 + return panes 191 + 192 + def capture_pane(self, pane_id: str) -> str: 193 + """Capture visible pane content with ANSI escape codes.""" 194 + output = run_tmux_command( 195 + ["capture-pane", "-p", "-e", "-t", pane_id] 196 + ) 197 + return output if output else "" 198 + 199 + def capture_session(self, session: str) -> CaptureResult | None: 200 + """Capture the active window of a session with all its panes.""" 201 + windows = self.get_windows(session) 202 + if not windows: 203 + return None 204 + 205 + active_window = next((w for w in windows if w.active), None) 206 + if not active_window: 207 + return None 208 + 209 + panes = self.get_panes(active_window.id) 210 + if not panes: 211 + return None 212 + 213 + for pane in panes: 214 + pane.content = self.capture_pane(pane.id) 215 + 216 + return CaptureResult( 217 + session=session, 218 + window=active_window, 219 + windows=windows, 220 + panes=panes, 221 + ) 222 + 223 + def compute_hash(self, result: CaptureResult) -> str: 224 + """Compute hash of capture for deduplication.""" 225 + parts = [result.window.id] 226 + for pane in sorted(result.panes, key=lambda p: p.id): 227 + parts.append(pane.content) 228 + content = "\n".join(parts) 229 + return hashlib.md5(content.encode()).hexdigest() 230 + 231 + def capture_changed(self, session: str) -> CaptureResult | None: 232 + """Capture session if content changed since last capture.""" 233 + result = self.capture_session(session) 234 + if not result: 235 + return None 236 + 237 + content_hash = self.compute_hash(result) 238 + if self.last_hash.get(session) == content_hash: 239 + return None 240 + 241 + self.last_hash[session] = content_hash 242 + return result 243 + 244 + def result_to_dict( 245 + self, result: CaptureResult, capture_id: int, relative_ts: float 246 + ) -> dict: 247 + """Convert CaptureResult to JSON-serializable dict. 248 + 249 + Output format matches screen.jsonl structure for unified processing. 250 + """ 251 + pane_count = len(result.panes) 252 + pane_word = "pane" if pane_count == 1 else "panes" 253 + visual_description = ( 254 + f"Terminal session '{result.session}' with {pane_count} {pane_word} " 255 + f"in window '{result.window.name}'" 256 + ) 257 + 258 + return { 259 + "frame_id": capture_id, 260 + "timestamp": relative_ts, 261 + "requests": [], 262 + "analysis": { 263 + "visual_description": visual_description, 264 + "primary": "tmux", 265 + "secondary": "none", 266 + "overlap": False, 267 + }, 268 + "content": { 269 + "tmux": { 270 + "session": result.session, 271 + "window": { 272 + "id": result.window.id, 273 + "index": result.window.index, 274 + "name": result.window.name, 275 + }, 276 + "windows": [ 277 + { 278 + "id": w.id, 279 + "index": w.index, 280 + "name": w.name, 281 + "active": w.active, 282 + } 283 + for w in result.windows 284 + ], 285 + "panes": [ 286 + { 287 + "id": p.id, 288 + "index": p.index, 289 + "left": p.left, 290 + "top": p.top, 291 + "width": p.width, 292 + "height": p.height, 293 + "active": p.active, 294 + "content": p.content, 295 + } 296 + for p in result.panes 297 + ], 298 + }, 299 + }, 300 + } 301 + 302 + 303 + def write_captures_jsonl(captures: list[dict], segment_dir: Path) -> list[str]: 304 + """Write tmux captures to JSONL files, grouped by session. 305 + 306 + Creates one file per session: tmux_{session}_screen.jsonl 307 + """ 308 + if not captures: 309 + return [] 310 + 311 + segment_dir.mkdir(parents=True, exist_ok=True) 312 + 313 + by_session: dict[str, list[dict]] = {} 314 + for capture in captures: 315 + session = capture.get("content", {}).get("tmux", {}).get("session", "unknown") 316 + if session not in by_session: 317 + by_session[session] = [] 318 + by_session[session].append(capture) 319 + 320 + files_written = [] 321 + for session, session_captures in by_session.items(): 322 + safe_session = session.replace("/", "_").replace(" ", "_") 323 + filename = f"tmux_{safe_session}_screen.jsonl" 324 + output_path = segment_dir / filename 325 + 326 + with open(output_path, "w") as f: 327 + for capture in session_captures: 328 + f.write(json.dumps(capture) + "\n") 329 + 330 + files_written.append(filename) 331 + logger.info(f"Wrote {len(session_captures)} tmux captures to {output_path}") 332 + 333 + return files_written
+285
src/solstone_tmux/cli.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI entry point for solstone-tmux. 5 + 6 + Subcommands: 7 + run Start capture loop + sync service (default) 8 + setup Interactive configuration 9 + install-service Write systemd user unit, enable, start 10 + status Show capture and sync state 11 + """ 12 + 13 + from __future__ import annotations 14 + 15 + import argparse 16 + import asyncio 17 + import json 18 + import logging 19 + import os 20 + import shutil 21 + import socket 22 + import subprocess 23 + import sys 24 + from pathlib import Path 25 + 26 + from .config import Config, load_config, save_config 27 + from .streams import stream_name 28 + 29 + 30 + def _setup_logging(verbose: bool = False) -> None: 31 + level = logging.DEBUG if verbose else logging.INFO 32 + logging.basicConfig( 33 + level=level, 34 + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", 35 + datefmt="%H:%M:%S", 36 + ) 37 + 38 + 39 + def cmd_run(args: argparse.Namespace) -> int: 40 + """Start the capture loop + sync service.""" 41 + from .observer import async_run 42 + from .recovery import recover_incomplete_segments 43 + 44 + config = load_config() 45 + config.ensure_dirs() 46 + 47 + if not config.stream: 48 + try: 49 + config.stream = stream_name( 50 + host=socket.gethostname(), qualifier="tmux" 51 + ) 52 + except ValueError as e: 53 + print(f"Error: {e}", file=sys.stderr) 54 + return 1 55 + 56 + if args.interval: 57 + config.segment_interval = args.interval 58 + 59 + # Crash recovery before starting 60 + recovered = recover_incomplete_segments(config.captures_dir) 61 + if recovered: 62 + print(f"Recovered {recovered} incomplete segment(s)") 63 + 64 + try: 65 + return asyncio.run(async_run(config)) 66 + except KeyboardInterrupt: 67 + return 0 68 + 69 + 70 + def cmd_setup(args: argparse.Namespace) -> int: 71 + """Interactive setup — configure server URL and register.""" 72 + from .upload import UploadClient 73 + 74 + config = load_config() 75 + 76 + # Prompt for server URL 77 + default_url = config.server_url or "" 78 + url = input(f"Solstone server URL [{default_url}]: ").strip() 79 + if url: 80 + config.server_url = url 81 + elif not config.server_url: 82 + print("Error: server URL is required", file=sys.stderr) 83 + return 1 84 + 85 + # Derive stream name 86 + if not config.stream: 87 + try: 88 + config.stream = stream_name( 89 + host=socket.gethostname(), qualifier="tmux" 90 + ) 91 + except ValueError as e: 92 + print(f"Error deriving stream name: {e}", file=sys.stderr) 93 + return 1 94 + print(f"Stream: {config.stream}") 95 + 96 + # Save config before registration (so URL is persisted) 97 + config.ensure_dirs() 98 + save_config(config) 99 + 100 + # Auto-register 101 + if not config.key: 102 + print("Registering with server...") 103 + client = UploadClient(config) 104 + if client.ensure_registered(config): 105 + # Reload config to pick up persisted key 106 + config = load_config() 107 + print(f"Registered (key: {config.key[:8]}...)") 108 + else: 109 + print("Warning: registration failed. Run setup again when server is available.") 110 + else: 111 + print(f"Already registered (key: {config.key[:8]}...)") 112 + 113 + print(f"\nConfig saved to {config.config_path}") 114 + print(f"Captures will go to {config.captures_dir}") 115 + print(f"\nRun 'solstone-tmux run' to start, or 'solstone-tmux install-service' for systemd.") 116 + return 0 117 + 118 + 119 + def cmd_install_service(args: argparse.Namespace) -> int: 120 + """Write systemd user unit file, enable, and start the service.""" 121 + binary = shutil.which("solstone-tmux") 122 + if not binary: 123 + print("Error: solstone-tmux not found on PATH", file=sys.stderr) 124 + print("Install with: pipx install solstone-tmux", file=sys.stderr) 125 + return 1 126 + 127 + unit_dir = Path.home() / ".config" / "systemd" / "user" 128 + unit_dir.mkdir(parents=True, exist_ok=True) 129 + unit_path = unit_dir / "solstone-tmux.service" 130 + 131 + unit_content = f"""\ 132 + [Unit] 133 + Description=Solstone Tmux Terminal Observer 134 + After=basic.target 135 + 136 + [Service] 137 + Type=simple 138 + ExecStart={binary} run 139 + Restart=on-failure 140 + RestartSec=5 141 + StartLimitIntervalSec=300 142 + StartLimitBurst=5 143 + 144 + [Install] 145 + WantedBy=default.target 146 + """ 147 + 148 + unit_path.write_text(unit_content) 149 + print(f"Wrote {unit_path}") 150 + 151 + # Reload, enable, start 152 + try: 153 + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) 154 + subprocess.run( 155 + ["systemctl", "--user", "enable", "--now", "solstone-tmux.service"], 156 + check=True, 157 + ) 158 + print("Service enabled and started.") 159 + subprocess.run( 160 + ["systemctl", "--user", "status", "solstone-tmux.service"], 161 + check=False, 162 + ) 163 + except FileNotFoundError: 164 + print("Warning: systemctl not found. Enable the service manually.") 165 + except subprocess.CalledProcessError as e: 166 + print(f"Warning: systemctl command failed: {e}") 167 + 168 + return 0 169 + 170 + 171 + def cmd_status(args: argparse.Namespace) -> int: 172 + """Show capture and sync state.""" 173 + config = load_config() 174 + 175 + print(f"Config: {config.config_path}") 176 + print(f"Server: {config.server_url or '(not configured)'}") 177 + print(f"Key: {config.key[:8] + '...' if config.key else '(not registered)'}") 178 + print(f"Stream: {config.stream or '(not set)'}") 179 + print() 180 + 181 + # Cache size 182 + captures_dir = config.captures_dir 183 + if captures_dir.exists(): 184 + total_size = 0 185 + segment_count = 0 186 + day_count = 0 187 + 188 + for day_dir in sorted(captures_dir.iterdir()): 189 + if not day_dir.is_dir(): 190 + continue 191 + day_count += 1 192 + for stream_dir in day_dir.iterdir(): 193 + if not stream_dir.is_dir(): 194 + continue 195 + for seg_dir in stream_dir.iterdir(): 196 + if not seg_dir.is_dir(): 197 + continue 198 + if seg_dir.name.endswith(".incomplete"): 199 + continue 200 + if seg_dir.name.endswith(".failed"): 201 + continue 202 + segment_count += 1 203 + for f in seg_dir.iterdir(): 204 + if f.is_file(): 205 + total_size += f.stat().st_size 206 + 207 + size_mb = total_size / (1024 * 1024) 208 + print(f"Cache: {captures_dir}") 209 + print(f" {segment_count} segments across {day_count} day(s), {size_mb:.1f} MB") 210 + else: 211 + print(f"Cache: {captures_dir} (not created yet)") 212 + 213 + # Synced days 214 + synced_path = config.state_dir / "synced_days.json" 215 + if synced_path.exists(): 216 + try: 217 + with open(synced_path) as f: 218 + synced = json.load(f) 219 + print(f"Synced: {len(synced)} day(s) fully synced") 220 + except (json.JSONDecodeError, OSError): 221 + pass 222 + 223 + # Systemd status 224 + try: 225 + result = subprocess.run( 226 + ["systemctl", "--user", "is-active", "solstone-tmux.service"], 227 + capture_output=True, 228 + text=True, 229 + ) 230 + state = result.stdout.strip() 231 + print(f"\nService: {state}") 232 + except FileNotFoundError: 233 + pass 234 + 235 + return 0 236 + 237 + 238 + def main() -> None: 239 + """CLI entry point.""" 240 + parser = argparse.ArgumentParser( 241 + prog="solstone-tmux", 242 + description="Standalone tmux terminal observer for solstone", 243 + ) 244 + parser.add_argument( 245 + "-v", "--verbose", action="store_true", help="Enable debug logging" 246 + ) 247 + subparsers = parser.add_subparsers(dest="command") 248 + 249 + # run 250 + run_parser = subparsers.add_parser("run", help="Start capture + sync") 251 + run_parser.add_argument( 252 + "--interval", 253 + type=int, 254 + default=None, 255 + help="Segment duration in seconds (default: 300)", 256 + ) 257 + 258 + # setup 259 + subparsers.add_parser("setup", help="Interactive configuration") 260 + 261 + # install-service 262 + subparsers.add_parser("install-service", help="Install systemd user service") 263 + 264 + # status 265 + subparsers.add_parser("status", help="Show capture and sync state") 266 + 267 + args = parser.parse_args() 268 + _setup_logging(args.verbose) 269 + 270 + # Default to run if no subcommand 271 + command = args.command or "run" 272 + 273 + commands = { 274 + "run": cmd_run, 275 + "setup": cmd_setup, 276 + "install-service": cmd_install_service, 277 + "status": cmd_status, 278 + } 279 + 280 + handler = commands.get(command) 281 + if handler: 282 + sys.exit(handler(args)) 283 + else: 284 + parser.print_help() 285 + sys.exit(1)
+118
src/solstone_tmux/config.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Configuration loading and persistence for solstone-tmux. 5 + 6 + Config lives at ~/.local/share/solstone-tmux/config/config.json. 7 + Captures go to ~/.local/share/solstone-tmux/captures/. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import json 13 + import logging 14 + import os 15 + import stat 16 + from dataclasses import dataclass, field 17 + from pathlib import Path 18 + 19 + logger = logging.getLogger(__name__) 20 + 21 + DEFAULT_BASE_DIR = Path.home() / ".local" / "share" / "solstone-tmux" 22 + DEFAULT_CAPTURE_INTERVAL = 5 23 + DEFAULT_SEGMENT_INTERVAL = 300 24 + DEFAULT_SYNC_RETRY_DELAYS = [5, 30, 120, 300] 25 + DEFAULT_SYNC_MAX_RETRIES = 10 26 + 27 + 28 + @dataclass 29 + class Config: 30 + """Configuration for the tmux observer.""" 31 + 32 + server_url: str = "" 33 + key: str = "" 34 + stream: str = "" 35 + capture_interval: int = DEFAULT_CAPTURE_INTERVAL 36 + segment_interval: int = DEFAULT_SEGMENT_INTERVAL 37 + sync_retry_delays: list[int] = field(default_factory=lambda: list(DEFAULT_SYNC_RETRY_DELAYS)) 38 + sync_max_retries: int = DEFAULT_SYNC_MAX_RETRIES 39 + base_dir: Path = DEFAULT_BASE_DIR 40 + 41 + @property 42 + def captures_dir(self) -> Path: 43 + return self.base_dir / "captures" 44 + 45 + @property 46 + def config_dir(self) -> Path: 47 + return self.base_dir / "config" 48 + 49 + @property 50 + def state_dir(self) -> Path: 51 + return self.base_dir / "state" 52 + 53 + @property 54 + def config_path(self) -> Path: 55 + return self.config_dir / "config.json" 56 + 57 + def ensure_dirs(self) -> None: 58 + """Create all required directories.""" 59 + self.captures_dir.mkdir(parents=True, exist_ok=True) 60 + self.config_dir.mkdir(parents=True, exist_ok=True) 61 + self.state_dir.mkdir(parents=True, exist_ok=True) 62 + 63 + 64 + def load_config(base_dir: Path | None = None) -> Config: 65 + """Load config from disk, returning defaults if not found.""" 66 + config = Config() 67 + if base_dir: 68 + config.base_dir = base_dir 69 + 70 + config_path = config.config_path 71 + if not config_path.exists(): 72 + return config 73 + 74 + try: 75 + with open(config_path, encoding="utf-8") as f: 76 + data = json.load(f) 77 + except (json.JSONDecodeError, OSError) as e: 78 + logger.warning(f"Failed to load config from {config_path}: {e}") 79 + return config 80 + 81 + config.server_url = data.get("server_url", "") 82 + config.key = data.get("key", "") 83 + config.stream = data.get("stream", "") 84 + config.capture_interval = data.get("capture_interval", DEFAULT_CAPTURE_INTERVAL) 85 + config.segment_interval = data.get("segment_interval", DEFAULT_SEGMENT_INTERVAL) 86 + if "sync_retry_delays" in data: 87 + config.sync_retry_delays = data["sync_retry_delays"] 88 + if "sync_max_retries" in data: 89 + config.sync_max_retries = data["sync_max_retries"] 90 + 91 + return config 92 + 93 + 94 + def save_config(config: Config) -> None: 95 + """Save config to disk with user-only permissions.""" 96 + config.ensure_dirs() 97 + 98 + data = { 99 + "server_url": config.server_url, 100 + "key": config.key, 101 + "stream": config.stream, 102 + "capture_interval": config.capture_interval, 103 + "segment_interval": config.segment_interval, 104 + "sync_retry_delays": config.sync_retry_delays, 105 + "sync_max_retries": config.sync_max_retries, 106 + } 107 + 108 + config_path = config.config_path 109 + tmp_path = config_path.with_suffix(f".{os.getpid()}.tmp") 110 + 111 + with open(tmp_path, "w", encoding="utf-8") as f: 112 + json.dump(data, f, indent=2) 113 + f.write("\n") 114 + 115 + # Set user-only read/write before moving into place 116 + os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) 117 + os.rename(str(tmp_path), str(config_path)) 118 + logger.info(f"Config saved to {config_path}")
+255
src/solstone_tmux/observer.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Standalone tmux terminal capture observer. 5 + 6 + Continuously polls all active tmux sessions and captures terminal content, 7 + creating 5-minute segments in a local cache directory. The sync service 8 + handles all network operations — the observer only writes locally. 9 + """ 10 + 11 + import asyncio 12 + import datetime 13 + import logging 14 + import os 15 + import platform 16 + import signal 17 + import socket 18 + import time 19 + from pathlib import Path 20 + 21 + from .capture import TmuxCapture, write_captures_jsonl 22 + from .config import Config 23 + from .streams import stream_name 24 + from .sync import SyncService 25 + from .upload import UploadClient 26 + 27 + logger = logging.getLogger(__name__) 28 + 29 + HOST = socket.gethostname() 30 + PLATFORM = platform.system().lower() 31 + 32 + 33 + def _get_timestamp_parts(timestamp: float | None = None) -> tuple[str, str]: 34 + """Get date and time parts from timestamp.""" 35 + if timestamp is None: 36 + timestamp = time.time() 37 + dt = datetime.datetime.fromtimestamp(timestamp) 38 + return dt.strftime("%Y%m%d"), dt.strftime("%H%M%S") 39 + 40 + 41 + class TmuxObserver: 42 + def __init__(self, config: Config): 43 + self.config = config 44 + self.interval = config.segment_interval 45 + self.capture_interval = config.capture_interval 46 + self.tmux_capture = TmuxCapture() 47 + self.running = True 48 + self.stream = config.stream 49 + self._client: UploadClient | None = None 50 + self._sync: SyncService | None = None 51 + self.start_at = time.time() 52 + self.start_at_mono = time.monotonic() 53 + self.segment_dir: Path | None = None 54 + self.captures: list[dict] = [] 55 + self.capture_id = 0 56 + self.sessions_seen: set[str] = set() 57 + self.last_capture_time: float = 0 58 + 59 + def setup(self) -> bool: 60 + """Initialize tmux availability and upload client.""" 61 + if not self.tmux_capture.is_available(): 62 + logger.error("Tmux not available") 63 + return False 64 + 65 + if not self.stream: 66 + try: 67 + self.stream = stream_name(host=HOST, qualifier="tmux") 68 + self.config.stream = self.stream 69 + except ValueError as e: 70 + logger.error(f"Failed to derive stream name: {e}") 71 + return False 72 + 73 + self._client = UploadClient(self.config) 74 + if self.config.server_url: 75 + self._client.ensure_registered(self.config) 76 + 77 + self._sync = SyncService(self.config, self._client) 78 + logger.info(f"Observer initialized: stream={self.stream}") 79 + return True 80 + 81 + def capture(self): 82 + """Poll tmux and accumulate captures.""" 83 + now = time.time() 84 + if now - self.last_capture_time < self.capture_interval: 85 + return 86 + 87 + active_sessions = self.tmux_capture.get_active_sessions( 88 + self.capture_interval 89 + ) 90 + if not active_sessions: 91 + return 92 + 93 + self.last_capture_time = now 94 + 95 + for session_info in active_sessions: 96 + session = session_info["session"] 97 + self.sessions_seen.add(session) 98 + 99 + result = self.tmux_capture.capture_changed(session) 100 + if not result: 101 + continue 102 + 103 + self.capture_id += 1 104 + relative_ts = now - self.start_at 105 + capture_dict = self.tmux_capture.result_to_dict( 106 + result, self.capture_id, relative_ts 107 + ) 108 + self.captures.append(capture_dict) 109 + logger.debug(f"Captured tmux session {session}: {len(result.panes)} panes") 110 + 111 + def _reset_capture_state(self): 112 + """Reset per-segment capture tracking.""" 113 + self.captures = [] 114 + self.capture_id = 0 115 + self.sessions_seen = set() 116 + self.tmux_capture.reset_hashes() 117 + self.last_capture_time = 0 118 + 119 + def _remove_empty_segment(self): 120 + """Remove an empty segment directory.""" 121 + if self.segment_dir and self.segment_dir.exists(): 122 + try: 123 + os.rmdir(self.segment_dir) 124 + except OSError: 125 + pass 126 + 127 + def finalize_segment(self): 128 + """Write captures to disk and trigger sync.""" 129 + if not self.captures or not self.segment_dir: 130 + self._remove_empty_segment() 131 + self._reset_capture_state() 132 + return 133 + 134 + write_captures_jsonl(self.captures, self.segment_dir) 135 + 136 + # Rename from .incomplete to final HHMMSS_DDD format 137 + date_part, time_part = _get_timestamp_parts(self.start_at) 138 + duration = int(time.time() - self.start_at) 139 + segment_key = f"{time_part}_{duration}" 140 + final_dir = self.segment_dir.parent / segment_key 141 + 142 + try: 143 + os.rename(str(self.segment_dir), str(final_dir)) 144 + logger.info(f"Segment finalized: {segment_key}") 145 + except OSError as e: 146 + logger.error(f"Failed to finalize segment: {e}") 147 + 148 + # Trigger sync 149 + if self._sync: 150 + self._sync.trigger() 151 + 152 + self._reset_capture_state() 153 + 154 + def _start_segment(self): 155 + """Start a new segment with .incomplete directory.""" 156 + self.start_at = time.time() 157 + self.start_at_mono = time.monotonic() 158 + 159 + date_part, time_part = _get_timestamp_parts(self.start_at) 160 + captures_dir = self.config.captures_dir 161 + 162 + # Create YYYYMMDD/stream/HHMMSS.incomplete/ 163 + segment_dir = captures_dir / date_part / self.stream / f"{time_part}.incomplete" 164 + segment_dir.mkdir(parents=True, exist_ok=True) 165 + self.segment_dir = segment_dir 166 + 167 + def emit_status(self): 168 + """Emit observe.status with current tmux capture state (fire-and-forget).""" 169 + if not self._client: 170 + return 171 + 172 + elapsed = int(time.monotonic() - self.start_at_mono) 173 + tmux_info = { 174 + "capturing": True, 175 + "captures": len(self.captures), 176 + "sessions": sorted(self.sessions_seen), 177 + "window_elapsed_seconds": elapsed, 178 + } 179 + self._client.relay_event( 180 + "observe", 181 + "status", 182 + mode="tmux", 183 + tmux=tmux_info, 184 + host=HOST, 185 + platform=PLATFORM, 186 + stream=self.stream, 187 + ) 188 + 189 + async def main_loop(self): 190 + """Run the capture loop with background sync.""" 191 + # Start sync service as background task 192 + sync_task = None 193 + if self._sync: 194 + sync_task = asyncio.create_task(self._sync.run()) 195 + 196 + self._start_segment() 197 + 198 + try: 199 + while self.running: 200 + await asyncio.sleep(1) 201 + self.capture() 202 + 203 + elapsed = time.monotonic() - self.start_at_mono 204 + if elapsed >= self.interval: 205 + self.finalize_segment() 206 + self._start_segment() 207 + 208 + self.emit_status() 209 + finally: 210 + await self.shutdown() 211 + if sync_task: 212 + if self._sync: 213 + self._sync.stop() 214 + sync_task.cancel() 215 + try: 216 + await sync_task 217 + except asyncio.CancelledError: 218 + pass 219 + 220 + async def shutdown(self): 221 + """Finalize the current segment and stop.""" 222 + self.finalize_segment() 223 + self.segment_dir = None 224 + if self._client: 225 + self._client.stop() 226 + self._client = None 227 + 228 + 229 + async def async_run(config: Config) -> int: 230 + """Async entry point for the observer.""" 231 + observer = TmuxObserver(config) 232 + 233 + loop = asyncio.get_running_loop() 234 + 235 + def signal_handler(): 236 + logger.info("Received shutdown signal") 237 + observer.running = False 238 + 239 + for sig in (signal.SIGINT, signal.SIGTERM): 240 + loop.add_signal_handler(sig, signal_handler) 241 + 242 + if not observer.setup(): 243 + logger.error("Tmux observer setup failed") 244 + return 1 245 + 246 + try: 247 + await observer.main_loop() 248 + except RuntimeError as e: 249 + logger.error(f"Tmux observer runtime error: {e}") 250 + return 1 251 + except Exception as e: 252 + logger.error(f"Tmux observer error: {e}", exc_info=True) 253 + return 1 254 + 255 + return 0
+127
src/solstone_tmux/recovery.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Crash recovery for orphaned .incomplete segment directories. 5 + 6 + Modeled on solstone-macos's IncompleteSegmentRecovery.swift. 7 + Runs on startup before the capture loop begins. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import logging 13 + import os 14 + import time 15 + from pathlib import Path 16 + 17 + logger = logging.getLogger(__name__) 18 + 19 + # Segments newer than this are assumed to be actively recording 20 + MINIMUM_AGE_SECONDS = 120 # 2 minutes 21 + 22 + 23 + def recover_incomplete_segments(captures_dir: Path) -> int: 24 + """Scan captures dir for orphaned .incomplete directories and finalize them. 25 + 26 + For each .incomplete directory older than 2 minutes: 27 + - Compute duration from filesystem timestamps (mtime - ctime) 28 + - Rename to HHMMSS_DDD/ format 29 + - If recovery fails, rename to HHMMSS.failed/ to prevent infinite retry 30 + 31 + Returns the number of successfully recovered segments. 32 + """ 33 + if not captures_dir.exists(): 34 + return 0 35 + 36 + recovered = 0 37 + now = time.time() 38 + 39 + for day_dir in sorted(captures_dir.iterdir()): 40 + if not day_dir.is_dir(): 41 + continue 42 + 43 + for stream_dir in sorted(day_dir.iterdir()): 44 + if not stream_dir.is_dir(): 45 + continue 46 + 47 + for segment_dir in sorted(stream_dir.iterdir()): 48 + if not segment_dir.is_dir(): 49 + continue 50 + 51 + dir_name = segment_dir.name 52 + if not dir_name.endswith(".incomplete"): 53 + continue 54 + 55 + # Check age 56 + try: 57 + dir_stat = segment_dir.stat() 58 + age = now - dir_stat.st_mtime 59 + if age < MINIMUM_AGE_SECONDS: 60 + logger.debug(f"Skipping recent incomplete: {dir_name}") 61 + continue 62 + except OSError: 63 + continue 64 + 65 + logger.info(f"Recovering incomplete segment: {dir_name}") 66 + if _recover_segment(segment_dir): 67 + recovered += 1 68 + 69 + if recovered: 70 + logger.info(f"Recovered {recovered} incomplete segment(s)") 71 + return recovered 72 + 73 + 74 + def _recover_segment(segment_dir: Path) -> bool: 75 + """Recover a single incomplete segment directory. 76 + 77 + Returns True on success. 78 + """ 79 + dir_name = segment_dir.name 80 + time_prefix = dir_name.removesuffix(".incomplete") 81 + 82 + # Compute duration from filesystem timestamps 83 + try: 84 + st = segment_dir.stat() 85 + # Use mtime - ctime as duration estimate (seconds since creation) 86 + duration = max(1, int(st.st_mtime - st.st_ctime)) 87 + except OSError: 88 + return _mark_failed(segment_dir) 89 + 90 + # Check there are actual files inside 91 + try: 92 + contents = list(segment_dir.iterdir()) 93 + if not contents: 94 + logger.warning(f"Empty incomplete segment: {dir_name}") 95 + return _mark_failed(segment_dir) 96 + except OSError: 97 + return _mark_failed(segment_dir) 98 + 99 + # Build final segment key with duration 100 + segment_key = f"{time_prefix}_{duration}" 101 + final_dir = segment_dir.parent / segment_key 102 + 103 + try: 104 + os.rename(str(segment_dir), str(final_dir)) 105 + logger.info(f"Recovered: {dir_name} -> {segment_key}") 106 + return True 107 + except OSError as e: 108 + logger.warning(f"Failed to rename {dir_name}: {e}") 109 + return _mark_failed(segment_dir) 110 + 111 + 112 + def _mark_failed(segment_dir: Path) -> bool: 113 + """Rename from .incomplete to .failed to prevent infinite retry.""" 114 + dir_name = segment_dir.name 115 + if not dir_name.endswith(".incomplete"): 116 + return False 117 + 118 + failed_name = dir_name.removesuffix(".incomplete") + ".failed" 119 + failed_dir = segment_dir.parent / failed_name 120 + 121 + try: 122 + os.rename(str(segment_dir), str(failed_dir)) 123 + logger.warning(f"Marked as failed: {dir_name} -> {failed_name}") 124 + except OSError as e: 125 + logger.error(f"Failed to mark as failed: {e}") 126 + 127 + return False
+87
src/solstone_tmux/streams.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Stream identity for observer segments. 5 + 6 + Extracted from solstone's think/streams.py — only the pure naming functions 7 + needed by standalone observers. 8 + 9 + Naming convention (separator is '.'): 10 + Local tmux: {hostname}.tmux e.g. "archon.tmux" 11 + Remote: {remote_name} e.g. "laptop" 12 + """ 13 + 14 + from __future__ import annotations 15 + 16 + import re 17 + 18 + _STREAM_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") 19 + 20 + 21 + def _strip_hostname(name: str) -> str: 22 + """Strip domain suffix from a hostname, keeping only the first label. 23 + 24 + Dots in stream names are reserved for qualifiers (e.g., '.tmux'). 25 + Hostnames like 'ja1r.local' or '192.168.1.1' must be reduced to a 26 + dot-free base name. 27 + 28 + Examples: 'ja1r.local' -> 'ja1r', '192.168.1.1' -> '192-168-1-1', 29 + 'archon' -> 'archon', 'my.host.example.com' -> 'my' 30 + """ 31 + name = name.strip() 32 + if not name: 33 + return name 34 + parts = name.split(".") 35 + if all(p.isdigit() for p in parts if p): 36 + return "-".join(p for p in parts if p) 37 + return parts[0] 38 + 39 + 40 + def stream_name( 41 + *, 42 + host: str | None = None, 43 + remote: str | None = None, 44 + qualifier: str | None = None, 45 + ) -> str: 46 + """Derive canonical stream name from source characteristics. 47 + 48 + Parameters 49 + ---------- 50 + host : str, optional 51 + Local hostname (e.g., "archon"). 52 + remote : str, optional 53 + Remote observer name (e.g., "laptop"). 54 + qualifier : str, optional 55 + Sub-stream qualifier (e.g., "tmux"). Appended with dot separator. 56 + 57 + Returns 58 + ------- 59 + str 60 + Canonical stream name. 61 + 62 + Raises 63 + ------ 64 + ValueError 65 + If no source is provided, or the resulting name is invalid. 66 + """ 67 + if host: 68 + base = _strip_hostname(host) 69 + elif remote: 70 + base = _strip_hostname(remote) 71 + else: 72 + raise ValueError("stream_name requires host or remote") 73 + 74 + name = base.lower().strip() 75 + name = re.sub(r"[\s/\\]+", "-", name) 76 + 77 + if qualifier: 78 + qualifier = qualifier.lower().strip() 79 + qualifier = re.sub(r"[\s/\\]+", "-", qualifier) 80 + name = f"{name}.{qualifier}" 81 + 82 + if not name or ".." in name: 83 + raise ValueError(f"Invalid stream name: {name!r}") 84 + if not _STREAM_NAME_RE.match(name): 85 + raise ValueError(f"Invalid stream name: {name!r}") 86 + 87 + return name
+243
src/solstone_tmux/sync.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Background sync service for uploading captured segments. 5 + 6 + Modeled on solstone-macos's SyncService.swift. Runs as an asyncio 7 + background task in the same event loop as capture. Walks cache days 8 + newest-to-oldest, queries server for existing segments, uploads missing ones. 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + import asyncio 14 + import json 15 + import logging 16 + import os 17 + import time 18 + from datetime import datetime 19 + from pathlib import Path 20 + from typing import Any 21 + 22 + from .config import Config 23 + from .upload import UploadClient 24 + 25 + logger = logging.getLogger(__name__) 26 + 27 + 28 + class SyncService: 29 + """Background sync service that uploads completed segments to the server.""" 30 + 31 + def __init__(self, config: Config, client: UploadClient): 32 + self._config = config 33 + self._client = client 34 + self._synced_days: set[str] = set() 35 + self._consecutive_failures = 0 36 + self._circuit_open = False 37 + self._last_full_sync: float = 0 38 + self._running = True 39 + self._trigger = asyncio.Event() 40 + 41 + # Load synced days cache 42 + self._load_synced_days() 43 + 44 + def _synced_days_path(self) -> Path: 45 + return self._config.state_dir / "synced_days.json" 46 + 47 + def _load_synced_days(self) -> None: 48 + path = self._synced_days_path() 49 + if not path.exists(): 50 + return 51 + try: 52 + with open(path, encoding="utf-8") as f: 53 + data = json.load(f) 54 + self._synced_days = set(data) if isinstance(data, list) else set() 55 + except (json.JSONDecodeError, OSError): 56 + self._synced_days = set() 57 + 58 + def _save_synced_days(self) -> None: 59 + self._config.state_dir.mkdir(parents=True, exist_ok=True) 60 + path = self._synced_days_path() 61 + tmp = path.with_suffix(f".{os.getpid()}.tmp") 62 + try: 63 + with open(tmp, "w", encoding="utf-8") as f: 64 + json.dump(sorted(self._synced_days), f) 65 + f.write("\n") 66 + os.rename(str(tmp), str(path)) 67 + except OSError as e: 68 + logger.warning(f"Failed to save synced days: {e}") 69 + 70 + def trigger(self) -> None: 71 + """Trigger a sync pass (called by observer on segment completion).""" 72 + self._trigger.set() 73 + 74 + def stop(self) -> None: 75 + """Stop the sync service.""" 76 + self._running = False 77 + self._trigger.set() 78 + 79 + async def run(self) -> None: 80 + """Main sync loop — waits for triggers, then syncs.""" 81 + while self._running: 82 + try: 83 + # Wait for trigger or periodic check (60s timeout) 84 + try: 85 + await asyncio.wait_for(self._trigger.wait(), timeout=60) 86 + except asyncio.TimeoutError: 87 + pass 88 + 89 + self._trigger.clear() 90 + 91 + if not self._running: 92 + break 93 + 94 + if self._circuit_open: 95 + logger.warning("Circuit breaker open — skipping sync") 96 + continue 97 + 98 + # Force full sync daily 99 + now = time.time() 100 + force_full = (now - self._last_full_sync) > 86400 101 + 102 + await self._sync(force_full=force_full) 103 + 104 + if force_full: 105 + self._last_full_sync = now 106 + 107 + except Exception as e: 108 + logger.error(f"Sync error: {e}", exc_info=True) 109 + await asyncio.sleep(5) 110 + 111 + async def _sync(self, force_full: bool = False) -> None: 112 + """Walk days newest-to-oldest and upload missing segments.""" 113 + captures_dir = self._config.captures_dir 114 + if not captures_dir.exists(): 115 + return 116 + 117 + today = datetime.now().strftime("%Y%m%d") 118 + 119 + # Collect segments by day 120 + segments_by_day = self._collect_segments(captures_dir) 121 + if not segments_by_day: 122 + return 123 + 124 + for day in sorted(segments_by_day.keys(), reverse=True): 125 + if not self._running: 126 + break 127 + 128 + if self._circuit_open: 129 + break 130 + 131 + # Skip past days already fully synced (unless forcing) 132 + if day != today and day in self._synced_days and not force_full: 133 + continue 134 + 135 + local_segments = segments_by_day[day] 136 + 137 + # Query server for existing segments 138 + server_segments = await asyncio.to_thread( 139 + self._client.get_server_segments, day 140 + ) 141 + if server_segments is None: 142 + logger.warning(f"Failed to query server for day {day}") 143 + continue 144 + 145 + # Build lookup 146 + server_keys: set[str] = set() 147 + for seg in server_segments: 148 + server_keys.add(seg.get("key", "")) 149 + if "original_key" in seg: 150 + server_keys.add(seg["original_key"]) 151 + 152 + any_needed_upload = False 153 + 154 + for segment_dir in local_segments: 155 + if not self._running or self._circuit_open: 156 + break 157 + 158 + segment_key = segment_dir.name 159 + if segment_key in server_keys: 160 + continue 161 + 162 + any_needed_upload = True 163 + success = await self._upload_segment(day, segment_dir) 164 + 165 + if not success: 166 + self._consecutive_failures += 1 167 + if self._consecutive_failures >= 3: 168 + self._circuit_open = True 169 + logger.error( 170 + "Circuit breaker OPEN: 3 consecutive failures across segments" 171 + ) 172 + break 173 + else: 174 + self._consecutive_failures = 0 175 + 176 + # Mark past days as synced if nothing needed upload 177 + if day != today and not any_needed_upload: 178 + self._synced_days.add(day) 179 + self._save_synced_days() 180 + 181 + def _collect_segments(self, captures_dir: Path) -> dict[str, list[Path]]: 182 + """Collect completed segments grouped by day.""" 183 + result: dict[str, list[Path]] = {} 184 + 185 + for day_dir in sorted(captures_dir.iterdir(), reverse=True): 186 + if not day_dir.is_dir(): 187 + continue 188 + 189 + day = day_dir.name 190 + 191 + for stream_dir in day_dir.iterdir(): 192 + if not stream_dir.is_dir(): 193 + continue 194 + 195 + segments = [] 196 + for seg_dir in sorted(stream_dir.iterdir(), reverse=True): 197 + if not seg_dir.is_dir(): 198 + continue 199 + name = seg_dir.name 200 + # Skip incomplete and failed 201 + if name.endswith(".incomplete") or name.endswith(".failed"): 202 + continue 203 + segments.append(seg_dir) 204 + 205 + if segments: 206 + result.setdefault(day, []).extend(segments) 207 + 208 + return result 209 + 210 + async def _upload_segment(self, day: str, segment_dir: Path) -> bool: 211 + """Upload a single segment with retry logic.""" 212 + segment_key = segment_dir.name 213 + files = [f for f in segment_dir.iterdir() if f.is_file()] 214 + if not files: 215 + return True # Nothing to upload 216 + 217 + meta: dict[str, Any] = {"stream": self._config.stream} 218 + 219 + retry_delays = self._config.sync_retry_delays 220 + max_retries = self._config.sync_max_retries 221 + 222 + for attempt in range(max_retries): 223 + result = await asyncio.to_thread( 224 + self._client.upload_segment, day, segment_key, files, meta 225 + ) 226 + 227 + if result.success: 228 + logger.info(f"Uploaded: {day}/{segment_key} ({len(files)} files)") 229 + return True 230 + 231 + # Non-retryable errors 232 + if self._client.is_revoked: 233 + logger.error("Client revoked — disabling sync") 234 + self._circuit_open = True 235 + return False 236 + 237 + if attempt < max_retries - 1: 238 + delay = retry_delays[min(attempt, len(retry_delays) - 1)] 239 + logger.info(f"Retrying {day}/{segment_key} in {delay}s (attempt {attempt + 2})") 240 + await asyncio.sleep(delay) 241 + 242 + logger.error(f"Upload failed after {max_retries} attempts: {day}/{segment_key}") 243 + return False
+211
src/solstone_tmux/upload.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """HTTP upload client for solstone ingest server. 5 + 6 + Extracted from solstone's observe/remote_client.py. Accepts Config 7 + as constructor parameter instead of reading config internally. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import json 13 + import logging 14 + import time 15 + from pathlib import Path 16 + from typing import Any, NamedTuple 17 + 18 + import requests 19 + 20 + from .config import Config 21 + 22 + logger = logging.getLogger(__name__) 23 + 24 + UPLOAD_TIMEOUT = 300 25 + EVENT_TIMEOUT = 30 26 + 27 + 28 + class UploadResult(NamedTuple): 29 + success: bool 30 + duplicate: bool = False 31 + 32 + 33 + class UploadClient: 34 + """HTTP client for uploading observer segments to the ingest server.""" 35 + 36 + def __init__(self, config: Config): 37 + self._url = config.server_url.rstrip("/") if config.server_url else "" 38 + self._key = config.key 39 + self._stream = config.stream 40 + self._revoked = False 41 + self._session = requests.Session() 42 + self._retry_backoff = config.sync_retry_delays[:3] or [1, 5, 15] 43 + self._max_retries = min(config.sync_max_retries, 3) 44 + 45 + @property 46 + def is_revoked(self) -> bool: 47 + return self._revoked 48 + 49 + def _persist_key(self, config: Config, key: str) -> None: 50 + """Save auto-registered key back to config.""" 51 + from .config import save_config 52 + 53 + config.key = key 54 + save_config(config) 55 + 56 + def ensure_registered(self, config: Config) -> bool: 57 + """Ensure the client has a valid key, auto-registering if needed. 58 + 59 + Returns True if a key is available. 60 + """ 61 + if self._key: 62 + return True 63 + if not self._url: 64 + return False 65 + 66 + url = f"{self._url}/app/remote/api/create" 67 + name = self._stream or "solstone-tmux" 68 + 69 + for attempt, delay in enumerate(self._retry_backoff): 70 + try: 71 + resp = self._session.post( 72 + url, json={"name": name}, timeout=EVENT_TIMEOUT 73 + ) 74 + if resp.status_code == 200: 75 + data = resp.json() 76 + self._key = data["key"] 77 + self._persist_key(config, self._key) 78 + logger.info(f"Auto-registered as '{name}' (key: {self._key[:8]}...)") 79 + return True 80 + elif resp.status_code == 403: 81 + self._revoked = True 82 + logger.error("Registration rejected (403)") 83 + return False 84 + else: 85 + logger.warning( 86 + f"Registration attempt {attempt + 1} failed: {resp.status_code}" 87 + ) 88 + except requests.RequestException as e: 89 + logger.warning(f"Registration attempt {attempt + 1} failed: {e}") 90 + if attempt < len(self._retry_backoff) - 1: 91 + time.sleep(delay) 92 + 93 + logger.error(f"Registration failed after {len(self._retry_backoff)} attempts") 94 + return False 95 + 96 + def upload_segment( 97 + self, 98 + day: str, 99 + segment: str, 100 + files: list[Path], 101 + meta: dict[str, Any] | None = None, 102 + ) -> UploadResult: 103 + """Upload a segment's files to the ingest server.""" 104 + if self._revoked or not self._key or not self._url: 105 + return UploadResult(False) 106 + 107 + url = f"{self._url}/app/remote/ingest/{self._key}" 108 + 109 + for attempt, delay in enumerate(self._retry_backoff): 110 + file_handles = [] 111 + files_data = [] 112 + try: 113 + for path in files: 114 + if not path.exists(): 115 + logger.warning(f"File not found, skipping: {path}") 116 + continue 117 + fh = open(path, "rb") 118 + file_handles.append(fh) 119 + files_data.append( 120 + ("files", (path.name, fh, "application/octet-stream")) 121 + ) 122 + 123 + if not files_data: 124 + return UploadResult(False) 125 + 126 + data: dict[str, Any] = {"day": day, "segment": segment} 127 + if meta: 128 + data["meta"] = json.dumps(meta) 129 + 130 + response = self._session.post( 131 + url, data=data, files=files_data, timeout=UPLOAD_TIMEOUT 132 + ) 133 + 134 + if response.status_code == 200: 135 + resp_data = response.json() 136 + is_duplicate = resp_data.get("status") == "duplicate" 137 + return UploadResult(True, duplicate=is_duplicate) 138 + if response.status_code in (400, 401, 403): 139 + if response.status_code == 403: 140 + self._revoked = True 141 + logger.error( 142 + f"Upload rejected ({response.status_code}): {response.text}" 143 + ) 144 + return UploadResult(False) 145 + 146 + logger.warning( 147 + f"Upload attempt {attempt + 1} failed: " 148 + f"{response.status_code} {response.text}" 149 + ) 150 + except requests.RequestException as e: 151 + logger.warning(f"Upload attempt {attempt + 1} failed: {e}") 152 + finally: 153 + for fh in file_handles: 154 + try: 155 + fh.close() 156 + except Exception: 157 + pass 158 + 159 + if attempt < len(self._retry_backoff) - 1: 160 + time.sleep(delay) 161 + 162 + logger.error(f"Upload failed after {len(self._retry_backoff)} attempts: {day}/{segment}") 163 + return UploadResult(False) 164 + 165 + def get_server_segments(self, day: str) -> list[dict] | None: 166 + """Query server for segments on a given day. 167 + 168 + Returns list of segment dicts, or None on failure. 169 + """ 170 + if self._revoked or not self._key or not self._url: 171 + return None 172 + 173 + url = f"{self._url}/app/remote/ingest/{self._key}/segments/{day}" 174 + params = {} 175 + if self._stream: 176 + params["stream"] = self._stream 177 + 178 + try: 179 + resp = self._session.get(url, params=params, timeout=EVENT_TIMEOUT) 180 + if resp.status_code == 200: 181 + return resp.json() 182 + if resp.status_code in (401, 403): 183 + if resp.status_code == 403: 184 + self._revoked = True 185 + logger.error(f"Segments query rejected ({resp.status_code})") 186 + return None 187 + logger.warning(f"Segments query failed: {resp.status_code}") 188 + return None 189 + except requests.RequestException as e: 190 + logger.debug(f"Segments query failed: {e}") 191 + return None 192 + 193 + def relay_event(self, tract: str, event: str, **fields: Any) -> bool: 194 + """Fire-and-forget event relay.""" 195 + if self._revoked or not self._key or not self._url: 196 + return False 197 + 198 + url = f"{self._url}/app/remote/ingest/{self._key}/event" 199 + payload = {"tract": tract, "event": event, **fields} 200 + try: 201 + resp = self._session.post(url, json=payload, timeout=EVENT_TIMEOUT) 202 + if resp.status_code == 200: 203 + return True 204 + if resp.status_code == 403: 205 + self._revoked = True 206 + return False 207 + except requests.RequestException: 208 + return False 209 + 210 + def stop(self) -> None: 211 + self._session.close()
tests/__init__.py

This is a binary file and will not be displayed.

+134
tests/test_capture.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + from pathlib import Path 6 + 7 + from solstone_tmux.capture import ( 8 + CaptureResult, 9 + PaneInfo, 10 + TmuxCapture, 11 + WindowInfo, 12 + write_captures_jsonl, 13 + ) 14 + 15 + 16 + class TestTmuxCapture: 17 + def test_result_to_dict(self): 18 + capture = TmuxCapture() 19 + result = CaptureResult( 20 + session="main", 21 + window=WindowInfo(id="@0", index=0, name="bash", active=True), 22 + windows=[WindowInfo(id="@0", index=0, name="bash", active=True)], 23 + panes=[ 24 + PaneInfo( 25 + id="%0", 26 + index=0, 27 + left=0, 28 + top=0, 29 + width=80, 30 + height=24, 31 + active=True, 32 + content="$ ls\nfile1.txt\nfile2.txt", 33 + ) 34 + ], 35 + ) 36 + 37 + d = capture.result_to_dict(result, capture_id=1, relative_ts=5.0) 38 + 39 + assert d["frame_id"] == 1 40 + assert d["timestamp"] == 5.0 41 + assert d["analysis"]["primary"] == "tmux" 42 + assert d["content"]["tmux"]["session"] == "main" 43 + assert len(d["content"]["tmux"]["panes"]) == 1 44 + assert d["content"]["tmux"]["panes"][0]["width"] == 80 45 + 46 + def test_compute_hash_stable(self): 47 + capture = TmuxCapture() 48 + result = CaptureResult( 49 + session="main", 50 + window=WindowInfo(id="@0", index=0, name="bash", active=True), 51 + windows=[], 52 + panes=[ 53 + PaneInfo( 54 + id="%0", index=0, left=0, top=0, 55 + width=80, height=24, active=True, content="hello", 56 + ) 57 + ], 58 + ) 59 + h1 = capture.compute_hash(result) 60 + h2 = capture.compute_hash(result) 61 + assert h1 == h2 62 + 63 + def test_compute_hash_changes(self): 64 + capture = TmuxCapture() 65 + result1 = CaptureResult( 66 + session="main", 67 + window=WindowInfo(id="@0", index=0, name="bash", active=True), 68 + windows=[], 69 + panes=[ 70 + PaneInfo( 71 + id="%0", index=0, left=0, top=0, 72 + width=80, height=24, active=True, content="hello", 73 + ) 74 + ], 75 + ) 76 + result2 = CaptureResult( 77 + session="main", 78 + window=WindowInfo(id="@0", index=0, name="bash", active=True), 79 + windows=[], 80 + panes=[ 81 + PaneInfo( 82 + id="%0", index=0, left=0, top=0, 83 + width=80, height=24, active=True, content="world", 84 + ) 85 + ], 86 + ) 87 + assert capture.compute_hash(result1) != capture.compute_hash(result2) 88 + 89 + 90 + class TestWriteCapturesJsonl: 91 + def test_write_groups_by_session(self, tmp_path: Path): 92 + captures = [ 93 + { 94 + "frame_id": 1, 95 + "timestamp": 0.0, 96 + "content": {"tmux": {"session": "main"}}, 97 + }, 98 + { 99 + "frame_id": 2, 100 + "timestamp": 1.0, 101 + "content": {"tmux": {"session": "work"}}, 102 + }, 103 + { 104 + "frame_id": 3, 105 + "timestamp": 2.0, 106 + "content": {"tmux": {"session": "main"}}, 107 + }, 108 + ] 109 + 110 + files = write_captures_jsonl(captures, tmp_path) 111 + assert len(files) == 2 112 + assert "tmux_main_screen.jsonl" in files 113 + assert "tmux_work_screen.jsonl" in files 114 + 115 + # Check main has 2 entries 116 + main_file = tmp_path / "tmux_main_screen.jsonl" 117 + lines = main_file.read_text().strip().split("\n") 118 + assert len(lines) == 2 119 + assert json.loads(lines[0])["frame_id"] == 1 120 + assert json.loads(lines[1])["frame_id"] == 3 121 + 122 + def test_write_empty(self, tmp_path: Path): 123 + assert write_captures_jsonl([], tmp_path) == [] 124 + 125 + def test_sanitizes_session_name(self, tmp_path: Path): 126 + captures = [ 127 + { 128 + "frame_id": 1, 129 + "timestamp": 0.0, 130 + "content": {"tmux": {"session": "my/session name"}}, 131 + }, 132 + ] 133 + files = write_captures_jsonl(captures, tmp_path) 134 + assert files == ["tmux_my_session_name_screen.jsonl"]
+57
tests/test_config.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + from pathlib import Path 6 + 7 + from solstone_tmux.config import Config, load_config, save_config 8 + 9 + 10 + class TestConfig: 11 + def test_defaults(self): 12 + config = Config() 13 + assert config.server_url == "" 14 + assert config.key == "" 15 + assert config.capture_interval == 5 16 + assert config.segment_interval == 300 17 + 18 + def test_captures_dir(self): 19 + config = Config() 20 + assert config.captures_dir == config.base_dir / "captures" 21 + 22 + def test_round_trip(self, tmp_path: Path): 23 + config = Config(base_dir=tmp_path) 24 + config.server_url = "https://example.com" 25 + config.key = "test-key-123" 26 + config.stream = "archon.tmux" 27 + config.capture_interval = 10 28 + 29 + save_config(config) 30 + 31 + loaded = load_config(tmp_path) 32 + assert loaded.server_url == "https://example.com" 33 + assert loaded.key == "test-key-123" 34 + assert loaded.stream == "archon.tmux" 35 + assert loaded.capture_interval == 10 36 + 37 + def test_load_missing(self, tmp_path: Path): 38 + config = load_config(tmp_path) 39 + assert config.server_url == "" 40 + assert config.key == "" 41 + 42 + def test_load_corrupt(self, tmp_path: Path): 43 + config_dir = tmp_path / "config" 44 + config_dir.mkdir(parents=True) 45 + (config_dir / "config.json").write_text("not json!") 46 + 47 + config = load_config(tmp_path) 48 + assert config.server_url == "" 49 + 50 + def test_permissions(self, tmp_path: Path): 51 + config = Config(base_dir=tmp_path) 52 + config.server_url = "https://example.com" 53 + config.key = "secret" 54 + save_config(config) 55 + 56 + mode = config.config_path.stat().st_mode & 0o777 57 + assert mode == 0o600
+48
tests/test_streams.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import pytest 5 + 6 + from solstone_tmux.streams import _strip_hostname, stream_name 7 + 8 + 9 + class TestStripHostname: 10 + def test_simple(self): 11 + assert _strip_hostname("archon") == "archon" 12 + 13 + def test_domain(self): 14 + assert _strip_hostname("ja1r.local") == "ja1r" 15 + 16 + def test_fqdn(self): 17 + assert _strip_hostname("my.host.example.com") == "my" 18 + 19 + def test_ip(self): 20 + assert _strip_hostname("192.168.1.1") == "192-168-1-1" 21 + 22 + def test_empty(self): 23 + assert _strip_hostname("") == "" 24 + 25 + def test_whitespace(self): 26 + assert _strip_hostname(" archon ") == "archon" 27 + 28 + 29 + class TestStreamName: 30 + def test_host(self): 31 + assert stream_name(host="archon") == "archon" 32 + 33 + def test_host_with_qualifier(self): 34 + assert stream_name(host="archon", qualifier="tmux") == "archon.tmux" 35 + 36 + def test_remote(self): 37 + assert stream_name(remote="laptop") == "laptop" 38 + 39 + def test_host_domain_stripped(self): 40 + assert stream_name(host="ja1r.local", qualifier="tmux") == "ja1r.tmux" 41 + 42 + def test_no_source_raises(self): 43 + with pytest.raises(ValueError): 44 + stream_name() 45 + 46 + def test_invalid_name_raises(self): 47 + with pytest.raises(ValueError): 48 + stream_name(host="", qualifier="tmux")
+108
tests/test_sync.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + from pathlib import Path 6 + 7 + from solstone_tmux.config import Config 8 + from solstone_tmux.recovery import recover_incomplete_segments 9 + 10 + 11 + class TestRecovery: 12 + """Test crash recovery for incomplete segments.""" 13 + 14 + def _make_incomplete( 15 + self, captures_dir: Path, day: str, stream: str, time_prefix: str, age: int = 300 16 + ) -> Path: 17 + """Create an incomplete segment directory with a dummy file.""" 18 + import os 19 + import time 20 + 21 + seg_dir = captures_dir / day / stream / f"{time_prefix}.incomplete" 22 + seg_dir.mkdir(parents=True) 23 + (seg_dir / f"tmux_main_screen.jsonl").write_text('{"frame_id": 1}\n') 24 + 25 + # Set timestamps to simulate age 26 + old_time = time.time() - age 27 + os.utime(seg_dir, (old_time, old_time)) 28 + return seg_dir 29 + 30 + def test_recovers_old_incomplete(self, tmp_path: Path): 31 + captures_dir = tmp_path / "captures" 32 + self._make_incomplete(captures_dir, "20260403", "archon.tmux", "140000", age=300) 33 + 34 + recovered = recover_incomplete_segments(captures_dir) 35 + assert recovered == 1 36 + 37 + # Should be renamed to HHMMSS_DDD 38 + stream_dir = captures_dir / "20260403" / "archon.tmux" 39 + dirs = [d.name for d in stream_dir.iterdir() if d.is_dir()] 40 + assert len(dirs) == 1 41 + assert dirs[0].startswith("140000_") 42 + assert not dirs[0].endswith(".incomplete") 43 + 44 + def test_skips_recent_incomplete(self, tmp_path: Path): 45 + captures_dir = tmp_path / "captures" 46 + seg_dir = captures_dir / "20260403" / "archon.tmux" / "140000.incomplete" 47 + seg_dir.mkdir(parents=True) 48 + (seg_dir / "test.jsonl").write_text("{}\n") 49 + # Don't age it — it's recent 50 + 51 + recovered = recover_incomplete_segments(captures_dir) 52 + assert recovered == 0 53 + assert seg_dir.exists() 54 + 55 + def test_marks_empty_as_failed(self, tmp_path: Path): 56 + captures_dir = tmp_path / "captures" 57 + import os 58 + import time 59 + 60 + seg_dir = captures_dir / "20260403" / "archon.tmux" / "140000.incomplete" 61 + seg_dir.mkdir(parents=True) 62 + # No files inside — should fail 63 + old_time = time.time() - 300 64 + os.utime(seg_dir, (old_time, old_time)) 65 + 66 + recovered = recover_incomplete_segments(captures_dir) 67 + assert recovered == 0 68 + 69 + # Should be renamed to .failed 70 + failed_dir = captures_dir / "20260403" / "archon.tmux" / "140000.failed" 71 + assert failed_dir.exists() 72 + 73 + def test_no_captures_dir(self, tmp_path: Path): 74 + assert recover_incomplete_segments(tmp_path / "nonexistent") == 0 75 + 76 + 77 + class TestSyncServiceCollect: 78 + """Test segment collection logic.""" 79 + 80 + def test_skips_incomplete_and_failed(self, tmp_path: Path): 81 + from solstone_tmux.sync import SyncService 82 + from solstone_tmux.upload import UploadClient 83 + 84 + config = Config(base_dir=tmp_path) 85 + config.ensure_dirs() 86 + 87 + captures = config.captures_dir 88 + stream_dir = captures / "20260403" / "archon.tmux" 89 + stream_dir.mkdir(parents=True) 90 + 91 + # Create various segment dirs 92 + (stream_dir / "140000_300").mkdir() 93 + (stream_dir / "140000_300" / "test.jsonl").write_text("{}\n") 94 + (stream_dir / "145000.incomplete").mkdir() 95 + (stream_dir / "143000.failed").mkdir() 96 + (stream_dir / "150000_300").mkdir() 97 + (stream_dir / "150000_300" / "test.jsonl").write_text("{}\n") 98 + 99 + client = UploadClient(config) 100 + sync = SyncService(config, client) 101 + 102 + segments = sync._collect_segments(captures) 103 + assert "20260403" in segments 104 + names = [s.name for s in segments["20260403"]] 105 + assert "140000_300" in names 106 + assert "150000_300" in names 107 + assert "145000.incomplete" not in names 108 + assert "143000.failed" not in names