overcode 0.1.2__py3-none-any.whl → 0.1.3__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.
@@ -1,142 +1,167 @@
1
1
  """
2
2
  Real implementations of protocol interfaces.
3
3
 
4
- These are production implementations that make actual subprocess calls
5
- to tmux, perform real file I/O, etc.
4
+ These are production implementations that use libtmux for tmux operations
5
+ and perform real file I/O.
6
6
  """
7
7
 
8
8
  import json
9
- import subprocess
10
9
  import os
11
10
  import time
12
11
  from pathlib import Path
13
12
  from typing import Optional, List, Dict, Any
14
13
 
14
+ import libtmux
15
+ from libtmux.exc import LibTmuxException
16
+ from libtmux._internal.query_list import ObjectDoesNotExist
17
+
15
18
 
16
19
  class RealTmux:
17
- """Production implementation of TmuxInterface using subprocess"""
20
+ """Production implementation of TmuxInterface using libtmux"""
21
+
22
+ def __init__(self, socket_name: Optional[str] = None):
23
+ """Initialize with optional socket name for test isolation.
24
+
25
+ If no socket_name is provided, checks OVERCODE_TMUX_SOCKET env var.
26
+ """
27
+ # Support OVERCODE_TMUX_SOCKET env var for testing
28
+ self._socket_name = socket_name or os.environ.get("OVERCODE_TMUX_SOCKET")
29
+ self._server: Optional[libtmux.Server] = None
30
+
31
+ @property
32
+ def server(self) -> libtmux.Server:
33
+ """Lazy-load the tmux server connection."""
34
+ if self._server is None:
35
+ if self._socket_name:
36
+ self._server = libtmux.Server(socket_name=self._socket_name)
37
+ else:
38
+ self._server = libtmux.Server()
39
+ return self._server
40
+
41
+ def _get_session(self, session: str) -> Optional[libtmux.Session]:
42
+ """Get a session by name, or None if it doesn't exist."""
43
+ try:
44
+ return self.server.sessions.get(session_name=session)
45
+ except (LibTmuxException, ObjectDoesNotExist):
46
+ return None
18
47
 
19
- def capture_pane(self, session: str, window: int, lines: int = 100) -> Optional[str]:
48
+ def _get_window(self, session: str, window: int) -> Optional[libtmux.Window]:
49
+ """Get a window by session name and window index."""
50
+ sess = self._get_session(session)
51
+ if sess is None:
52
+ return None
20
53
  try:
21
- result = subprocess.run(
22
- ["tmux", "capture-pane", "-t", f"{session}:{window}",
23
- "-p", "-S", f"-{lines}"],
24
- capture_output=True, text=True, timeout=5
25
- )
26
- if result.returncode == 0:
27
- return result.stdout
54
+ return sess.windows.get(window_index=str(window))
55
+ except (LibTmuxException, ObjectDoesNotExist):
28
56
  return None
29
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
57
+
58
+ def _get_pane(self, session: str, window: int) -> Optional[libtmux.Pane]:
59
+ """Get the first pane of a window."""
60
+ win = self._get_window(session, window)
61
+ if win is None or not win.panes:
62
+ return None
63
+ return win.panes[0]
64
+
65
+ def capture_pane(self, session: str, window: int, lines: int = 100) -> Optional[str]:
66
+ try:
67
+ pane = self._get_pane(session, window)
68
+ if pane is None:
69
+ return None
70
+ # capture_pane returns list of lines
71
+ # escape_sequences=True preserves ANSI color codes for TUI rendering
72
+ captured = pane.capture_pane(start=-lines, escape_sequences=True)
73
+ if isinstance(captured, list):
74
+ return '\n'.join(captured)
75
+ return captured
76
+ except LibTmuxException:
30
77
  return None
31
78
 
32
79
  def send_keys(self, session: str, window: int, keys: str, enter: bool = True) -> bool:
33
80
  try:
81
+ pane = self._get_pane(session, window)
82
+ if pane is None:
83
+ return False
84
+
34
85
  # For Claude Code: text and Enter must be sent as SEPARATE commands
35
86
  # with a small delay, otherwise Claude Code doesn't process the Enter.
36
- target = f"{session}:{window}"
37
-
38
- # Send text first (if any)
39
87
  if keys:
