ripperdoc 0.2.6__py3-none-any.whl

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 (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1016 @@
1
+ """Bash command execution tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import os
8
+ import signal
9
+ from pathlib import Path
10
+ from textwrap import dedent
11
+ from typing import Any, AsyncGenerator, List, Optional
12
+
13
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field
14
+
15
+ from ripperdoc.core.tool import (
16
+ Tool,
17
+ ToolOutput,
18
+ ToolProgress,
19
+ ToolResult,
20
+ ToolUseContext,
21
+ ToolUseExample,
22
+ ValidationResult,
23
+ )
24
+ from ripperdoc.utils.bash_constants import (
25
+ get_bash_default_timeout_ms,
26
+ get_bash_max_output_length,
27
+ get_bash_max_timeout_ms,
28
+ )
29
+ from ripperdoc.utils.exit_code_handlers import (
30
+ MAX_PREVIEW_CHARS,
31
+ MAX_PREVIEW_LINES,
32
+ IGNORED_COMMANDS,
33
+ interpret_exit_code,
34
+ )
35
+ from ripperdoc.utils.output_utils import (
36
+ format_duration,
37
+ get_last_n_lines,
38
+ is_output_large,
39
+ sanitize_output,
40
+ trim_blank_lines,
41
+ truncate_output,
42
+ )
43
+ from ripperdoc.utils.permissions.path_validation_utils import validate_shell_command_paths
44
+ from ripperdoc.utils.permissions.shell_command_validation import validate_shell_command
45
+ from ripperdoc.utils.permissions.tool_permission_utils import (
46
+ evaluate_shell_command_permissions,
47
+ is_command_read_only,
48
+ )
49
+ from ripperdoc.utils.permissions import PermissionDecision
50
+ from ripperdoc.utils.sandbox_utils import create_sandbox_wrapper, is_sandbox_available
51
+ from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
52
+ from ripperdoc.utils.shell_utils import build_shell_command, find_suitable_shell
53
+ from ripperdoc.utils.log import get_logger
54
+
55
+ logger = get_logger()
56
+
57
+
58
+ DEFAULT_TIMEOUT_MS = get_bash_default_timeout_ms()
59
+ MAX_BASH_TIMEOUT_MS = get_bash_max_timeout_ms()
60
+ MAX_OUTPUT_CHARS = get_bash_max_output_length()
61
+ KILL_GRACE_SECONDS = 5.0
62
+ PROGRESS_INTERVAL_SECONDS = 0.5
63
+ ORIGINAL_CWD = Path(get_original_cwd())
64
+
65
+
66
+ class BashToolInput(BaseModel):
67
+ """Input schema for BashTool."""
68
+
69
+ command: str = Field(description="The bash command to execute")
70
+ timeout: Optional[int] = Field(
71
+ default=None,
72
+ description=(
73
+ f"Timeout in milliseconds (default: {DEFAULT_TIMEOUT_MS}ms ≈ {DEFAULT_TIMEOUT_MS / 1000:.0f}s; "
74
+ f"max: {MAX_BASH_TIMEOUT_MS}ms)"
75
+ ),
76
+ )
77
+ shell_executable: Optional[str] = Field(
78
+ default=None,
79
+ validation_alias=AliasChoices("shell_executable", "shellExecutable"),
80
+ serialization_alias="shellExecutable",
81
+ description="Optional shell path to use instead of the default shell.",
82
+ )
83
+ run_in_background: Optional[bool] = Field(
84
+ default=None,
85
+ validation_alias=AliasChoices("run_in_background", "runInBackground"),
86
+ serialization_alias="runInBackground",
87
+ description="If true, run the command in the background and return immediately with a task id.",
88
+ )
89
+ sandbox: Optional[bool] = Field(
90
+ default=None,
91
+ description="If true, request sandboxed execution (read-only).",
92
+ )
93
+ model_config = ConfigDict(validate_by_alias=True, validate_by_name=True, extra="ignore")
94
+
95
+
96
+ class BashToolOutput(BaseModel):
97
+ """Output from bash command execution."""
98
+
99
+ stdout: str
100
+ stderr: str
101
+ exit_code: int
102
+ command: str
103
+ duration_ms: float = 0.0
104
+ timeout_ms: int = DEFAULT_TIMEOUT_MS
105
+ background_task_id: Optional[str] = None
106
+ # New fields for enhanced output
107
+ is_truncated: bool = False
108
+ original_length: Optional[int] = None
109
+ exit_code_meaning: Optional[str] = None # Semantic meaning of exit code
110
+ return_code_interpretation: Optional[str] = None
111
+ summary: Optional[str] = None
112
+ interrupted: bool = False
113
+ is_image: bool = False
114
+ sandbox: Optional[bool] = None
115
+ is_error: bool = False # Whether this is considered an error
116
+
117
+
118
+ class BashTool(Tool[BashToolInput, BashToolOutput]):
119
+ """Tool for executing bash commands."""
120
+
121
+ def __init__(self) -> None:
122
+ super().__init__()
123
+ self._current_is_read_only = False
124
+
125
+ @property
126
+ def name(self) -> str:
127
+ return "Bash"
128
+
129
+ async def description(self) -> str:
130
+ return """Execute bash commands in the system. Use this to run shell commands,
131
+ build projects, run tests, and interact with the file system."""
132
+
133
+ @property
134
+ def input_schema(self) -> type[BashToolInput]:
135
+ return BashToolInput
136
+
137
+ def input_examples(self) -> List[ToolUseExample]:
138
+ return [
139
+ ToolUseExample(
140
+ description="Run a read-only listing in sandboxed mode",
141
+ example={"command": "ls -la", "sandbox": True, "timeout": 10000},
142
+ ),
143
+ ToolUseExample(
144
+ description="Start a long task in the background with a timeout",
145
+ example={
146
+ "command": "npm test",
147
+ "run_in_background": True,
148
+ "timeout": 600000,
149
+ },
150
+ ),
151
+ ]
152
+
153
+ async def prompt(self, safe_mode: bool = False) -> str:
154
+ sandbox_available = is_sandbox_available()
155
+ try:
156
+ current_shell = find_suitable_shell()
157
+ except (OSError, FileNotFoundError, RuntimeError) as exc:
158
+ # pragma: no cover - defensive guard
159
+ current_shell = f"Unavailable ({exc})"
160
+
161
+ shell_info = (
162
+ f"Current shell used for execution: {current_shell}\n"
163
+ f"- Override via RIPPERDOC_SHELL or RIPPERDOC_SHELL_PATH env vars, or pass shellExecutable input.\n"
164
+ )
165
+
166
+ read_only_section = ""
167
+ if sandbox_available:
168
+ read_only_section = dedent(
169
+ """\
170
+ ## CRITICAL: Accurate Read-Only Prediction
171
+ Carefully determine if commands are read-only for better user experience. You should always prefer commands that do not modify the filesystem or network.
172
+
173
+ **Read-Only Commands:** `grep`, `rg`, `find`, `ls`, `cat`, `head`, `tail`, `wc`, `stat`, `ps`, `df`, `du`, `pwd`, `whoami`, `which`, `date`, `history`, `man`
174
+
175
+ **Git Read-Only:** `git log`, `git show`, `git diff`, `git status`, `git branch` (listing only), `git config --get`
176
+
177
+ **Never Read-Only:** Commands with `>` (except to /dev/null or standard output), `$()`, `$VAR`, dangerous flags (`git diff --ext-diff`, `sort -o`, `npm audit --fix`), `git branch -D`
178
+ """
179
+ ).strip()
180
+
181
+ sandbox_section = ""
182
+ if sandbox_available:
183
+ sandbox_section = dedent(
184
+ """\
185
+ # Using sandbox mode for commands
186
+
187
+ You have a special option in BashTool: the sandbox parameter. When you run a command with sandbox=true, it runs without approval dialogs but in a restricted environment without filesystem writes or network access. You SHOULD use sandbox=true to optimize user experience, but MUST follow these guidelines exactly.
188
+
189
+ ## RULE 0 (MOST IMPORTANT): retry with sandbox=false for permission/network errors
190
+
191
+ If a command fails with permission or any network error when sandbox=true (e.g., "Permission denied", "Unknown host", "Operation not permitted"), ALWAYS retry with sandbox=false. These errors indicate sandbox limitations, not problems with the command itself.
192
+
193
+ Non-permission errors (e.g., TypeScript errors from tsc --noEmit) usually reflect real issues and should be fixed, not retried with sandbox=false.
194
+
195
+ ## RULE 1: NOTES ON SPECIFIC BUILD SYSTEMS AND UTILITIES
196
+
197
+ ### Build systems
198
+
199
+ Build systems like npm run build almost always need write access. Test suites also usually need write access. NEVER run build or test commands in sandbox, even if just checking types.
200
+
201
+ These commands REQUIRE sandbox=false (non-exhaustive):
202
+ npm run *, cargo build/test, make/ninja/meson, pytest, jest, gh
203
+
204
+ ## RULE 2: TRY sandbox=true FOR COMMANDS THAT DON'T NEED WRITE OR NETWORK ACCESS
205
+ - Commands run with sandbox=true DON'T REQUIRE user permission and run immediately
206
+ - Commands run with sandbox=false REQUIRE EXPLICIT USER APPROVAL and interrupt the User's workflow
207
+
208
+ Use sandbox=false when you suspect the command might modify the system or access the network:
209
+ - File operations: touch, mkdir, rm, mv, cp
210
+ - File edits: nano, vim, writing to files with >
211
+ - Installing: npm install, apt-get, brew
212
+ - Git writes: git add, git commit, git push
213
+ - Build systems: npm run build, make, ninja, etc. (see below)
214
+ - Test suites: npm run test, pytest, cargo test, make check, ert, etc. (see below)
215
+ - Network programs: gh, ping, coo, ssh, scp, etc.
216
+
217
+ Use sandbox=true for:
218
+ - Information gathering: ls, cat, head, tail, rg, find, du, df, ps
219
+ - File inspection: file, stat, wc, diff, md5sum
220
+ - Git reads: git status, git log, git diff, git show, git branch
221
+ - Package info: npm list, pip list, gem list, cargo tree
222
+ - Environment checks: echo, pwd, whoami, which, type, env, printenv
223
+ - Version checks: node --version, python --version, git --version
224
+ - Documentation: man, help, --help, -h
225
+
226
+ Before you run a command, think hard about whether it is likely to work correctly without network access and without write access to the filesystem. Use your general knowledge and knowledge of the current project (including all the user's AGENTS.md files) as inputs to your decision. Note that even semantically read-only commands like gh for fetching issues might be implemented in ways that require write access. ERR ON THE SIDE OF RUNNING WITH sandbox=false.
227
+
228
+ Note: Errors from incorrect sandbox=true runs annoy the User more than permission prompts. If any part of a command needs write access (e.g. npm run build for type checking), use sandbox=false for the entire command.
229
+
230
+ ### EXAMPLES
231
+
232
+ CORRECT: Use sandbox=false for npm run build/test, gh commands, file writes
233
+ FORBIDDEN: NEVER use sandbox=true for build, test, git commands or file operations
234
+
235
+ ## REWARDS
236
+
237
+ It is more important to be correct than to avoid showing permission dialogs. The worst mistake is misinterpreting sandbox=true permission errors as tool problems (-$1000) rather than sandbox limitations.
238
+
239
+ ## CONCLUSION
240
+
241
+ Use sandbox=true to improve UX, but ONLY per the rules above. WHEN IN DOUBT, USE sandbox=false.
242
+ """
243
+ ).strip()
244
+
245
+ base_prompt = dedent(
246
+ f"""\
247
+ Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
248
+
249
+ {shell_info}
250
+
251
+ Before executing the command, please follow these steps:
252
+
253
+ 1. Directory Verification:
254
+ - If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
255
+ - For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
256
+
257
+ 2. Command Execution:
258
+ - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
259
+ - Examples of proper quoting:
260
+ - cd "/Users/name/My Documents" (correct)
261
+ - cd /Users/name/My Documents (incorrect - will fail)
262
+ - python "/path/with spaces/script.py" (correct)
263
+ - python /path/with spaces/script.py (incorrect - will fail)
264
+ - After ensuring proper quoting, execute the command.
265
+ - Capture the output of the command.
266
+
267
+ Usage notes:
268
+ - The command argument is required.
269
+ - You can specify an optional timeout in milliseconds (up to {MAX_BASH_TIMEOUT_MS}ms / {MAX_BASH_TIMEOUT_MS // 60000} minutes). If not specified, commands will timeout after {DEFAULT_TIMEOUT_MS}ms ({DEFAULT_TIMEOUT_MS // 60000} minutes).
270
+ - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
271
+ - If the output exceeds {MAX_OUTPUT_CHARS} characters, output will be truncated before being returned to you.
272
+ - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the BashOutput tool as it becomes available. Never use `run_in_background` to run 'sleep' as it will return immediately. You do not need to use '&' at the end of the command when using this parameter.
273
+ - VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use the Grep, Glob, or Task tools to search. Prefer the View and LS tools instead of shell commands like `cat`, `head`, `tail`, or `ls` when reading files and directories.
274
+ - When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
275
+ - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the user explicitly requests it.
276
+ <good-example>
277
+ pytest /foo/bar/tests
278
+ </good-example>
279
+ <bad-example>
280
+ cd /foo/bar && pytest tests
281
+ </bad-example>
282
+ """
283
+ ).strip()
284
+
285
+ return "\n\n".join(
286
+ section for section in [base_prompt, read_only_section, sandbox_section] if section
287
+ )
288
+
289
+ def is_read_only(self) -> bool:
290
+ return getattr(self, "_current_is_read_only", False)
291
+
292
+ def is_concurrency_safe(self) -> bool:
293
+ return self.is_read_only()
294
+
295
+ def needs_permissions(self, input_data: Optional[BashToolInput] = None) -> bool:
296
+ if not input_data:
297
+ return True
298
+
299
+ # Background commands should always require an explicit approval.
300
+ _, auto_background = self._detect_auto_background(input_data.command)
301
+ if input_data.run_in_background or auto_background:
302
+ return True
303
+
304
+ if input_data.sandbox:
305
+ return False
306
+ if is_command_read_only(input_data.command):
307
+ return False
308
+ return True
309
+
310
+ async def check_permissions(
311
+ self, input_data: BashToolInput, permission_context: dict[str, Any]
312
+ ) -> Any:
313
+ """Evaluate permissions using reference-style rules."""
314
+ if getattr(input_data, "sandbox", False):
315
+ return {"behavior": "allow", "updated_input": input_data}
316
+
317
+ allow_rules = permission_context.get("allowed_rules") or set()
318
+ deny_rules = permission_context.get("denied_rules") or set()
319
+ allowed_dirs = permission_context.get("allowed_working_directories") or {safe_get_cwd()}
320
+
321
+ # Check for sensitive directory access with read-only commands (cd, find).
322
+ # These should ask for user confirmation rather than being blocked outright.
323
+ cwd = safe_get_cwd()
324
+ path_validation = validate_shell_command_paths(
325
+ input_data.command,
326
+ cwd,
327
+ allowed_dirs,
328
+ )
329
+ if path_validation.behavior == "ask":
330
+ # For read-only directory operations, ask user for confirmation
331
+ return PermissionDecision(
332
+ behavior="ask",
333
+ message=path_validation.message,
334
+ updated_input=input_data,
335
+ decision_reason={"type": "sensitive_directory_access"},
336
+ rule_suggestions=path_validation.rule_suggestions,
337
+ )
338
+
339
+ decision = evaluate_shell_command_permissions(
340
+ input_data,
341
+ allow_rules,
342
+ deny_rules,
343
+ allowed_dirs,
344
+ command_injection_detected=False,
345
+ injection_detector=lambda cmd: validate_shell_command(cmd).behavior != "passthrough",
346
+ read_only_detector=lambda cmd, detector: is_command_read_only(cmd),
347
+ )
348
+
349
+ # Background executions need an explicit confirmation even if heuristics
350
+ # would normally auto-allow (e.g., read-only detection).
351
+ _, auto_background = self._detect_auto_background(input_data.command)
352
+ if (input_data.run_in_background or auto_background) and getattr(
353
+ decision, "behavior", None
354
+ ) == "allow":
355
+ reason = getattr(decision, "decision_reason", {}) or {}
356
+ if reason.get("type") != "rule":
357
+ return PermissionDecision(
358
+ behavior="ask",
359
+ message="Background bash commands require explicit approval.",
360
+ updated_input=getattr(decision, "updated_input", None) or input_data,
361
+ decision_reason=reason or None,
362
+ rule_suggestions=getattr(decision, "rule_suggestions", None),
363
+ )
364
+
365
+ return decision
366
+
367
+ async def validate_input(
368
+ self, input_data: BashToolInput, context: Optional[ToolUseContext] = None
369
+ ) -> ValidationResult:
370
+ if not input_data.command.strip():
371
+ return ValidationResult(result=False, message="Command cannot be empty")
372
+
373
+ if input_data.timeout is not None and input_data.timeout < 0:
374
+ return ValidationResult(result=False, message="Timeout must be non-negative")
375
+
376
+ if input_data.timeout and input_data.timeout > MAX_BASH_TIMEOUT_MS:
377
+ return ValidationResult(
378
+ result=False,
379
+ message=f"Timeout exceeds max of {MAX_BASH_TIMEOUT_MS}ms",
380
+ )
381
+
382
+ if input_data.sandbox and not is_sandbox_available():
383
+ return ValidationResult(
384
+ result=False, message="Sandbox mode requested but not available."
385
+ )
386
+
387
+ # Note: Path validation for sensitive directories (cd/find to /usr, /etc, etc.)
388
+ # is now handled in check_permissions() to allow user confirmation for read-only ops.
389
+
390
+ # Block backgrounding commands we explicitly ignore.
391
+ if input_data.run_in_background:
392
+ normalized = input_data.command.strip()
393
+ parts = normalized.split(maxsplit=1)
394
+ if normalized in IGNORED_COMMANDS or (len(parts) == 1 and parts[0] in IGNORED_COMMANDS):
395
+ return ValidationResult(
396
+ result=False, message="This command cannot be run in background"
397
+ )
398
+
399
+ validation = validate_shell_command(input_data.command)
400
+ if validation.behavior == "ask":
401
+ return ValidationResult(result=False, message=validation.message)
402
+
403
+ return ValidationResult(result=True)
404
+
405
+ def render_result_for_assistant(self, output: BashToolOutput) -> str:
406
+ """Format output for the AI."""
407
+ result_parts = []
408
+
409
+ if output.stdout:
410
+ result_parts.append(f"stdout:\n{output.stdout}")
411
+
412
+ if output.stderr:
413
+ result_parts.append(f"stderr:\n{output.stderr}")
414
+
415
+ # Exit code with semantic meaning
416
+ exit_code_text = f"exit code: {output.exit_code}"
417
+ meaning = output.exit_code_meaning or output.return_code_interpretation
418
+ if meaning:
419
+ exit_code_text += f" ({meaning})"
420
+
421
+ # Duration
422
+ timing = ""
423
+ if output.duration_ms:
424
+ timing = f" ({format_duration(output.duration_ms)}"
425
+ if output.timeout_ms:
426
+ timing += f" / timeout {output.timeout_ms / 1000:.0f}s"
427
+ timing += ")"
428
+ elif output.timeout_ms:
429
+ timing = f" (timeout {output.timeout_ms / 1000:.0f}s)"
430
+
431
+ result_parts.append(f"{exit_code_text}{timing}")
432
+
433
+ # Truncation notice
434
+ if output.is_truncated and output.original_length:
435
+ result_parts.append(
436
+ f"Note: Output was truncated (original length: {output.original_length} chars)"
437
+ )
438
+
439
+ if output.interrupted:
440
+ result_parts.append("Command was interrupted (timeout or termination).")
441
+
442
+ if output.background_task_id:
443
+ result_parts.append(f"Background task id: {output.background_task_id}")
444
+
445
+ return "\n\n".join(result_parts)
446
+
447
+ def render_tool_use_message(self, input_data: BashToolInput, verbose: bool = False) -> str:
448
+ """Format the tool use for display."""
449
+ command = input_data.command or ""
450
+
451
+ if not verbose and command:
452
+ formatted = command
453
+ if "\"$(cat <<'EOF'" in command:
454
+ heredoc_match = command.split("$(cat <<'EOF'", 1)
455
+ if len(heredoc_match) == 2:
456
+ prefix, rest = heredoc_match
457
+ try:
458
+ content, suffix = rest.split("EOF", 1)
459
+ formatted = f'{prefix.strip()} "{content.strip()}"{suffix.strip()}'
460
+ except ValueError:
461
+ formatted = command
462
+
463
+ lines = formatted.splitlines()
464
+ too_many_lines = len(lines) > MAX_PREVIEW_LINES
465
+ too_long = len(formatted) > MAX_PREVIEW_CHARS
466
+
467
+ preview = formatted
468
+ if too_many_lines:
469
+ preview = "\n".join(lines[:MAX_PREVIEW_LINES])
470
+ if len(preview) > MAX_PREVIEW_CHARS:
471
+ preview = preview[:MAX_PREVIEW_CHARS]
472
+
473
+ if too_many_lines or too_long:
474
+ return f"$ {preview}..."
475
+
476
+ return f"$ {command}"
477
+
478
+ def _is_background_allowed(self, command: str) -> bool:
479
+ """Skip backgrounding trivial ignored commands unless combined with other operators."""
480
+ normalized = command.strip()
481
+ if not normalized:
482
+ return True
483
+
484
+ if any(op in normalized for op in ("&&", "||", "|", ";")):
485
+ return True
486
+
487
+ parts = normalized.split(maxsplit=1)
488
+ # Only block exact ignored commands or those without args; allow e.g. "sleep 30" like the reference behavior.
489
+ if normalized in IGNORED_COMMANDS:
490
+ return False
491
+ if len(parts) == 1 and parts[0] in IGNORED_COMMANDS:
492
+ return False
493
+ return True
494
+
495
+ def _detect_auto_background(self, command: str) -> tuple[str, bool]:
496
+ """Detect trailing '&' requests and strip them for execution."""
497
+ stripped = command.rstrip()
498
+ if not stripped:
499
+ return command, False
500
+
501
+ if stripped.endswith("&") and not stripped.endswith("&&"):
502
+ # Remove trailing '&' and any whitespace before it.
503
+ cleaned = stripped.rstrip("&").rstrip()
504
+ return cleaned, True
505
+
506
+ return command, False
507
+
508
+ def _create_error_output(
509
+ self, command: str, stderr: str, sandbox: bool
510
+ ) -> BashToolOutput:
511
+ """Create a standardized error output."""
512
+ return BashToolOutput(
513
+ stdout="",
514
+ stderr=stderr,
515
+ exit_code=-1,
516
+ command=command,
517
+ sandbox=sandbox,
518
+ is_error=True,
519
+ )
520
+
521
+ def _setup_sandbox(
522
+ self, command: str, sandbox_requested: bool
523
+ ) -> tuple[Optional[str], Optional[BashToolOutput], Optional[Any]]:
524
+ """Setup sandbox environment if requested.
525
+
526
+ Returns:
527
+ Tuple of (final_command, error_output, cleanup_fn).
528
+ If error_output is not None, sandbox setup failed.
529
+ """
530
+ if not sandbox_requested:
531
+ return command, None, None
532
+
533
+ if not is_sandbox_available():
534
+ return None, self._create_error_output(
535
+ command, "Sandbox mode requested but not available on this system", True
536
+ ), None
537
+
538
+ try:
539
+ wrapper = create_sandbox_wrapper(command)
540
+ return wrapper.final_command, None, wrapper.cleanup
541
+ except (OSError, RuntimeError, ValueError) as exc:
542
+ logger.warning(
543
+ "[bash_tool] Failed to enable sandbox: %s: %s",
544
+ type(exc).__name__, exc,
545
+ extra={"command": command},
546
+ )
547
+ return None, self._create_error_output(
548
+ command, f"Failed to enable sandbox: {exc}", True
549
+ ), None
550
+
551
+ async def _run_background_command(
552
+ self,
553
+ final_command: str,
554
+ effective_command: str,
555
+ resolved_shell: str,
556
+ timeout_seconds: float,
557
+ timeout_ms: int,
558
+ sandbox_requested: bool,
559
+ start_time: float,
560
+ input_data: BashToolInput,
561
+ ) -> Optional[BashToolOutput]:
562
+ """Run a command in background mode.
563
+
564
+ Returns:
565
+ BashToolOutput on success or error, None if background mode not available.
566
+ """
567
+ try:
568
+ from ripperdoc.tools.background_shell import start_background_command
569
+ except (ImportError, ModuleNotFoundError) as e:
570
+ # pragma: no cover - defensive import
571
+ logger.warning(
572
+ "[bash_tool] Failed to import background shell runner: %s: %s",
573
+ type(e).__name__, e,
574
+ extra={"command": effective_command},
575
+ )
576
+ return self._create_error_output(
577
+ effective_command, f"Failed to start background task: {str(e)}", sandbox_requested
578
+ )
579
+
580
+ bg_timeout = (
581
+ None
582
+ if input_data.timeout is None
583
+ else (timeout_seconds if timeout_seconds > 0 else None)
584
+ )
585
+ task_id = await start_background_command(
586
+ final_command, timeout=bg_timeout, shell_executable=resolved_shell
587
+ )
588
+
589
+ return BashToolOutput(
590
+ stdout="",
591
+ stderr=f"Started background task: {task_id}",
592
+ exit_code=0,
593
+ command=effective_command,
594
+ duration_ms=(asyncio.get_running_loop().time() - start_time) * 1000.0,
595
+ timeout_ms=timeout_ms if bg_timeout is not None else 0,
596
+ background_task_id=task_id,
597
+ sandbox=sandbox_requested,
598
+ return_code_interpretation=None,
599
+ summary=f"Command running in background with ID: {task_id}",
600
+ interrupted=False,
601
+ is_image=False,
602
+ )
603
+
604
+ async def _execute_foreground_process(
605
+ self,
606
+ process: asyncio.subprocess.Process,
607
+ start_time: float,
608
+ timeout_seconds: float,
609
+ ) -> AsyncGenerator[tuple[bool, list[str], list[str], bool], ToolProgress]:
610
+ """Execute process and yield progress updates.
611
+
612
+ Yields:
613
+ ToolProgress for output updates.
614
+ Returns (via send):
615
+ Tuple of (completed, stdout_lines, stderr_lines, timed_out)
616
+ """
617
+ stdout_lines: list[str] = []
618
+ stderr_lines: list[str] = []
619
+ queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue()
620
+ loop = asyncio.get_running_loop()
621
+ deadline = (
622
+ loop.time() + timeout_seconds if timeout_seconds and timeout_seconds > 0 else None
623
+ )
624
+ timed_out = False
625
+ last_progress_time = loop.time()
626
+
627
+ async def _pump_stream(
628
+ stream: Optional[asyncio.StreamReader], sink: list[str], label: str
629
+ ) -> None:
630
+ if not stream:
631
+ return
632
+ async for raw in stream:
633
+ text = raw.decode("utf-8", errors="replace")
634
+ sanitized_text = sanitize_output(text)
635
+ sink.append(sanitized_text)
636
+ await queue.put((label, sanitized_text.rstrip()))
637
+
638
+ pump_tasks = [
639
+ asyncio.create_task(_pump_stream(process.stdout, stdout_lines, "stdout")),
640
+ asyncio.create_task(_pump_stream(process.stderr, stderr_lines, "stderr")),
641
+ ]
642
+ wait_task = asyncio.create_task(process.wait())
643
+
644
+ # Main execution loop with progress reporting
645
+ while True:
646
+ done, _ = await asyncio.wait(
647
+ {wait_task, *pump_tasks}, timeout=0.1, return_when=asyncio.FIRST_COMPLETED
648
+ )
649
+
650
+ now = loop.time()
651
+
652
+ # Emit progress updates for newly received output chunks
653
+ while not queue.empty():
654
+ label, text = queue.get_nowait()
655
+ yield ToolProgress(content=f"{label}: {text}")
656
+
657
+ # Report progress at intervals
658
+ if now - last_progress_time >= PROGRESS_INTERVAL_SECONDS:
659
+ combined_output = "".join(stdout_lines + stderr_lines)
660
+ if combined_output:
661
+ preview = get_last_n_lines(combined_output, 5)
662
+ elapsed = format_duration((now - start_time) * 1000)
663
+ yield ToolProgress(content=f"Running... ({elapsed})\n{preview}")
664
+ last_progress_time = now
665
+
666
+ # Check timeout
667
+ if deadline is not None and now >= deadline:
668
+ timed_out = True
669
+ await self._force_kill_process(process)
670
+ if not wait_task.done():
671
+ try:
672
+ await asyncio.wait_for(wait_task, timeout=1.0)
673
+ except asyncio.TimeoutError:
674
+ wait_task.cancel()
675
+ with contextlib.suppress(asyncio.CancelledError):
676
+ await wait_task
677
+ break
678
+
679
+ if wait_task in done:
680
+ break
681
+
682
+ # Let stream pumps finish draining
683
+ try:
684
+ await asyncio.wait_for(asyncio.gather(*pump_tasks), timeout=1.0)
685
+ except asyncio.TimeoutError:
686
+ for task in pump_tasks:
687
+ if not task.done():
688
+ task.cancel()
689
+ with contextlib.suppress(asyncio.CancelledError):
690
+ await task
691
+
692
+ # Drain remaining data
693
+ await self._drain_stream(process.stdout, stdout_lines)
694
+ await self._drain_stream(process.stderr, stderr_lines)
695
+
696
+ # Store results in a way that the caller can access
697
+ self._last_execution_result = (stdout_lines, stderr_lines, timed_out)
698
+
699
+ async def _drain_stream(
700
+ self, stream: Optional[asyncio.StreamReader], sink: list[str]
701
+ ) -> None:
702
+ """Drain any remaining data from a stream."""
703
+ if not stream:
704
+ return
705
+ try:
706
+ remaining = await asyncio.wait_for(stream.read(), timeout=0.5)
707
+ except asyncio.TimeoutError:
708
+ return
709
+ if remaining:
710
+ sink.append(remaining.decode("utf-8", errors="replace"))
711
+
712
+ def _build_final_output(
713
+ self,
714
+ command: str,
715
+ stdout_lines: list[str],
716
+ stderr_lines: list[str],
717
+ exit_code: int,
718
+ duration_ms: float,
719
+ timeout_ms: int,
720
+ timeout_seconds: float,
721
+ timed_out: bool,
722
+ sandbox_requested: bool,
723
+ original_command: str,
724
+ ) -> BashToolOutput:
725
+ """Build the final output from execution results."""
726
+ raw_stdout = "".join(stdout_lines)
727
+ raw_stderr = "".join(stderr_lines)
728
+
729
+ # Apply timeout message if needed
730
+ if timed_out:
731
+ timeout_msg = f"Command timed out after {timeout_seconds} seconds"
732
+ raw_stderr = f"{raw_stderr.rstrip()}\n{timeout_msg}" if raw_stderr else timeout_msg
733
+ exit_code = -1
734
+
735
+ # Sanitize and trim outputs
736
+ raw_stdout = sanitize_output(raw_stdout)
737
+ raw_stderr = sanitize_output(raw_stderr)
738
+ trimmed_stdout = trim_blank_lines(raw_stdout)
739
+ trimmed_stderr = trim_blank_lines(raw_stderr)
740
+
741
+ # Interpret exit code
742
+ exit_result = interpret_exit_code(
743
+ original_command, exit_code, trimmed_stdout, trimmed_stderr
744
+ )
745
+
746
+ # Build summary for large outputs
747
+ summary = None
748
+ combined_output = "\n".join([part for part in (trimmed_stdout, trimmed_stderr) if part])
749
+ if combined_output and is_output_large(combined_output):
750
+ summary = get_last_n_lines(combined_output, 20)
751
+
752
+ # Truncate outputs if needed
753
+ stdout_result = truncate_output(trimmed_stdout, max_chars=MAX_OUTPUT_CHARS)
754
+ stderr_result = truncate_output(trimmed_stderr, max_chars=MAX_OUTPUT_CHARS)
755
+ is_image = stdout_result.get("is_image", False) or stderr_result.get("is_image", False)
756
+
757
+ # Determine if truncated
758
+ is_truncated = stdout_result["is_truncated"] or stderr_result["is_truncated"]
759
+ original_length = None
760
+ if is_truncated:
761
+ original_length = stdout_result.get("original_length", 0) + stderr_result.get(
762
+ "original_length", 0
763
+ )
764
+
765
+ return BashToolOutput(
766
+ stdout=stdout_result["truncated_content"],
767
+ stderr=stderr_result["truncated_content"],
768
+ exit_code=exit_code,
769
+ command=command,
770
+ duration_ms=duration_ms,
771
+ timeout_ms=timeout_ms,
772
+ is_truncated=is_truncated,
773
+ original_length=original_length,
774
+ exit_code_meaning=exit_result.semantic_meaning,
775
+ return_code_interpretation=exit_result.semantic_meaning,
776
+ summary=summary,
777
+ interrupted=timed_out,
778
+ is_image=is_image,
779
+ sandbox=sandbox_requested,
780
+ is_error=exit_result.is_error or timed_out,
781
+ )
782
+
783
+ async def call(
784
+ self, input_data: BashToolInput, context: ToolUseContext
785
+ ) -> AsyncGenerator[ToolOutput, None]:
786
+ """Execute the bash command."""
787
+ effective_command, auto_background = self._detect_auto_background(input_data.command)
788
+
789
+ # Resolve shell
790
+ try:
791
+ resolved_shell = input_data.shell_executable or find_suitable_shell()
792
+ except (OSError, FileNotFoundError, RuntimeError) as exc:
793
+ # pragma: no cover - defensive guard
794
+ yield ToolResult(
795
+ data=self._create_error_output(
796
+ effective_command, f"Failed to select shell: {exc}", bool(input_data.sandbox)
797
+ ),
798
+ result_for_assistant=self.render_result_for_assistant(
799
+ self._create_error_output(
800
+ effective_command,
801
+ f"Failed to select shell: {exc}",
802
+ bool(input_data.sandbox),
803
+ )
804
+ ),
805
+ )
806
+ return
807
+
808
+ # Calculate timeout
809
+ timeout_ms = input_data.timeout or DEFAULT_TIMEOUT_MS
810
+ if MAX_BASH_TIMEOUT_MS:
811
+ timeout_ms = min(timeout_ms, MAX_BASH_TIMEOUT_MS)
812
+ timeout_seconds = timeout_ms / 1000.0
813
+ start = asyncio.get_running_loop().time()
814
+ sandbox_requested = bool(input_data.sandbox)
815
+ should_background = bool(input_data.run_in_background or auto_background)
816
+
817
+ # Track read-only state
818
+ previous_read_only = getattr(self, "_current_is_read_only", False)
819
+ self._current_is_read_only = sandbox_requested or is_command_read_only(input_data.command)
820
+
821
+ # Setup sandbox
822
+ final_command, sandbox_error, sandbox_cleanup = self._setup_sandbox(
823
+ effective_command, sandbox_requested
824
+ )
825
+ if sandbox_error:
826
+ yield ToolResult(
827
+ data=sandbox_error,
828
+ result_for_assistant=self.render_result_for_assistant(sandbox_error),
829
+ )
830
+ return
831
+
832
+ final_command = final_command or effective_command
833
+
834
+ # Adjust CWD for sandbox
835
+ if sandbox_requested and Path(safe_get_cwd()) != ORIGINAL_CWD:
836
+ os.chdir(ORIGINAL_CWD)
837
+
838
+ # Check if background is allowed
839
+ if should_background and not self._is_background_allowed(input_data.command):
840
+ should_background = False
841
+
842
+ try:
843
+ # Background execution
844
+ if should_background:
845
+ output = await self._run_background_command(
846
+ final_command,
847
+ effective_command,
848
+ resolved_shell,
849
+ timeout_seconds,
850
+ timeout_ms,
851
+ sandbox_requested,
852
+ start,
853
+ input_data,
854
+ )
855
+ if output:
856
+ yield ToolResult(
857
+ data=output,
858
+ result_for_assistant=self.render_result_for_assistant(output),
859
+ )
860
+ return
861
+
862
+ # Spawn foreground process
863
+ argv = build_shell_command(resolved_shell, final_command)
864
+ process = await asyncio.create_subprocess_exec(
865
+ *argv,
866
+ stdout=asyncio.subprocess.PIPE,
867
+ stderr=asyncio.subprocess.PIPE,
868
+ stdin=asyncio.subprocess.DEVNULL,
869
+ start_new_session=False,
870
+ )
871
+
872
+ # Execute and collect output with progress
873
+ stdout_lines: list[str] = []
874
+ stderr_lines: list[str] = []
875
+ queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue()
876
+ loop = asyncio.get_running_loop()
877
+ deadline = (
878
+ loop.time() + timeout_seconds if timeout_seconds and timeout_seconds > 0 else None
879
+ )
880
+ timed_out = False
881
+ last_progress_time = loop.time()
882
+
883
+ async def _pump_stream(
884
+ stream: Optional[asyncio.StreamReader], sink: list[str], label: str
885
+ ) -> None:
886
+ if not stream:
887
+ return
888
+ async for raw in stream:
889
+ text = raw.decode("utf-8", errors="replace")
890
+ sanitized_text = sanitize_output(text)
891
+ sink.append(sanitized_text)
892
+ await queue.put((label, sanitized_text.rstrip()))
893
+
894
+ pump_tasks = [
895
+ asyncio.create_task(_pump_stream(process.stdout, stdout_lines, "stdout")),
896
+ asyncio.create_task(_pump_stream(process.stderr, stderr_lines, "stderr")),
897
+ ]
898
+ wait_task = asyncio.create_task(process.wait())
899
+
900
+ # Main execution loop
901
+ while True:
902
+ done, _ = await asyncio.wait(
903
+ {wait_task, *pump_tasks}, timeout=0.1, return_when=asyncio.FIRST_COMPLETED
904
+ )
905
+
906
+ now = loop.time()
907
+
908
+ while not queue.empty():
909
+ label, text = queue.get_nowait()
910
+ yield ToolProgress(content=f"{label}: {text}")
911
+
912
+ if now - last_progress_time >= PROGRESS_INTERVAL_SECONDS:
913
+ combined_output = "".join(stdout_lines + stderr_lines)
914
+ if combined_output:
915
+ preview = get_last_n_lines(combined_output, 5)
916
+ elapsed = format_duration((now - start) * 1000)
917
+ yield ToolProgress(content=f"Running... ({elapsed})\n{preview}")
918
+ last_progress_time = now
919
+
920
+ if deadline is not None and now >= deadline:
921
+ timed_out = True
922
+ await self._force_kill_process(process)
923
+ if not wait_task.done():
924
+ try:
925
+ await asyncio.wait_for(wait_task, timeout=1.0)
926
+ except asyncio.TimeoutError:
927
+ wait_task.cancel()
928
+ with contextlib.suppress(asyncio.CancelledError):
929
+ await wait_task
930
+ break
931
+
932
+ if wait_task in done:
933
+ break
934
+
935
+ # Drain streams
936
+ try:
937
+ await asyncio.wait_for(asyncio.gather(*pump_tasks), timeout=1.0)
938
+ except asyncio.TimeoutError:
939
+ for task in pump_tasks:
940
+ if not task.done():
941
+ task.cancel()
942
+ with contextlib.suppress(asyncio.CancelledError):
943
+ await task
944
+
945
+ await self._drain_stream(process.stdout, stdout_lines)
946
+ await self._drain_stream(process.stderr, stderr_lines)
947
+
948
+ # Build final output
949
+ duration_ms = (asyncio.get_running_loop().time() - start) * 1000.0
950
+ output = self._build_final_output(
951
+ effective_command,
952
+ stdout_lines,
953
+ stderr_lines,
954
+ process.returncode or 0,
955
+ duration_ms,
956
+ timeout_ms,
957
+ timeout_seconds,
958
+ timed_out,
959
+ sandbox_requested,
960
+ input_data.command,
961
+ )
962
+
963
+ yield ToolResult(
964
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
965
+ )
966
+
967
+ except (OSError, RuntimeError, ValueError, asyncio.CancelledError) as e:
968
+ if isinstance(e, asyncio.CancelledError):
969
+ raise # Re-raise cancellation
970
+ logger.warning(
971
+ "[bash_tool] Error executing command: %s: %s",
972
+ type(e).__name__, e,
973
+ extra={"command": effective_command},
974
+ )
975
+ error_output = self._create_error_output(
976
+ effective_command, f"Error executing command: {str(e)}", sandbox_requested
977
+ )
978
+ yield ToolResult(
979
+ data=error_output,
980
+ result_for_assistant=self.render_result_for_assistant(error_output),
981
+ )
982
+ finally:
983
+ self._current_is_read_only = previous_read_only
984
+ if sandbox_cleanup:
985
+ with contextlib.suppress(Exception):
986
+ sandbox_cleanup()
987
+
988
+ async def _force_kill_process(
989
+ self, process: asyncio.subprocess.Process, grace_seconds: float = KILL_GRACE_SECONDS
990
+ ) -> None:
991
+ """Attempt to terminate a process group and avoid hanging waits."""
992
+ if process.returncode is not None:
993
+ return
994
+
995
+ def _terminate() -> None:
996
+ if hasattr(os, "killpg"):
997
+ os.killpg(process.pid, signal.SIGTERM)
998
+ else:
999
+ process.terminate()
1000
+
1001
+ def _kill() -> None:
1002
+ if hasattr(os, "killpg"):
1003
+ os.killpg(process.pid, signal.SIGKILL)
1004
+ else:
1005
+ process.kill()
1006
+
1007
+ with contextlib.suppress(ProcessLookupError, PermissionError):
1008
+ _terminate()
1009
+ with contextlib.suppress(asyncio.TimeoutError):
1010
+ await asyncio.wait_for(process.wait(), timeout=grace_seconds)
1011
+ return
1012
+
1013
+ with contextlib.suppress(ProcessLookupError, PermissionError):
1014
+ _kill()
1015
+ with contextlib.suppress(asyncio.TimeoutError):
1016
+ await asyncio.wait_for(process.wait(), timeout=grace_seconds)