overcode 0.1.6__tar.gz → 0.1.7__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 (90) hide show
  1. {overcode-0.1.6/src/overcode.egg-info → overcode-0.1.7}/PKG-INFO +1 -1
  2. {overcode-0.1.6 → overcode-0.1.7}/pyproject.toml +1 -1
  3. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/claude_config.py +49 -0
  4. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/cli.py +238 -132
  5. overcode-0.1.7/src/overcode/dependency_check.py +111 -0
  6. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/history_reader.py +85 -21
  7. overcode-0.1.7/src/overcode/hook_handler.py +108 -0
  8. overcode-0.1.7/src/overcode/hook_status_detector.py +189 -0
  9. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/launcher.py +10 -68
  10. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/monitor_daemon.py +43 -17
  11. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/protocols.py +28 -1
  12. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/session_manager.py +26 -0
  13. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/settings.py +6 -9
  14. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/status_detector.py +42 -45
  15. overcode-0.1.7/src/overcode/status_detector_factory.py +73 -0
  16. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/status_patterns.py +46 -12
  17. overcode-0.1.7/src/overcode/summary_columns.py +701 -0
  18. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/summary_groups.py +24 -6
  19. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/supervisor_daemon.py +13 -51
  20. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui.py +312 -164
  21. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui.tcss +5 -0
  22. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_actions/input.py +1 -1
  23. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_actions/navigation.py +5 -6
  24. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_actions/session.py +133 -222
  25. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_actions/view.py +2 -2
  26. overcode-0.1.7/src/overcode/tui_formatters.py +235 -0
  27. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_helpers.py +18 -227
  28. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_widgets/daemon_panel.py +27 -14
  29. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_widgets/daemon_status_bar.py +66 -28
  30. overcode-0.1.7/src/overcode/tui_widgets/preview_pane.py +103 -0
  31. overcode-0.1.7/src/overcode/tui_widgets/session_summary.py +437 -0
  32. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_widgets/status_timeline.py +26 -12
  33. {overcode-0.1.6 → overcode-0.1.7/src/overcode.egg-info}/PKG-INFO +1 -1
  34. {overcode-0.1.6 → overcode-0.1.7}/src/overcode.egg-info/SOURCES.txt +5 -0
  35. overcode-0.1.6/src/overcode/dependency_check.py +0 -227
  36. overcode-0.1.6/src/overcode/tui_widgets/preview_pane.py +0 -69
  37. overcode-0.1.6/src/overcode/tui_widgets/session_summary.py +0 -621
  38. {overcode-0.1.6 → overcode-0.1.7}/LICENSE +0 -0
  39. {overcode-0.1.6 → overcode-0.1.7}/MANIFEST.in +0 -0
  40. {overcode-0.1.6 → overcode-0.1.7}/README.md +0 -0
  41. {overcode-0.1.6 → overcode-0.1.7}/setup.cfg +0 -0
  42. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/__init__.py +0 -0
  43. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/config.py +0 -0
  44. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/daemon_claude_skill.md +0 -0
  45. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/daemon_logging.py +0 -0
  46. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/daemon_utils.py +0 -0
  47. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/data_export.py +0 -0
  48. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/exceptions.py +0 -0
  49. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/implementations.py +0 -0
  50. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/interfaces.py +0 -0
  51. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/logging_config.py +0 -0
  52. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/mocks.py +0 -0
  53. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/monitor_daemon_core.py +0 -0
  54. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/monitor_daemon_state.py +0 -0
  55. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/pid_utils.py +0 -0
  56. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/presence_logger.py +0 -0
  57. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/standing_instructions.py +0 -0
  58. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/status_constants.py +0 -0
  59. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/status_history.py +0 -0
  60. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/summarizer_client.py +0 -0
  61. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/summarizer_component.py +0 -0
  62. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/supervisor_daemon_core.py +0 -0
  63. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/supervisor_layout.sh +0 -0
  64. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/testing/__init__.py +0 -0
  65. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/testing/renderer.py +0 -0
  66. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/testing/tmux_driver.py +0 -0
  67. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/testing/tui_eye.py +0 -0
  68. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/testing/tui_eye_skill.md +0 -0
  69. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/time_context.py +0 -0
  70. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tmux_manager.py +0 -0
  71. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tmux_utils.py +0 -0
  72. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_actions/__init__.py +0 -0
  73. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_actions/daemon.py +0 -0
  74. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_logic.py +0 -0
  75. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_render.py +0 -0
  76. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_widgets/__init__.py +0 -0
  77. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_widgets/command_bar.py +0 -0
  78. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
  79. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_widgets/help_overlay.py +0 -0
  80. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
  81. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/web_api.py +0 -0
  82. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/web_chartjs.py +0 -0
  83. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/web_server.py +0 -0
  84. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/web_server_runner.py +0 -0
  85. {overcode-0.1.6 → overcode-0.1.7}/src/overcode/web_templates.py +0 -0
  86. {overcode-0.1.6 → overcode-0.1.7}/src/overcode.egg-info/dependency_links.txt +0 -0
  87. {overcode-0.1.6 → overcode-0.1.7}/src/overcode.egg-info/entry_points.txt +0 -0
  88. {overcode-0.1.6 → overcode-0.1.7}/src/overcode.egg-info/requires.txt +0 -0
  89. {overcode-0.1.6 → overcode-0.1.7}/src/overcode.egg-info/top_level.txt +0 -0
  90. {overcode-0.1.6 → overcode-0.1.7}/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.6