40
- result = subprocess.run(
41
- ["tmux", "send-keys", "-t", target, keys],
42
- timeout=5, capture_output=True
43
- )
44
- if result.returncode != 0:
45
- return False
88
+ pane.send_keys(keys, enter=False)
46
89
  # Small delay for Claude Code to process text
47
90
  time.sleep(0.1)
48
91
 
49
- # Send Enter separately
50
92
  if enter:
51
- result = subprocess.run(
52
- ["tmux", "send-keys", "-t", target, "Enter"],
53
- timeout=5, capture_output=True
54
- )
55
- if result.returncode != 0:
56
- return False
93
+ pane.send_keys('', enter=True)
57
94
 
58
95
  return True
59
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
96
+ except LibTmuxException:
60
97
  return False
61
98
 
62
99
  def has_session(self, session: str) -> bool:
63
100
  try:
64
- result = subprocess.run(
65
- ["tmux", "has-session", "-t", session],
66
- capture_output=True, timeout=5
67
- )
68
- return result.returncode == 0
69
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
101
+ return self.server.has_session(session)
102
+ except LibTmuxException:
70
103
  return False
71
104
 
72
105
  def new_session(self, session: str) -> bool:
73
106
  try:
74
- result = subprocess.run(
75
- ["tmux", "new-session", "-d", "-s", session],
76
- capture_output=True, timeout=5
77
- )
78
- return result.returncode == 0
79
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
107
+ self.server.new_session(session_name=session, attach=False)
108
+ return True
109
+ except LibTmuxException:
80
110
  return False
81
111
 
82
112
  def new_window(self, session: str, name: str, command: Optional[List[str]] = None,
83
113
  cwd: Optional[str] = None) -> Optional[int]:
84
114
  try:
85
- cmd = ["tmux", "new-window", "-t", session, "-n", name, "-P", "-F", "#{window_index}"]
115
+ sess = self._get_session(session)
116
+ if sess is None:
117
+ return None
118
+
119
+ kwargs: Dict[str, Any] = {'window_name': name, 'attach': False}
86
120
  if cwd:
87
- cmd.extend(["-c", cwd])
121
+ kwargs['start_directory'] = cwd
88
122
  if command:
89
- cmd.append(" ".join(command))
123
+ kwargs['window_shell'] = ' '.join(command)
90
124
 
91
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
92
- if result.returncode == 0:
93
- return int(result.stdout.strip())
94
- return None
95
- except (subprocess.TimeoutExpired, subprocess.SubprocessError, ValueError):
125
+ window = sess.new_window(**kwargs)
126
+ return int(window.window_index)
127
+ except (LibTmuxException, ValueError):
96
128
  return None
97
129
 
98
130
  def kill_window(self, session: str, window: int) -> bool:
99
131
  try:
100
- result = subprocess.run(
101
- ["tmux", "kill-window", "-t", f"{session}:{window}"],
102
- capture_output=True, timeout=5
103
- )
104
- return result.returncode == 0
105
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
132
+ win = self._get_window(session, window)
133
+ if win is None:
134
+ return False
135
+ win.kill()
136
+ return True
137
+ except LibTmuxException:
106
138
  return False
107
139
 
108
140
  def kill_session(self, session: str) -> bool:
109
141
  try:
110
- result = subprocess.run(
111
- ["tmux", "kill-session", "-t", session],
112
- capture_output=True, timeout=5
113
- )
114
- return result.returncode == 0
115
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
142
+ sess = self._get_session(session)
143
+ if sess is None:
144
+ return False
145
+ sess.kill()
146
+ return True
147
+ except LibTmuxException:
116
148
  return False
117
149
 
118
150
  def list_windows(self, session: str) -> List[Dict[str, Any]]:
119
151
  try:
120
- result = subprocess.run(
121
- ["tmux", "list-windows", "-t", session, "-F",
122
- "#{window_index}:#{window_name}:#{window_active}"],
123
- capture_output=True, text=True, timeout=5
124
- )
125
- if result.returncode != 0:
152
+ sess = self._get_session(session)
153
+ if sess is None:
126
154
  return []
127
155
 
128
156
  windows = []
