claude-dev-env 1.31.0 → 1.32.0

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.
@@ -0,0 +1,148 @@
1
+ """Unit tests for windows_rmtree_blocker PreToolUse hook."""
2
+
3
+ import importlib.util
4
+ import json
5
+ import io
6
+ import pathlib
7
+ import sys
8
+ from contextlib import redirect_stdout
9
+
10
+ _HOOK_DIR = pathlib.Path(__file__).parent
11
+ if str(_HOOK_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_HOOK_DIR))
13
+
14
+ hook_spec = importlib.util.spec_from_file_location(
15
+ "windows_rmtree_blocker",
16
+ _HOOK_DIR / "windows_rmtree_blocker.py",
17
+ )
18
+ assert hook_spec is not None
19
+ assert hook_spec.loader is not None
20
+ hook_module = importlib.util.module_from_spec(hook_spec)
21
+ hook_spec.loader.exec_module(hook_module)
22
+
23
+ payload_contains_unsafe_rmtree = hook_module.payload_contains_unsafe_rmtree
24
+ extract_payload_text = hook_module.extract_payload_text
25
+
26
+
27
+ def test_detects_basic_ignore_errors_call() -> None:
28
+ assert payload_contains_unsafe_rmtree(
29
+ "shutil.rmtree(target_path, ignore_errors=True)"
30
+ )
31
+
32
+
33
+ def test_detects_call_with_path_first_then_ignore_errors() -> None:
34
+ assert payload_contains_unsafe_rmtree(
35
+ 'shutil.rmtree(r"C:\\temp\\foo", ignore_errors=True)'
36
+ )
37
+
38
+
39
+ def test_detects_oneliner_python_dash_c_form() -> None:
40
+ bash_command = (
41
+ 'python -c "import shutil; '
42
+ "shutil.rmtree(r'<team_temp_dir>', ignore_errors=True)\""
43
+ )
44
+ assert payload_contains_unsafe_rmtree(bash_command)
45
+
46
+
47
+ def test_detects_call_with_extra_whitespace() -> None:
48
+ assert payload_contains_unsafe_rmtree(
49
+ "shutil .rmtree (path, ignore_errors = True)"
50
+ )
51
+
52
+
53
+ def test_detects_call_split_across_lines() -> None:
54
+ multiline_code = "shutil.rmtree(\n target_path,\n ignore_errors=True,\n)"
55
+ assert payload_contains_unsafe_rmtree(multiline_code)
56
+
57
+
58
+ def test_allows_rmtree_with_onexc_handler() -> None:
59
+ safe_code = "shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)"
60
+ assert not payload_contains_unsafe_rmtree(safe_code)
61
+
62
+
63
+ def test_allows_rmtree_with_onerror_handler() -> None:
64
+ safe_code = "shutil.rmtree(target_path, onerror=_strip_read_only_and_retry)"
65
+ assert not payload_contains_unsafe_rmtree(safe_code)
66
+
67
+
68
+ def test_allows_bare_rmtree_call() -> None:
69
+ bare_call = "shutil.rmtree(target_path)"
70
+ assert not payload_contains_unsafe_rmtree(bare_call)
71
+
72
+
73
+ def test_allows_ignore_errors_false() -> None:
74
+ assert not payload_contains_unsafe_rmtree(
75
+ "shutil.rmtree(target_path, ignore_errors=False)"
76
+ )
77
+
78
+
79
+ def test_extract_payload_handles_write_content() -> None:
80
+ extracted = extract_payload_text("Write", {"content": "abc"})
81
+ assert extracted == "abc"
82
+
83
+
84
+ def test_extract_payload_handles_edit_new_string() -> None:
85
+ extracted = extract_payload_text("Edit", {"new_string": "abc"})
86
+ assert extracted == "abc"
87
+
88
+
89
+ def test_extract_payload_handles_bash_command() -> None:
90
+ extracted = extract_payload_text("Bash", {"command": "ls"})
91
+ assert extracted == "ls"
92
+
93
+
94
+ def test_extract_payload_returns_empty_for_unknown_tool() -> None:
95
+ extracted = extract_payload_text("OtherTool", {"content": "abc"})
96
+ assert extracted == ""
97
+
98
+
99
+ def _run_hook(hook_input: dict) -> tuple[str, int]:
100
+ captured = io.StringIO()
101
+ sys.stdin = io.StringIO(json.dumps(hook_input))
102
+ try:
103
+ with redirect_stdout(captured):
104
+ try:
105
+ hook_module.main()
106
+ except SystemExit as exit_signal:
107
+ exit_code = exit_signal.code or 0
108
+ finally:
109
+ sys.stdin = sys.__stdin__
110
+ return captured.getvalue(), exit_code
111
+
112
+
113
+ def test_main_blocks_unsafe_bash_command() -> None:
114
+ stdout_text, exit_code = _run_hook(
115
+ {
116
+ "tool_name": "Bash",
117
+ "tool_input": {
118
+ "command": (
119
+ 'python -c "import shutil; '
120
+ "shutil.rmtree(r'/tmp/x', ignore_errors=True)\""
121
+ )
122
+ },
123
+ }
124
+ )
125
+ assert exit_code == 0
126
+ response_payload = json.loads(stdout_text)
127
+ decision_block = response_payload["hookSpecificOutput"]
128
+ assert decision_block["permissionDecision"] == "deny"
129
+ assert "windows-rmtree" in decision_block["permissionDecisionReason"]
130
+
131
+
132
+ def test_main_passes_through_safe_write() -> None:
133
+ stdout_text, exit_code = _run_hook(
134
+ {
135
+ "tool_name": "Write",
136
+ "tool_input": {"content": "shutil.rmtree(path, onexc=handler)"},
137
+ }
138
+ )
139
+ assert exit_code == 0
140
+ assert stdout_text == ""
141
+
142
+
143
+ def test_main_passes_through_unrelated_tool() -> None:
144
+ stdout_text, exit_code = _run_hook(
145
+ {"tool_name": "Read", "tool_input": {"file_path": "/tmp/x"}}
146
+ )
147
+ assert exit_code == 0
148
+ assert stdout_text == ""
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block shutil.rmtree(..., ignore_errors=True).
3
+
4
+ shutil.rmtree on Windows raises PermissionError when it encounters a file carrying
5
+ the ReadOnly attribute (FILE_ATTRIBUTE_READONLY). With ignore_errors=True the failure
6
+ is silently swallowed and the tree stays on disk — cleanup looks successful but
7
+ pruned nothing. Linux never hits this because unlink on Linux only needs write on
8
+ the parent directory, not on the file itself. Tests run inside pytest's tmp_path
9
+ do not exercise the regression path because tmp dirs do not carry the attribute.
10
+
11
+ This hook scans Write/Edit content and Bash commands for the dangerous pattern and
12
+ blocks it with a corrective message pointing to the force_rmtree replacement.
13
+ """
14
+
15
+ import json
16
+ import re
17
+ import sys
18
+
19
+ _WRITE_EDIT_TOOL_NAMES = {"Write", "Edit"}
20
+ _BASH_TOOL_NAME = "Bash"
21
+
22
+ _RMTREE_IGNORE_ERRORS_PATTERN = re.compile(
23
+ r"shutil\s*\.\s*rmtree\s*\([^)]*\bignore_errors\s*=\s*True\b",
24
+ re.DOTALL,
25
+ )
26
+
27
+ _CORRECTIVE_MESSAGE = (
28
+ "BLOCKED [windows-rmtree]: shutil.rmtree(..., ignore_errors=True) silently "
29
+ "fails on Windows when a file carries the ReadOnly attribute "
30
+ "(FILE_ATTRIBUTE_READONLY). The PermissionError is swallowed and the tree "
31
+ "stays on disk -- cleanup looks successful but removes nothing. Linux is "
32
+ "unaffected because unlink only needs write on the parent directory.\n\n"
33
+ "Use a Windows-safe handler that strips the attribute and retries the "
34
+ "syscall:\n\n"
35
+ " import os\n"
36
+ " import shutil\n"
37
+ " import stat\n"
38
+ " import sys\n\n"
39
+ " def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n"
40
+ " try:\n"
41
+ " os.chmod(target_path, stat.S_IWRITE)\n"
42
+ " removal_function(target_path)\n"
43
+ " except OSError:\n"
44
+ " pass\n\n"
45
+ " def force_rmtree(target_path: str) -> None:\n"
46
+ " handler_kw = (\n"
47
+ ' {"onexc": _strip_read_only_and_retry}\n'
48
+ " if sys.version_info >= (3, 12)\n"
49
+ ' else {"onerror": _strip_read_only_and_retry}\n'
50
+ " )\n"
51
+ " try:\n"
52
+ " shutil.rmtree(target_path, **handler_kw)\n"
53
+ " except OSError:\n"
54
+ " pass\n\n"
55
+ "Two things to know about the handler:\n"
56
+ " - *_exc_info collapses the signature difference. onerror passes "
57
+ "(type, value, traceback); onexc (Python 3.12+) passes a single exception.\n"
58
+ " - removal_function is whichever syscall rmtree was attempting "
59
+ "(os.unlink for files, os.rmdir for dirs). Re-call it after chmod to finish "
60
+ "the work that originally failed.\n\n"
61
+ "See ~/.claude/rules/windows-filesystem-safe.md for full guidance."
62
+ )
63
+
64
+
65
+ def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
66
+ if not payload_text:
67
+ return False
68
+ return bool(_RMTREE_IGNORE_ERRORS_PATTERN.search(payload_text))
69
+
70
+
71
+ def extract_payload_text(tool_name: str, tool_input: dict) -> str:
72
+ if tool_name in _WRITE_EDIT_TOOL_NAMES:
73
+ return tool_input.get("content", "") or tool_input.get("new_string", "") or ""
74
+ if tool_name == _BASH_TOOL_NAME:
75
+ return tool_input.get("command", "") or ""
76
+ return ""
77
+
78
+
79
+ def main() -> None:
80
+ try:
81
+ hook_input = json.load(sys.stdin)
82
+ except json.JSONDecodeError:
83
+ sys.exit(0)
84
+
85
+ tool_name = hook_input.get("tool_name", "")
86
+ tool_input = hook_input.get("tool_input", {})
87
+
88
+ payload_text = extract_payload_text(tool_name, tool_input)
89
+
90
+ if not payload_contains_unsafe_rmtree(payload_text):
91
+ sys.exit(0)
92
+
93
+ deny_response = {
94
+ "hookSpecificOutput": {
95
+ "hookEventName": "PreToolUse",
96
+ "permissionDecision": "deny",
97
+ "permissionDecisionReason": _CORRECTIVE_MESSAGE,
98
+ }
99
+ }
100
+ print(json.dumps(deny_response))
101
+ sys.stdout.flush()
102
+ sys.exit(0)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -216,6 +216,19 @@ BWS_RUN_SEPARATOR: str = "--"
216
216
  BWS_RUN_SUBCOMMAND: str = "run"
217
217
  STOP_WRAPPER_EXTRACTOR_SCRIPT_NAME: str = "hook_log_extractor.py"
218
218
 
219
+ STOP_WRAPPER_DEBOUNCE_SECONDS: int = 60
220
+ STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE: str = str(
221
+ _resolve_claude_home_directory()
222
+ / "logs"
223
+ / "hooks"
224
+ / ".state"
225
+ / "stop_wrapper_last_run.txt"
226
+ )
227
+
228
+ WINDOWS_OS_NAME: str = "nt"
229
+ WINDOWS_DETACHED_PROCESS_FLAG: int = 0x00000008
230
+ WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG: int = 0x00000200
231
+
219
232
  LOCK_MAXIMUM_RETRY_COUNT: int = 30
220
233
  LOCK_RETRY_SLEEP_SECONDS: float = 0.1
221
234
 
@@ -0,0 +1,18 @@
1
+ """Configuration constants for the session_env_cleanup SessionStart hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+
8
+ SESSION_ENV_DIRECTORY = os.path.join(os.path.expanduser("~"), ".claude", "session-env")
9
+
10
+ SECONDS_PER_DAY = 24 * 60 * 60
11
+ STALE_AGE_DAYS = 7
12
+ STALE_AGE_SECONDS = STALE_AGE_DAYS * SECONDS_PER_DAY
13
+
14
+ RMTREE_ONEXC_PYTHON_VERSION = (3, 12)
15
+
16
+ SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
17
+
18
+ WINDOWS_PLATFORM_TAG = "win32"
@@ -21,6 +21,10 @@ from config.hook_log_extractor_constants import (
21
21
  QUERY_NAME_PATTERN,
22
22
  SENTINEL_INSERT_FAILURE_MESSAGE,
23
23
  SENTINEL_SELECT_FAILURE_MESSAGE,
24
+ STOP_WRAPPER_DEBOUNCE_SECONDS,
25
+ STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE,
26
+ WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG,
27
+ WINDOWS_DETACHED_PROCESS_FLAG,
24
28
  )
