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.
- {overcode-0.3.2/src/overcode.egg-info → overcode-0.3.3}/PKG-INFO +1 -1
- {overcode-0.3.2 → overcode-0.3.3}/pyproject.toml +1 -1
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/claude_config.py +17 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/agent.py +2 -1
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/split.py +3 -4
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/history_reader.py +31 -1
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/hook_handler.py +3 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/hook_status_detector.py +104 -91
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/monitor_daemon.py +46 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/settings.py +32 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/status_detector.py +25 -5
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/status_detector_factory.py +26 -16
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/summary_columns.py +13 -4
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui.py +33 -12
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui.tcss +1 -1
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/session.py +22 -39
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/daemon_panel.py +41 -32
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/help_overlay.py +1 -1
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/session_summary.py +1 -1
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/usage_monitor.py +2 -2
- {overcode-0.3.2 → overcode-0.3.3/src/overcode.egg-info}/PKG-INFO +1 -1
- {overcode-0.3.2 → overcode-0.3.3}/LICENSE +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/MANIFEST.in +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/README.md +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/setup.cfg +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/__init__.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/agent_scanner.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/bundled_skills.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/claude_pid.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/__init__.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/__main__.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/_shared.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/budget.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/config.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/daemon.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/hooks.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/jobs.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/monitoring.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/perms.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/sister.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/cli/skills.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/config.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/daemon_claude_skill.md +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/daemon_logging.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/daemon_utils.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/data_export.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/dependency_check.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/duration.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/exceptions.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/follow_mode.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/implementations.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/interfaces.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/job_launcher.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/job_manager.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/launcher.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/logging_config.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/mocks.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/monitor_daemon_core.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/monitor_daemon_state.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/notifier.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/pid_utils.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/presence_logger.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/protocols.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/session_manager.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/sister_controller.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/sister_poller.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/standing_instructions.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/status_constants.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/status_history.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/status_patterns.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/summarizer_client.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/summarizer_component.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/summary_groups.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/supervisor_daemon.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/supervisor_daemon_core.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/supervisor_layout.sh +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/testing/__init__.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/testing/renderer.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/testing/tmux_driver.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/testing/tui_eye.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/testing/tui_eye_skill.md +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/time_context.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tmux_manager.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tmux_utils.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/__init__.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/daemon.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/input.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/navigation.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_actions/view.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_helpers.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_logic.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_render.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/__init__.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/command_bar.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/daemon_status_bar.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/job_summary.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/preview_pane.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/status_timeline.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web/__init__.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web/templates/analytics.html +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web/templates/dashboard.html +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_api.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_chartjs.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_control_api.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_server.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_server_runner.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode/web_templates.py +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode.egg-info/SOURCES.txt +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode.egg-info/dependency_links.txt +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode.egg-info/entry_points.txt +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode.egg-info/requires.txt +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/src/overcode.egg-info/top_level.txt +0 -0
- {overcode-0.3.2 → overcode-0.3.3}/tests/test_e2e_multi_agent_jokes.py +0 -0
|
@@ -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=
|
|
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
|
-
#
|
|
494
|
+
# Create agents session if it doesn't exist (#397)
|
|
495
495
|
if not _tmux_check("has-session", "-t", session):
|
|
496
|
-
rprint(f"[
|
|
497
|
-
|
|
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":
|
|
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,
|
|
5
|
-
|
|
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
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
134
|
+
# Validate timestamp is a number
|
|
139
135
|
try:
|
|
140
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
160
|
-
|
|
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
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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(
|
|
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
|
-
|
|
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 →
|
|
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,
|
|
245
|
-
return
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
|
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
|
|
176
|
-
# "
|
|
177
|
-
#
|
|
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
|
-
|
|
180
|
-
|
|
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()
|