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
overcode/cli.py ADDED
@@ -0,0 +1,812 @@
1
+ """
2
+ CLI interface for Overcode using Typer.
3
+ """
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated, Optional, List
8
+
9
+ import typer
10
+ from rich import print as rprint
11
+ from rich.console import Console
12
+
13
+ from .launcher import ClaudeLauncher
14
+
15
+ # Main app
16
+ app = typer.Typer(
17
+ name="overcode",
18
+ help="Manage and supervise Claude Code agents",
19
+ no_args_is_help=True,
20
+ rich_markup_mode="rich",
21
+ )
22
+
23
+
24
+ # Monitor daemon subcommand group
25
+ monitor_daemon_app = typer.Typer(
26
+ name="monitor-daemon",
27
+ help="Manage the Monitor Daemon (metrics/state tracking)",
28
+ no_args_is_help=False,
29
+ invoke_without_command=True,
30
+ )
31
+ app.add_typer(monitor_daemon_app, name="monitor-daemon")
32
+
33
+ # Supervisor daemon subcommand group
34
+ supervisor_daemon_app = typer.Typer(
35
+ name="supervisor-daemon",
36
+ help="Manage the Supervisor Daemon (Claude orchestration)",
37
+ no_args_is_help=False,
38
+ invoke_without_command=True,
39
+ )
40
+ app.add_typer(supervisor_daemon_app, name="supervisor-daemon")
41
+
42
+ # Console for rich output
43
+ console = Console()
44
+
45
+ # Global session option (hidden advanced usage)
46
+ SessionOption = Annotated[
47
+ str,
48
+ typer.Option(
49
+ "--session",
50
+ hidden=True,
51
+ help="Tmux session name for agents",
52
+ ),
53
+ ]
54
+
55
+
56
+ # =============================================================================
57
+ # Agent Commands
58
+ # =============================================================================
59
+
60
+
61
+ @app.command()
62
+ def launch(
63
+ name: Annotated[str, typer.Option("--name", "-n", help="Name for the agent")],
64
+ directory: Annotated[
65
+ Optional[str], typer.Option("--directory", "-d", help="Working directory")
66
+ ] = None,
67
+ prompt: Annotated[
68
+ Optional[str], typer.Option("--prompt", "-p", help="Initial prompt to send")
69
+ ] = None,
70
+ skip_permissions: Annotated[
71
+ bool,
72
+ typer.Option(
73
+ "--skip-permissions",
74
+ help="Auto-deny permission prompts (--permission-mode dontAsk)",
75
+ ),
76
+ ] = False,
77
+ bypass_permissions: Annotated[
78
+ bool,
79
+ typer.Option(
80
+ "--bypass-permissions",
81
+ help="Bypass all permission checks (--dangerously-skip-permissions)",
82
+ ),
83
+ ] = False,
84
+ session: SessionOption = "agents",
85
+ ):
86
+ """Launch a new Claude agent."""
87
+ import os
88
+
89
+ # Default to current directory if not specified
90
+ working_dir = directory if directory else os.getcwd()
91
+
92
+ launcher = ClaudeLauncher(session)
93
+
94
+ result = launcher.launch(
95
+ name=name,
96
+ start_directory=working_dir,
97
+ initial_prompt=prompt,
98
+ skip_permissions=skip_permissions,
99
+ dangerously_skip_permissions=bypass_permissions,
100
+ )
101
+
102
+ if result:
103
+ rprint(f"\n[green]✓[/green] Agent '[bold]{name}[/bold]' launched")
104
+ if prompt:
105
+ rprint(" Initial prompt sent")
106
+ rprint("\nTo view: [bold]overcode attach[/bold]")
107
+
108
+
109
+ @app.command("list")
110
+ def list_agents(session: SessionOption = "agents"):
111
+ """List running agents with status."""
112
+ from .status_detector import StatusDetector
113
+ from .history_reader import get_session_stats
114
+ from .tui_helpers import (
115
+ calculate_uptime, format_duration, format_tokens,
116
+ get_current_state_times, get_status_symbol
117
+ )
118
+
119
+ launcher = ClaudeLauncher(session)
120
+ sessions = launcher.list_sessions()
121
+
122
+ if not sessions:
123
+ rprint("[dim]No running agents[/dim]")
124
+ return
125
+
126
+ status_detector = StatusDetector(session)
127
+ terminated_count = 0
128
+
129
+ for sess in sessions:
130
+ # For terminated sessions, use stored status; otherwise detect from tmux
131
+ if sess.status == "terminated":
132
+ status = "terminated"
133
+ activity = "(tmux window no longer exists)"
134
+ terminated_count += 1
135
+ else:
136
+ status, activity, _ = status_detector.detect_status(sess)
137
+
138
+ symbol, _ = get_status_symbol(status)
139
+
140
+ # Calculate uptime using shared helper
141
+ uptime = calculate_uptime(sess.start_time) if sess.start_time else "?"
142
+
143
+ # Get state times using shared helper
144
+ green_time, non_green_time = get_current_state_times(sess.stats)
145
+
146
+ # Get stats from Claude Code history and session files
147
+ stats = get_session_stats(sess)
148
+ if stats:
149
+ stats_display = f"{stats.interaction_count:>2}i {format_tokens(stats.total_tokens):>5}"
150
+ else:
151
+ stats_display = " -i -"
152
+
153
+ print(
154
+ f"{symbol} {sess.name:<16} ↑{uptime:>5} "
155
+ f"▶{format_duration(green_time):>5} ⏸{format_duration(non_green_time):>5} "
156
+ f"{stats_display} {activity[:50]}"
157
+ )
158
+
159
+ if terminated_count > 0:
160
+ rprint(f"\n[dim]{terminated_count} terminated session(s). Run 'overcode cleanup' to remove.[/dim]")
161
+
162
+
163
+ @app.command()
164
+ def attach(session: SessionOption = "agents"):
165
+ """Attach to the tmux session to view agents."""
166
+ launcher = ClaudeLauncher(session)
167
+ rprint("[dim]Attaching to overcode...[/dim]")
168
+ rprint("[dim](Ctrl-b d to detach, Ctrl-b <number> to switch agents)[/dim]")
169
+ launcher.attach()
170
+
171
+
172
+ @app.command()
173
+ def kill(
174
+ name: Annotated[str, typer.Argument(help="Name of agent to kill")],
175
+ session: SessionOption = "agents",
176
+ ):
177
+ """Kill a running agent."""
178
+ launcher = ClaudeLauncher(session)
179
+ launcher.kill_session(name)
180
+
181
+
182
+ @app.command()
183
+ def cleanup(session: SessionOption = "agents"):
184
+ """Remove terminated sessions from tracking.
185
+
186
+ Terminated sessions are those whose tmux window no longer exists
187
+ (e.g., after a machine reboot). Use 'overcode list' to see them.
188
+ """
189
+ launcher = ClaudeLauncher(session)
190
+ count = launcher.cleanup_terminated_sessions()
191
+ if count > 0:
192
+ rprint(f"[green]✓ Cleaned up {count} terminated session(s)[/green]")
193
+ else:
194
+ rprint("[dim]No terminated sessions to clean up[/dim]")
195
+
196
+
197
+ @app.command()
198
+ def send(
199
+ name: Annotated[str, typer.Argument(help="Name of agent")],
200
+ text: Annotated[
201
+ Optional[List[str]], typer.Argument(help="Text to send (or special key: enter, escape)")
202
+ ] = None,
203
+ no_enter: Annotated[
204
+ bool, typer.Option("--no-enter", help="Don't press Enter after text")
205
+ ] = False,
206
+ session: SessionOption = "agents",
207
+ ):
208
+ """
209
+ Send input to an agent.
210
+
211
+ Special keys: enter, escape, tab, up, down, left, right
212
+
213
+ Examples:
214
+ overcode send my-agent "yes" # Send "yes" + Enter
215
+ overcode send my-agent enter # Just press Enter (approve)
216
+ overcode send my-agent escape # Press Escape (reject)
217
+ overcode send my-agent --no-enter "y" # Send "y" without Enter
218
+ """
219
+ launcher = ClaudeLauncher(session)
220
+
221
+ # Join all text parts if multiple were given
222
+ text_str = " ".join(text) if text else ""
223
+ enter = not no_enter
224
+
225
+ if launcher.send_to_session(name, text_str, enter=enter):
226
+ if text_str.lower() in ("enter", "escape", "esc"):
227
+ rprint(f"[green]✓[/green] Sent {text_str.upper()} to '[bold]{name}[/bold]'")
228
+ elif enter:
229
+ display = text_str[:50] + "..." if len(text_str) > 50 else text_str
230
+ rprint(f"[green]✓[/green] Sent to '[bold]{name}[/bold]': {display}")
231
+ else:
232
+ display = text_str[:50] + "..." if len(text_str) > 50 else text_str
233
+ rprint(f"[green]✓[/green] Sent (no enter) to '[bold]{name}[/bold]': {display}")
234
+ else:
235
+ rprint(f"[red]✗[/red] Failed to send to '[bold]{name}[/bold]'")
236
+ raise typer.Exit(1)
237
+
238
+
239
+ @app.command()
240
+ def show(
241
+ name: Annotated[str, typer.Argument(help="Name of agent")],
242
+ lines: Annotated[
243
+ int, typer.Option("--lines", "-n", help="Number of lines to show")
244
+ ] = 50,
245
+ session: SessionOption = "agents",
246
+ ):
247
+ """Show recent output from an agent."""
248
+ launcher = ClaudeLauncher(session)
249
+
250
+ output = launcher.get_session_output(name, lines=lines)
251
+ if output is not None:
252
+ print(f"=== {name} (last {lines} lines) ===")
253
+ print(output)
254
+ print(f"=== end {name} ===")
255
+ else:
256
+ rprint(f"[red]✗[/red] Could not get output from '[bold]{name}[/bold]'")
257
+ raise typer.Exit(1)
258
+
259
+
260
+ @app.command()
261
+ def instruct(
262
+ name: Annotated[
263
+ Optional[str], typer.Argument(help="Name of agent")
264
+ ] = None,
265
+ instructions: Annotated[
266
+ Optional[List[str]],
267
+ typer.Argument(help="Instructions or preset name (e.g., DEFAULT, CODING)"),
268
+ ] = None,
269
+ clear: Annotated[
270
+ bool, typer.Option("--clear", "-c", help="Clear standing instructions")
271
+ ] = False,
272
+ list_presets: Annotated[
273
+ bool, typer.Option("--list", "-l", help="List available presets")
274
+ ] = False,
275
+ session: SessionOption = "agents",
276
+ ):
277
+ """Set standing instructions for an agent.
278
+
279
+ Use a preset name (DEFAULT, CODING, TESTING, etc.) or provide custom instructions.
280
+ Use --list to see all available presets.
281
+ """
282
+ from .session_manager import SessionManager
283
+ from .standing_instructions import resolve_instructions, load_presets
284
+
285
+ if list_presets:
286
+ presets_dict = load_presets()
287
+ rprint("\n[bold]Standing Instruction Presets:[/bold]\n")
288
+ for preset_name in sorted(presets_dict.keys(), key=lambda x: (x != "DEFAULT", x)):
289
+ preset = presets_dict[preset_name]
290
+ rprint(f" [cyan]{preset_name:12}[/cyan] {preset.description}")
291
+ rprint("\n[dim]Usage: overcode instruct <agent> <PRESET>[/dim]")
292
+ rprint("[dim] overcode instruct <agent> \"custom instructions\"[/dim]")
293
+ rprint("[dim]Config: ~/.overcode/presets.json[/dim]\n")
294
+ return
295
+
296
+ if not name:
297
+ rprint("[red]Error:[/red] Agent name required")
298
+ rprint("[dim]Usage: overcode instruct <agent> <PRESET or instructions>[/dim]")
299
+ raise typer.Exit(1)
300
+
301
+ sessions = SessionManager()
302
+ sess = sessions.get_session_by_name(name)
303
+
304
+ if sess is None:
305
+ rprint(f"[red]✗[/red] Agent '[bold]{name}[/bold]' not found")
306
+ raise typer.Exit(1)
307
+
308
+ instructions_str = " ".join(instructions) if instructions else ""
309
+
310
+ if clear:
311
+ sessions.set_standing_instructions(sess.id, "", preset_name=None)
312
+ rprint(f"[green]✓[/green] Cleared standing instructions for '[bold]{name}[/bold]'")
313
+ elif instructions_str:
314
+ # Resolve preset or use as custom instructions
315
+ full_instructions, preset_name = resolve_instructions(instructions_str)
316
+ sessions.set_standing_instructions(sess.id, full_instructions, preset_name=preset_name)
317
+
318
+ if preset_name:
319
+ rprint(f"[green]✓[/green] Set '[bold]{name}[/bold]' to [cyan]{preset_name}[/cyan] preset")
320
+ rprint(f" [dim]{full_instructions[:80]}...[/dim]" if len(full_instructions) > 80 else f" [dim]{full_instructions}[/dim]")
321
+ else:
322
+ rprint(f"[green]✓[/green] Set standing instructions for '[bold]{name}[/bold]':")
323
+ rprint(f' "{instructions_str}"')
324
+ else:
325
+ # Show current instructions
326
+ if sess.standing_instructions:
327
+ if sess.standing_instructions_preset:
328
+ rprint(f"Standing instructions for '[bold]{name}[/bold]': [cyan]{sess.standing_instructions_preset}[/cyan] preset")
329
+ else:
330
+ rprint(f"Standing instructions for '[bold]{name}[/bold]':")
331
+ rprint(f' "{sess.standing_instructions}"')
332
+ else:
333
+ rprint(f"[dim]No standing instructions set for '{name}'[/dim]")
334
+ rprint(f"[dim]Tip: Use 'overcode presets' to see available presets[/dim]")
335
+
336
+
337
+ # =============================================================================
338
+ # Monitoring Commands
339
+ # =============================================================================
340
+
341
+
342
+ @app.command()
343
+ def monitor(
344
+ session: SessionOption = "agents",
345
+ diagnostics: Annotated[
346
+ bool, typer.Option("--diagnostics", help="Diagnostic mode: disable all auto-refresh timers")
347
+ ] = False,
348
+ ):
349
+ """Launch the standalone TUI monitor."""
350
+ from .tui import run_tui
351
+
352
+ run_tui(session, diagnostics=diagnostics)
353
+
354
+
355
+ @app.command()
356
+ def supervisor(
357
+ restart: Annotated[
358
+ bool, typer.Option("--restart", help="Restart if already running")
359
+ ] = False,
360
+ session: SessionOption = "agents",
361
+ ):
362
+ """Launch the TUI monitor with embedded controller Claude."""
363
+ import subprocess
364
+ import os
365
+
366
+ if restart:
367
+ rprint("[dim]Killing existing controller session...[/dim]")
368
+ result = subprocess.run(
369
+ ["tmux", "kill-session", "-t", "overcode-controller"],
370
+ capture_output=True,
371
+ )
372
+ if result.returncode == 0:
373
+ rprint("[green]✓[/green] Existing session killed")
374
+
375
+ script_dir = Path(__file__).parent
376
+ layout_script = script_dir / "supervisor_layout.sh"
377
+
378
+ os.execvp("bash", ["bash", str(layout_script), session])
379
+
380
+
381
+ @app.command()
382
+ def serve(
383
+ host: Annotated[
384
+ str, typer.Option("--host", "-h", help="Host to bind to")
385
+ ] = "0.0.0.0",
386
+ port: Annotated[
387
+ int, typer.Option("--port", "-p", help="Port to listen on")
388
+ ] = 8080,
389
+ session: SessionOption = "agents",
390
+ ):
391
+ """Start web dashboard server for remote monitoring.
392
+
393
+ Provides a mobile-optimized read-only dashboard that displays
394
+ agent status and timeline data. Auto-refreshes every 5 seconds.
395
+
396
+ Access from your phone at http://<your-ip>:8080
397
+
398
+ Examples:
399
+ overcode serve # Listen on all interfaces, port 8080
400
+ overcode serve --port 3000 # Custom port
401
+ overcode serve --host 127.0.0.1 # Local only
402
+ """
403
+ from .web_server import run_server
404
+
405
+ run_server(host=host, port=port, tmux_session=session)
406
+
407
+
408
+
409
+
410
+ @app.command()
411
+ def export(
412
+ output: Annotated[
413
+ str, typer.Argument(help="Output file path (.parquet)")
414
+ ],
415
+ include_archived: Annotated[
416
+ bool, typer.Option("--archived", "-a", help="Include archived sessions")
417
+ ] = True,
418
+ include_timeline: Annotated[
419
+ bool, typer.Option("--timeline", "-t", help="Include timeline data")
420
+ ] = True,
421
+ include_presence: Annotated[
422
+ bool, typer.Option("--presence", "-p", help="Include presence data")
423
+ ] = True,
424
+ ):
425
+ """Export session data to Parquet format for Jupyter analysis.
426
+
427
+ Creates a parquet file with session stats, timeline history,
428
+ and presence data suitable for pandas/jupyter analysis.
429
+ """
430
+ from .data_export import export_to_parquet
431
+
432
+ try:
433
+ result = export_to_parquet(
434
+ output,
435
+ include_archived=include_archived,
436
+ include_timeline=include_timeline,
437
+ include_presence=include_presence,
438
+ )
439
+ rprint(f"[green]✓[/green] Exported to [bold]{output}[/bold]")
440
+ rprint(f" Sessions: {result['sessions_count']}")
441
+ if include_archived:
442
+ rprint(f" Archived: {result['archived_count']}")
443
+ if include_timeline:
444
+ rprint(f" Timeline rows: {result['timeline_rows']}")
445
+ if include_presence:
446
+ rprint(f" Presence rows: {result['presence_rows']}")
447
+ except ImportError as e:
448
+ rprint(f"[red]Error:[/red] {e}")
449
+ rprint("[dim]Install pyarrow: pip install pyarrow[/dim]")
450
+ raise typer.Exit(1)
451
+ except Exception as e:
452
+ rprint(f"[red]Export failed:[/red] {e}")
453
+ raise typer.Exit(1)
454
+
455
+
456
+ @app.command()
457
+ def history(
458
+ name: Annotated[
459
+ Optional[str], typer.Argument(help="Agent name (omit for all archived)")
460
+ ] = None,
461
+ ):
462
+ """Show archived session history."""
463
+ from .session_manager import SessionManager
464
+ from .tui_helpers import format_duration, format_tokens
465
+
466
+ sessions = SessionManager()
467
+
468
+ if name:
469
+ # Show specific archived session
470
+ archived = sessions.list_archived_sessions()
471
+ session = next((s for s in archived if s.name == name), None)
472
+ if not session:
473
+ rprint(f"[red]✗[/red] No archived session named '[bold]{name}[/bold]'")
474
+ raise typer.Exit(1)
475
+
476
+ rprint(f"\n[bold]{session.name}[/bold]")
477
+ rprint(f" ID: {session.id}")
478
+ rprint(f" Started: {session.start_time}")
479
+ end_time = getattr(session, '_end_time', None)
480
+ if end_time:
481
+ rprint(f" Ended: {end_time}")
482
+ rprint(f" Directory: {session.start_directory or '-'}")
483
+ rprint(f" Repo: {session.repo_name or '-'} ({session.branch or '-'})")
484
+ rprint(f"\n [bold]Stats:[/bold]")
485
+ stats = session.stats
486
+ rprint(f" Interactions: {stats.interaction_count}")
487
+ rprint(f" Tokens: {format_tokens(stats.total_tokens)}")
488
+ rprint(f" Cost: ${stats.estimated_cost_usd:.4f}")
489
+ rprint(f" Green time: {format_duration(stats.green_time_seconds)}")
490
+ rprint(f" Non-green time: {format_duration(stats.non_green_time_seconds)}")
491
+ rprint(f" Steers: {stats.steers_count}")
492
+ else:
493
+ # List all archived sessions
494
+ archived = sessions.list_archived_sessions()
495
+ if not archived:
496
+ rprint("[dim]No archived sessions[/dim]")
497
+ return
498
+
499
+ rprint(f"\n[bold]Archived Sessions ({len(archived)}):[/bold]\n")
500
+ for s in sorted(archived, key=lambda x: x.start_time, reverse=True):
501
+ end_time = getattr(s, '_end_time', None)
502
+ stats = s.stats
503
+ duration = ""
504
+ if end_time and s.start_time:
505
+ try:
506
+ from datetime import datetime
507
+ start = datetime.fromisoformat(s.start_time)
508
+ end = datetime.fromisoformat(end_time)
509
+ dur_sec = (end - start).total_seconds()
510
+ duration = f" ({format_duration(dur_sec)})"
511
+ except ValueError:
512
+ pass
513
+
514
+ rprint(
515
+ f" {s.name:<16} {stats.interaction_count:>3}i "
516
+ f"{format_tokens(stats.total_tokens):>6} "
517
+ f"${stats.estimated_cost_usd:.2f}{duration}"
518
+ )
519
+
520
+
521
+ # =============================================================================
522
+ # Monitor Daemon Commands
523
+ # =============================================================================
524
+
525
+
526
+ @monitor_daemon_app.callback(invoke_without_command=True)
527
+ def monitor_daemon_default(ctx: typer.Context, session: SessionOption = "agents"):
528
+ """Show monitor daemon status (default when no subcommand given)."""
529
+ if ctx.invoked_subcommand is None:
530
+ _monitor_daemon_status(session)
531
+
532
+
533
+ @monitor_daemon_app.command("start")
534
+ def monitor_daemon_start(
535
+ interval: Annotated[
536
+ int, typer.Option("--interval", "-i", help="Polling interval in seconds")
537
+ ] = 10,
538
+ session: SessionOption = "agents",
539
+ ):
540
+ """Start the Monitor Daemon.
541
+
542
+ The Monitor Daemon tracks session state and metrics:
543
+ - Status detection (running, waiting, etc.)
544
+ - Time accumulation (green_time, non_green_time)
545
+ - Claude Code stats (tokens, interactions)
546
+ - User presence state (macOS only)
547
+ """
548
+ from .monitor_daemon import MonitorDaemon, is_monitor_daemon_running, get_monitor_daemon_pid
549
+
550
+ if is_monitor_daemon_running(session):
551
+ pid = get_monitor_daemon_pid(session)
552
+ rprint(f"[yellow]Monitor Daemon already running[/yellow] (PID {pid}) for session '{session}'")
553
+ raise typer.Exit(1)
554
+
555
+ rprint(f"[dim]Starting Monitor Daemon for session '{session}' with interval {interval}s...[/dim]")
556
+ daemon = MonitorDaemon(session)
557
+ daemon.run(interval)
558
+
559
+
560
+ @monitor_daemon_app.command("stop")
561
+ def monitor_daemon_stop(session: SessionOption = "agents"):
562
+ """Stop the running Monitor Daemon."""
563
+ from .monitor_daemon import stop_monitor_daemon, is_monitor_daemon_running, get_monitor_daemon_pid
564
+
565
+ if not is_monitor_daemon_running(session):
566
+ rprint(f"[dim]Monitor Daemon is not running for session '{session}'[/dim]")
567
+ return
568
+
569
+ pid = get_monitor_daemon_pid(session)
570
+ if stop_monitor_daemon(session):
571
+ rprint(f"[green]✓[/green] Monitor Daemon stopped (was PID {pid}) for session '{session}'")
572
+ else:
573
+ rprint("[red]Failed to stop Monitor Daemon[/red]")
574
+ raise typer.Exit(1)
575
+
576
+
577
+ @monitor_daemon_app.command("status")
578
+ def monitor_daemon_status_cmd(session: SessionOption = "agents"):
579
+ """Show Monitor Daemon status."""
580
+ _monitor_daemon_status(session)
581
+
582
+
583
+ def _monitor_daemon_status(session: str):
584
+ """Internal function for showing monitor daemon status."""
585
+ from .monitor_daemon import is_monitor_daemon_running, get_monitor_daemon_pid
586
+ from .monitor_daemon_state import get_monitor_daemon_state
587
+ from .settings import get_monitor_daemon_state_path
588
+
589
+ state_path = get_monitor_daemon_state_path(session)
590
+
591
+ if not is_monitor_daemon_running(session):
592
+ rprint(f"[dim]Monitor Daemon ({session}):[/dim] ○ stopped")
593
+ state = get_monitor_daemon_state(session)
594
+ if state and state.last_loop_time:
595
+ from .tui_helpers import format_ago
596
+ rprint(f" [dim]Last active: {format_ago(state.last_loop_time)}[/dim]")
597
+ return
598
+
599
+ pid = get_monitor_daemon_pid(session)
600
+ state = get_monitor_daemon_state(session)
601
+
602
+ rprint(f"[green]Monitor Daemon ({session}):[/green] ● running (PID {pid})")
603
+ if state:
604
+ rprint(f" Status: {state.status}")
605
+ rprint(f" Loop count: {state.loop_count}")
606
+ rprint(f" Interval: {state.current_interval}s")
607
+ rprint(f" Sessions: {len(state.sessions)}")
608
+ if state.last_loop_time:
609
+ from .tui_helpers import format_ago
610
+ rprint(f" Last loop: {format_ago(state.last_loop_time)}")
611
+ if state.presence_available:
612
+ rprint(f" Presence: state={state.presence_state}, idle={state.presence_idle_seconds:.0f}s")
613
+
614
+
615
+ @monitor_daemon_app.command("watch")
616
+ def monitor_daemon_watch(session: SessionOption = "agents"):
617
+ """Watch Monitor Daemon logs in real-time."""
618
+ import subprocess
619
+ from .settings import get_session_dir
620
+
621
+ log_file = get_session_dir(session) / "monitor_daemon.log"
622
+
623
+ if not log_file.exists():
624
+ rprint(f"[red]Log file not found:[/red] {log_file}")
625
+ rprint("[dim]The Monitor Daemon may not have run yet.[/dim]")
626
+ raise typer.Exit(1)
627
+
628
+ rprint(f"[dim]Watching {log_file} (Ctrl-C to stop)[/dim]")
629
+ print("-" * 60)
630
+
631
+ try:
632
+ subprocess.run(["tail", "-f", str(log_file)])
633
+ except KeyboardInterrupt:
634
+ print("\nStopped watching.")
635
+
636
+
637
+ # =============================================================================
638
+ # Supervisor Daemon Commands
639
+ # =============================================================================
640
+
641
+
642
+ @supervisor_daemon_app.callback(invoke_without_command=True)
643
+ def supervisor_daemon_default(ctx: typer.Context, session: SessionOption = "agents"):
644
+ """Show supervisor daemon status (default when no subcommand given)."""
645
+ if ctx.invoked_subcommand is None:
646
+ _supervisor_daemon_status(session)
647
+
648
+
649
+ @supervisor_daemon_app.command("start")
650
+ def supervisor_daemon_start(
651
+ interval: Annotated[
652
+ int, typer.Option("--interval", "-i", help="Polling interval in seconds")
653
+ ] = 10,
654
+ session: SessionOption = "agents",
655
+ ):
656
+ """Start the Supervisor Daemon.
657
+
658
+ The Supervisor Daemon handles Claude orchestration:
659
+ - Launches daemon claude when sessions need attention
660
+ - Waits for daemon claude to complete
661
+ - Tracks interventions and steers
662
+
663
+ Requires Monitor Daemon to be running (reads session state from it).
664
+ """
665
+ from .supervisor_daemon import SupervisorDaemon, is_supervisor_daemon_running, get_supervisor_daemon_pid
666
+
667
+ if is_supervisor_daemon_running(session):
668
+ pid = get_supervisor_daemon_pid(session)
669
+ rprint(f"[yellow]Supervisor Daemon already running[/yellow] (PID {pid}) for session '{session}'")
670
+ raise typer.Exit(1)
671
+
672
+ rprint(f"[dim]Starting Supervisor Daemon for session '{session}' with interval {interval}s...[/dim]")
673
+ daemon = SupervisorDaemon(session)
674
+ daemon.run(interval)
675
+
676
+
677
+ @supervisor_daemon_app.command("stop")
678
+ def supervisor_daemon_stop(session: SessionOption = "agents"):
679
+ """Stop the running Supervisor Daemon."""
680
+ from .supervisor_daemon import stop_supervisor_daemon, is_supervisor_daemon_running, get_supervisor_daemon_pid
681
+
682
+ if not is_supervisor_daemon_running(session):
683
+ rprint(f"[dim]Supervisor Daemon is not running for session '{session}'[/dim]")
684
+ return
685
+
686
+ pid = get_supervisor_daemon_pid(session)
687
+ if stop_supervisor_daemon(session):
688
+ rprint(f"[green]✓[/green] Supervisor Daemon stopped (was PID {pid}) for session '{session}'")
689
+ else:
690
+ rprint("[red]Failed to stop Supervisor Daemon[/red]")
691
+ raise typer.Exit(1)
692
+
693
+
694
+ @supervisor_daemon_app.command("status")
695
+ def supervisor_daemon_status_cmd(session: SessionOption = "agents"):
696
+ """Show Supervisor Daemon status."""
697
+ _supervisor_daemon_status(session)
698
+
699
+
700
+ def _supervisor_daemon_status(session: str):
701
+ """Internal function for showing supervisor daemon status."""
702
+ from .supervisor_daemon import is_supervisor_daemon_running, get_supervisor_daemon_pid
703
+
704
+ if not is_supervisor_daemon_running(session):
705
+ rprint(f"[dim]Supervisor Daemon ({session}):[/dim] ○ stopped")
706
+ return
707
+
708
+ pid = get_supervisor_daemon_pid(session)
709
+ rprint(f"[green]Supervisor Daemon ({session}):[/green] ● running (PID {pid})")
710
+
711
+
712
+ @supervisor_daemon_app.command("watch")
713
+ def supervisor_daemon_watch(session: SessionOption = "agents"):
714
+ """Watch Supervisor Daemon logs in real-time."""
715
+ import subprocess
716
+ from .settings import get_session_dir
717
+
718
+ log_file = get_session_dir(session) / "supervisor_daemon.log"
719
+
720
+ if not log_file.exists():
721
+ rprint(f"[red]Log file not found:[/red] {log_file}")
722
+ rprint("[dim]The Supervisor Daemon may not have run yet.[/dim]")
723
+ raise typer.Exit(1)
724
+
725
+ rprint(f"[dim]Watching {log_file} (Ctrl-C to stop)[/dim]")
726
+ print("-" * 60)
727
+
728
+ try:
729
+ subprocess.run(["tail", "-f", str(log_file)])
730
+ except KeyboardInterrupt:
731
+ print("\nStopped watching.")
732
+
733
+
734
+ # =============================================================================
735
+ # Summarizer Commands
736
+ # =============================================================================
737
+
738
+
739
+ @app.command()
740
+ def summarizer(
741
+ action: Annotated[
742
+ str, typer.Argument(help="Action: on, off, or status")
743
+ ] = "status",
744
+ session: SessionOption = "agents",
745
+ ):
746
+ """Control the agent activity summarizer.
747
+
748
+ The summarizer uses GPT-4o-mini to generate human-readable summaries
749
+ of what each agent has been doing. Requires OPENAI_API_KEY env var.
750
+
751
+ Examples:
752
+ overcode summarizer status # Check current state
753
+ overcode summarizer on # Enable summarizer
754
+ overcode summarizer off # Disable summarizer
755
+ """
756
+ from .summarizer_component import (
757
+ set_summarizer_enabled,
758
+ is_summarizer_enabled,
759
+ SummarizerClient,
760
+ )
761
+ from .monitor_daemon_state import get_monitor_daemon_state
762
+
763
+ action = action.lower()
764
+
765
+ if action == "status":
766
+ # Check if API key is available
767
+ api_available = SummarizerClient.is_available()
768
+ enabled = is_summarizer_enabled(session)
769
+
770
+ # Get stats from daemon state
771
+ state = get_monitor_daemon_state(session)
772
+
773
+ rprint(f"[bold]Summarizer Status ({session}):[/bold]")
774
+ rprint(f" API key: {'[green]available[/green]' if api_available else '[red]not set[/red] (export OPENAI_API_KEY=...)'}")
775
+ rprint(f" Enabled: {'[green]yes[/green]' if enabled else '[dim]no[/dim]'}")
776
+
777
+ if state:
778
+ rprint(f" API calls: {state.summarizer_calls}")
779
+ rprint(f" Est. cost: ${state.summarizer_cost_usd:.4f}")
780
+
781
+ elif action == "on":
782
+ if not SummarizerClient.is_available():
783
+ rprint("[red]Error:[/red] OPENAI_API_KEY environment variable not set")
784
+ rprint("[dim]Export your API key: export OPENAI_API_KEY='sk-...'[/dim]")
785
+ raise typer.Exit(1)
786
+
787
+ set_summarizer_enabled(session, True)
788
+ rprint(f"[green]✓[/green] Summarizer enabled for session '{session}'")
789
+ rprint("[dim]Summaries will appear in the web dashboard and TUI[/dim]")
790
+
791
+ elif action == "off":
792
+ set_summarizer_enabled(session, False)
793
+ rprint(f"[green]✓[/green] Summarizer disabled for session '{session}'")
794
+
795
+ else:
796
+ rprint(f"[red]Unknown action:[/red] {action}")
797
+ rprint("[dim]Use: on, off, or status[/dim]")
798
+ raise typer.Exit(1)
799
+
800
+
801
+ # =============================================================================
802
+ # Entry Point
803
+ # =============================================================================
804
+
805
+
806
+ def main():
807
+ """Main entry point for the CLI."""
808
+ app()
809
+
810
+
811
+ if __name__ == "__main__":
812
+ main()