overcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. overcode/__init__.py +5 -0
  2. overcode/cli.py +812 -0
  3. overcode/config.py +72 -0
  4. overcode/daemon.py +1184 -0
  5. overcode/daemon_claude_skill.md +180 -0
  6. overcode/daemon_state.py +113 -0
  7. overcode/data_export.py +257 -0
  8. overcode/dependency_check.py +227 -0
  9. overcode/exceptions.py +219 -0
  10. overcode/history_reader.py +448 -0
  11. overcode/implementations.py +214 -0
  12. overcode/interfaces.py +49 -0
  13. overcode/launcher.py +434 -0
  14. overcode/logging_config.py +193 -0
  15. overcode/mocks.py +152 -0
  16. overcode/monitor_daemon.py +808 -0
  17. overcode/monitor_daemon_state.py +358 -0
  18. overcode/pid_utils.py +225 -0
  19. overcode/presence_logger.py +454 -0
  20. overcode/protocols.py +143 -0
  21. overcode/session_manager.py +606 -0
  22. overcode/settings.py +412 -0
  23. overcode/standing_instructions.py +276 -0
  24. overcode/status_constants.py +190 -0
  25. overcode/status_detector.py +339 -0
  26. overcode/status_history.py +164 -0
  27. overcode/status_patterns.py +264 -0
  28. overcode/summarizer_client.py +136 -0
  29. overcode/summarizer_component.py +312 -0
  30. overcode/supervisor_daemon.py +1000 -0
  31. overcode/supervisor_layout.sh +50 -0
  32. overcode/tmux_manager.py +228 -0
  33. overcode/tui.py +2549 -0
  34. overcode/tui_helpers.py +495 -0
  35. overcode/web_api.py +279 -0
  36. overcode/web_server.py +138 -0
  37. overcode/web_templates.py +563 -0
  38. overcode-0.1.0.dist-info/METADATA +87 -0
  39. overcode-0.1.0.dist-info/RECORD +43 -0
  40. overcode-0.1.0.dist-info/WHEEL +5 -0
  41. overcode-0.1.0.dist-info/entry_points.txt +2 -0
  42. overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. overcode-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,264 @@