25
29
 
26
30
 
@@ -94,3 +98,26 @@ def test_resolver_falls_back_to_home_when_claude_home_is_whitespace(
94
98
  def test_lock_retry_constants_are_positive_and_bounded() -> None:
95
99
  assert LOCK_MAXIMUM_RETRY_COUNT > 0
96
100
  assert LOCK_RETRY_SLEEP_SECONDS > 0
101
+
102
+
103
+ def test_stop_wrapper_debounce_seconds_is_positive() -> None:
104
+ assert STOP_WRAPPER_DEBOUNCE_SECONDS > 0
105
+
106
+
107
+ def test_stop_wrapper_last_run_timestamp_file_is_under_claude_home() -> None:
108
+ expected_path = (
109
+ hook_log_extractor_constants._resolve_claude_home_directory()
110
+ / "logs"
111
+ / "hooks"
112
+ / ".state"
113
+ / "stop_wrapper_last_run.txt"
114
+ )
115
+ assert Path(STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE) == expected_path
116
+
117
+
118
+ def test_windows_creation_flags_are_distinct_nonzero_bits() -> None:
119
+ assert WINDOWS_DETACHED_PROCESS_FLAG > 0
120
+ assert WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG > 0
121
+ assert (
122
+ WINDOWS_DETACHED_PROCESS_FLAG & WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG
123
+ ) == 0
@@ -0,0 +1,55 @@
1
+ """Tests for session_env_cleanup_constants — behavioral checks on SESSION_ID_PATTERN."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _CONFIG_DIRECTORY = Path(__file__).resolve().parent
9
+ _HOOKS_ROOT = _CONFIG_DIRECTORY.parent
10
+ for each_sys_path_entry in (str(_CONFIG_DIRECTORY), str(_HOOKS_ROOT)):
11
+ if each_sys_path_entry not in sys.path:
12
+ sys.path.insert(0, each_sys_path_entry)
13
+
14
+ from config.session_env_cleanup_constants import SESSION_ID_PATTERN
15
+
16
+
17
+ class TestSessionIdPatternAccepts:
18
+ def test_accepts_uuid_with_hyphens(self) -> None:
19
+ valid_uuid_input = "5fcc01b3-138b-49e1-9976-ff1035013a4f"
20
+ matched = SESSION_ID_PATTERN.fullmatch(valid_uuid_input)
21
+ assert matched.group(0) == valid_uuid_input
22
+
23
+ def test_accepts_alphanumeric_only(self) -> None:
24
+ alphanumeric_input = "abc123XYZ"
25
+ matched = SESSION_ID_PATTERN.fullmatch(alphanumeric_input)
26
+ assert matched.group(0) == alphanumeric_input
27
+
28
+ def test_accepts_underscore_separated(self) -> None:
29
+ underscore_input = "session_42_alpha"
30
+ matched = SESSION_ID_PATTERN.fullmatch(underscore_input)
31
+ assert matched.group(0) == underscore_input
32
+
33
+
34
+ class TestSessionIdPatternRejects:
35
+ def test_rejects_forward_slash(self) -> None:
36
+ assert SESSION_ID_PATTERN.match("etc/passwd") is None
37
+
38
+ def test_rejects_back_slash(self) -> None:
39
+ assert SESSION_ID_PATTERN.match("Users\\jon") is None
40
+
41
+ def test_rejects_parent_traversal(self) -> None:
42
+ assert SESSION_ID_PATTERN.match("..") is None
43
+
44
+ def test_rejects_absolute_windows_path(self) -> None:
45
+ assert SESSION_ID_PATTERN.match("C:\\Windows\\Temp") is None
46
+
47
+ def test_rejects_empty_string(self) -> None:
48
+ assert SESSION_ID_PATTERN.match("") is None
49
+
50
+ def test_rejects_overlong_input(self) -> None:
51
+ overlong_input = "a" * 65
52
+ assert SESSION_ID_PATTERN.match(overlong_input) is None
53
+
54
+ def test_rejects_dot_in_middle(self) -> None:
55
+ assert SESSION_ID_PATTERN.match("session.uuid") is None
@@ -1,12 +1,25 @@
1
1
  #!/usr/bin/env python3
2
- """Stop-hook wrapper for hook_log_extractor that never surfaces a hook failure.
3
-
4
- Invokes ``hook_log_extractor.py --incremental`` via ``bws run`` only when
5
- both ``bws`` is on PATH and ``BWS_ACCESS_TOKEN`` is set; otherwise falls
6
- through to run the extractor directly. The extractor itself exits 0 when
7
- ``NEON_HOOK_LOGS_DATABASE_URL`` is unset, so the wrapper can rely on that
8
- offline-graceful path. This wrapper always exits 0 so the Stop hook
9
- never blocks session end on a missing dependency.
2
+ """Stop-hook wrapper for hook_log_extractor: debounced, fire-and-forget.
3
+
4
+ Runs after every assistant turn (Stop hook), so per-turn latency must
5
+ stay near zero. The wrapper:
6
+
7
+ 1. Reads the last-spawn timestamp; if it falls within the debounce
8
+ window, exits 0 immediately without spawning anything (typical fast
9
+ path: a small file read, well under 10ms).
10
+ 2. Otherwise records the current timestamp, then launches the extractor
11
+ as a fully detached background process (no stdio, separate process
12
+ group on POSIX or DETACHED_PROCESS|CREATE_NEW_PROCESS_GROUP on
13
+ Windows) and returns without waiting for it.
14
+
15
+ Bitwarden injection: when both ``bws`` is on PATH and
16
+ ``BWS_ACCESS_TOKEN`` is set, the extractor is launched via
17
+ ``bws run --`` so the Neon URL never hits disk; otherwise it is
18
+ launched directly. The extractor itself exits 0 when
19
+ ``NEON_HOOK_LOGS_DATABASE_URL`` is unset, so missing dependencies
20
+ cannot block session shutdown.
21
+
22
+ This wrapper always exits 0 so the Stop hook never surfaces a failure.
10
23
  """
11
24
 
12
25
  from __future__ import annotations
@@ -15,6 +28,7 @@ import os
15
28
  import shutil
16
29
  import subprocess
17
30
  import sys
31
+ import time
18
32
  from pathlib import Path
19
33
 
20
34
  if str(Path(__file__).resolve().parent.parent) not in sys.path:
@@ -27,7 +41,12 @@ from config.hook_log_extractor_constants import (
27
41
  BWS_RUN_SUBCOMMAND,
28
42
  EXIT_CODE_SUCCESS,
29
43
  FLAG_INCREMENTAL,
44
+ STOP_WRAPPER_DEBOUNCE_SECONDS,
30
45
  STOP_WRAPPER_EXTRACTOR_SCRIPT_NAME,
46
+ STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE,
47
+ WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG,
48
+ WINDOWS_DETACHED_PROCESS_FLAG,
49
+ WINDOWS_OS_NAME,
31
50
  )
32
51
 
33
52
 
@@ -37,14 +56,77 @@ def _extractor_script_path() -> str:
37
56
  )
