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.
- overcode/__init__.py +5 -0
- overcode/cli.py +812 -0
- overcode/config.py +72 -0
- overcode/daemon.py +1184 -0
- overcode/daemon_claude_skill.md +180 -0
- overcode/daemon_state.py +113 -0
- overcode/data_export.py +257 -0
- overcode/dependency_check.py +227 -0
- overcode/exceptions.py +219 -0
- overcode/history_reader.py +448 -0
- overcode/implementations.py +214 -0
- overcode/interfaces.py +49 -0
- overcode/launcher.py +434 -0
- overcode/logging_config.py +193 -0
- overcode/mocks.py +152 -0
- overcode/monitor_daemon.py +808 -0
- overcode/monitor_daemon_state.py +358 -0
- overcode/pid_utils.py +225 -0
- overcode/presence_logger.py +454 -0
- overcode/protocols.py +143 -0
- overcode/session_manager.py +606 -0
- overcode/settings.py +412 -0
- overcode/standing_instructions.py +276 -0
- overcode/status_constants.py +190 -0
- overcode/status_detector.py +339 -0
- overcode/status_history.py +164 -0
- overcode/status_patterns.py +264 -0
- overcode/summarizer_client.py +136 -0
- overcode/summarizer_component.py +312 -0
- overcode/supervisor_daemon.py +1000 -0
- overcode/supervisor_layout.sh +50 -0
- overcode/tmux_manager.py +228 -0
- overcode/tui.py +2549 -0
- overcode/tui_helpers.py +495 -0
- overcode/web_api.py +279 -0
- overcode/web_server.py +138 -0
- overcode/web_templates.py +563 -0
- overcode-0.1.0.dist-info/METADATA +87 -0
- overcode-0.1.0.dist-info/RECORD +43 -0
- overcode-0.1.0.dist-info/WHEEL +5 -0
- overcode-0.1.0.dist-info/entry_points.txt +2 -0
- overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|