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,495 @@
1
+ """
2
+ Pure helper functions for TUI rendering.
3
+
4
+ These functions are extracted for testability - they perform
5
+ formatting and calculations without requiring Textual or other
6
+ UI dependencies.
7
+ """
8
+
9
+ from datetime import datetime, timedelta
10
+ from typing import List, Optional, Tuple
11
+ import statistics
12
+ import subprocess
13
+
14
+ from .status_constants import (
15
+ get_status_symbol as _get_status_symbol,
16
+ get_status_color as _get_status_color,
17
+ get_agent_timeline_char as _get_agent_timeline_char,
18
+ get_presence_timeline_char as _get_presence_timeline_char,
19
+ get_presence_color as _get_presence_color,
20
+ get_daemon_status_style as _get_daemon_status_style,
21
+ STATUS_RUNNING,
22
+ STATUS_TERMINATED,
23
+ )
24
+
25
+
26
+ def format_interval(seconds: int) -> str:
27
+ """Format integer interval to human readable (s/m/h) without decimals.
28
+
29
+ Use for displaying fixed intervals like polling rates: "@30s", "@1m"
30
+ For durations with precision (e.g., work times), use format_duration().
31
+
32
+ Examples: 30 -> "30s", 60 -> "1m", 3600 -> "1h"
33
+ """
34
+ if seconds < 60:
35
+ return f"{seconds}s"
36
+ elif seconds < 3600:
37
+ return f"{seconds // 60}m"
38
+ else:
39
+ return f"{seconds // 3600}h"
40
+
41
+
42
+ def format_ago(dt: Optional[datetime], now: Optional[datetime] = None) -> str:
43
+ """Format datetime as time ago string.
44
+
45
+ Args:
46
+ dt: The datetime to format
47
+ now: Reference time (defaults to datetime.now())
48
+
49
+ Returns:
50
+ String like "30s ago", "5m ago", "2.5h ago", or "never"
51
+ """
52
+ if not dt:
53
+ return "never"
54
+ if now is None:
55
+ now = datetime.now()
56
+ delta = (now - dt).total_seconds()
57
+ if delta < 60:
58
+ return f"{int(delta)}s ago"
59
+ elif delta < 3600:
60
+ return f"{int(delta // 60)}m ago"
61
+ else:
62
+ return f"{delta / 3600:.1f}h ago"
63
+
64
+
65
+ def format_duration(seconds: float) -> str:
66
+ """Format duration in seconds to human readable (s/m/h/d).
67
+
68
+ Shows one decimal place for all units except seconds.
69
+ Examples: 45s, 6.3m, 2.5h, 1.2d
70
+ """
71
+ if seconds < 60:
72
+ return f"{int(seconds)}s"
73
+ elif seconds < 3600:
74
+ minutes = seconds / 60
75
+ return f"{minutes:.1f}m"
76
+ elif seconds < 86400: # Less than 1 day
77
+ return f"{seconds/3600:.1f}h"
78
+ else:
79
+ return f"{seconds/86400:.1f}d"
80
+
81
+
82
+ def format_tokens(tokens: int) -> str:
83
+ """Format token count to human readable (K/M).
84
+
85
+ Args:
86
+ tokens: Number of tokens
87
+
88
+ Returns:
89
+ Formatted string like "1.2K", "3.5M", or "500" for small counts
90
+ """
91
+ if tokens >= 1_000_000:
92
+ return f"{tokens / 1_000_000:.1f}M"
93
+ elif tokens >= 1_000:
94
+ return f"{tokens / 1_000:.1f}K"
95
+ else:
96
+ return str(tokens)
97
+
98
+
99
+ def calculate_uptime(start_time: str, now: Optional[datetime] = None) -> str:
100
+ """Calculate uptime from ISO format start_time.
101
+
102
+ Args:
103
+ start_time: ISO format datetime string
104
+ now: Reference time (defaults to datetime.now())
105
+
106
+ Returns:
107
+ String like "30m", "4.5h", "2.5d", or "0m" on error
108
+ """
109
+ try:
110
+ if now is None:
111
+ now = datetime.now()
112
+ start = datetime.fromisoformat(start_time)
113
+ delta = now - start
114
+ hours = delta.total_seconds() / 3600
115
+ if hours < 1:
116
+ minutes = delta.total_seconds() / 60
117
+ return f"{int(minutes)}m"
118
+ elif hours < 24:
119
+ return f"{hours:.1f}h"
120
+ else:
121
+ days = hours / 24
122
+ return f"{days:.1f}d"
123
+ except (ValueError, AttributeError, TypeError):
124
+ return "0m"
125
+
126
+
127
+ def calculate_percentiles(times: List[float]) -> Tuple[float, float, float]:
128
+ """Calculate mean, 5th, and 95th percentile of operation times.
129
+
130
+ Args:
131
+ times: List of operation times in seconds
132
+
133
+ Returns:
134
+ Tuple of (mean, p5, p95)
135
+ """
136
+ if not times:
137
+ return 0.0, 0.0, 0.0
138
+
139
+ mean_time = statistics.mean(times)
140
+
141
+ if len(times) < 2:
142
+ return mean_time, mean_time, mean_time
143
+
144
+ sorted_times = sorted(times)
145
+ p5_idx = int(len(sorted_times) * 0.05)
146
+ p95_idx = int(len(sorted_times) * 0.95)
147
+ p5 = sorted_times[p5_idx]
148
+ p95 = sorted_times[p95_idx]
149
+
150
+ return mean_time, p5, p95
151
+
152
+
153
+ def presence_state_to_char(state: int) -> str:
154
+ """Convert presence state to timeline character.
155
+
156
+ Args:
157
+ state: 1=locked/sleep, 2=inactive, 3=active
158
+
159
+ Returns:
160
+ Block character for timeline visualization
161
+ """
162
+ return _get_presence_timeline_char(state)
163
+
164
+
165
+ def agent_status_to_char(status: str) -> str:
166
+ """Convert agent status to timeline character.
167
+
168
+ Args:
169
+ status: One of running, no_instructions, waiting_supervisor, waiting_user
170
+
171
+ Returns:
172
+ Block character for timeline visualization
173
+ """
174
+ return _get_agent_timeline_char(status)
175
+
176
+
177
+ def status_to_color(status: str) -> str:
178
+ """Map agent status to display color name.
179
+
180
+ Args:
181
+ status: Agent status string
182
+
183
+ Returns:
184
+ Color name for Rich styling
185
+ """
186
+ return _get_status_color(status)
187
+
188
+
189
+ def get_standing_orders_indicator(session) -> str:
190
+ """Get standing orders display indicator.
191
+
192
+ Args:
193
+ session: Session object with standing_instructions and standing_orders_complete
194
+
195
+ Returns:
196
+ Emoji indicator: "➖" (none), "📋" (active), "✓" (complete)
197
+ """
198
+ if not session.standing_instructions:
199
+ return "➖"
200
+ elif session.standing_orders_complete:
201
+ return "✓"
202
+ else:
203
+ return "📋"
204
+
205
+
206
+ def get_current_state_times(stats, now: Optional[datetime] = None) -> Tuple[float, float]:
207
+ """Get current green and non-green times including ongoing state.
208
+
209
+ Adds the time elapsed since the last daemon accumulation to the accumulated times.
210
+ This provides real-time updates between daemon polling cycles.
211
+
212
+ Args:
213
+ stats: SessionStats object with green_time_seconds, non_green_time_seconds,
214
+ last_time_accumulation, and current_state
215
+ now: Reference time (defaults to datetime.now())
216
+
217
+ Returns:
218
+ Tuple of (green_time, non_green_time) in seconds
219
+ """
220
+ if now is None:
221
+ now = datetime.now()
222
+
223
+ green_time = stats.green_time_seconds
224
+ non_green_time = stats.non_green_time_seconds
225
+
226
+ # Add elapsed time since the daemon last accumulated times
227
+ # Use last_time_accumulation (when daemon last updated), NOT state_since (when state started)
228
+ # This prevents double-counting: daemon already accumulated time up to last_time_accumulation
229
+ time_anchor = stats.last_time_accumulation or stats.state_since
230
+ if time_anchor:
231
+ try:
232
+ anchor_time = datetime.fromisoformat(time_anchor)
233
+ current_elapsed = (now - anchor_time).total_seconds()
234
+
235
+ # Only add positive elapsed time
236
+ if current_elapsed > 0:
237
+ if stats.current_state == STATUS_RUNNING:
238
+ green_time += current_elapsed
239
+ elif stats.current_state != STATUS_TERMINATED:
240
+ # Only count non-green time for non-terminated states
241
+ non_green_time += current_elapsed
242
+ # else: terminated state - time is frozen, don't accumulate
243
+ except (ValueError, AttributeError, TypeError):
244
+ pass
245
+
246
+ return green_time, non_green_time
247
+
248
+
249
+ def build_timeline_slots(
250
+ history: list,
251
+ width: int,
252
+ hours: float,
253
+ now: Optional[datetime] = None
254
+ ) -> dict:
255
+ """Build a dictionary mapping slot indices to states from history data.
256
+
257
+ Args:
258
+ history: List of (timestamp, state) tuples
259
+ width: Number of slots in the timeline
260
+ hours: Number of hours the timeline covers
261
+ now: Reference time (defaults to datetime.now())
262
+
263
+ Returns:
264
+ Dict mapping slot index to state value
265
+ """
266
+ if now is None:
267
+ now = datetime.now()
268
+
269
+ if not history:
270
+ return {}
271
+
272
+ start_time = now - timedelta(hours=hours)
273
+ slot_duration_sec = (hours * 3600) / width
274
+ slot_states = {}
275
+
276
+ for ts, state in history:
277
+ if ts < start_time:
278
+ continue
279
+ elapsed = (ts - start_time).total_seconds()
280
+ slot_idx = int(elapsed / slot_duration_sec)
281
+ if 0 <= slot_idx < width:
282
+ slot_states[slot_idx] = state
283
+
284
+ return slot_states
285
+
286
+
287
+ def build_timeline_string(
288
+ slot_states: dict,
289
+ width: int,
290
+ state_to_char: callable
291
+ ) -> str:
292
+ """Build a timeline string from slot states.
293
+
294
+ Args:
295
+ slot_states: Dict mapping slot index to state
296
+ width: Number of characters in timeline
297
+ state_to_char: Function to convert state to display character
298
+
299
+ Returns:
300
+ String of width characters representing the timeline
301
+ """
302
+ timeline = []
303
+ for i in range(width):
304
+ if i in slot_states:
305
+ timeline.append(state_to_char(slot_states[i]))
306
+ else:
307
+ timeline.append("─")
308
+ return "".join(timeline)
309
+
310
+
311
+ def get_status_symbol(status: str) -> Tuple[str, str]:
312
+ """Get status emoji and base style for agent status.
313
+
314
+ Args:
315
+ status: Agent status string
316
+
317
+ Returns:
318
+ Tuple of (emoji, color) for the status
319
+ """
320
+ return _get_status_symbol(status)
321
+
322
+
323
+ def get_presence_color(state: int) -> str:
324
+ """Get color for presence state.
325
+
326
+ Args:
327
+ state: Presence state (1=locked/sleep, 2=inactive, 3=active)
328
+
329
+ Returns:
330
+ Color name for Rich styling
331
+ """
332
+ return _get_presence_color(state)
333
+
334
+
335
+ def get_agent_timeline_color(status: str) -> str:
336
+ """Get color for agent status in timeline.
337
+
338
+ Args:
339
+ status: Agent status string
340
+
341
+ Returns:
342
+ Color name for Rich styling
343
+ """
344
+ return _get_status_color(status)
345
+
346
+
347
+ def style_pane_line(line: str) -> Tuple[str, str]:
348
+ """Determine styling for a pane content line.
349
+
350
+ Args:
351
+ line: The line content to style
352
+
353
+ Returns:
354
+ Tuple of (prefix_style, content_style) color names
355
+ """
356
+ if line.startswith('✓') or 'success' in line.lower():
357
+ return ("bold green", "green")
358
+ elif line.startswith('✗') or 'error' in line.lower() or 'fail' in line.lower():
359
+ return ("bold red", "red")
360
+ elif line.startswith('>') or line.startswith('$') or line.startswith('❯'):
361
+ return ("bold cyan", "bold white")
362
+ else:
363
+ return ("cyan", "white") # Punchier bar color
364
+
365
+
366
+ def truncate_name(name: str, max_len: int = 14) -> str:
367
+ """Truncate and pad name for display.
368
+
369
+ Args:
370
+ name: Name to truncate
371
+ max_len: Maximum length (default 14 for timeline view)
372
+
373
+ Returns:
374
+ Name truncated and left-justified to max_len
375
+ """
376
+ return name[:max_len].ljust(max_len)
377
+
378
+
379
+ def get_daemon_status_style(status: str) -> Tuple[str, str]:
380
+ """Get symbol and style for daemon status.
381
+
382
+ Args:
383
+ status: Daemon status string
384
+
385
+ Returns:
386
+ Tuple of (symbol, style) for display
387
+ """
388
+ return _get_daemon_status_style(status)
389
+
390
+
391
+ def calculate_safe_break_duration(sessions: list, now: Optional[datetime] = None) -> Optional[float]:
392
+ """Calculate how long you can be AFK before 50%+ of agents need attention.
393
+
394
+ For each running agent:
395
+ - Get their median work time (p50 autonomous operation time)
396
+ - Subtract time already spent in current running state
397
+ - That gives expected time until they need attention
398
+
399
+ Returns the duration (in seconds) until 50%+ of agents will turn red,
400
+ or None if no running agents or insufficient data.
401
+
402
+ Args:
403
+ sessions: List of SessionDaemonState objects
404
+ now: Reference time (defaults to datetime.now())
405
+
406
+ Returns:
407
+ Safe break duration in seconds, or None if cannot calculate
408
+ """
409
+ if now is None:
410
+ now = datetime.now()
411
+
412
+ # Get running agents with valid median work times
413
+ time_until_attention = []
414
+ for s in sessions:
415
+ # Only consider running agents
416
+ if s.current_status != "running":
417
+ continue
418
+
419
+ # Need median work time data
420
+ if s.median_work_time <= 0:
421
+ continue
422
+
423
+ # Calculate time in current state
424
+ time_in_state = 0.0
425
+ if s.status_since:
426
+ try:
427
+ state_start = datetime.fromisoformat(s.status_since)
428
+ time_in_state = (now - state_start).total_seconds()
429
+ except (ValueError, TypeError):
430
+ pass
431
+
432
+ # Expected time until needing attention
433
+ remaining = s.median_work_time - time_in_state
434
+ # If already past median, they could need attention any moment (0 remaining)
435
+ time_until_attention.append(max(0, remaining))
436
+
437
+ if not time_until_attention:
438
+ return None
439
+
440
+ # Sort by time until attention
441
+ time_until_attention.sort()
442
+
443
+ # Find when 50%+ will need attention
444
+ # If we have N agents, we need to find when ceil(N/2) have turned red
445
+ half_point = (len(time_until_attention) + 1) // 2
446
+ return time_until_attention[half_point - 1]
447
+
448
+
449
+ def get_git_diff_stats(directory: str) -> Optional[Tuple[int, int, int]]:
450
+ """Get git diff stats for a directory.
451
+
452
+ Args:
453
+ directory: Path to the git repository
454
+
455
+ Returns:
456
+ Tuple of (files_changed, insertions, deletions) or None if not a git repo
457
+ """
458
+ try:
459
+ result = subprocess.run(
460
+ ["git", "diff", "--stat", "HEAD"],
461
+ cwd=directory,
462
+ capture_output=True,
463
+ text=True,
464
+ timeout=2,
465
+ )
466
+ if result.returncode != 0:
467
+ return None
468
+
469
+ # Parse the last line which looks like:
470
+ # "3 files changed, 10 insertions(+), 5 deletions(-)"
471
+ # or just "1 file changed, 2 insertions(+)"
472
+ lines = result.stdout.strip().split('\n')
473
+ if not lines or not lines[-1]:
474
+ return (0, 0, 0) # No changes
475
+
476
+ summary = lines[-1]
477
+ files = 0
478
+ insertions = 0
479
+ deletions = 0
480
+
481
+ import re
482
+ files_match = re.search(r'(\d+) files? changed', summary)
483
+ ins_match = re.search(r'(\d+) insertions?', summary)
484
+ del_match = re.search(r'(\d+) deletions?', summary)
485
+
486
+ if files_match:
487
+ files = int(files_match.group(1))
488
+ if ins_match:
489
+ insertions = int(ins_match.group(1))
490
+ if del_match:
491
+ deletions = int(del_match.group(1))
492
+
493
+ return (files, insertions, deletions)
494
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
495
+ return None