129
- for line in result.stdout.strip().split('\n'):
130
- if line:
131
- parts = line.split(':')
132
- if len(parts) >= 3:
133
- windows.append({
134
- 'index': int(parts[0]),
135
- 'name': parts[1],
136
- 'active': parts[2] == '1'
137
- })
157
+ for win in sess.windows:
158
+ windows.append({
159
+ 'index': int(win.window_index),
160
+ 'name': win.window_name,
161
+ 'active': win.window_active == '1'
162
+ })
138
163
  return windows
139
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
164
+ except LibTmuxException:
140
165
  return []
141
166
 
142
167
  def attach(self, session: str) -> None:
@@ -145,12 +170,12 @@ class RealTmux:
145
170
  def select_window(self, session: str, window: int) -> bool:
146
171
  """Select a window in a tmux session (for external pane sync)."""
147
172
  try:
148
- result = subprocess.run(
149
- ["tmux", "select-window", "-t", f"{session}:{window}"],
150
- capture_output=True, timeout=5
151
- )
152
- return result.returncode == 0
153
- except (subprocess.TimeoutExpired, subprocess.SubprocessError):
173
+ win = self._get_window(session, window)
174
+ if win is None:
175
+ return False
176
+ win.select()
177
+ return True
178
+ except LibTmuxException:
154
179
  return False
155
180
 
156
181
 
@@ -30,7 +30,7 @@ from typing import Dict, List, Optional
30
30
 
31
31
  from .daemon_logging import BaseDaemonLogger
32
32
  from .daemon_utils import create_daemon_helpers
33
- from .history_reader import get_session_stats
33
+ from .history_reader import get_session_stats, get_current_session_id_for_directory
34
34
  from .monitor_daemon_state import (
35
35
  MonitorDaemonState,
36
36
  SessionDaemonState,
@@ -53,10 +53,9 @@ from .settings import (
53
53
  get_supervisor_stats_path,
54
54
  )
55
55
  from .config import get_relay_config
56
- from .status_constants import STATUS_RUNNING, STATUS_TERMINATED
56
+ from .status_constants import STATUS_ASLEEP, STATUS_RUNNING, STATUS_TERMINATED
57
57
  from .status_detector import StatusDetector
58
58
  from .status_history import log_agent_status
59
- from .summarizer_component import SummarizerComponent, SummarizerConfig
60
59
 
61
60
 
62
61
  # Check for macOS presence APIs (optional)
@@ -187,12 +186,6 @@ class MonitorDaemon:
187
186
  # Presence tracking (graceful degradation)
188
187
  self.presence = PresenceComponent()
189
188
 
190
- # Summarizer component (graceful degradation if no API key)
191
- self.summarizer = SummarizerComponent(
192
- tmux_session=tmux_session,
193
- config=SummarizerConfig(enabled=False), # Off by default, enable via CLI
194
- )
195
-
196
189
  # Logging - session-specific log file
197
190
  self.log = _create_monitor_logger(session=tmux_session)
198
191
 
@@ -209,8 +202,8 @@ class MonitorDaemon:
209
202
  self.last_state_times: Dict[str, datetime] = {}
210
203
  self.operation_start_times: Dict[str, datetime] = {}
211
204
 
212
- # Stats sync throttling
213
- self._last_stats_sync = datetime.now()
205
+ # Stats sync throttling - start with min time to force immediate sync on first loop
206
+ self._last_stats_sync = datetime.min
214
207
  self._stats_sync_interval = 60 # seconds
215
208
 
216
209
  # Relay configuration (for pushing state to cloud)
@@ -293,6 +286,8 @@ class MonitorDaemon:
293
286
  start_time=session.start_time,
294
287
  permissiveness_mode=session.permissiveness_mode,
295
288
  start_directory=session.start_directory,
289
+ is_asleep=session.is_asleep,
290
+ agent_value=session.agent_value,
296
291
  )
297
292
 
298
293
  def _update_state_time(self, session, status: str, now: datetime) -> None:
@@ -332,10 +327,10 @@ class MonitorDaemon:
332
327
 
333
328
  if status == STATUS_RUNNING:
334
329
  green_time += elapsed
335
- elif status != STATUS_TERMINATED:
336
- # Only count non-green time for non-terminated states
330
+ elif status not in (STATUS_TERMINATED, STATUS_ASLEEP):
331
+ # Only count non-green time for non-terminated/non-asleep states (#68)
337
332
  non_green_time += elapsed
