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.
Files changed (84) hide show
  1. {overcode-0.1.5/src/overcode.egg-info → overcode-0.1.6}/PKG-INFO +1 -1
  2. {overcode-0.1.5 → overcode-0.1.6}/pyproject.toml +1 -1
  3. overcode-0.1.6/src/overcode/__init__.py +14 -0
  4. overcode-0.1.6/src/overcode/claude_config.py +90 -0
  5. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/cli.py +435 -8
  6. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/config.py +23 -0
  7. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/daemon_utils.py +9 -0
  8. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/history_reader.py +13 -0
  9. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/launcher.py +7 -4
  10. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/monitor_daemon.py +146 -6
  11. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/monitor_daemon_core.py +13 -7
  12. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/monitor_daemon_state.py +56 -4
  13. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/session_manager.py +23 -1
  14. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/settings.py +16 -1
  15. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/status_constants.py +32 -14
  16. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/status_detector.py +69 -6
  17. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/status_patterns.py +112 -0
  18. overcode-0.1.6/src/overcode/summary_groups.py +114 -0
  19. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/supervisor_daemon.py +4 -1
  20. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/supervisor_daemon_core.py +6 -2
  21. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/supervisor_layout.sh +1 -1
  22. overcode-0.1.6/src/overcode/time_context.py +313 -0
  23. overcode-0.1.6/src/overcode/tmux_utils.py +120 -0
  24. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui.py +212 -8
  25. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui.tcss +31 -0
  26. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/input.py +66 -2
  27. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/navigation.py +7 -7
  28. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/session.py +89 -2
  29. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/view.py +63 -4
  30. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_helpers.py +27 -7
  31. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_logic.py +18 -12
  32. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_render.py +2 -1
  33. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/__init__.py +4 -0
  34. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/command_bar.py +138 -1
  35. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/daemon_panel.py +4 -1
  36. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/daemon_status_bar.py +4 -2
  37. overcode-0.1.6/src/overcode/tui_widgets/fullscreen_preview.py +111 -0
  38. overcode-0.1.6/src/overcode/tui_widgets/help_overlay.py +185 -0
  39. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/session_summary.py +184 -77
  40. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/status_timeline.py +27 -5
  41. overcode-0.1.6/src/overcode/tui_widgets/summary_config_modal.py +165 -0
  42. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/web_api.py +5 -2
  43. {overcode-0.1.5 → overcode-0.1.6/src/overcode.egg-info}/PKG-INFO +1 -1
  44. {overcode-0.1.5 → overcode-0.1.6}/src/overcode.egg-info/SOURCES.txt +6 -0
  45. overcode-0.1.5/src/overcode/__init__.py +0 -5
  46. overcode-0.1.5/src/overcode/tui_widgets/help_overlay.py +0 -71
  47. {overcode-0.1.5 → overcode-0.1.6}/LICENSE +0 -0
  48. {overcode-0.1.5 → overcode-0.1.6}/MANIFEST.in +0 -0
  49. {overcode-0.1.5 → overcode-0.1.6}/README.md +0 -0
  50. {overcode-0.1.5 → overcode-0.1.6}/setup.cfg +0 -0
  51. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/daemon_claude_skill.md +0 -0
  52. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/daemon_logging.py +0 -0
  53. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/data_export.py +0 -0
  54. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/dependency_check.py +0 -0
  55. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/exceptions.py +0 -0
  56. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/implementations.py +0 -0
  57. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/interfaces.py +0 -0
  58. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/logging_config.py +0 -0
  59. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/mocks.py +0 -0
  60. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/pid_utils.py +0 -0
  61. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/presence_logger.py +0 -0
  62. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/protocols.py +0 -0
  63. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/standing_instructions.py +0 -0
  64. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/status_history.py +0 -0
  65. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/summarizer_client.py +0 -0
  66. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/summarizer_component.py +0 -0
  67. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/testing/__init__.py +0 -0
  68. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/testing/renderer.py +0 -0
  69. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/testing/tmux_driver.py +0 -0
  70. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/testing/tui_eye.py +0 -0
  71. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/testing/tui_eye_skill.md +0 -0
  72. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tmux_manager.py +0 -0
  73. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/__init__.py +0 -0
  74. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_actions/daemon.py +0 -0
  75. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/tui_widgets/preview_pane.py +0 -0
  76. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/web_chartjs.py +0 -0
  77. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/web_server.py +0 -0
  78. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/web_server_runner.py +0 -0
  79. {overcode-0.1.5 → overcode-0.1.6}/src/overcode/web_templates.py +0 -0
  80. {overcode-0.1.5 → overcode-0.1.6}/src/overcode.egg-info/dependency_links.txt +0 -0
  81. {overcode-0.1.5 → overcode-0.1.6}/src/overcode.egg-info/entry_points.txt +0 -0
  82. {overcode-0.1.5 → overcode-0.1.6}/src/overcode.egg-info/requires.txt +0 -0
  83. {overcode-0.1.5 → overcode-0.1.6}/src/overcode.egg-info/top_level.txt +0 -0
  84. {overcode-0.1.5 → overcode-0.1.6}/tests/test_e2e_multi_agent_jokes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overcode
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: A supervisor for managing multiple Claude Code instances in tmux
5
5
  Author: Mike Bond
6
6
  Project-URL: Homepage, https://github.com/mkb23/overcode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "overcode"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "A supervisor for managing multiple Claude Code instances in tmux"
9
9
  authors = [
10
10
  {name = "Mike Bond"}
@@ -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=True,
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 recent output from an agent."""
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
- output = launcher.get_session_output(name, lines=lines)
292
- if output is not None:
293
- print(f"=== {name} (last {lines} lines) ===")
294
- print(output)
295
- print(f"=== end {name} ===")
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
- rprint(f"[red]✗[/red] Could not get output from '[bold]{name}[/bold]'")
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):