overcode 0.3.2__tar.gz → 0.3.3__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 (119) hide show
  1. {overcode-0.3.2/src/overcode.egg-info → overcode-0.3.3}/PKG-INFO +1 -1
  2. {overcode-0.3.2 → overcode-0.3.3}/pyproject.toml +1 -1
  3. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/claude_config.py +17 -0
  4. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/agent.py +2 -1
  5. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/split.py +3 -4
  6. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/history_reader.py +31 -1
  7. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/hook_handler.py +3 -0
  8. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/hook_status_detector.py +104 -91
  9. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/monitor_daemon.py +46 -0
  10. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/settings.py +32 -0
  11. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/status_detector.py +25 -5
  12. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/status_detector_factory.py +26 -16
  13. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/summary_columns.py +13 -4
  14. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui.py +33 -12
  15. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui.tcss +1 -1
  16. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/session.py +22 -39
  17. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/daemon_panel.py +41 -32
  18. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/help_overlay.py +1 -1
  19. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/session_summary.py +1 -1
  20. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/usage_monitor.py +2 -2
  21. {overcode-0.3.2 → overcode-0.3.3/src/overcode.egg-info}/PKG-INFO +1 -1
  22. {overcode-0.3.2 → overcode-0.3.3}/LICENSE +0 -0
  23. {overcode-0.3.2 → overcode-0.3.3}/MANIFEST.in +0 -0
  24. {overcode-0.3.2 → overcode-0.3.3}/README.md +0 -0
  25. {overcode-0.3.2 → overcode-0.3.3}/setup.cfg +0 -0
  26. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/__init__.py +0 -0
  27. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/agent_scanner.py +0 -0
  28. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/bundled_skills.py +0 -0
  29. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/claude_pid.py +0 -0
  30. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/__init__.py +0 -0
  31. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/__main__.py +0 -0
  32. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/_shared.py +0 -0
  33. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/budget.py +0 -0
  34. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/config.py +0 -0
  35. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/daemon.py +0 -0
  36. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/hooks.py +0 -0
  37. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/jobs.py +0 -0
  38. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/monitoring.py +0 -0
  39. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/perms.py +0 -0
  40. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/sister.py +0 -0
  41. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/skills.py +0 -0
  42. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/config.py +0 -0
  43. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/daemon_claude_skill.md +0 -0
  44. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/daemon_logging.py +0 -0
  45. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/daemon_utils.py +0 -0
  46. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/data_export.py +0 -0
  47. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/dependency_check.py +0 -0
  48. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/duration.py +0 -0
  49. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/exceptions.py +0 -0
  50. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/follow_mode.py +0 -0
  51. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/implementations.py +0 -0
  52. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/interfaces.py +0 -0
  53. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/job_launcher.py +0 -0
  54. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/job_manager.py +0 -0
  55. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/launcher.py +0 -0
  56. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/logging_config.py +0 -0
  57. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/mocks.py +0 -0
  58. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/monitor_daemon_core.py +0 -0
  59. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/monitor_daemon_state.py +0 -0
  60. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/notifier.py +0 -0
  61. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/pid_utils.py +0 -0
  62. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/presence_logger.py +0 -0
  63. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/protocols.py +0 -0
  64. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/session_manager.py +0 -0
  65. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/sister_controller.py +0 -0
  66. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/sister_poller.py +0 -0
  67. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/standing_instructions.py +0 -0
  68. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/status_constants.py +0 -0
  69. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/status_history.py +0 -0
  70. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/status_patterns.py +0 -0
  71. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/summarizer_client.py +0 -0
  72. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/summarizer_component.py +0 -0
  73. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/summary_groups.py +0 -0
  74. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/supervisor_daemon.py +0 -0
  75. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/supervisor_daemon_core.py +0 -0
  76. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/supervisor_layout.sh +0 -0
  77. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/testing/__init__.py +0 -0
  78. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/testing/renderer.py +0 -0
  79. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/testing/tmux_driver.py +0 -0
  80. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/testing/tui_eye.py +0 -0
  81. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/testing/tui_eye_skill.md +0 -0
  82. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/time_context.py +0 -0
  83. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tmux_manager.py +0 -0
  84. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tmux_utils.py +0 -0
  85. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/__init__.py +0 -0
  86. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/daemon.py +0 -0
  87. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/input.py +0 -0
  88. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/navigation.py +0 -0
  89. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/view.py +0 -0
  90. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_helpers.py +0 -0
  91. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_logic.py +0 -0
  92. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_render.py +0 -0
  93. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/__init__.py +0 -0
  94. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
  95. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/command_bar.py +0 -0
  96. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/daemon_status_bar.py +0 -0
  97. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
  98. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
  99. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/job_summary.py +0 -0
  100. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
  101. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/preview_pane.py +0 -0
  102. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
  103. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/status_timeline.py +0 -0
  104. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
  105. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web/__init__.py +0 -0
  106. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web/templates/analytics.html +0 -0
  107. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web/templates/dashboard.html +0 -0
  108. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_api.py +0 -0
  109. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_chartjs.py +0 -0
  110. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_control_api.py +0 -0
  111. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_server.py +0 -0
  112. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_server_runner.py +0 -0
  113. {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_templates.py +0 -0
  114. {overcode-0.3.2 → overcode-0.3.3}/src/overcode.egg-info/SOURCES.txt +0 -0
  115. {overcode-0.3.2 → overcode-0.3.3}/src/overcode.egg-info/dependency_links.txt +0 -0
  116. {overcode-0.3.2 → overcode-0.3.3}/src/overcode.egg-info/entry_points.txt +0 -0
  117. {overcode-0.3.2 → overcode-0.3.3}/src/overcode.egg-info/requires.txt +0 -0
  118. {overcode-0.3.2 → overcode-0.3.3}/src/overcode.egg-info/top_level.txt +0 -0
  119. {overcode-0.3.2 → overcode-0.3.3}/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.3.2
3
+ Version: 0.3.3
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.3.2"
7
+ version = "0.3.3"
8
8
  description = "A supervisor for managing multiple Claude Code instances in tmux"
9
9
  authors = [
10
10
  {name = "Mike Bond"}
@@ -137,6 +137,23 @@ class ClaudeConfigEditor:
137
137
  results.append((event, cmd))
138
138
  return results
139
139
 
140
+ # ----- Hook detection -----
141
+
142
+ @staticmethod
143
+ def are_overcode_hooks_installed() -> bool:
144
+ """Check if core overcode hooks are installed at user level.
145
+
146
+ Requires at least UserPromptSubmit, Stop, and PostToolUse hooks
147
+ to consider hooks mode viable.
148
+ """
149
+ editor = ClaudeConfigEditor.user_level()
150
+ try:
151
+ editor.load()
152
+ except (ValueError, FileNotFoundError):
153
+ return False
154
+ core_events = ("UserPromptSubmit", "Stop", "PostToolUse")
155
+ return all(editor.has_hook(event, "overcode hook-handler") for event in core_events)
156
+
140
157
  # ----- Permission management -----
141
158
 
142
159
  def add_permission(self, tool_pattern: str) -> bool:
@@ -970,9 +970,10 @@ def show(
970
970
  else:
971
971
  # Daemon not running — fall back to direct detection
972
972
  from ..status_detector_factory import create_status_detector
973
+ from ..settings import resolve_detection_mode
973
974
  detector = create_status_detector(
974
975
  session,
975
- strategy="hooks" if sess.hook_status_detection else "polling",
976
+ strategy=resolve_detection_mode(session),
976
977
  )
977
978
  status, activity, pane_content_raw = detector.detect_status(sess)
978
979
 
@@ -491,11 +491,10 @@ def tmux_layout(
491
491
  rprint(" tmux set -g terminal-features ''")
492
492
  return
493
493
 
494
- # Verify agents session exists
494
+ # Create agents session if it doesn't exist (#397)
495
495
  if not _tmux_check("has-session", "-t", session):
496
- rprint(f"[red]No tmux session '{session}' found.[/red]")
497
- rprint(f"[dim]Launch some agents first, or create it: tmux new-session -d -s {session}[/dim]")
498
- raise typer.Exit(1)
496
+ rprint(f"[dim]Creating tmux session '{session}'...[/dim]")
497
+ _tmux("new-session", "-d", "-s", session)
499
498
 
500
499
  # --- Toggle key selection (runs if not yet configured) ---
501
500
  from ..config import get_tmux_toggle_key, set_tmux_toggle_key
@@ -33,8 +33,12 @@ CLAUDE_PROJECTS_PATH = Path.home() / ".claude" / "projects"
33
33
 
34
34
  # Model name → context window size in tokens.
35
35
  # Default 200K for unknown models. Update as new models ship.
36
+ # Claude Code with 1M context reports the same model ID — we detect
37
+ # the actual context size from token counts at runtime and update here
38
+ # for the models known to support extended context.
36
39
  MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
37
- "claude-opus-4-6": 200_000,
40
+ "claude-opus-4-6": 1_000_000,
41
+ "claude-sonnet-4-6": 1_000_000,
38
42
  "claude-sonnet-4-5-20250929": 200_000,
39
43
  "claude-haiku-4-5-20251001": 200_000,
40
44
  "claude-3-5-sonnet-20241022": 200_000,
@@ -45,6 +49,32 @@ MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
45
49
  }
46
50
  DEFAULT_CONTEXT_WINDOW = 200_000
47
51
 
52
+ # Model ID → human-readable short name for display
53
+ MODEL_SHORT_NAMES: Dict[str, str] = {
54
+ "claude-opus-4-6": "Op4.6",
55
+ "claude-sonnet-4-6": "Sn4.6",
56
+ "claude-sonnet-4-5-20250929": "Sn4.5",
57
+ "claude-haiku-4-5-20251001": "Hk4.5",
58
+ "claude-3-5-sonnet-20241022": "Sn3.5",
59
+ "claude-3-5-haiku-20241022": "Hk3.5",
60
+ "claude-3-opus-20240229": "Op3",
61
+ "claude-3-sonnet-20240229": "Sn3",
62
+ "claude-3-haiku-20240307": "Hk3",
63
+ }
64
+
65
+
66
+ def model_short_name(model: Optional[str]) -> str:
67
+ """Return a short display name for a model ID.
68
+
69
+ Examples:
70
+ "claude-opus-4-6" → "Op4.6"
71
+ "claude-haiku-4-5-20251001" → "Hk4.5"
72
+ "unknown-model" → "unknown-model"
73
+ """
74
+ if not model:
75
+ return ""
76
+ return MODEL_SHORT_NAMES.get(model, model)
77
+
48
78
 
49
79
  def model_context_window(model: Optional[str]) -> int:
50
80
  """Return the context window size for a given model name.
@@ -25,8 +25,11 @@ logger = logging.getLogger(__name__)
25
25
  # All hooks that overcode installs
26
26
  OVERCODE_HOOKS: list[tuple[str, str]] = [
27
27
  ("UserPromptSubmit", "overcode hook-handler"),
28
+ ("PreToolUse", "overcode hook-handler"),
28
29
  ("PostToolUse", "overcode hook-handler"),
30
+ ("PostToolUseFailure", "overcode hook-handler"),
29
31
  ("Stop", "overcode hook-handler"),
32
+ ("StopFailure", "overcode hook-handler"),
30
33
  ("PermissionRequest", "overcode hook-handler"),
31
34
  ("SessionEnd", "overcode hook-handler"),
32
35
  ]
@@ -1,30 +1,33 @@
1
1
  """
2
2
  Hook-based status detector for Claude sessions (#5).
3
3
 
4
- Reads hook state files written by Claude Code hooks (UserPromptSubmit, Stop,
5
- PermissionRequest, PostToolUse, SessionEnd) to determine agent status without
6
- tmux pane scraping.
4
+ Reads hook state files written by Claude Code hooks (UserPromptSubmit,
5
+ PreToolUse, PostToolUse, Stop, PermissionRequest, SessionEnd) to determine
6
+ agent status without tmux pane scraping.
7
7
 
8
8
  Design:
9
- - Hook state is authoritative for STATUS when fresh (< stale_threshold_seconds).
10
- - Pane content is read for enrichment (activity description) but never for status.
11
- - Falls back to PollingStatusDetector when hook state is missing or stale.
12
- - One source of truth at any given moment: either hooks or polling, never both.
9
+ - Hook state is the sole authority for status. No polling fallback.
10
+ - Running-state hooks (UserPromptSubmit, PreToolUse, PostToolUse) are
11
+ trusted indefinitely Claude will send Stop or SessionEnd when done.
12
+ - Pane content is read only for activity enrichment, never for status.
13
13
  """
14
14
 
15
15
  import json
16
16
  import os
17
+ import subprocess
17
18
  import time
18
19
  from pathlib import Path
19
- from typing import Optional, Tuple, TYPE_CHECKING
20
+ from typing import Dict, Optional, Tuple, TYPE_CHECKING
20
21
 
21
22
  from .status_constants import (
22
23
  DEFAULT_CAPTURE_LINES,
24
+ STATUS_CAPTURE_LINES,
23
25
  STATUS_RUNNING,
24
26
  STATUS_BUSY_SLEEPING,
25
27
  STATUS_WAITING_USER,
26
28
  STATUS_WAITING_OVERSIGHT,
27
29
  STATUS_TERMINATED,
30
+ STATUS_ERROR,
28
31
  )
29
32
  from .status_patterns import (
30
33
  is_sleep_command,
@@ -43,14 +46,15 @@ if TYPE_CHECKING:
43
46
  # Hook event → status mapping
44
47
  _HOOK_STATUS_MAP = {
45
48
  "UserPromptSubmit": STATUS_RUNNING,
49
+ "PreToolUse": STATUS_RUNNING,
50
+ "PostToolUse": STATUS_RUNNING,
51
+ "PostToolUseFailure": STATUS_RUNNING, # Tool failed but agent is still working
46
52
  "Stop": STATUS_WAITING_USER,
53
+ "StopFailure": STATUS_ERROR, # API error ended the turn (purple indicator)
47
54
  "PermissionRequest": STATUS_WAITING_USER,
48
- "PostToolUse": STATUS_RUNNING,
49
55
  "SessionEnd": STATUS_TERMINATED,
50
56
  }
51
57
 
52
- DEFAULT_STALE_THRESHOLD = 120 # seconds
53
-
54
58
 
55
59
  class HookStatusDetector:
56
60
  """Detects session status from hook state files.
@@ -62,10 +66,11 @@ class HookStatusDetector:
62
66
  {
63
67
  "event": "UserPromptSubmit",
64
68
  "timestamp": 1234567890.123,
65
- "tool_name": "Read" // optional, for PostToolUse
69
+ "tool_name": "Read" // optional, for PostToolUse/PreToolUse
66
70
  }
67
71
 
68
- When hook state is missing or stale (>120s), falls back to polling.
72
+ No polling fallback. If no hook state file exists, the detector checks
73
+ whether the tmux window is alive and returns a sensible default.
69
74
  """
70
75
 
71
76
  # Re-export status constants for backward compat (same interface as PollingStatusDetector)
@@ -79,14 +84,17 @@ class HookStatusDetector:
79
84
  tmux: "TmuxInterface" = None,
80
85
  patterns: "StatusPatterns" = None,
81
86
  state_dir: Optional[Path] = None,
82
- stale_threshold_seconds: float = DEFAULT_STALE_THRESHOLD,
87
+ # Legacy params kept for API compat — ignored
88
+ stale_threshold_seconds: float = 0,
83
89
  polling_fallback=None,
84
90
  ):
85
91
  self.tmux_session = tmux_session
86
92
  self.capture_lines = DEFAULT_CAPTURE_LINES
87
93
  self._tmux = tmux
88
94
  self._patterns = patterns
89
- self._stale_threshold = stale_threshold_seconds
95
+ # Diagnostic phase tracking (same interface as PollingStatusDetector)
96
+ self._last_detect_phase: Dict[str, str] = {}
97
+ self._content_changed: Dict[str, bool] = {}
90
98
 
91
99
  # Resolve state directory — must match hook_handler._get_hook_state_path()
92
100
  if state_dir is not None:
@@ -98,19 +106,6 @@ class HookStatusDetector:
98
106
  else:
99
107
  self._state_dir = Path.home() / ".overcode" / "sessions" / tmux_session
100
108
 
101
- # Shared or lazy-created polling fallback. When the dispatcher injects
102
- # its polling detector, hash state and phase tracking are shared.
103
- self._polling_fallback = polling_fallback
104
-
105
- def _get_polling_fallback(self):
106
- """Return (or lazily create) the polling fallback detector."""
107
- if self._polling_fallback is None:
108
- from .status_detector import PollingStatusDetector
109
- self._polling_fallback = PollingStatusDetector(
110
- self.tmux_session, tmux=self._tmux, patterns=self._patterns
111
- )
112
- return self._polling_fallback
113
-
114
109
  def _hook_state_path(self, session_name: str) -> Path:
115
110
  """Get the hook state file path for a session."""
116
111
  return self._state_dir / f"hook_state_{session_name}.json"
@@ -120,7 +115,8 @@ class HookStatusDetector:
120
115
 
121
116
  Returns:
122
117
  Parsed dict with 'event', 'timestamp', optional 'tool_name',
123
- or None if file is missing, corrupt, or stale.
118
+ or None if file is missing or corrupt.
119
+ No staleness check — running hooks are trusted indefinitely.
124
120
  """
125
121
  path = self._hook_state_path(session_name)
126
122
  try:
@@ -135,58 +131,61 @@ class HookStatusDetector:
135
131
  if "event" not in data or "timestamp" not in data:
136
132
  return None
137
133
 
138
- # Check staleness
134
+ # Validate timestamp is a number
139
135
  try:
140
- ts = float(data["timestamp"])
136
+ float(data["timestamp"])
141
137
  except (TypeError, ValueError):
142
138
  return None
143
139
 
144
- age = time.time() - ts
145
- if age > self._stale_threshold:
146
- return None
147
-
148
140
  return data
149
141
 
150
142
  def get_pane_content(self, window: str, num_lines: int = 0) -> Optional[str]:
151
- """Get pane content via the polling detector's tmux interface."""
152
- fallback = self._get_polling_fallback()
153
- fallback.capture_lines = self.capture_lines
154
- return fallback.get_pane_content(window, num_lines)
143
+ """Get pane content via tmux capture-pane."""
144
+ if self._tmux:
145
+ return self._tmux.capture_pane(
146
+ self.tmux_session, window,
147
+ lines=num_lines or self.capture_lines
148
+ )
149
+ # Direct tmux subprocess fallback
150
+ lines_arg = num_lines or self.capture_lines
151
+ try:
152
+ result = subprocess.run(
153
+ ["tmux", "capture-pane", "-t", f"{self.tmux_session}:{window}",
154
+ "-p", "-S", f"-{lines_arg}"],
155
+ capture_output=True, text=True, timeout=5,
156
+ )
157
+ return result.stdout if result.returncode == 0 else None
158
+ except (subprocess.TimeoutExpired, OSError):
159
+ return None
155
160
 
156
161
  def detect_status(self, session: "Session", num_lines: int = 0) -> Tuple[str, str, str]:
157
162
  """Detect session status using hook state files.
158
163
 
159
- When fresh hook state exists, uses that for status determination.
160
- Still reads pane content for activity enrichment.
161
- Falls back to full polling when hook state is missing or stale.
164
+ No polling fallback. When no hook state exists, checks if the
165
+ tmux window is alive and returns a sensible default.
162
166
 
163
167
  Returns:
164
168
  Tuple of (status, current_activity, pane_content)
165
169
  """
166
170
  hook_state = self._read_hook_state(session.name)
167
171
 
168
- # Record phase on the shared polling detector for diagnostics
169
- polling = self._get_polling_fallback()
170
-
171
172
  if hook_state is None:
172
- # No hook state or stale full polling fallback
173
- # Phase tracking is set by the polling detector itself.
174
- return polling.detect_status(session, num_lines=num_lines)
175
-
176
- # Hook state is fresh → use it for status
173
+ # No hook state file agent hasn't triggered a hook yet.
174
+ # Check if the window exists to distinguish fresh-start from terminated.
175
+ pane_content = self.get_pane_content(session.tmux_window, num_lines=num_lines)
176
+ if pane_content is None:
177
+ self._last_detect_phase[session.id] = "hook:no_state+no_window"
178
+ return STATUS_TERMINATED, "Window no longer exists", ""
179
+ # Window alive, no hooks yet — assume waiting for input
180
+ self._last_detect_phase[session.id] = "hook:no_state"
181
+ return STATUS_WAITING_USER, "Waiting for first hook event", pane_content
182
+
183
+ # Hook state exists — use it for status
177
184
  event = hook_state.get("event", "")
178
185
 
179
186
  if event == "SessionEnd":
180
- # SessionEnd fires both on actual exit AND on /clear.
181
- # We know Claude reported ending, so do a targeted pane check:
182
- # - Shell prompt on last line → actual exit → TERMINATED
183
- # - Claude's prompt (› or >) on last line → /clear → fall back to polling
184
- #
185
- # We can't use the full polling _is_shell_prompt() here because it
186
- # rejects shell prompts when Claude's ⏺ output is in the last 5 lines,
187
- # which is always the case right after exit.
188
- polling._last_detect_phase[session.id] = f"hook:SessionEnd"
189
- return self._detect_session_end_status(session)
187
+ self._last_detect_phase[session.id] = "hook:SessionEnd"
188
+ return self._detect_session_end_status(session, num_lines)
190
189
 
191
190
  status = _HOOK_STATUS_MAP.get(event, STATUS_WAITING_USER)
192
191
 
@@ -200,7 +199,7 @@ class HookStatusDetector:
200
199
  # Check for busy-sleeping: agent is "running" but executing a sleep command (#289)
201
200
  sleep_dur = None
202
201
  if status == STATUS_RUNNING:
203
- sleep_dur = self._find_sleep_duration(pane_content, hook_state)
202
+ sleep_dur = self._find_sleep_duration(hook_state)
204
203
  if sleep_dur is not None:
205
204
  status = STATUS_BUSY_SLEEPING
206
205
 
@@ -212,24 +211,19 @@ class HookStatusDetector:
212
211
  activity = f"Sleeping {format_duration(sleep_dur)}" if sleep_dur else "Sleeping"
213
212
 
214
213
  # Record hook phase for diagnostics
215
- polling._last_detect_phase[session.id] = f"hook:{event}"
214
+ self._last_detect_phase[session.id] = f"hook:{event}"
216
215
 
217
216
  return status, activity, pane_content
218
217
 
219
- def _detect_session_end_status(self, session: "Session") -> Tuple[str, str, str]:
218
+ def _detect_session_end_status(self, session: "Session", num_lines: int = 0) -> Tuple[str, str, str]:
220
219
  """Determine status after a SessionEnd hook event.
221
220
 
222
221
  SessionEnd fires both on actual exit AND on /clear. We distinguish
223
222
  by checking the last line of the pane:
224
223
  - Shell prompt (user@host path %) → actual exit → TERMINATED
225
- - Claude's prompt (› or >) → /clear was used → fall back to polling
226
-
227
- Unlike the full polling _is_shell_prompt(), this does NOT reject shell
228
- prompts when Claude's ⏺ output appears in nearby lines — that output
229
- is always present right after exit and is irrelevant once we know
230
- SessionEnd fired.
224
+ - Claude's prompt (› or >) → /clear was used → WAITING_USER
231
225
  """
232
- pane_content = self.get_pane_content(session.tmux_window) or ""
226
+ pane_content = self.get_pane_content(session.tmux_window, num_lines=num_lines) or ""
233
227
  clean = strip_ansi(pane_content)
234
228
  lines = [l.strip() for l in clean.strip().split('\n') if l.strip()]
235
229
 
@@ -241,45 +235,61 @@ class HookStatusDetector:
241
235
  if is_shell_prompt(last_line):
242
236
  return STATUS_TERMINATED, "Claude exited - shell prompt", pane_content
243
237
 
244
- # No shell prompt → likely /clear, fall back to full polling
245
- return self._get_polling_fallback().detect_status(session)
246
-
247
- def _find_sleep_duration(self, pane_content: str, hook_state: dict) -> int | None:
248
- """Find sleep duration from pane content or hook state (#289).
238
+ # No shell prompt → likely /clear, agent is waiting for input
239
+ return STATUS_WAITING_USER, "Waiting for user input", pane_content
249
240
 
250
- Checks two signals:
251
- 1. Pane content for sleep commands (e.g., "Running Bash("sleep 30")")
252
- 2. Hook state tool_input for sleep commands (from PostToolUse)
241
+ def _find_sleep_duration(self, hook_state: dict) -> int | None:
242
+ """Find sleep duration from hook state's tool_input (#289).
253
243
 
254
- Returns:
255
- Sleep duration in seconds, or None if not sleeping.
256
- A return value of 0 means sleeping but duration unknown.
244
+ PreToolUse and PostToolUse include tool_input with the Bash command.
245
+ Parse the command directly no pane scraping needed.
257
246
  """
258
- # Check pane content for sleep patterns
259
- clean = strip_ansi(pane_content)
260
- for line in clean.strip().split('\n')[-10:]:
261
- dur = extract_sleep_duration(line)
262
- if dur is not None:
263
- return dur
264
-
265
- # Check tool_input from hook state (PostToolUse includes tool_input)
266
247
  tool_input = hook_state.get("tool_input")
267
248
  if isinstance(tool_input, dict):
268
249
  command = tool_input.get("command", "")
269
250
  dur = extract_sleep_duration(command)
270
251
  if dur is not None:
271
252
  return dur
272
-
273
253
  return None
274
254
 
255
+ @staticmethod
256
+ def _parse_bash_activity(hook_state: dict) -> str | None:
257
+ """Parse a Bash tool_input command into a concise activity string.
258
+
259
+ Returns a human-readable summary of what the Bash command does,
260
+ or None if the command isn't parseable or isn't Bash.
261
+ """
262
+ if hook_state.get("tool_name") != "Bash":
263
+ return None
264
+ tool_input = hook_state.get("tool_input")
265
+ if not isinstance(tool_input, dict):
266
+ return None
267
+ command = tool_input.get("command", "")
268
+ if not command:
269
+ return None
270
+ # Truncate long commands
271
+ if len(command) > 80:
272
+ command = command[:77] + "..."
273
+ return f"Bash: {command}"
274
+
275
275
  def _build_activity(self, event: str, hook_state: dict, pane_content: str, session: "Session" = None) -> str:
276
276
  """Build an activity description from hook event and pane content."""
277
- if event == "PostToolUse":
277
+ if event in ("PreToolUse", "PostToolUse"):
278
+ # For Bash, show the actual command for better visibility
279
+ bash_activity = self._parse_bash_activity(hook_state)
280
+ if bash_activity:
281
+ return bash_activity
278
282
  tool_name = hook_state.get("tool_name", "")
279
283
  if tool_name:
280
284
  return f"Using {tool_name}"
281
285
  return "Running tool"
282
286
 
287
+ if event == "PostToolUseFailure":
288
+ tool_name = hook_state.get("tool_name", "")
289
+ if tool_name:
290
+ return f"Tool failed: {tool_name}"
291
+ return "Tool failed"
292
+
283
293
  if event == "UserPromptSubmit":
284
294
  return "Processing prompt"
285
295
 
@@ -288,6 +298,9 @@ class HookStatusDetector:
288
298
  return "Waiting for oversight report"
289
299
  return "Waiting for user input"
290
300
 
301
+ if event == "StopFailure":
302
+ return "API error"
303
+
291
304
  if event == "PermissionRequest":
292
305
  return "Permission: approval required"
293
306
 
@@ -276,9 +276,12 @@ class MonitorDaemon:
276
276
 
277
277
  # Dependencies (allow injection for testing)
278
278
  self.session_manager = session_manager or SessionManager()
279
+ from .settings import resolve_detection_mode
280
+ detection_mode = resolve_detection_mode(tmux_session)
279
281
  self.detector = StatusDetectorDispatcher(
280
282
  tmux_session,
281
283
  polling_detector=status_detector,
284
+ mode=detection_mode,
282
285
  )
283
286
 
284
287
  # Presence tracking (graceful degradation)
@@ -299,6 +302,7 @@ class MonitorDaemon:
299
302
  self.previous_states: Dict[str, str] = {}
300
303
  self.last_state_times: Dict[str, datetime] = {}
301
304
  self.operation_start_times: Dict[str, datetime] = {}
305
+ self._last_hook_phases: Dict[str, str] = {} # session_id → last logged phase
302
306
 
303
307
  # Stats sync throttling - None forces immediate sync on first loop
304
308
  self._last_stats_sync: Optional[datetime] = None
@@ -505,6 +509,19 @@ class MonitorDaemon:
505
509
  ):
506
510
  continue
507
511
 
512
+ # In hooks mode, double-check the hook state right before sending.
513
+ # The agent may have started working between the daemon's last
514
+ # detection and now (e.g., user typed something). Sending a
515
+ # heartbeat into a running session corrupts the prompt (#374).
516
+ if self.detector.mode == "hooks":
517
+ hook_detector = self.detector.hooks
518
+ hook_state = hook_detector._read_hook_state(session.name)
519
+ if hook_state:
520
+ event = hook_state.get("event", "")
521
+ if event in ("UserPromptSubmit", "PreToolUse", "PostToolUse"):
522
+ self.log.info(f"[{session.name}] Heartbeat skipped (hook says {event})")
523
+ continue
524
+
508
525
  # Send the heartbeat instruction
509
526
  if send_text_to_tmux_window(
510
527
  session.tmux_session,
@@ -869,6 +886,12 @@ class MonitorDaemon:
869
886
 
870
887
  def _tick(self, now: datetime) -> None:
871
888
  """Execute one monitoring loop iteration."""
889
+ # Re-read detection mode in case the TUI toggled it via K hotkey
890
+ from .settings import resolve_detection_mode
891
+ current_mode = resolve_detection_mode(self.tmux_session)
892
+ if self.detector.mode != current_mode:
893
+ self.detector.mode = current_mode
894
+ self.log.info(f"Detection mode changed to: {current_mode}")
872
895
  sessions = [s for s in self.session_manager.list_sessions()
873
896
  if s.tmux_session == self.tmux_session]
874
897
  if not self._legacy_windows_migrated:
@@ -926,6 +949,9 @@ class MonitorDaemon:
926
949
  # Detect status - dispatches per-session via dispatcher (#5)
927
950
  status, activity, pane_content = self.detector.detect_status(session)
928
951
 
952
+ # Log hook events when they change (diagnostic visibility)
953
+ self._log_hook_event(session, status, activity)
954
+
929
955
  # Extract PR number from pane content
930
956
  if pane_content:
931
957
  pr = extract_pr_number(pane_content)
@@ -1005,6 +1031,26 @@ class MonitorDaemon:
1005
1031
 
1006
1032
  return session_states, all_waiting_user
1007
1033
 
1034
+ def _log_hook_event(self, session, status: str, activity: str) -> None:
1035
+ """Log hook events to the daemon log when they change.
1036
+
1037
+ Reads the detector's _last_detect_phase diagnostic and logs
1038
+ when a new hook event fires for an agent, showing the agent name,
1039
+ hook event, and resulting status.
1040
+ """
1041
+ # Get the phase from whichever detector is active
1042
+ detector = self.detector.hooks if self.detector.mode == "hooks" else self.detector.polling
1043
+ phases = getattr(detector, '_last_detect_phase', {})
1044
+ current_phase = phases.get(session.id, "")
1045
+
1046
+ prev_phase = self._last_hook_phases.get(session.id)
1047
+ if current_phase and current_phase != prev_phase:
1048
+ self._last_hook_phases[session.id] = current_phase
1049
+ # Format: "agent-name hook:PostToolUse → running (Using Read)"
1050
+ self.log.info(
1051
+ f"{session.name} {current_phase} → {status} ({activity})"
1052
+ )
1053
+
1008
1054
  def _compute_subtree_costs(self, session_states):
1009
1055
  """Compute subtree cost (self + all descendants) for each parent agent."""
1010
1056
  by_name = {s.name: s for s in session_states}
@@ -407,6 +407,38 @@ def get_web_server_port_path(session: str) -> Path:
407
407
  return get_session_dir(session) / "web_server.port"
408
408
 
409
409
 
410
+ def get_detection_mode_path(session: str) -> Path:
411
+ """Get the detection mode file path for a specific session."""
412
+ return get_session_dir(session) / "detection_mode"
413
+
414
+
415
+ def read_detection_mode(session: str) -> str:
416
+ """Read the global detection mode ('hooks' or 'polling').
417
+
418
+ Returns 'auto' if no explicit mode is set (auto-detect on startup).
419
+ """
420
+ try:
421
+ return get_detection_mode_path(session).read_text().strip()
422
+ except (FileNotFoundError, IOError):
423
+ return "auto"
424
+
425
+
426
+ def write_detection_mode(session: str, mode: str) -> None:
427
+ """Write the global detection mode for daemon/TUI synchronisation."""
428
+ path = get_detection_mode_path(session)
429
+ path.parent.mkdir(parents=True, exist_ok=True)
430
+ path.write_text(mode)
431
+
432
+
433
+ def resolve_detection_mode(session: str) -> str:
434
+ """Resolve 'auto' to 'hooks' or 'polling' based on hook installation."""
435
+ mode = read_detection_mode(session)
436
+ if mode in ("hooks", "polling"):
437
+ return mode
438
+ from .claude_config import ClaudeConfigEditor
439
+ return "hooks" if ClaudeConfigEditor.are_overcode_hooks_installed() else "polling"
440
+
441
+
410
442
  def get_diagnostics_dir(session: str) -> Path:
411
443
  """Get the diagnostics directory for a specific session."""
412
444
  return get_session_dir(session) / "diagnostics"
@@ -172,12 +172,18 @@ class PollingStatusDetector:
172
172
  # prompt detection.
173
173
  #
174
174
  # HOWEVER: Claude Code always renders the ❯ prompt as UI chrome, even
175
- # while actively working (#393). When active indicators like
176
- # "esc to interrupt" are present, the prompt is just decoration
177
- # trust the active indicators over the prompt char.
175
+ # while actively working (#393). When Claude IS active, "esc to
176
+ # interrupt" appears at the bottom of the pane either as a
177
+ # standalone line or appended to the ⏵⏵ permissions status bar.
178
+ # We check only the last 2 lines for this, because historical
179
+ # thinking output (✻ Twisting…, ⎿ Running…) persists in scrollback
180
+ # above the prompt and would false-match general active indicators.
178
181
  if content_changed:
179
- has_active_indicator = matches_any(last_few, self.patterns.active_indicators)
180
- if not has_active_indicator:
182
+ status_bar_active = any(
183
+ "esc to interrupt" in line.lower()
184
+ for line in last_lines[-2:]
185
+ )
186
+ if not status_bar_active:
181
187
  prompt_result = self._detect_user_prompt(last_lines, content)
182
188
  if prompt_result is not None:
183
189
  self._last_detect_phase[session.id] = "P5+P12:prompt_override"
@@ -351,7 +357,21 @@ class PollingStatusDetector:
351
357
  Distinguishes:
352
358
  - Empty prompt `>` or `› ` = waiting for user input
353
359
  - User input `> some text` with no Claude response = stalled
360
+
361
+ Claude Code always renders the ❯ prompt as UI chrome, even while
362
+ actively working (#393). When active indicators are present in the
363
+ last lines, the prompt is just decoration — skip prompt detection.
354
364
  """
365
+ # If "esc to interrupt" appears at the bottom of the pane, Claude
366
+ # is actively working — the prompt and user input are just UI chrome.
367
+ # Only check the last 2 lines to avoid matching historical thinking
368
+ # output (✻, Running…) that persists in scrollback (#393).
369
+ if any(
370
+ "esc to interrupt" in line.lower()
371
+ for line in last_lines[-2:]
372
+ ):
373
+ return None
374
+
355
375
  # Check for empty prompt or autocomplete suggestion
356
376
  for line in last_lines[-4:]:
357
377
  stripped = line.strip()