338
- # else: terminated - don't accumulate time
333
+ # else: terminated or asleep - don't accumulate time
339
334
 
340
335
  # INVARIANT CHECK: accumulated time should never exceed uptime
341
336
  # This catches bugs like multiple daemons running simultaneously
@@ -381,6 +376,19 @@ class MonitorDaemon:
381
376
  def sync_claude_code_stats(self, session) -> None:
382
377
  """Sync token/interaction stats from Claude Code history files."""
383
378
  try:
379
+ # Capture current Claude sessionId if not already tracked (#119)
380
+ # This ensures accurate context window calculation for this agent
381
+ if session.start_directory:
382
+ try:
383
+ session_start = datetime.fromisoformat(session.start_time)
384
+ current_id = get_current_session_id_for_directory(
385
+ session.start_directory, session_start
386
+ )
387
+ if current_id:
388
+ self.session_manager.add_claude_session_id(session.id, current_id)
389
+ except (ValueError, TypeError):
390
+ pass
391
+
384
392
  stats = get_session_stats(session)
385
393
  if stats is None:
386
394
  return
@@ -481,13 +489,6 @@ class MonitorDaemon:
481
489
  except (json.JSONDecodeError, OSError):
482
490
  pass
483
491
 
484
- # Update summarizer stats
485
- self.state.summarizer_available = self.summarizer.available
486
- self.state.summarizer_enabled = self.summarizer.enabled
487
- self.state.summarizer_calls = self.summarizer.total_calls
488
- # Estimate cost: ~$0.0007 per call (4K input tokens + 150 output tokens)
489
- self.state.summarizer_cost_usd = round(self.summarizer.total_calls * 0.0007, 4)
490
-
491
492
  self.state.save(self.state_path)
492
493
 
493
494
  # Push to relay if configured and interval elapsed
@@ -587,6 +588,13 @@ class MonitorDaemon:
587
588
  # Get all sessions
588
589
  sessions = self.session_manager.list_sessions()
589
590
 
591
+ # Sync Claude Code stats BEFORE building session_states so token counts are fresh
592
+ # This ensures the first loop has accurate data (fixes #103)
593
+ if (now - self._last_stats_sync).total_seconds() >= self._stats_sync_interval:
594
+ for session in sessions:
595
+ self.sync_claude_code_stats(session)
596
+ self._last_stats_sync = now
597
+
590
598
  # Detect status and track stats for each session
591
599
  session_states = []
592
600
  all_waiting_user = True
@@ -610,12 +618,14 @@ class MonitorDaemon:
610
618
  continue
611
619
 
612
620
  # Track stats and build state
613
- session_state = self.track_session_stats(session, status)
621
+ # Use "asleep" status if session is marked as sleeping (#68)
622
+ effective_status = STATUS_ASLEEP if session.is_asleep else status
623
+ session_state = self.track_session_stats(session, effective_status)
614
624
  session_state.current_activity = activity
615
625
  session_states.append(session_state)
616
626
 
617
627
  # Log status history to session-specific file
618
- log_agent_status(session.name, status, activity, history_file=self.history_path)
628
+ log_agent_status(session.name, effective_status, activity, history_file=self.history_path)
619
629
 
620
630
  # Track if any session is not waiting for user
621
631
  if status != "waiting_user":
@@ -630,20 +640,6 @@ class MonitorDaemon:
630
640
  for stale_id in stale_ids:
631
641
  del self.previous_states[stale_id]
632
642
 
633
- # Sync Claude Code stats periodically (git context is refreshed every loop above)
634
- if (now - self._last_stats_sync).total_seconds() >= self._stats_sync_interval:
635
- for session in sessions:
636
- self.sync_claude_code_stats(session)
637
- self._last_stats_sync = now
638
-
639
- # Update summaries (if enabled)
640
- summaries = self.summarizer.update(sessions)
641
- for session_state in session_states:
642
- summary = summaries.get(session_state.session_id)
643
- if summary:
644
- session_state.activity_summary = summary.text
645
- session_state.activity_summary_updated = summary.updated_at
646
-
647
643
  # Calculate interval