1
+ """
2
+ Centralized status detection patterns.
3
+
4
+ This module contains all the pattern lists used by StatusDetector to identify
5
+ Claude's current state. Centralizing these makes them:
6
+ - Easier to maintain and extend
7
+ - Testable in isolation
8
+ - Potentially configurable via config file in the future
9
+
10
+ Each pattern set includes documentation about when it's used and what it matches.
11
+ """
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import List
15
+
16
+
17
+ @dataclass
18
+ class StatusPatterns:
19
+ """All patterns used for status detection.
20
+
21
+ Patterns are case-insensitive unless noted otherwise.
22
+ """
23
+
24
+ # Permission/confirmation prompts - HIGHEST priority
25
+ # These indicate Claude needs user approval before proceeding.
26
+ # Matched against the last few lines of output (lowercased).
27
+ permission_patterns: List[str] = field(default_factory=lambda: [
28
+ "enter to confirm",
29
+ "esc to reject",
30
+ # Note: removed "approve" - too broad, matches "auto-approve" in status bar
31
+ # Note: removed "permission" - too broad, matches "bypass permissions" in status bar
32
+ "allow this",
33
+ # Claude Code v2 permission dialog format
34
+ "do you want to proceed",
35
+ "❯ 1. yes", # Menu selector on first option
36
+ "tell claude what to do differently", # Option 3 text
37
+ ])
38
+
39
+ # Active work indicators - checked when content hasn't changed
40
+ # These indicate Claude is busy even if the prompt appears visible.
41
+ # Matched against the last few lines of output (lowercased).
42
+ active_indicators: List[str] = field(default_factory=lambda: [
43
+ "web search",
44
+ "searching",
45
+ "fetching",
46
+ "esc to interrupt", # Shows active operation in progress
47
+ "thinking",
48
+ "✽", # Spinner character
49
+ # Fun thinking indicators from Claude Code
50
+ "razzmatazzing",
51
+ "fiddle-faddling",
52
+ "pondering",
53
+ "cogitating",
54
+ # Note: removed "tokens" - too broad, matches normal text
55
+ # The spinner ✽ and "esc to interrupt" are sufficient
56
+ ])
57
+
58
+ # Tool execution indicators - CASE SENSITIVE
59
+ # These indicate Claude is executing a tool.
60
+ # Matched directly against lines (case-sensitive).
61
+ execution_indicators: List[str] = field(default_factory=lambda: [
62
+ "Reading",
63
+ "Writing",
64
+ "Editing",
65
+ "Running",
66
+ "Executing",
67
+ "Searching",
68
+ "Analyzing",
69
+ "Processing",
70
+ "Installing",
71
+ "Building",
72
+ "Compiling",
73
+ "Testing",
74
+ "Deploying",
75
+ ])
76
+
77
+ # Waiting patterns - indicate Claude is waiting for user decision
78
+ # Matched against the last few lines of output (lowercased).
79
+ waiting_patterns: List[str] = field(default_factory=lambda: [
80
+ "paused",
81
+ "do you want",
82
+ "proceed",
83
+ "continue",
84
+ "yes/no",
85
+ "[y/n]",
86
+ "press any key",
87
+ ])
88
+
89
+ # Prompt characters - indicate empty prompt waiting for user input
90
+ # These are exact matches for line content.
91
+ prompt_chars: List[str] = field(default_factory=lambda: [
92
+ ">",
93
+ "›",
94
+ "❯", # Claude Code's prompt character (U+276F)
95
+ ])
96
+
97
+ # Line prefixes to clean/remove for display
98
+ # These are stripped from the beginning of lines.
99
+ line_prefixes: List[str] = field(default_factory=lambda: [
100
+ "› ",
101
+ "> ",
102
+ "❯ ", # Claude Code's prompt character (U+276F)
103
+ "- ",
104
+ "• ",
105
+ ])
106
+
107
+ # Status bar prefixes to filter out
108
+ # Lines starting with these are UI chrome, not Claude output.
109
+ status_bar_prefixes: List[str] = field(default_factory=lambda: [
110
+ "⏵⏵", # Status bar indicator (e.g., "⏵⏵ bypass permissions on")
111
+ ])
112
+
113
+ # Command menu pattern - regex pattern for slash command menu lines
114
+ # These appear when user types a slash command and Claude shows autocomplete
115
+ # Format: " /command-name Description text"
116
+ command_menu_pattern: str = r"^\s*/[\w-]+\s{2,}\S"
117
+
118
+
119
+ # Default patterns instance
120
+ DEFAULT_PATTERNS = StatusPatterns()
121
+
122
+
123
+ def get_patterns() -> StatusPatterns:
124
+ """Get the status detection patterns.
125
+
126
+ Returns the default patterns. In the future, this could be
127
+ extended to load from a config file.
128
+
129
+ Returns:
130
+ StatusPatterns instance with all pattern lists
131
+ """
132
+ return DEFAULT_PATTERNS
133
+
134
+
135
+ def matches_any(text: str, patterns: List[str], case_sensitive: bool = False) -> bool:
136
+ """Check if text matches any of the patterns.
137
+
138
+ Args:
139
+ text: Text to search in
140
+ patterns: List of patterns to match
141
+ case_sensitive: Whether matching is case-sensitive
142
+
143
+ Returns:
144
+ True if any pattern is found in text
145
+ """
146
+ if not case_sensitive:
147
+ text = text.lower()
148
+ return any(p.lower() in text for p in patterns)
149
+ return any(p in text for p in patterns)
150
+
151
+
152
+ def find_matching_line(
153
+ lines: List[str],
154
+ patterns: List[str],
155
+ case_sensitive: bool = False,
156
+ reverse: bool = True
157
+ ) -> str | None:
158
+ """Find the first line that matches any pattern.
159
+
160
+ Args:
161
+ lines: Lines to search
162
+ patterns: Patterns to match
163
+ case_sensitive: Whether matching is case-sensitive
164
+ reverse: Search from end to beginning
165
+
166
+ Returns:
167
+ The matching line, or None if no match
168
+ """
169
+ search_lines = reversed(lines) if reverse else lines
170
+ for line in search_lines:
171
+ if matches_any(line, patterns, case_sensitive):
172
+ return line
173
+ return None
174
+
175
+
176
+ def is_prompt_line(line: str, patterns: StatusPatterns = None) -> bool:
177
+ """Check if a line is an empty prompt waiting for input.
178
+
179
+ Args:
180
+ line: Line to check
181
+ patterns: StatusPatterns to use (defaults to DEFAULT_PATTERNS)
182
+
183
+ Returns:
184
+ True if line is an empty prompt
185
+ """
186
+ patterns = patterns or DEFAULT_PATTERNS
187
+ stripped = line.strip()
188
+ return stripped in patterns.prompt_chars
189
+
190
+
191
+ def is_status_bar_line(line: str, patterns: StatusPatterns = None) -> bool:
192
+ """Check if a line is status bar UI chrome.
193
+
194
+ Args:
195
+ line: Line to check
196
+ patterns: StatusPatterns to use (defaults to DEFAULT_PATTERNS)
197
+
198
+ Returns:
199
+ True if line is status bar chrome
200
+ """
201
+ patterns = patterns or DEFAULT_PATTERNS
202
+ stripped = line.strip()
203
+ return any(stripped.startswith(prefix) for prefix in patterns.status_bar_prefixes)
204
+
205
+
206
+ def is_command_menu_line(line: str, patterns: StatusPatterns = None) -> bool:
207
+ """Check if a line is part of a slash command menu.
208
+
209
+ Claude Code shows a menu of commands when user types a slash.
210
+ Format: " /command-name Description text"
211
+
212
+ Args:
213
+ line: Line to check
214
+ patterns: StatusPatterns to use (defaults to DEFAULT_PATTERNS)
215
+
216
+ Returns:
217
+ True if line is a command menu entry
218
+ """
219
+ import re
220
+ patterns = patterns or DEFAULT_PATTERNS
221
+ return bool(re.match(patterns.command_menu_pattern, line))
222
+
223
+
224
+ def count_command_menu_lines(lines: List[str], patterns: StatusPatterns = None) -> int:
225
+ """Count how many lines in the list are command menu lines.
226
+
227
+ Args:
228
+ lines: Lines to check
229
+ patterns: StatusPatterns to use (defaults to DEFAULT_PATTERNS)
230
+
231
+ Returns:
232
+ Number of lines matching the command menu pattern
233
+ """
234
+ patterns = patterns or DEFAULT_PATTERNS
235
+ return sum(1 for line in lines if is_command_menu_line(line, patterns))
236
+
237
+
238
+ def clean_line(line: str, patterns: StatusPatterns = None, max_length: int = 80) -> str:
239
+ """Clean a line for display.
240
+
241
+ Removes prefixes, strips whitespace, and truncates.
242
+
243
+ Args:
244
+ line: Line to clean
245
+ patterns: StatusPatterns to use (defaults to DEFAULT_PATTERNS)
246
+ max_length: Maximum length before truncation
247
+
248
+ Returns:
249
+ Cleaned line
250
+ """
251
+ patterns = patterns or DEFAULT_PATTERNS
252
+ cleaned = line.strip()
253
+
254
+ # Remove common prefixes
255
+ for prefix in patterns.line_prefixes:
256
+ if cleaned.startswith(prefix):
257
+ cleaned = cleaned[len(prefix):]
258
+ break # Only remove one prefix
259
+
260
+ # Truncate if too long
261
+ if len(cleaned) > max_length:
262
+ cleaned = cleaned[:max_length - 3] + "..."
263
+
264
+ return cleaned
@@ -0,0 +1,136 @@
1
+ """
2
+ OpenAI API client for agent summarization.
3
+
4
+ Uses GPT-4o-mini for cost-effective, high-frequency summaries.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import urllib.error
11
+ import urllib.request
12
+ from typing import Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ MODEL = "gpt-4o-mini"
17
+ API_URL = "https://api.openai.com/v1/chat/completions"
18
+
19
+ # Anti-oscillation prompt template
20
+ SUMMARIZE_PROMPT = """You are summarizing a Claude Code agent's terminal output.
21
+
22
+ ## Current Terminal Content (last {lines} lines):
23
+ {pane_content}
24
+
25
+ ## Current Status: {status}
26
+
27
+ ## Previous Summary:
28
+ {previous_summary}
29
+
30
+ ## Instructions:
31
+ 1. Summarize what the agent has been doing in 1-2 sentences
32
+ 2. If not running, explain the halt state (waiting for user input, permission needed, etc.)
33
+ 3. IMPORTANT: Only provide a new summary if there's meaningful new information.
34
+ If the situation is essentially the same as the previous summary, respond with exactly:
35
+ UNCHANGED
36
+
37
+ Keep summaries concise (under 80 words). Focus on:
38
+ - What task/feature is being worked on
39
+ - Current action (reading files, writing code, running tests, etc.)
40
+ - If halted: why and what's needed to continue"""
41
+
42
+
43
+ class SummarizerClient:
44
+ """Client for OpenAI API to generate agent summaries."""
45
+
46
+ def __init__(self, api_key: Optional[str] = None):
47
+ """Initialize the client.
48
+
49
+ Args:
50
+ api_key: OpenAI API key. If None, reads from OPENAI_API_KEY env var.
51
+ """
52
+ self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
53
+ self._available = bool(self.api_key)
54
+
55
+ @property
56
+ def available(self) -> bool:
57
+ """Check if the client is available (API key present)."""
58
+ return self._available
59
+
60
+ def summarize(
61
+ self,
62
+ pane_content: str,
63
+ previous_summary: str,
64
+ current_status: str,
65
+ lines: int = 200,
66
+ max_tokens: int = 150,
67
+ ) -> Optional[str]:
68
+ """Get summary from GPT-4o-mini.
69
+
70
+ Args:
71
+ pane_content: Terminal pane content to summarize
72
+ previous_summary: Previous summary for anti-oscillation
73
+ current_status: Current agent status (running, waiting_user, etc.)
74
+ lines: Number of lines being summarized (for prompt context)
75
+ max_tokens: Maximum tokens in response
76
+
77
+ Returns:
78
+ New summary text, "UNCHANGED" if no update needed, or None on error
79
+ """
80
+ if not self.available:
81
+ return None
82
+
83
+ prompt = SUMMARIZE_PROMPT.format(
84
+ lines=lines,
85
+ pane_content=pane_content,
86
+ status=current_status,
87
+ previous_summary=previous_summary or "(no previous summary)",
88
+ )
89
+
90
+ payload = json.dumps({
91
+ "model": MODEL,
92
+ "max_tokens": max_tokens,
93
+ "temperature": 0.3, # Low temperature for consistent summaries
94
+ "messages": [{"role": "user", "content": prompt}],
95
+ }).encode("utf-8")
96
+
97
+ req = urllib.request.Request(
98
+ API_URL,
99
+ data=payload,
100
+ headers={
101
+ "Authorization": f"Bearer {self.api_key}",
102
+ "Content-Type": "application/json",
103
+ },
104
+ method="POST",
105
+ )
106
+
107
+ try:
108
+ with urllib.request.urlopen(req, timeout=15.0) as response:
109
+ if response.status == 200:
110
+ result = json.loads(response.read().decode("utf-8"))
111
+ content = result["choices"][0]["message"]["content"]
112
+ return content.strip()
113
+ else:
114
+ logger.warning(
115
+ f"Summarizer API error: {response.status}"
116
+ )
117
+ return None
118
+
119
+ except urllib.error.URLError as e:
120
+ logger.warning(f"Summarizer API error: {e.reason}")
121
+ return None
122
+ except TimeoutError:
123
+ logger.warning("Summarizer API timeout")
124
+ return None
125
+ except Exception as e:
126
+ logger.warning(f"Summarizer API error: {e}")
127
+ return None
128
+
129
+ def close(self) -> None:
130
+ """Clean up resources (no-op for urllib)."""
131
+ pass
132
+
133
+ @staticmethod
134
+ def is_available() -> bool:
135
+ """Check if OPENAI_API_KEY is set in environment."""
136
+ return bool(os.environ.get("OPENAI_API_KEY"))
@@ -0,0 +1,312 @@
1
+ """
2
+ Summarizer component for generating agent activity summaries.
3
+
4
+ Uses GPT-4o-mini to summarize what each agent has been doing and
5
+ their current halt state if not running.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Dict, Optional, TYPE_CHECKING
15
+
16
+ from .summarizer_client import SummarizerClient
17
+ from .settings import get_session_dir
18
+
19
+ if TYPE_CHECKING:
20
+ from .interfaces import TmuxInterface
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class AgentSummary:
27
+ """Summary for a single agent."""
28
+
29
+ text: str = ""
30
+ updated_at: Optional[str] = None # ISO timestamp
31
+ tokens_used: int = 0
32
+
33
+
34
+ @dataclass
35
+ class SummarizerConfig:
36
+ """Configuration for the summarizer."""
37
+
38
+ enabled: bool = False # Off by default
39
+ interval: float = 5.0 # Seconds between updates per agent
40
+ lines: int = 200 # Pane lines to capture
41
+ max_tokens: int = 150 # Max response tokens
42
+
43
+
44
+ class SummarizerComponent:
45
+ """Component for generating agent activity summaries.
46
+
47
+ Follows the daemon component pattern (like PresenceComponent).
48
+ Gracefully degrades if OPENAI_API_KEY is not available.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ tmux_session: str,
54
+ tmux: "TmuxInterface" = None,
55
+ config: Optional[SummarizerConfig] = None,
56
+ ):
57
+ """Initialize the summarizer component.
58
+
59
+ Args:
60
+ tmux_session: Name of the tmux session
61
+ tmux: TmuxInterface for pane capture (defaults to RealTmux)
62
+ config: SummarizerConfig (defaults to disabled)
63
+ """
64
+ self.tmux_session = tmux_session
65
+ self.config = config or SummarizerConfig()
66
+
67
+ # Dependency injection for testability
68
+ if tmux is None:
69
+ from .interfaces import RealTmux
70
+ tmux = RealTmux()
71
+ self.tmux = tmux
72
+
73
+ # Initialize client (gracefully handles missing API key)
74
+ self._client: Optional[SummarizerClient] = None
75
+ if self.config.enabled and SummarizerClient.is_available():
76
+ self._client = SummarizerClient()
77
+
78
+ # Per-agent summaries
79
+ self.summaries: Dict[str, AgentSummary] = {}
80
+
81
+ # Rate limiting per session
82
+ self._last_update: Dict[str, datetime] = {}
83
+
84
+ # Content hashes for change detection (avoid API calls when nothing changed)
85
+ self._last_content_hash: Dict[str, int] = {}
86
+
87
+ # Stats
88
+ self.total_calls = 0
89
+ self.total_tokens = 0
90
+
91
+ # Control file path
92
+ self._control_file = get_session_dir(tmux_session) / "summarizer_control.json"
93
+
94
+ @property
95
+ def available(self) -> bool:
96
+ """Check if summarizer is available (API key present)."""
97
+ return SummarizerClient.is_available()
98
+
99
+ @property
100
+ def enabled(self) -> bool:
101
+ """Check if summarizer is currently enabled."""
102
+ return self.config.enabled and self._client is not None
103
+
104
+ def check_control_file(self) -> None:
105
+ """Check control file for enable/disable commands."""
106
+ if not self._control_file.exists():
107
+ return
108
+
109
+ try:
110
+ with open(self._control_file) as f:
111
+ data = json.load(f)
112
+
113
+ should_enable = data.get("enabled", False)
114
+
115
+ if should_enable and not self.config.enabled:
116
+ # Enable requested
117
+ if SummarizerClient.is_available():
118
+ self.config.enabled = True
119
+ self._client = SummarizerClient()
120
+ logger.info("Summarizer enabled via control file")
121
+ else:
122
+ logger.warning("Summarizer enable requested but OPENAI_API_KEY not set")
123
+ elif not should_enable and self.config.enabled:
124
+ # Disable requested
125
+ self.config.enabled = False
126
+ if self._client:
127
+ self._client.close()
128
+ self._client = None
129
+ logger.info("Summarizer disabled via control file")
130
+
131
+ except (json.JSONDecodeError, OSError) as e:
132
+ logger.warning(f"Error reading summarizer control file: {e}")
133
+
134
+ def update(self, sessions) -> Dict[str, AgentSummary]:
135
+ """Update summaries for all sessions.
136
+
137
+ Args:
138
+ sessions: List of Session objects from SessionManager
139
+
140
+ Returns:
141
+ Dict mapping session_id to AgentSummary
142
+ """
143
+ # Check control file for enable/disable
144
+ self.check_control_file()
145
+
146
+ if not self.enabled:
147
+ return self.summaries
148
+
149
+ for session in sessions:
150
+ self._update_session(session)
151
+
152
+ return self.summaries
153
+
154
+ def _update_session(self, session) -> None:
155
+ """Update summary for a single session.
156
+
157
+ Args:
158
+ session: Session object with id, tmux_window, current_status
159
+ """
160
+ if not self._client:
161
+ return
162
+
163
+ session_id = session.id
164
+ now = datetime.now()
165
+
166
+ # Rate limiting - don't call API too frequently for same session
167
+ last_update = self._last_update.get(session_id)
168
+ if last_update:
169
+ elapsed = (now - last_update).total_seconds()
170
+ if elapsed < self.config.interval:
171
+ return
172
+
173
+ # Skip terminated sessions
174
+ current_status = getattr(session, 'stats', None)
175
+ if current_status:
176
+ status = getattr(current_status, 'current_state', 'unknown')
177
+ if status == 'terminated':
178
+ return
179
+
180
+ # Capture pane content
181
+ content = self._capture_pane(session.tmux_window)
182
+ if not content:
183
+ return
184
+
185
+ # Check if content has actually changed (avoid unnecessary API calls)
186
+ content_hash = hash(content)
187
+ if session_id in self._last_content_hash:
188
+ if self._last_content_hash[session_id] == content_hash:
189
+ # Content hasn't changed - skip API call
190
+ return
191
+
192
+ self._last_content_hash[session_id] = content_hash
193
+
194
+ # Get previous summary for anti-oscillation
195
+ prev_summary = self.summaries.get(session_id)
196
+ prev_text = prev_summary.text if prev_summary else ""
197
+
198
+ # Get current detected status
199
+ status = "unknown"
200
+ if current_status:
201
+ status = getattr(current_status, 'current_state', 'unknown')
202
+
203
+ # Call API
204
+ try:
205
+ result = self._client.summarize(
206
+ pane_content=content,
207
+ previous_summary=prev_text,
208
+ current_status=status,
209
+ lines=self.config.lines,
210
+ max_tokens=self.config.max_tokens,
211
+ )
212
+
213
+ self.total_calls += 1
214
+
215
+ if result and result.strip().upper() != "UNCHANGED":
216
+ # New summary
217
+ self.summaries[session_id] = AgentSummary(
218
+ text=result.strip(),
219
+ updated_at=now.isoformat(),
220
+ )
221
+ logger.debug(f"Updated summary for {session.name}: {result[:50]}...")
222
+ # If "UNCHANGED", keep the old summary
223
+
224
+ self._last_update[session_id] = now
225
+
226
+ except Exception as e:
227
+ logger.warning(f"Summarizer error for {session.name}: {e}")
228
+
229
+ def _capture_pane(self, window: int) -> Optional[str]:
230
+ """Capture pane content for summarization.
231
+
232
+ Args:
233
+ window: tmux window index
234
+
235
+ Returns:
236
+ Pane content string or None on error
237
+ """
238
+ try:
239
+ content = self.tmux.capture_pane(
240
+ self.tmux_session,
241
+ window,
242
+ lines=self.config.lines + 50, # Capture extra for filtering
243
+ )
244
+ if not content:
245
+ return None
246
+
247
+ # Strip trailing blank lines and return last N lines
248
+ lines = content.rstrip().split('\n')
249
+ meaningful_lines = lines[-self.config.lines:] if len(lines) > self.config.lines else lines
250
+ return '\n'.join(meaningful_lines)
251
+
252
+ except Exception as e:
253
+ logger.warning(f"Failed to capture pane {window}: {e}")
254
+ return None
255
+
256
+ def get_summary(self, session_id: str) -> Optional[AgentSummary]:
257
+ """Get summary for a specific session.
258
+
259
+ Args:
260
+ session_id: Session ID
261
+
262
+ Returns:
263
+ AgentSummary or None if not available
264
+ """
265
+ return self.summaries.get(session_id)
266
+
267
+ def stop(self) -> None:
268
+ """Clean up resources."""
269
+ if self._client:
270
+ self._client.close()
271
+ self._client = None
272
+
273
+
274
+ def get_summarizer_control_path(session: str) -> Path:
275
+ """Get the summarizer control file path for a session."""
276
+ return get_session_dir(session) / "summarizer_control.json"
277
+
278
+
279
+ def set_summarizer_enabled(session: str, enabled: bool) -> None:
280
+ """Set summarizer enabled state via control file.
281
+
282
+ The daemon reads this file each loop and adjusts accordingly.
283
+
284
+ Args:
285
+ session: tmux session name
286
+ enabled: Whether to enable or disable
287
+ """
288
+ control_path = get_summarizer_control_path(session)
289
+ control_path.parent.mkdir(parents=True, exist_ok=True)
290
+ with open(control_path, 'w') as f:
291
+ json.dump({"enabled": enabled}, f)
292
+
293
+
294
+ def is_summarizer_enabled(session: str) -> bool:
295
+ """Check if summarizer is enabled for a session.
296
+
297
+ Args:
298
+ session: tmux session name
299
+
300
+ Returns:
301
+ True if enabled, False otherwise
302
+ """
303
+ control_path = get_summarizer_control_path(session)
304
+ if not control_path.exists():
305
+ return False
306
+
307
+ try:
308
+ with open(control_path) as f:
309
+ data = json.load(f)
310
+ return data.get("enabled", False)
311
+ except (json.JSONDecodeError, OSError):
312
+ return False