38
57
 
39
58
 
59
+ def _last_run_timestamp_path() -> Path:
60
+ return Path(STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE)
61
+
62
+
63
+ def _is_within_debounce_window() -> bool:
64
+ timestamp_path = _last_run_timestamp_path()
65
+ if not timestamp_path.exists():
66
+ return False
67
+ try:
68
+ previous_timestamp = float(timestamp_path.read_text().strip())
69
+ except (OSError, ValueError):
70
+ return False
71
+ seconds_since_previous_spawn = time.time() - previous_timestamp
72
+ if seconds_since_previous_spawn < 0:
73
+ return False
74
+ return seconds_since_previous_spawn < STOP_WRAPPER_DEBOUNCE_SECONDS
75
+
76
+
77
+ def _record_current_timestamp() -> None:
78
+ timestamp_path = _last_run_timestamp_path()
79
+ timestamp_path.parent.mkdir(parents=True, exist_ok=True)
80
+ timestamp_path.write_text(str(time.time()))
81
+
82
+
83
+ def _clear_recorded_timestamp() -> None:
84
+ """Remove the debounce timestamp regardless of which process wrote it.
85
+
86
+ The unlink is unconditional: if process A writes a timestamp, process
87
+ B observes it and debounces, then A's spawn fails, A's rollback here
88
+ deletes the timestamp B already saw. The next Stop hook then spawns
89
+ a fresh extractor instead of debouncing the remainder of the window.
90
+ Consequence is benign because the extractor's own offset-file lock
91
+ (LOCK_MAXIMUM_RETRY_COUNT in hook_log_extractor_constants) serializes
92
+ concurrent extractor runs, so at most one extra extractor briefly
93
+ waits on the lock. Scoping the rollback to A's own write would
94
+ require a per-process sentinel and tighter atomicity than the
95
+ benefit warrants for this Stop-hook fast path.
96
+ """
97
+ timestamp_path = _last_run_timestamp_path()
98
+ timestamp_path.unlink(missing_ok=True)
99
+
100
+
40
101
  def _can_use_bws() -> bool:
41
102
  if not os.environ.get(BWS_ACCESS_TOKEN_ENV_VAR):
42
103
  return False
43
104
  return shutil.which(BWS_EXECUTABLE_NAME) is not None
44
105
 
45
106
 
46
- def _run_with_bws() -> None:
47
- subprocess.run(
107
+ def _detached_spawn_keyword_arguments() -> dict[str, object]:
108
+ spawn_arguments: dict[str, object] = {
109
+ "stdin": subprocess.DEVNULL,
110
+ "stdout": subprocess.DEVNULL,
111
+ "stderr": subprocess.DEVNULL,
112
+ "close_fds": True,
113
+ }
114
+ if os.name == WINDOWS_OS_NAME:
115
+ spawn_arguments["creationflags"] = (
116
+ WINDOWS_DETACHED_PROCESS_FLAG
117
+ | WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG
118
+ )
119
+ startup_info = subprocess.STARTUPINFO()
120
+ startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW
121
+ startup_info.wShowWindow = subprocess.SW_HIDE
122
+ spawn_arguments["startupinfo"] = startup_info
123
+ else:
124
+ spawn_arguments["start_new_session"] = True
125
+ return spawn_arguments
126
+
127
+
128
+ def _spawn_with_bws() -> None:
129
+ subprocess.Popen(
48
130
  [
49
131
  BWS_EXECUTABLE_NAME,
50
132
  BWS_RUN_SUBCOMMAND,
@@ -53,28 +135,34 @@ def _run_with_bws() -> None:
53
135
  _extractor_script_path(),
54
136
  FLAG_INCREMENTAL,
55
137
  ],
56
- check=False,
138
+ **_detached_spawn_keyword_arguments(),
57
139
  )
58
140
 
59
141
 
60
- def _run_without_bws() -> None:
61
- subprocess.run(
142
+ def _spawn_without_bws() -> None:
143
+ subprocess.Popen(
62
144
  [
63
145
  sys.executable,
64
146
  _extractor_script_path(),
65
147
  FLAG_INCREMENTAL,
66
148
  ],
67
- check=False,
149
+ **_detached_spawn_keyword_arguments(),
68
150
  )
69
151
 
70
152
 
71
153
  def main() -> int:
72
- """Invoke the extractor with or without bws; swallow all failures."""
154
+ """Debounce, then fire-and-forget the extractor; always exit 0."""
73
155
  try:
74
- if _can_use_bws():
75
- _run_with_bws()
76
- else:
77
- _run_without_bws()
156
+ if _is_within_debounce_window():
157
+ return EXIT_CODE_SUCCESS
158
+ _record_current_timestamp()
159
+ try:
160
+ if _can_use_bws():
161
+ _spawn_with_bws()
162
+ else:
163
+ _spawn_without_bws()
164
+ except Exception:
165
+ _clear_recorded_timestamp()
78
166
  except Exception:
79
167
  pass
80
168
  return EXIT_CODE_SUCCESS