648
644
  interval = self.calculate_interval(sessions, all_waiting_user)
649
645
  self.state.current_interval = interval
@@ -673,7 +669,6 @@ class MonitorDaemon:
673
669
  finally:
674
670
  self.log.info("Monitor daemon shutting down")
675
671
  self.presence.stop()
676
- self.summarizer.stop()
677
672
  self.state.status = "stopped"
678
673
  self.state.save(self.state_path)
679
674
  remove_pid_file(self.pid_path)
@@ -67,10 +67,18 @@ class SessionDaemonState:
67
67
  start_time: Optional[str] = None # ISO timestamp when session started
68
68
  permissiveness_mode: str = "normal" # normal, permissive, bypass
69
69
  start_directory: Optional[str] = None # For git diff stats
70
+ is_asleep: bool = False # Agent is paused and excluded from stats (#70)
70
71
 
71
- # Activity summary (from SummarizerComponent)
72
+ # Agent priority value (#61)
73
+ agent_value: int = 1000 # Default 1000, higher = more important
74
+
75
+ # Activity summaries (from SummarizerComponent)
76
+ # Short: current activity - what's happening right now (~50 chars)
72
77
  activity_summary: str = ""
73
78
  activity_summary_updated: Optional[str] = None # ISO timestamp
79
+ # Context: wider context - what's being worked on overall (~80 chars)
80
+ activity_summary_context: str = ""
81
+ activity_summary_context_updated: Optional[str] = None # ISO timestamp
74
82
 
75
83
  def to_dict(self) -> dict:
76
84
  """Convert to dictionary for JSON serialization."""
@@ -98,8 +106,12 @@ class SessionDaemonState:
98
106
  "start_time": self.start_time,
99
107
  "permissiveness_mode": self.permissiveness_mode,
100
108
  "start_directory": self.start_directory,
109
+ "is_asleep": self.is_asleep,
110
+ "agent_value": self.agent_value,
101
111
  "activity_summary": self.activity_summary,
102
112
  "activity_summary_updated": self.activity_summary_updated,
113
+ "activity_summary_context": self.activity_summary_context,
114
+ "activity_summary_context_updated": self.activity_summary_context_updated,
103
115
  }
104
116
 
105
117
  @classmethod
@@ -129,8 +141,12 @@ class SessionDaemonState:
129
141
  start_time=data.get("start_time"),
130
142
  permissiveness_mode=data.get("permissiveness_mode", "normal"),
131
143
  start_directory=data.get("start_directory"),
144
+ is_asleep=data.get("is_asleep", False),
145
+ agent_value=data.get("agent_value", 1000),
132
146
  activity_summary=data.get("activity_summary", ""),
133
147
  activity_summary_updated=data.get("activity_summary_updated"),
148
+ activity_summary_context=data.get("activity_summary_context", ""),
149
+ activity_summary_context_updated=data.get("activity_summary_context_updated"),
134
150
  )
135
151
 
136
152
 
@@ -180,12 +196,6 @@ class MonitorDaemonState:
180
196
  relay_last_push: Optional[str] = None # ISO timestamp of last successful push
181
197
  relay_last_status: str = "disabled" # "ok", "error", "disabled"
182
198
 
183
- # Summarizer status
184
- summarizer_enabled: bool = False
185
- summarizer_available: bool = False # True if OPENAI_API_KEY is set
186
- summarizer_calls: int = 0
187
- summarizer_cost_usd: float = 0.0
188
-
189
199
  def to_dict(self) -> dict:
190
200
  """Convert to dictionary for JSON serialization."""
191
201
  return {
@@ -213,10 +223,6 @@ class MonitorDaemonState:
213
223
  "relay_enabled": self.relay_enabled,
214
224
  "relay_last_push": self.relay_last_push,
215
225
  "relay_last_status": self.relay_last_status,
216
- "summarizer_enabled": self.summarizer_enabled,
217
- "summarizer_available": self.summarizer_available,
218
- "summarizer_calls": self.summarizer_calls,
219
- "summarizer_cost_usd": self.summarizer_cost_usd,
220
226
  }
221
227
 
222
228
  @classmethod
@@ -252,10 +258,6 @@ class MonitorDaemonState:
252
258
  relay_enabled=data.get("relay_enabled", False),