3
+ Version: 0.1.7
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.6"
7
+ version = "0.1.7"
8
8
  description = "A supervisor for managing multiple Claude Code instances in tmux"
9
9
  authors = [
10
10
  {name = "Mike Bond"}
@@ -88,3 +88,52 @@ class ClaudeConfigEditor:
88
88
 
89
89
  self.save(updated)
90
90
  return True
91
+
92
+ def remove_hook(self, event: str, command: str) -> bool:
93
+ """Remove a matcher group containing this command.
94
+
95
+ Returns True if found and removed, False if not found.
96
+ Cleans up empty event arrays and empty hooks dict.
97
+ """
98
+ settings = self.load()
99
+ hooks_dict = settings.get("hooks", {})
100
+ event_list = hooks_dict.get(event, [])
101
+
102
+ # Find the matcher group index containing this command
103
+ index_to_remove = None
104
+ for i, entry in enumerate(event_list):
105
+ for hook in entry.get("hooks", []):
106
+ if hook.get("command") == command:
107
+ index_to_remove = i
108
+ break
109
+ if index_to_remove is not None:
110
+ break
111
+
112
+ if index_to_remove is None:
113
+ return False
114
+
115
+ updated = copy.deepcopy(settings)
116
+ del updated["hooks"][event][index_to_remove]
117
+
118
+ # Clean up empty event array
119
+ if not updated["hooks"][event]:
120
+ del updated["hooks"][event]
121
+
122
+ # Clean up empty hooks dict
123
+ if not updated["hooks"]:
124
+ del updated["hooks"]
125
+
126
+ self.save(updated)
127
+ return True
128
+
129
+ def list_hooks_matching(self, command_prefix: str) -> list[tuple[str, str]]:
130
+ """Return [(event, command)] for all hooks whose command starts with prefix."""
131
+ settings = self.load()
132
+ results = []
133
+ for event, entries in settings.get("hooks", {}).items():
134
+ for entry in entries:
135
+ for hook in entry.get("hooks", []):
136
+ cmd = hook.get("command", "")
137
+ if cmd.startswith(command_prefix):
138
+ results.append((event, cmd))
139
+ return results
@@ -40,6 +40,14 @@ supervisor_daemon_app = typer.Typer(
40
40
  )
41
41
  app.add_typer(supervisor_daemon_app, name="supervisor-daemon")
42
42
 
43
+ # Hooks subcommand group
44
+ hooks_app = typer.Typer(
45
+ name="hooks",
46
+ help="Manage Claude Code hook integration.",
47
+ no_args_is_help=True,
48
+ )
49
+ app.add_typer(hooks_app, name="hooks")
50
+
43
51
  # Config subcommand group
44
52
  config_app = typer.Typer(
45
53
  name="config",
@@ -128,12 +136,11 @@ def launch(
128
136
  @app.command("list")
129
137
  def list_agents(session: SessionOption = "agents"):
130
138
  """List running agents with status."""
131
- from .status_detector import StatusDetector
132
- from .history_reader import get_session_stats
133
139
  from .tui_helpers import (
134
140
  calculate_uptime, format_duration, format_tokens,
135
141
  get_current_state_times, get_status_symbol
136
142
  )
143
+ from .monitor_daemon_state import get_monitor_daemon_state
137
144
 
138
145
  launcher = ClaudeLauncher(session)
139
146
  sessions = launcher.list_sessions()
@@ -142,17 +149,39 @@ def list_agents(session: SessionOption = "agents"):
142
149
  rprint("[dim]No running agents[/dim]")
143
150
  return
144
151
 
145
- status_detector = StatusDetector(session)
152
+ # Prefer daemon state for status/activity (single source of truth)
153
+ daemon_state = get_monitor_daemon_state(session)
154
+ use_daemon = daemon_state is not None and not daemon_state.is_stale()
155
+
156
+ # Only create detector as fallback when daemon isn't running
157
+ detector = None
158
+ if not use_daemon:
159
+ from .status_detector_factory import StatusDetectorDispatcher
160
+ detector = StatusDetectorDispatcher(session)
161
+
146
162
  terminated_count = 0
147
163
 
148
164
  for sess in sessions:
149
- # For terminated sessions, use stored status; otherwise detect from tmux
150
165
  if sess.status == "terminated":
151
166
  status = "terminated"
152
167
  activity = "(tmux window no longer exists)"
153
168
  terminated_count += 1
169
+ elif use_daemon:
170
+ ds = daemon_state.get_session_by_name(sess.name)
171
+ if ds:
172
+ status = ds.current_status
173
+ activity = ds.current_activity
174
+ else:
175
+ # Session not yet in daemon state — detect directly
176
+ if detector is None:
177
+ from .status_detector_factory import StatusDetectorDispatcher
178
+ detector = StatusDetectorDispatcher(session)
179
+ status, activity, _ = detector.detect_status(sess)
154
180
  else:
155
- status, activity, _ = status_detector.detect_status(sess)
181
+ status, activity, _ = detector.detect_status(sess)
182
+
183
+ if sess.is_asleep:
184
+ status = "asleep"
156
185
 
157
186
  symbol, _ = get_status_symbol(status)
158
187
 
@@ -162,10 +191,10 @@ def list_agents(session: SessionOption = "agents"):
162
191
  # Get state times using shared helper
163
192
  green_time, non_green_time, sleep_time = get_current_state_times(sess.stats, is_asleep=sess.is_asleep)
164
193
 
165
- # Get stats from Claude Code history and session files
166
- stats = get_session_stats(sess)
167
- if stats:
168
- stats_display = f"{stats.interaction_count:>2}i {format_tokens(stats.total_tokens):>5}"
194
+ # Stats from session manager (already synced by daemon)
195
+ stats = sess.stats
196
+ if stats.interaction_count > 0:
197
+ stats_display = f"{stats.interaction_count:>2}i {format_tokens(stats.input_tokens + stats.output_tokens):>5}"
169
198
  else:
170
199
  stats_display = " -i -"
171
200
 
@@ -279,6 +308,40 @@ def set_budget(
279
308
  rprint(f"[green]✓ Cleared budget for {name}[/green]")
280
309
 
281
310
 
311
+ @app.command()
312
+ def annotate(
313
+ name: Annotated[str, typer.Argument(help="Name of agent")],
314
+ text: Annotated[
315
+ Optional[List[str]], typer.Argument(help="Annotation text (omit to clear)")
316
+ ] = None,
317
+ session: SessionOption = "agents",
318
+ ):
319
+ """Set or clear a human annotation on an agent (#223).
320
+
321
+ Allows programmatic annotation of agents so scripts and other tools
322
+ can communicate status to the overcode TUI.
323
+
324
+ Examples:
325
+ overcode annotate my-agent "Working on auth module"
326
+ overcode annotate my-agent Building the API layer
327
+ overcode annotate my-agent # Clear annotation
328
+ """
329
+ from .session_manager import SessionManager
330
+
331
+ manager = SessionManager()
332
+ agent = manager.get_session_by_name(name)
333
+ if not agent:
334
+ rprint(f"[red]Error: Agent '{name}' not found[/red]")
335
+ raise typer.Exit(code=1)
336
+
337
+ annotation = " ".join(text) if text else ""
338
+ manager.set_human_annotation(agent.id, annotation)
339
+ if annotation:
340
+ rprint(f"[green]✓ Annotation set for {name}:[/green] {annotation}")
341
+ else:
342
+ rprint(f"[green]✓ Annotation cleared for {name}[/green]")
343
+
344
+
282
345
  @app.command()
283
346
  def send(
284
347
  name: Annotated[str, typer.Argument(help="Name of agent")],
@@ -336,14 +399,10 @@ def show(
336
399
  session: SessionOption = "agents",
337
400
  ):
338
401
  """Show agent details and recent output."""
339
- from .status_detector import StatusDetector
340
402
  from .history_reader import get_session_stats
341
403
  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
- )
404
+ from .tui_helpers import get_git_diff_stats
405
+ from .summary_columns import build_cli_context, render_cli_stats
347
406
  from .monitor_daemon_state import get_monitor_daemon_state
348
407
 
349
408
  launcher = ClaudeLauncher(session)
@@ -354,18 +413,39 @@ def show(
354
413
  rprint(f"[red]✗[/red] Agent '[bold]{name}[/bold]' not found")
355
414
  raise typer.Exit(1)
356
415
 
357
- # Detect live status (gets status + pane content with ANSI for bash count)
416
+ # Read daemon state for status/activity (single source of truth)
417
+ daemon_state = get_monitor_daemon_state(session)
418
+ daemon_session = None
419
+ if daemon_state and not daemon_state.is_stale():
420
+ daemon_session = daemon_state.get_session_by_name(name)
421
+
422
+ # Get status/activity from daemon state, falling back to detection
358
423
  pane_content_raw = ""
359
424
  if sess.status == "terminated":
360
425
  status = "terminated"
361
426
  activity = "(tmux window no longer exists)"
427
+ elif daemon_session:
428
+ status = daemon_session.current_status
429
+ activity = daemon_session.current_activity
362
430
  else:
363
- status_detector = StatusDetector(session)
364
- status, activity, pane_content_raw = status_detector.detect_status(sess)
431
+ # Daemon not running — fall back to direct detection
432
+ from .status_detector_factory import create_status_detector
433
+ detector = create_status_detector(
434
+ session,
435
+ strategy="hooks" if sess.hook_status_detection else "polling",
436
+ )
437
+ status, activity, pane_content_raw = detector.detect_status(sess)
365
438
 
366
439
  if sess.is_asleep:
367
440
  status = "asleep"
368
441
 
442
+ # Capture pane content separately if needed for display or stats parsing
443
+ need_pane = (not stats_only and lines > 0) or not no_stats
444
+ if need_pane and not pane_content_raw and sess.status != "terminated":
445
+ from .status_detector_factory import StatusDetectorDispatcher
446
+ dispatcher = StatusDetectorDispatcher(session)
447
+ pane_content_raw = dispatcher.get_pane_content(sess.tmux_window, num_lines=max(lines, 50))
448
+
369
449
  if not no_stats:
370
450
  # Gather all stats
371
451
  bg_bash_count = extract_background_bash_count(pane_content_raw) if pane_content_raw else 0
@@ -384,97 +464,40 @@ def show(
384
464
  except Exception:
385
465
  pass
386
466
 
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
-
467
+ # AI summaries from daemon state
394
468
  ai_short = ""
395
469
  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
470
+ if daemon_session:
471
+ ai_short = daemon_session.activity_summary or ""
472
+ ai_context = daemon_session.activity_summary_context or ""
473
+
474
+ # Build context and render via column system
475
+ any_has_budget = sess.cost_budget_usd > 0
476
+ ctx = build_cli_context(
477
+ session=sess,
478
+ stats=sess.stats,
479
+ claude_stats=claude_stats,
480
+ git_diff_stats=git_diff,
481
+ status=status,
482
+ bg_bash_count=bg_bash_count,
483
+ live_sub_count=live_sub_count,
484
+ any_has_budget=any_has_budget,
485
+ )
405
486
 
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
487
  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}")
488
+ label_width = max(len(label) for label, _ in render_cli_stats(ctx)) + 1
489
+ for label, value in render_cli_stats(ctx):
490
+ print(f"{label + ':':<{label_width + 1}} {value}")
468
491
 
469
- # AI summaries
492
+ # AI summaries (not a column — comes from daemon state)
470
493
  if ai_short:
471
- print(f"AI: {ai_short}")
494
+ print(f"{'AI:':<{label_width + 1}} {ai_short}")
472
495
  if ai_context:
473
- print(f"Context: {ai_context}")
496
+ print(f"{'Context:':<{label_width + 1}} {ai_context}")
474
497
 
475
- # Activity from status detector
498
+ # Activity from status detector (not a column — transient)
476
499
  if activity:
477
- print(f"Activity: {activity[:100]}")
500
+ print(f"{'Activity:':<{label_width + 1}} {activity[:100]}")
478
501
 
479
502
  print()
480
503
 
@@ -498,42 +521,25 @@ def show(
498
521
  rprint(f"[dim]No pane output available[/dim]")
499
522
 
500
523
 
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)
524
+ # =============================================================================
525
+ # Hooks Commands
526
+ # =============================================================================
519
527
 
520
528
 
521
- @app.command("install-hook")
522
- def install_hook(
529
+ @hooks_app.command("install")
530
+ def hooks_install(
523
531
  project: Annotated[
524
532
  bool,
525
533
  typer.Option("--project", "-p", help="Install to project-level .claude/settings.json instead of user-level"),
526
534
  ] = False,
527
535
  ):
528
- """Install the time-context hook into Claude Code settings.
536
+ """Install all overcode hooks into Claude Code settings.
529
537
 
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.
538
+ Installs hooks for: UserPromptSubmit, PostToolUse, Stop,
539
+ PermissionRequest, SessionEnd. All use the unified 'overcode hook-handler'.
535
540
  """
536
541
  from .claude_config import ClaudeConfigEditor
542
+ from .hook_handler import OVERCODE_HOOKS
537
543
 
538
544
  if project:
539
545
  editor = ClaudeConfigEditor.project_level()
@@ -543,18 +549,107 @@ def install_hook(
543
549
  level = "user"
544
550
 
545
551
  try:
546
- added = editor.add_hook("UserPromptSubmit", "overcode time-context")
552
+ settings = editor.load()
547
553
  except ValueError as e:
548
554
  rprint(f"[red]Error:[/red] {e}")
549
555
  raise typer.Exit(1)
550
556
 
551
- if added:
552
- rprint(f"[green]\u2713[/green] Installed time-context hook in {level} settings")
557
+ # Install all overcode hooks (idempotent)
558
+ installed = 0
559
+ already = 0
560
+ for event, command in OVERCODE_HOOKS:
561
+ if editor.add_hook(event, command):
562
+ installed += 1
563
+ else:
564
+ already += 1
565
+
566
+ if installed > 0:
567
+ events = ", ".join(event for event, _ in OVERCODE_HOOKS)
568
+ rprint(f"[green]\u2713[/green] Installed {installed} hook(s) in {level} settings")
553
569
  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]")
570
+ rprint(f"\n Events: {events}")
571
+ rprint(f" All hooks run 'overcode hook-handler' (reads event from stdin).")
572
+ elif already == len(OVERCODE_HOOKS):
573
+ rprint(f"[green]\u2713[/green] All {already} hooks already installed in {level} settings")
574
+
575
+
576
+ @hooks_app.command("uninstall")
577
+ def hooks_uninstall(
578
+ project: Annotated[
579
+ bool,
580
+ typer.Option("--project", "-p", help="Uninstall from project-level .claude/settings.json instead of user-level"),
581
+ ] = False,
582
+ ):
583
+ """Remove all overcode hooks from Claude Code settings."""
584
+ from .claude_config import ClaudeConfigEditor
585
+ from .hook_handler import OVERCODE_HOOKS
586
+
587
+ if project:
588
+ editor = ClaudeConfigEditor.project_level()
589
+ level = "project"
556
590
  else:
557
- rprint(f"[green]\u2713[/green] Hook already installed in {level} settings ({editor.path})")
591
+ editor = ClaudeConfigEditor.user_level()
592
+ level = "user"
593
+
594
+ try:
595
+ editor.load()
596
+ except ValueError as e:
597
+ rprint(f"[red]Error:[/red] {e}")
598
+ raise typer.Exit(1)
599
+
600
+ removed = 0
601
+ for event, command in OVERCODE_HOOKS:
602
+ if editor.remove_hook(event, command):
603
+ removed += 1
604
+
605
+ if removed > 0:
606
+ rprint(f"[green]\u2713[/green] Removed {removed} hook(s) from {level} settings")
607
+ else:
608
+ rprint(f"[dim]No overcode hooks found in {level} settings[/dim]")
609
+
610
+
611
+ @hooks_app.command("status")
612
+ def hooks_status():
613
+ """Show which overcode hooks are installed."""
614
+ from .claude_config import ClaudeConfigEditor
615
+ from .hook_handler import OVERCODE_HOOKS
616
+
617
+ for level_name, editor in [
618
+ ("User-level", ClaudeConfigEditor.user_level()),
619
+ ("Project-level", ClaudeConfigEditor.project_level()),
620
+ ]:
621
+ try:
622
+ editor.load()
623
+ except ValueError:
624
+ rprint(f"\n{level_name} ({editor.path}):")
625
+ rprint(f" [red](invalid JSON)[/red]")
626
+ continue
627
+
628
+ if not editor.path.exists():
629
+ rprint(f"\n{level_name} ({editor.path}):")
630
+ rprint(f" [dim](no settings file)[/dim]")
631
+ continue
632
+
633
+ rprint(f"\n{level_name} ({editor.path}):")
634
+
635
+ for event, command in OVERCODE_HOOKS:
636
+ if editor.has_hook(event, command):
637
+ rprint(f" {event:<20} {command} [green]\u2713[/green]")
638
+ else:
639
+ rprint(f" {event:<20} [dim]not installed[/dim]")
640
+
641
+
642
+ @app.command("hook-handler", hidden=True)
643
+ def hook_handler_cmd():
644
+ """Handle Claude Code hook events (internal).
645
+
646
+ Called by Claude Code hooks, not by users directly.
647
+ Reads event JSON from stdin, writes state for status detection,
648
+ and outputs time-context for UserPromptSubmit events.
649
+ """
650
+ from .hook_handler import handle_hook_event
651
+
652
+ handle_hook_event()
558
653
 
559
654
 
560
655
  @app.command()
@@ -634,6 +729,12 @@ def instruct(
634
729
  rprint(f"[dim]Tip: Use 'overcode presets' to see available presets[/dim]")
635
730
 
636
731
 
732
+ def _signal_heartbeat_change(session: str) -> None:
733
+ """Wake the monitor daemon so heartbeat status updates immediately (#212)."""
734
+ from .settings import signal_activity
735
+ signal_activity(session)
736
+
737
+
637
738
  @app.command()
638
739
  def heartbeat(
639
740
  name: Annotated[str, typer.Argument(help="Name of agent")],
@@ -726,6 +827,7 @@ def heartbeat(
726
827
  heartbeat_paused=False,
727
828
  heartbeat_instruction="",
728
829
  )
830
+ _signal_heartbeat_change(session)
729
831
  rprint(f"[green]✓ Heartbeat disabled for {name}[/green]")
730
832
  return
731
833
 
@@ -735,6 +837,7 @@ def heartbeat(
735
837
  rprint(f"[yellow]Heartbeat is not enabled for {name}[/yellow]")
736
838
  return
737
839
  manager.update_session(agent.id, heartbeat_paused=True)
840
+ _signal_heartbeat_change(session)
738
841
  rprint(f"[green]✓ Heartbeat paused for {name}[/green]")
739
842
  return
740
843
 
@@ -744,6 +847,7 @@ def heartbeat(
744
847
  rprint(f"[yellow]Heartbeat is not enabled for {name}[/yellow]")
745
848
  return
746
849
  manager.update_session(agent.id, heartbeat_paused=False)
850
+ _signal_heartbeat_change(session)
747
851
  rprint(f"[green]✓ Heartbeat resumed for {name}[/green]")
748
852
  return
749
853
 
@@ -761,6 +865,7 @@ def heartbeat(
761
865
  heartbeat_frequency_seconds=final_freq,
762
866
  heartbeat_instruction=instruction,
763
867
  )
868
+ _signal_heartbeat_change(session)
764
869
  rprint(f"[green]✓ Heartbeat enabled for {name}[/green]")
765
870
  rprint(f" Frequency: {format_duration(final_freq)}")
766
871
  rprint(f" Instruction: {instruction}")
@@ -775,6 +880,7 @@ def heartbeat(
775
880
 
776
881
  if updates:
777
882
  manager.update_session(agent.id, **updates)
883
+ _signal_heartbeat_change(session)
778
884
  rprint(f"[green]✓ Heartbeat config updated for {name}[/green]")
779
885
  if freq_seconds:
780
886
  rprint(f" Frequency: {format_duration(freq_seconds)}")