overcode 0.1.5__tar.gz → 0.1.6__tar.gz
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-0.1.5/src/overcode.egg-info → overcode-0.1.6}/PKG-INFO +1 -1
- {overcode-0.1.5 → overcode-0.1.6}/pyproject.toml +1 -1
- overcode-0.1.6/src/overcode/__init__.py +14 -0
- overcode-0.1.6/src/overcode/claude_config.py +90 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/cli.py +435 -8
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/config.py +23 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/daemon_utils.py +9 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/history_reader.py +13 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/launcher.py +7 -4
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/monitor_daemon.py +146 -6
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/monitor_daemon_core.py +13 -7
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/monitor_daemon_state.py +56 -4
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/session_manager.py +23 -1
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/settings.py +16 -1
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/status_constants.py +32 -14
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/status_detector.py +69 -6
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/status_patterns.py +112 -0
- overcode-0.1.6/src/overcode/summary_groups.py +114 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/supervisor_daemon.py +4 -1
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/supervisor_daemon_core.py +6 -2
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/supervisor_layout.sh +1 -1
- overcode-0.1.6/src/overcode/time_context.py +313 -0
- overcode-0.1.6/src/overcode/tmux_utils.py +120 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui.py +212 -8
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui.tcss +31 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/input.py +66 -2
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/navigation.py +7 -7
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/session.py +89 -2
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/view.py +63 -4
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_helpers.py +27 -7
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_logic.py +18 -12
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_render.py +2 -1
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/__init__.py +4 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/command_bar.py +138 -1
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/daemon_panel.py +4 -1
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/daemon_status_bar.py +4 -2
- overcode-0.1.6/src/overcode/tui_widgets/fullscreen_preview.py +111 -0
- overcode-0.1.6/src/overcode/tui_widgets/help_overlay.py +185 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/session_summary.py +184 -77
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/status_timeline.py +27 -5
- overcode-0.1.6/src/overcode/tui_widgets/summary_config_modal.py +165 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/web_api.py +5 -2
- {overcode-0.1.5 → overcode-0.1.6/src/overcode.egg-info}/PKG-INFO +1 -1
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode.egg-info/SOURCES.txt +6 -0
- overcode-0.1.5/src/overcode/__init__.py +0 -5
- overcode-0.1.5/src/overcode/tui_widgets/help_overlay.py +0 -71
- {overcode-0.1.5 → overcode-0.1.6}/LICENSE +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/MANIFEST.in +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/README.md +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/setup.cfg +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/daemon_claude_skill.md +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/daemon_logging.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/data_export.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/dependency_check.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/exceptions.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/implementations.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/interfaces.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/logging_config.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/mocks.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/pid_utils.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/presence_logger.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/protocols.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/standing_instructions.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/status_history.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/summarizer_client.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/summarizer_component.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/testing/__init__.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/testing/renderer.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/testing/tmux_driver.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/testing/tui_eye.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/testing/tui_eye_skill.md +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tmux_manager.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/__init__.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/daemon.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/preview_pane.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/web_chartjs.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/web_server.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/web_server_runner.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode/web_templates.py +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode.egg-info/dependency_links.txt +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode.egg-info/entry_points.txt +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode.egg-info/requires.txt +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/src/overcode.egg-info/top_level.txt +0 -0
- {overcode-0.1.5 → overcode-0.1.6}/tests/test_e2e_multi_agent_jokes.py +0 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Overcode - A supervisor for managing multiple Claude Code instances.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_toml = Path(__file__).resolve().parent.parent.parent / "pyproject.toml"
|
|
8
|
+
if _toml.is_file():
|
|
9
|
+
import tomllib
|
|
10
|
+
with open(_toml, "rb") as _f:
|
|
11
|
+
__version__ = tomllib.load(_f)["project"]["version"]
|
|
12
|
+
else:
|
|
13
|
+
from importlib.metadata import version as _version
|
|
14
|
+
__version__ = _version("overcode")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Read and write Claude Code settings.json files.
|
|
2
|
+
|
|
3
|
+
Provides a reusable editor for Claude Code's JSON settings, with
|
|
4
|
+
convenience methods for managing hooks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ClaudeConfigEditor:
|
|
15
|
+
"""Read and write Claude Code settings.json files."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, path: Path):
|
|
18
|
+
self.path = Path(path)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def user_level(cls) -> ClaudeConfigEditor:
|
|
22
|
+
"""Editor for user-level settings (~/.claude/settings.json)."""
|
|
23
|
+
return cls(Path.home() / ".claude" / "settings.json")
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def project_level(cls, project_dir: Path | None = None) -> ClaudeConfigEditor:
|
|
27
|
+
"""Editor for project-level settings (.claude/settings.json).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
project_dir: Project root. Defaults to cwd.
|
|
31
|
+
"""
|
|
32
|
+
base = Path(project_dir) if project_dir else Path.cwd()
|
|
33
|
+
return cls(base / ".claude" / "settings.json")
|
|
34
|
+
|
|
35
|
+
def load(self) -> dict:
|
|
36
|
+
"""Load settings from file.
|
|
37
|
+
|
|
38
|
+
Returns empty dict if file doesn't exist.
|
|
39
|
+
Raises ValueError on invalid JSON or non-object content.
|
|
40
|
+
"""
|
|
41
|
+
if not self.path.exists():
|
|
42
|
+
return {}
|
|
43
|
+
text = self.path.read_text()
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(text)
|
|
46
|
+
except json.JSONDecodeError as e:
|
|
47
|
+
raise ValueError(f"Invalid JSON in {self.path}: {e}") from e
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
raise ValueError(f"{self.path} contains non-object JSON")
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
def save(self, settings: dict) -> None:
|
|
53
|
+
"""Write settings to file. Creates parent dirs as needed."""
|
|
54
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
self.path.write_text(json.dumps(settings, indent=2) + "\n")
|
|
56
|
+
|
|
57
|
+
def has_hook(self, event: str, command: str) -> bool:
|
|
58
|
+
"""Check if a command hook exists for the given event."""
|
|
59
|
+
settings = self.load()
|
|
60
|
+
for entry in settings.get("hooks", {}).get(event, []):
|
|
61
|
+
for hook in entry.get("hooks", []):
|
|
62
|
+
if hook.get("command") == command:
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
def add_hook(self, event: str, command: str, matcher: str = "") -> bool:
|
|
67
|
+
"""Add a command hook for an event.
|
|
68
|
+
|
|
69
|
+
Returns True if the hook was added, False if it already exists.
|
|
70
|
+
"""
|
|
71
|
+
settings = self.load()
|
|
72
|
+
# Check existing
|
|
73
|
+
for entry in settings.get("hooks", {}).get(event, []):
|
|
74
|
+
for hook in entry.get("hooks", []):
|
|
75
|
+
if hook.get("command") == command:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
updated = copy.deepcopy(settings)
|
|
79
|
+
if "hooks" not in updated:
|
|
80
|
+
updated["hooks"] = {}
|
|
81
|
+
if event not in updated["hooks"]:
|
|
82
|
+
updated["hooks"][event] = []
|
|
83
|
+
|
|
84
|
+
updated["hooks"][event].append({
|
|
85
|
+
"matcher": matcher,
|
|
86
|
+
"hooks": [{"type": "command", "command": command}],
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
self.save(updated)
|
|
90
|
+
return True
|
|
@@ -16,7 +16,8 @@ from .launcher import ClaudeLauncher
|
|
|
16
16
|
app = typer.Typer(
|
|
17
17
|
name="overcode",
|
|
18
18
|
help="Manage and supervise Claude Code agents",
|
|
19
|
-
no_args_is_help=
|
|
19
|
+
no_args_is_help=False,
|
|
20
|
+
invoke_without_command=True,
|
|
20
21
|
rich_markup_mode="rich",
|
|
21
22
|
)
|
|
22
23
|
|
|
@@ -62,6 +63,15 @@ SessionOption = Annotated[
|
|
|
62
63
|
]
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
@app.callback(invoke_without_command=True)
|
|
67
|
+
def main_callback(ctx: typer.Context):
|
|
68
|
+
"""Launch the TUI monitor when no command is given."""
|
|
69
|
+
if ctx.invoked_subcommand is None:
|
|
70
|
+
from .tui import run_tui
|
|
71
|
+
|
|
72
|
+
run_tui("agents")
|
|
73
|
+
|
|
74
|
+
|
|
65
75
|
# =============================================================================
|
|
66
76
|
# Agent Commands
|
|
67
77
|
# =============================================================================
|
|
@@ -235,6 +245,40 @@ def set_value(
|
|
|
235
245
|
rprint(f"[green]✓ Set {name} value to {value}[/green]")
|
|
236
246
|
|
|
237
247
|
|
|
248
|
+
@app.command(name="set-budget")
|
|
249
|
+
def set_budget(
|
|
250
|
+
name: Annotated[str, typer.Argument(help="Name of agent")],
|
|
251
|
+
budget: Annotated[float, typer.Argument(help="Budget in USD (0 to clear)")],
|
|
252
|
+
session: SessionOption = "agents",
|
|
253
|
+
):
|
|
254
|
+
"""Set cost budget for an agent (#173).
|
|
255
|
+
|
|
256
|
+
When an agent's estimated cost reaches the budget, heartbeats are
|
|
257
|
+
disabled and supervision is skipped.
|
|
258
|
+
|
|
259
|
+
Examples:
|
|
260
|
+
overcode set-budget my-agent 5.00 # $5 budget
|
|
261
|
+
overcode set-budget my-agent 0 # Clear budget
|
|
262
|
+
"""
|
|
263
|
+
from .session_manager import SessionManager
|
|
264
|
+
|
|
265
|
+
manager = SessionManager()
|
|
266
|
+
agent = manager.get_session_by_name(name)
|
|
267
|
+
if not agent:
|
|
268
|
+
rprint(f"[red]Error: Agent '{name}' not found[/red]")
|
|
269
|
+
raise typer.Exit(code=1)
|
|
270
|
+
|
|
271
|
+
if budget < 0:
|
|
272
|
+
rprint("[red]Error: Budget cannot be negative[/red]")
|
|
273
|
+
raise typer.Exit(code=1)
|
|
274
|
+
|
|
275
|
+
manager.set_cost_budget(agent.id, budget)
|
|
276
|
+
if budget > 0:
|
|
277
|
+
rprint(f"[green]✓ Set {name} budget to ${budget:.2f}[/green]")
|
|
278
|
+
else:
|
|
279
|
+
rprint(f"[green]✓ Cleared budget for {name}[/green]")
|
|
280
|
+
|
|
281
|
+
|
|
238
282
|
@app.command()
|
|
239
283
|
def send(
|
|
240
284
|
name: Annotated[str, typer.Argument(help="Name of agent")],
|
|
@@ -283,20 +327,235 @@ def show(
|
|
|
283
327
|
lines: Annotated[
|
|
284
328
|
int, typer.Option("--lines", "-n", help="Number of lines to show")
|
|
285
329
|
] = 50,
|
|
330
|
+
no_stats: Annotated[
|
|
331
|
+
bool, typer.Option("--no-stats", help="Skip stats, show only pane output")
|
|
332
|
+
] = False,
|
|
333
|
+
stats_only: Annotated[
|
|
334
|
+
bool, typer.Option("--stats-only", "-s", help="Show only stats, no pane output")
|
|
335
|
+
] = False,
|
|
286
336
|
session: SessionOption = "agents",
|
|
287
337
|
):
|
|
288
|
-
"""Show
|
|
338
|
+
"""Show agent details and recent output."""
|
|
339
|
+
from .status_detector import StatusDetector
|
|
340
|
+
from .history_reader import get_session_stats
|
|
341
|
+
from .status_patterns import extract_background_bash_count, extract_live_subagent_count, strip_ansi
|
|
342
|
+
from .tui_helpers import (
|
|
343
|
+
calculate_uptime, format_duration, format_tokens, format_cost,
|
|
344
|
+
format_line_count, get_current_state_times, get_status_symbol,
|
|
345
|
+
get_git_diff_stats,
|
|
346
|
+
)
|
|
347
|
+
from .monitor_daemon_state import get_monitor_daemon_state
|
|
348
|
+
|
|
289
349
|
launcher = ClaudeLauncher(session)
|
|
290
350
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
351
|
+
# Get the Session object
|
|
352
|
+
sess = launcher.sessions.get_session_by_name(name)
|
|
353
|
+
if sess is None:
|
|
354
|
+
rprint(f"[red]✗[/red] Agent '[bold]{name}[/bold]' not found")
|
|
355
|
+
raise typer.Exit(1)
|
|
356
|
+
|
|
357
|
+
# Detect live status (gets status + pane content with ANSI for bash count)
|
|
358
|
+
pane_content_raw = ""
|
|
359
|
+
if sess.status == "terminated":
|
|
360
|
+
status = "terminated"
|
|
361
|
+
activity = "(tmux window no longer exists)"
|
|
362
|
+
else:
|
|
363
|
+
status_detector = StatusDetector(session)
|
|
364
|
+
status, activity, pane_content_raw = status_detector.detect_status(sess)
|
|
365
|
+
|
|
366
|
+
if sess.is_asleep:
|
|
367
|
+
status = "asleep"
|
|
368
|
+
|
|
369
|
+
if not no_stats:
|
|
370
|
+
# Gather all stats
|
|
371
|
+
bg_bash_count = extract_background_bash_count(pane_content_raw) if pane_content_raw else 0
|
|
372
|
+
live_sub_count = extract_live_subagent_count(pane_content_raw) if pane_content_raw else 0
|
|
373
|
+
|
|
374
|
+
claude_stats = None
|
|
375
|
+
try:
|
|
376
|
+
claude_stats = get_session_stats(sess)
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
git_diff = None
|
|
381
|
+
try:
|
|
382
|
+
if sess.start_directory:
|
|
383
|
+
git_diff = get_git_diff_stats(sess.start_directory)
|
|
384
|
+
except Exception:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
uptime = calculate_uptime(sess.start_time) if sess.start_time else "-"
|
|
388
|
+
green_time, non_green_time, sleep_time = get_current_state_times(
|
|
389
|
+
sess.stats, is_asleep=sess.is_asleep
|
|
390
|
+
)
|
|
391
|
+
active_time = green_time + non_green_time
|
|
392
|
+
active_pct = (green_time / active_time * 100) if active_time > 0 else 0
|
|
393
|
+
|
|
394
|
+
ai_short = ""
|
|
395
|
+
ai_context = ""
|
|
396
|
+
try:
|
|
397
|
+
daemon_state = get_monitor_daemon_state(session)
|
|
398
|
+
if daemon_state:
|
|
399
|
+
ds = daemon_state.get_session_by_name(name)
|
|
400
|
+
if ds:
|
|
401
|
+
ai_short = ds.activity_summary or ""
|
|
402
|
+
ai_context = ds.activity_summary_context or ""
|
|
403
|
+
except Exception:
|
|
404
|
+
pass
|
|
405
|
+
|
|
406
|
+
# Status line
|
|
407
|
+
symbol, _ = get_status_symbol(status)
|
|
408
|
+
time_in_state = ""
|
|
409
|
+
if sess.stats.state_since:
|
|
410
|
+
try:
|
|
411
|
+
from datetime import datetime
|
|
412
|
+
elapsed = (datetime.now() - datetime.fromisoformat(sess.stats.state_since)).total_seconds()
|
|
413
|
+
time_in_state = f" ({format_duration(elapsed)})"
|
|
414
|
+
except (ValueError, TypeError):
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
# Permissiveness emoji
|
|
418
|
+
perm_map = {"bypass": "🔥 bypass", "permissive": "🏃 permissive", "normal": "👮 normal"}
|
|
419
|
+
perm_display = perm_map.get(sess.permissiveness_mode, sess.permissiveness_mode)
|
|
420
|
+
|
|
421
|
+
# Render stats
|
|
422
|
+
print(f"=== {name} ===")
|
|
423
|
+
print(f"Status: {symbol} {status}{time_in_state:<16} Uptime: {uptime}")
|
|
424
|
+
repo_info = f"{sess.repo_name or '-'}:{sess.branch or '-'}"
|
|
425
|
+
tc_display = "🕐 enabled" if sess.time_context_enabled else "disabled"
|
|
426
|
+
print(f"Repo: {repo_info:<28} Mode: {perm_display}")
|
|
427
|
+
print(f"Time ctx: {tc_display}")
|
|
428
|
+
|
|
429
|
+
# Time
|
|
430
|
+
time_str = f"▶ {format_duration(green_time):>5} active ⏸ {format_duration(non_green_time):>5} stalled 💤 {format_duration(sleep_time):>5} sleep ({active_pct:.0f}%)"
|
|
431
|
+
print(f"Time: {time_str}")
|
|
432
|
+
|
|
433
|
+
# Tokens & cost
|
|
434
|
+
if claude_stats:
|
|
435
|
+
token_str = f"Σ {format_tokens(claude_stats.total_tokens)}"
|
|
436
|
+
if claude_stats.current_context_tokens > 0:
|
|
437
|
+
ctx_pct = min(100, claude_stats.current_context_tokens / 200_000 * 100)
|
|
438
|
+
token_str += f" (context {ctx_pct:.0f}%)"
|
|
439
|
+
cost = sess.stats.estimated_cost_usd
|
|
440
|
+
budget = sess.cost_budget_usd
|
|
441
|
+
if budget > 0:
|
|
442
|
+
cost_display = f"{format_cost(cost)}/{format_cost(budget)}"
|
|
443
|
+
else:
|
|
444
|
+
cost_display = format_cost(cost)
|
|
445
|
+
print(f"Tokens: {token_str:<28} Cost: {cost_display}")
|
|
446
|
+
|
|
447
|
+
# Work & interactions
|
|
448
|
+
median_work = claude_stats.median_work_time
|
|
449
|
+
work_str = format_duration(median_work) if median_work > 0 else "-"
|
|
450
|
+
human_count = max(0, claude_stats.interaction_count - sess.stats.steers_count)
|
|
451
|
+
print(f"Work: ⏱ {work_str} median{'':<18} Interactions: 👤 {human_count} human 🤖 {sess.stats.steers_count} robot")
|
|
452
|
+
else:
|
|
453
|
+
print(f"Tokens: -")
|
|
454
|
+
|
|
455
|
+
# Git
|
|
456
|
+
if git_diff:
|
|
457
|
+
files, ins, dels = git_diff
|
|
458
|
+
print(f"Git: Δ{files} files +{format_line_count(ins)} -{format_line_count(dels)}")
|
|
459
|
+
|
|
460
|
+
# Subagents & background bashes (live counts from status bar)
|
|
461
|
+
print(f"Agents: 🤿 {live_sub_count} subagents 🐚 {bg_bash_count} background bashes")
|
|
462
|
+
|
|
463
|
+
# Standing orders
|
|
464
|
+
if sess.standing_instructions:
|
|
465
|
+
prefix = "✓ " if sess.standing_orders_complete else ""
|
|
466
|
+
instr = sess.standing_instructions[:80]
|
|
467
|
+
print(f"Orders: 📋 {prefix}{instr}")
|
|
468
|
+
|
|
469
|
+
# AI summaries
|
|
470
|
+
if ai_short:
|
|
471
|
+
print(f"AI: {ai_short}")
|
|
472
|
+
if ai_context:
|
|
473
|
+
print(f"Context: {ai_context}")
|
|
474
|
+
|
|
475
|
+
# Activity from status detector
|
|
476
|
+
if activity:
|
|
477
|
+
print(f"Activity: {activity[:100]}")
|
|
478
|
+
|
|
479
|
+
print()
|
|
480
|
+
|
|
481
|
+
# Pane output section (skip if --stats-only or --lines 0)
|
|
482
|
+
if not stats_only and lines > 0:
|
|
483
|
+
if pane_content_raw:
|
|
484
|
+
clean_content = strip_ansi(pane_content_raw)
|
|
485
|
+
content_lines = clean_content.rstrip().split('\n')
|
|
486
|
+
display_lines = content_lines[-lines:]
|
|
487
|
+
print(f"=== {name} (last {lines} lines) ===")
|
|
488
|
+
print('\n'.join(display_lines))
|
|
489
|
+
print(f"=== end {name} ===")
|
|
490
|
+
else:
|
|
491
|
+
# Fallback for terminated sessions
|
|
492
|
+
output = launcher.get_session_output(name, lines=lines)
|
|
493
|
+
if output is not None:
|
|
494
|
+
print(f"=== {name} (last {lines} lines) ===")
|
|
495
|
+
print(output)
|
|
496
|
+
print(f"=== end {name} ===")
|
|
497
|
+
else:
|
|
498
|
+
rprint(f"[dim]No pane output available[/dim]")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@app.command("time-context")
|
|
502
|
+
def time_context():
|
|
503
|
+
"""Output a compact time-awareness line for Claude Code hooks.
|
|
504
|
+
|
|
505
|
+
Called by a UserPromptSubmit hook on every prompt. Outputs a single
|
|
506
|
+
line with clock, presence, office hours, uptime, and heartbeat info.
|
|
507
|
+
Silently exits when not in an overcode-managed session (env vars missing).
|
|
508
|
+
"""
|
|
509
|
+
from .time_context import get_agent_identity, generate_time_context
|
|
510
|
+
|
|
511
|
+
name, tmux = get_agent_identity()
|
|
512
|
+
if not name or not tmux:
|
|
513
|
+
raise typer.Exit(0)
|
|
514
|
+
|
|
515
|
+
line = generate_time_context(tmux, name)
|
|
516
|
+
if not line:
|
|
517
|
+
raise typer.Exit(0)
|
|
518
|
+
print(line)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@app.command("install-hook")
|
|
522
|
+
def install_hook(
|
|
523
|
+
project: Annotated[
|
|
524
|
+
bool,
|
|
525
|
+
typer.Option("--project", "-p", help="Install to project-level .claude/settings.json instead of user-level"),
|
|
526
|
+
] = False,
|
|
527
|
+
):
|
|
528
|
+
"""Install the time-context hook into Claude Code settings.
|
|
529
|
+
|
|
530
|
+
By default installs to user-level settings (~/.claude/settings.json).
|
|
531
|
+
Use --project to install to the current project's .claude/settings.json.
|
|
532
|
+
|
|
533
|
+
The hook runs 'overcode time-context' on every prompt, giving Claude
|
|
534
|
+
continuous awareness of clock, presence, office hours, and uptime.
|
|
535
|
+
"""
|
|
536
|
+
from .claude_config import ClaudeConfigEditor
|
|
537
|
+
|
|
538
|
+
if project:
|
|
539
|
+
editor = ClaudeConfigEditor.project_level()
|
|
540
|
+
level = "project"
|
|
296
541
|
else:
|
|
297
|
-
|
|
542
|
+
editor = ClaudeConfigEditor.user_level()
|
|
543
|
+
level = "user"
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
added = editor.add_hook("UserPromptSubmit", "overcode time-context")
|
|
547
|
+
except ValueError as e:
|
|
548
|
+
rprint(f"[red]Error:[/red] {e}")
|
|
298
549
|
raise typer.Exit(1)
|
|
299
550
|
|
|
551
|
+
if added:
|
|
552
|
+
rprint(f"[green]\u2713[/green] Installed time-context hook in {level} settings")
|
|
553
|
+
rprint(f" [dim]{editor.path}[/dim]")
|
|
554
|
+
rprint(f"\n [dim]The hook runs 'overcode time-context' on every prompt.[/dim]")
|
|
555
|
+
rprint(f" [dim]Toggle per-agent with F in the TUI.[/dim]")
|
|
556
|
+
else:
|
|
557
|
+
rprint(f"[green]\u2713[/green] Hook already installed in {level} settings ({editor.path})")
|
|
558
|
+
|
|
300
559
|
|
|
301
560
|
@app.command()
|
|
302
561
|
def instruct(
|
|
@@ -375,6 +634,154 @@ def instruct(
|
|
|
375
634
|
rprint(f"[dim]Tip: Use 'overcode presets' to see available presets[/dim]")
|
|
376
635
|
|
|
377
636
|
|
|
637
|
+
@app.command()
|
|
638
|
+
def heartbeat(
|
|
639
|
+
name: Annotated[str, typer.Argument(help="Name of agent")],
|
|
640
|
+
enable: Annotated[
|
|
641
|
+
bool, typer.Option("--enable", "-e", help="Enable heartbeat")
|
|
642
|
+
] = False,
|
|
643
|
+
disable: Annotated[
|
|
644
|
+
bool, typer.Option("--disable", "-d", help="Disable heartbeat")
|
|
645
|
+
] = False,
|
|
646
|
+
pause: Annotated[
|
|
647
|
+
bool, typer.Option("--pause", help="Pause heartbeat (keep config)")
|
|
648
|
+
] = False,
|
|
649
|
+
resume: Annotated[
|
|
650
|
+
bool, typer.Option("--resume", help="Resume paused heartbeat")
|
|
651
|
+
] = False,
|
|
652
|
+
frequency: Annotated[
|
|
653
|
+
Optional[str], typer.Option("--frequency", "-f", help="Interval (e.g., 300, 5m, 1h)")
|
|
654
|
+
] = None,
|
|
655
|
+
instruction: Annotated[
|
|
656
|
+
Optional[str], typer.Option("--instruction", "-i", help="Instruction to send")
|
|
657
|
+
] = None,
|
|
658
|
+
show: Annotated[
|
|
659
|
+
bool, typer.Option("--show", "-s", help="Show current heartbeat config")
|
|
660
|
+
] = False,
|
|
661
|
+
session: SessionOption = "agents",
|
|
662
|
+
):
|
|
663
|
+
"""Configure heartbeat for an agent (#171).
|
|
664
|
+
|
|
665
|
+
Heartbeat sends a periodic instruction to keep agents active or provide
|
|
666
|
+
regular status updates. The instruction is sent at the configured frequency.
|
|
667
|
+
|
|
668
|
+
Examples:
|
|
669
|
+
overcode heartbeat my-agent --show # Show current config
|
|
670
|
+
overcode heartbeat my-agent -e -f 5m -i "Status check" # Enable
|
|
671
|
+
overcode heartbeat my-agent --pause # Temporarily pause
|
|
672
|
+
overcode heartbeat my-agent --resume # Resume
|
|
673
|
+
overcode heartbeat my-agent --disable # Disable completely
|
|
674
|
+
"""
|
|
675
|
+
from .session_manager import SessionManager
|
|
676
|
+
from .tui_helpers import format_duration
|
|
677
|
+
|
|
678
|
+
manager = SessionManager()
|
|
679
|
+
agent = manager.get_session_by_name(name)
|
|
680
|
+
if not agent:
|
|
681
|
+
rprint(f"[red]Error: Agent '{name}' not found[/red]")
|
|
682
|
+
raise typer.Exit(code=1)
|
|
683
|
+
|
|
684
|
+
# Parse frequency if provided
|
|
685
|
+
freq_seconds = None
|
|
686
|
+
if frequency:
|
|
687
|
+
freq = frequency.strip().lower()
|
|
688
|
+
try:
|
|
689
|
+
if freq.endswith('s'):
|
|
690
|
+
freq_seconds = int(freq[:-1])
|
|
691
|
+
elif freq.endswith('m'):
|
|
692
|
+
freq_seconds = int(freq[:-1]) * 60
|
|
693
|
+
elif freq.endswith('h'):
|
|
694
|
+
freq_seconds = int(freq[:-1]) * 3600
|
|
695
|
+
else:
|
|
696
|
+
freq_seconds = int(freq)
|
|
697
|
+
except ValueError:
|
|
698
|
+
rprint(f"[red]Error: Invalid frequency format '{frequency}'[/red]")
|
|
699
|
+
rprint("[dim]Use: 300, 5m, or 1h[/dim]")
|
|
700
|
+
raise typer.Exit(code=1)
|
|
701
|
+
|
|
702
|
+
if freq_seconds < 30:
|
|
703
|
+
rprint("[red]Error: Minimum heartbeat interval is 30 seconds[/red]")
|
|
704
|
+
raise typer.Exit(code=1)
|
|
705
|
+
|
|
706
|
+
# Show current config
|
|
707
|
+
if show or (not enable and not disable and not pause and not resume
|
|
708
|
+
and not frequency and not instruction):
|
|
709
|
+
if agent.heartbeat_enabled:
|
|
710
|
+
freq_str = format_duration(agent.heartbeat_frequency_seconds)
|
|
711
|
+
status = "[yellow]paused[/yellow]" if agent.heartbeat_paused else "[green]enabled[/green]"
|
|
712
|
+
rprint(f"Heartbeat for '[bold]{name}[/bold]': {status}")
|
|
713
|
+
rprint(f" Frequency: {freq_str}")
|
|
714
|
+
rprint(f" Instruction: {agent.heartbeat_instruction or '(none)'}")
|
|
715
|
+
if agent.last_heartbeat_time:
|
|
716
|
+
rprint(f" Last sent: {agent.last_heartbeat_time}")
|
|
717
|
+
else:
|
|
718
|
+
rprint(f"Heartbeat for '[bold]{name}[/bold]': [dim]disabled[/dim]")
|
|
719
|
+
return
|
|
720
|
+
|
|
721
|
+
# Disable
|
|
722
|
+
if disable:
|
|
723
|
+
manager.update_session(
|
|
724
|
+
agent.id,
|
|
725
|
+
heartbeat_enabled=False,
|
|
726
|
+
heartbeat_paused=False,
|
|
727
|
+
heartbeat_instruction="",
|
|
728
|
+
)
|
|
729
|
+
rprint(f"[green]✓ Heartbeat disabled for {name}[/green]")
|
|
730
|
+
return
|
|
731
|
+
|
|
732
|
+
# Pause
|
|
733
|
+
if pause:
|
|
734
|
+
if not agent.heartbeat_enabled:
|
|
735
|
+
rprint(f"[yellow]Heartbeat is not enabled for {name}[/yellow]")
|
|
736
|
+
return
|
|
737
|
+
manager.update_session(agent.id, heartbeat_paused=True)
|
|
738
|
+
rprint(f"[green]✓ Heartbeat paused for {name}[/green]")
|
|
739
|
+
return
|
|
740
|
+
|
|
741
|
+
# Resume
|
|
742
|
+
if resume:
|
|
743
|
+
if not agent.heartbeat_enabled:
|
|
744
|
+
rprint(f"[yellow]Heartbeat is not enabled for {name}[/yellow]")
|
|
745
|
+
return
|
|
746
|
+
manager.update_session(agent.id, heartbeat_paused=False)
|
|
747
|
+
rprint(f"[green]✓ Heartbeat resumed for {name}[/green]")
|
|
748
|
+
return
|
|
749
|
+
|
|
750
|
+
# Enable with frequency and instruction
|
|
751
|
+
if enable:
|
|
752
|
+
if not instruction:
|
|
753
|
+
rprint("[red]Error: --instruction required when enabling heartbeat[/red]")
|
|
754
|
+
raise typer.Exit(code=1)
|
|
755
|
+
|
|
756
|
+
final_freq = freq_seconds or 300 # Default 5 minutes
|
|
757
|
+
manager.update_session(
|
|
758
|
+
agent.id,
|
|
759
|
+
heartbeat_enabled=True,
|
|
760
|
+
heartbeat_paused=False,
|
|
761
|
+
heartbeat_frequency_seconds=final_freq,
|
|
762
|
+
heartbeat_instruction=instruction,
|
|
763
|
+
)
|
|
764
|
+
rprint(f"[green]✓ Heartbeat enabled for {name}[/green]")
|
|
765
|
+
rprint(f" Frequency: {format_duration(final_freq)}")
|
|
766
|
+
rprint(f" Instruction: {instruction}")
|
|
767
|
+
return
|
|
768
|
+
|
|
769
|
+
# Update frequency or instruction without full enable
|
|
770
|
+
updates = {}
|
|
771
|
+
if freq_seconds:
|
|
772
|
+
updates['heartbeat_frequency_seconds'] = freq_seconds
|
|
773
|
+
if instruction:
|
|
774
|
+
updates['heartbeat_instruction'] = instruction
|
|
775
|
+
|
|
776
|
+
if updates:
|
|
777
|
+
manager.update_session(agent.id, **updates)
|
|
778
|
+
rprint(f"[green]✓ Heartbeat config updated for {name}[/green]")
|
|
779
|
+
if freq_seconds:
|
|
780
|
+
rprint(f" Frequency: {format_duration(freq_seconds)}")
|
|
781
|
+
if instruction:
|
|
782
|
+
rprint(f" Instruction: {instruction}")
|
|
783
|
+
|
|
784
|
+
|
|
378
785
|
# =============================================================================
|
|
379
786
|
# Monitoring Commands
|
|
380
787
|
# =============================================================================
|
|
@@ -383,11 +790,25 @@ def instruct(
|
|
|
383
790
|
@app.command()
|
|
384
791
|
def monitor(
|
|
385
792
|
session: SessionOption = "agents",
|
|
793
|
+
restart: Annotated[
|
|
794
|
+
bool, typer.Option("--restart", help="Restart the monitor daemon before launching")
|
|
795
|
+
] = False,
|
|
386
796
|
diagnostics: Annotated[
|
|
387
797
|
bool, typer.Option("--diagnostics", help="Diagnostic mode: disable all auto-refresh timers")
|
|
388
798
|
] = False,
|
|
389
799
|
):
|
|
390
800
|
"""Launch the standalone TUI monitor."""
|
|
801
|
+
if restart:
|
|
802
|
+
from .monitor_daemon import stop_monitor_daemon, is_monitor_daemon_running, get_monitor_daemon_pid
|
|
803
|
+
|
|
804
|
+
if is_monitor_daemon_running(session):
|
|
805
|
+
pid = get_monitor_daemon_pid(session)
|
|
806
|
+
if stop_monitor_daemon(session):
|
|
807
|
+
rprint(f"[green]✓[/green] Monitor daemon stopped (was PID {pid})")
|
|
808
|
+
else:
|
|
809
|
+
rprint("[red]Failed to stop monitor daemon[/red]")
|
|
810
|
+
raise typer.Exit(1)
|
|
811
|
+
|
|
391
812
|
from .tui import run_tui
|
|
392
813
|
|
|
393
814
|
run_tui(session, diagnostics=diagnostics)
|
|
@@ -844,6 +1265,12 @@ CONFIG_TEMPLATE = """\
|
|
|
844
1265
|
# - name: "Full Day"
|
|
845
1266
|
# start: "09:00"
|
|
846
1267
|
# end: "17:00"
|
|
1268
|
+
|
|
1269
|
+
# Time context hook settings (for 'overcode time-context')
|
|
1270
|
+
# time_context:
|
|
1271
|
+
# office_start: 9
|
|
1272
|
+
# office_end: 17
|
|
1273
|
+
# heartbeat_interval_minutes: 15 # omit to disable
|
|
847
1274
|
"""
|
|
848
1275
|
|
|
849
1276
|
|
|
@@ -185,3 +185,26 @@ def get_web_time_presets() -> list:
|
|
|
185
185
|
{"name": "Evening", "start": "18:00", "end": "22:00"},
|
|
186
186
|
{"name": "All Time", "start": None, "end": None},
|
|
187
187
|
]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_time_context_config() -> dict:
|
|
191
|
+
"""Get time context configuration for the time-context hook.
|
|
192
|
+
|
|
193
|
+
Config format in ~/.overcode/config.yaml:
|
|
194
|
+
time_context:
|
|
195
|
+
office_start: 9
|
|
196
|
+
office_end: 17
|
|
197
|
+
heartbeat_interval_minutes: 15 # omit to disable
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Dict with office_start (int), office_end (int),
|
|
201
|
+
heartbeat_interval_minutes (Optional[int])
|
|
202
|
+
"""
|
|
203
|
+
config = load_config()
|
|
204
|
+
tc = config.get("time_context", {})
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
"office_start": tc.get("office_start", 9),
|
|
208
|
+
"office_end": tc.get("office_end", 17),
|
|
209
|
+
"heartbeat_interval_minutes": tc.get("heartbeat_interval_minutes"),
|
|
210
|
+
}
|
|
@@ -7,6 +7,7 @@ avoiding code duplication between monitor_daemon and supervisor_daemon.
|
|
|
7
7
|
|
|
8
8
|
import os
|
|
9
9
|
import signal
|
|
10
|
+
import time
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Callable, Optional, Tuple
|
|
12
13
|
|
|
@@ -75,6 +76,14 @@ def create_daemon_helpers(
|
|
|
75
76
|
|
|
76
77
|
try:
|
|
77
78
|
os.kill(pid, signal.SIGTERM)
|
|
79
|
+
# Wait for process to actually terminate before removing PID file
|
|
80
|
+
start = time.time()
|
|
81
|
+
while time.time() - start < 5.0:
|
|
82
|
+
try:
|
|
83
|
+
os.kill(pid, 0)
|
|
84
|
+
time.sleep(0.1)
|
|
85
|
+
except (OSError, ProcessLookupError):
|
|
86
|
+
break
|
|
78
87
|
remove_pid_file(pid_path)
|
|
79
88
|
return True
|
|
80
89
|
except (OSError, ProcessLookupError):
|