253
259
  relay_last_push=data.get("relay_last_push"),
254
260
  relay_last_status=data.get("relay_last_status", "disabled"),
255
- summarizer_enabled=data.get("summarizer_enabled", False),
256
- summarizer_available=data.get("summarizer_available", False),
257
- summarizer_calls=data.get("summarizer_calls", 0),
258
- summarizer_cost_usd=data.get("summarizer_cost_usd", 0.0),
259
261
  )
260
262
 
261
263
  def update_summaries(self) -> None:
overcode/pid_utils.py CHANGED
@@ -88,6 +88,10 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
88
88
  Uses file locking to prevent TOCTOU race conditions when multiple
89
89
  processes try to start the daemon simultaneously.
90
90
 
91
+ IMPORTANT: The lock is held for the daemon's entire lifetime. The lock
92
+ file descriptor is stored in a module-level variable and released
93
+ automatically when the process exits (normal or crash).
94
+
91
95
  Args:
92
96
  pid_file: Path to the PID file
93
97
 
@@ -96,6 +100,8 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
96
100
  - (True, None) if lock was acquired and PID file written
97
101
  - (False, existing_pid) if another daemon is already running
98
102
  """
103
+ global _held_lock_fd
104
+
99
105
  pid_file.parent.mkdir(parents=True, exist_ok=True)
100
106
 
101
107
  # Use a separate lock file to avoid truncation issues
@@ -110,12 +116,14 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
110
116
  fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
111
117
 
112
118
  # We have the lock - now check if another daemon is running
119
+ # (handles case where previous daemon crashed without releasing lock)
113
120
  if pid_file.exists():
114
121
  try:
115
122
  existing_pid = int(pid_file.read_text().strip())
116
123
  # Check if process is still alive
117
124
  os.kill(existing_pid, 0)
118
125
  # Process exists - another daemon is running
126
+ # This shouldn't happen if locking works, but check anyway
119
127
  fcntl.flock(fd, fcntl.LOCK_UN)
120
128
  os.close(fd)
121
129
  return False, existing_pid
@@ -127,9 +135,10 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
127
135
  current_pid = os.getpid()
128
136
  pid_file.write_text(str(current_pid))
129
137
 
130
- # Release the lock (but keep file for tracking)
131
- fcntl.flock(fd, fcntl.LOCK_UN)
132
- os.close(fd)
138
+ # IMPORTANT: Keep the lock held for the daemon's entire lifetime!
139
+ # The OS will automatically release it when the process exits.
140
+ # Store fd in module-level variable to prevent garbage collection.
141
+ _held_lock_fd = fd
133
142
 
134
143
  return True, None
135
144
 
@@ -150,6 +159,11 @@ def acquire_daemon_lock(pid_file: Path) -> Tuple[bool, Optional[int]]:
150
159
  return False, None
151
160
 
152
161
 
162
+ # Module-level variable to hold the lock file descriptor.
163
+ # This prevents garbage collection from closing the fd and releasing the lock.
164
+ _held_lock_fd: Optional[int] = None
165
+
166
+
153
167
  def count_daemon_processes(pattern: str = "monitor_daemon", session: str = None) -> int:
154
168
  """Count running daemon processes matching the pattern.
155
169
 
@@ -91,6 +91,17 @@ class Session:
91
91
  # Sleep mode - agent is paused and excluded from stats
92
92
  is_asleep: bool = False
93
93
 
94
+ # Agent value - priority indicator for sorting/attention (#61)
95
+ # Default 1000, higher = more important
96
+ agent_value: int = 1000
97
+
98
+ # Human annotation - user's notes about this agent (#74)
99
+ human_annotation: str = ""
100
+
101
+ # Claude sessionIds owned by this overcode session (#119)
102
+ # Used to accurately calculate context window for this specific agent
103
+ claude_session_ids: List[str] = field(default_factory=list)
104
+
94
105
  def to_dict(self) -> dict:
95
106
  data = asdict(self)
96
107
  # Convert stats to dict
@@ -607,3 +618,45 @@ class SessionManager:
607
618
  def set_permissiveness(self, session_id: str, mode: str):
608
619
  """Set permissiveness mode (normal, permissive, strict)"""
609
620
  self.update_session(session_id, permissiveness_mode=mode)
621
+
622
+ def set_agent_value(self, session_id: str, value: int):
623
+ """Set agent value for priority sorting (#61).
624
+
625
+ Args:
626
+ session_id: The session ID
627
+ value: Priority value (default 1000, higher = more important)
628
+ """
629
+ self.update_session(session_id, agent_value=value)
630
+
631
+ def set_human_annotation(self, session_id: str, annotation: str):
632
+ """Set human annotation for a session (#74)."""
633
+ self.update_session(session_id, human_annotation=annotation)
634
+
635
+ def add_claude_session_id(self, session_id: str, claude_session_id: str) -> bool:
636
+ """Add a Claude sessionId to a session's owned list if not already present.
637
+
638
+ This tracks which Claude sessionIds belong to this overcode agent,
639
+ enabling accurate context window calculation when multiple agents
640
+ run in the same directory (#119).
641
+
642
+ Args:
643
+ session_id: The overcode session ID
644
+ claude_session_id: The Claude Code sessionId to add
645
+
646
+ Returns:
647
+ True if the sessionId was added, False if already present or session not found
648
+ """
649
+ session = self.get_session(session_id)
650
+ if not session or claude_session_id in session.claude_session_ids:
651
+ return False
652
+
653
+ def do_update(state):
654
+ if session_id in state:
655
+ ids = state[session_id].get('claude_session_ids', [])
656
+ if claude_session_id not in ids:
657
+ ids.append(claude_session_id)
658
+ state[session_id]['claude_session_ids'] = ids
659
+ return state
660
+
661
+ self._atomic_update(do_update)
662
+ return True
overcode/settings.py CHANGED
@@ -380,6 +380,10 @@ class TUIPreferences:
380
380
  daemon_panel_visible: bool = False
381
381
  view_mode: str = "tree" # tree, list_preview
382
382
  tmux_sync: bool = False # sync navigation to external tmux pane
383
+ show_terminated: bool = False # keep killed sessions visible in timeline
384
+ hide_asleep: bool = False # hide sleeping agents from display
385
+ sort_mode: str = "alphabetical" # alphabetical, by_status, by_value (#61)
386
+ summary_content_mode: str = "ai_short" # ai_short, ai_long, orders, annotation (#98)
383
387
  # Session IDs of stalled agents that have been visited by the user
384
388
  visited_stalled_agents: Set[str] = field(default_factory=set)
385
389
 
@@ -405,6 +409,10 @@ class TUIPreferences:
405
409
  daemon_panel_visible=data.get("daemon_panel_visible", False),
406
410
  view_mode=data.get("view_mode", "tree"),
407
411
  tmux_sync=data.get("tmux_sync", False),
412
+ show_terminated=data.get("show_terminated", False),
413
+ hide_asleep=data.get("hide_asleep", False),
414
+ sort_mode=data.get("sort_mode", "alphabetical"),
415
+ summary_content_mode=data.get("summary_content_mode", "ai_short"),
408
416
  visited_stalled_agents=set(data.get("visited_stalled_agents", [])),
409
417
  )
410
418
  except (json.JSONDecodeError, IOError):
@@ -425,6 +433,10 @@ class TUIPreferences:
425
433
  "daemon_panel_visible": self.daemon_panel_visible,
426
434
  "view_mode": self.view_mode,
427
435
  "tmux_sync": self.tmux_sync,
436
+ "show_terminated": self.show_terminated,
437
+ "hide_asleep": self.hide_asleep,
438
+ "sort_mode": self.sort_mode,
439
+ "summary_content_mode": self.summary_content_mode,
428
440
  "visited_stalled_agents": list(self.visited_stalled_agents),
429
441
  }, f, indent=2)
430
442
  except (IOError, OSError):
@@ -119,7 +119,7 @@ AGENT_TIMELINE_CHARS = {
119
119
  STATUS_WAITING_SUPERVISOR: "▒",
120
120
  STATUS_WAITING_USER: "░",
121
121
  STATUS_TERMINATED: "×", # Small X - terminated
122
- STATUS_ASLEEP: "z", # Lowercase z for sleeping
122
+ STATUS_ASLEEP: "", # Light shade hatching (grey) - sleeping/paused
123
123
  }
124
124
 
125
125