ripperdoc 0.2.2__py3-none-any.whl → 0.2.4__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 (61) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +9 -2
  3. ripperdoc/cli/commands/agents_cmd.py +8 -4
  4. ripperdoc/cli/commands/context_cmd.py +3 -3
  5. ripperdoc/cli/commands/cost_cmd.py +5 -0
  6. ripperdoc/cli/commands/doctor_cmd.py +12 -4
  7. ripperdoc/cli/commands/memory_cmd.py +6 -13
  8. ripperdoc/cli/commands/models_cmd.py +36 -6
  9. ripperdoc/cli/commands/resume_cmd.py +4 -2
  10. ripperdoc/cli/commands/status_cmd.py +1 -1
  11. ripperdoc/cli/ui/rich_ui.py +135 -2
  12. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  13. ripperdoc/core/agents.py +174 -6
  14. ripperdoc/core/config.py +9 -1
  15. ripperdoc/core/default_tools.py +6 -0
  16. ripperdoc/core/providers/__init__.py +47 -0
  17. ripperdoc/core/providers/anthropic.py +147 -0
  18. ripperdoc/core/providers/base.py +236 -0
  19. ripperdoc/core/providers/gemini.py +496 -0
  20. ripperdoc/core/providers/openai.py +253 -0
  21. ripperdoc/core/query.py +337 -141
  22. ripperdoc/core/query_utils.py +65 -24
  23. ripperdoc/core/system_prompt.py +67 -61
  24. ripperdoc/core/tool.py +12 -3
  25. ripperdoc/sdk/client.py +12 -1
  26. ripperdoc/tools/ask_user_question_tool.py +433 -0
  27. ripperdoc/tools/background_shell.py +104 -18
  28. ripperdoc/tools/bash_tool.py +33 -13
  29. ripperdoc/tools/enter_plan_mode_tool.py +223 -0
  30. ripperdoc/tools/exit_plan_mode_tool.py +150 -0
  31. ripperdoc/tools/file_edit_tool.py +13 -0
  32. ripperdoc/tools/file_read_tool.py +16 -0
  33. ripperdoc/tools/file_write_tool.py +13 -0
  34. ripperdoc/tools/glob_tool.py +5 -1
  35. ripperdoc/tools/ls_tool.py +14 -10
  36. ripperdoc/tools/mcp_tools.py +113 -4
  37. ripperdoc/tools/multi_edit_tool.py +12 -0
  38. ripperdoc/tools/notebook_edit_tool.py +12 -0
  39. ripperdoc/tools/task_tool.py +88 -5
  40. ripperdoc/tools/todo_tool.py +1 -3
  41. ripperdoc/tools/tool_search_tool.py +8 -4
  42. ripperdoc/utils/file_watch.py +134 -0
  43. ripperdoc/utils/git_utils.py +36 -38
  44. ripperdoc/utils/json_utils.py +1 -2
  45. ripperdoc/utils/log.py +3 -4
  46. ripperdoc/utils/mcp.py +49 -10
  47. ripperdoc/utils/memory.py +1 -3
  48. ripperdoc/utils/message_compaction.py +5 -11
  49. ripperdoc/utils/messages.py +9 -13
  50. ripperdoc/utils/output_utils.py +1 -3
  51. ripperdoc/utils/prompt.py +17 -0
  52. ripperdoc/utils/session_usage.py +7 -0
  53. ripperdoc/utils/shell_utils.py +159 -0
  54. ripperdoc/utils/token_estimation.py +33 -0
  55. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/METADATA +3 -1
  56. ripperdoc-0.2.4.dist-info/RECORD +99 -0
  57. ripperdoc-0.2.2.dist-info/RECORD +0 -86
  58. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/WHEEL +0 -0
  59. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/entry_points.txt +0 -0
  60. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/licenses/LICENSE +0 -0
  61. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,433 @@
1
+ """Ask user question tool for interactive clarification.
2
+
3
+ This tool allows the AI to ask the user questions during execution,
4
+ enabling clarification of ambiguous instructions, gathering preferences,
5
+ and making decisions on implementation choices.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from textwrap import dedent
12
+ from typing import AsyncGenerator, Dict, List, Optional
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ from ripperdoc.core.tool import (
17
+ Tool,
18
+ ToolOutput,
19
+ ToolResult,
20
+ ToolUseContext,
21
+ ValidationResult,
22
+ )
23
+ from ripperdoc.utils.log import get_logger
24
+
25
+ logger = get_logger()
26
+
27
+ TOOL_NAME = "AskUserQuestion"
28
+ OTHER_VALUE = "__other__"
29
+ HEADER_MAX_CHARS = 12
30
+
31
+ ASK_USER_QUESTION_PROMPT = dedent(
32
+ """\
33
+ Use this tool when you need to ask the user questions during execution. This allows you to:
34
+ 1. Gather user preferences or requirements
35
+ 2. Clarify ambiguous instructions
36
+ 3. Get decisions on implementation choices as you work
37
+ 4. Offer choices to the user about what direction to take.
38
+
39
+ Usage notes:
40
+ - Users will always be able to select "Other" to provide custom text input
41
+ - Use multiSelect: true to allow multiple answers to be selected for a question
42
+ - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
43
+ """
44
+ )
45
+
46
+
47
+ class OptionInput(BaseModel):
48
+ """Single option for a question."""
49
+
50
+ label: str = Field(
51
+ description="The display text for this option that the user will see and select. "
52
+ "Should be concise (1-5 words) and clearly describe the choice."
53
+ )
54
+ description: str = Field(
55
+ description="Explanation of what this option means or what will happen if chosen. "
56
+ "Useful for providing context about trade-offs or implications."
57
+ )
58
+
59
+
60
+ class QuestionInput(BaseModel):
61
+ """Single question to ask the user."""
62
+
63
+ question: str = Field(
64
+ description="The complete question to ask the user. Should be clear, specific, and end "
65
+ 'with a question mark. Example: "Which library should we use for date formatting?" '
66
+ 'If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"'
67
+ )
68
+ header: str = Field(
69
+ description=f"Very short label displayed as a chip/tag (max {HEADER_MAX_CHARS} chars). "
70
+ 'Examples: "Auth method", "Library", "Approach".'
71
+ )
72
+ options: List[OptionInput] = Field(
73
+ min_length=2,
74
+ max_length=4,
75
+ description="The available choices for this question. Must have 2-4 options. "
76
+ "Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). "
77
+ "There should be no 'Other' option, that will be provided automatically.",
78
+ )
79
+ multiSelect: bool = Field(
80
+ description="Set to true to allow the user to select multiple options instead of just one. "
81
+ "Use when choices are not mutually exclusive."
82
+ )
83
+
84
+
85
+ class AskUserQuestionToolInput(BaseModel):
86
+ """Input for the AskUserQuestion tool."""
87
+
88
+ questions: List[QuestionInput] = Field(
89
+ min_length=1,
90
+ max_length=4,
91
+ description="Questions to ask the user (1-4 questions)",
92
+ )
93
+ answers: Optional[Dict[str, str]] = Field(
94
+ default=None,
95
+ description="User answers collected by the permission component",
96
+ )
97
+
98
+
99
+ class AskUserQuestionToolOutput(BaseModel):
100
+ """Output from the AskUserQuestion tool."""
101
+
102
+ questions: List[QuestionInput]
103
+ answers: Dict[str, str]
104
+ cancelled: bool = False
105
+
106
+
107
+ def truncate_header(header: str) -> str:
108
+ """Truncate header to maximum characters."""
109
+ if len(header) <= HEADER_MAX_CHARS:
110
+ return header
111
+ return f"{header[: HEADER_MAX_CHARS - 1]}..."
112
+
113
+
114
+ def format_option_display(option: OptionInput, index: int) -> str:
115
+ """Format a single option for display."""
116
+ desc = f" - {option.description}" if option.description.strip() else ""
117
+ return f" {index}. {option.label}{desc}"
118
+
119
+
120
+ def format_question_prompt(
121
+ question: QuestionInput, question_num: int, total: int
122
+ ) -> str:
123
+ """Format a question for terminal display."""
124
+ header = truncate_header(question.header)
125
+ lines = [
126
+ "",
127
+ f"[{header}] Question {question_num}/{total}",
128
+ f" {question.question}",
129
+ "",
130
+ ]
131
+
132
+ for idx, opt in enumerate(question.options, start=1):
133
+ lines.append(format_option_display(opt, idx))
134
+
135
+ # Add "Other" option
136
+ lines.append(f" {len(question.options) + 1}. Other (type your own answer)")
137
+
138
+ if question.multiSelect:
139
+ lines.append("")
140
+ lines.append(
141
+ " Enter numbers separated by commas (e.g., 1,3), or 'o' for other: "
142
+ )
143
+ else:
144
+ lines.append("")
145
+ lines.append(" Enter choice (1-{}) or 'o' for other: ".format(len(question.options) + 1))
146
+
147
+ return "\n".join(lines)
148
+
149
+
150
+ async def prompt_user_for_answer(
151
+ question: QuestionInput, question_num: int, total: int
152
+ ) -> Optional[str]:
153
+ """Prompt user for an answer to a single question.
154
+
155
+ Returns the answer string, or None if cancelled.
156
+ """
157
+ loop = asyncio.get_running_loop()
158
+
159
+ def _prompt() -> Optional[str]:
160
+ try:
161
+ from prompt_toolkit import prompt as pt_prompt
162
+
163
+ prompt_text = format_question_prompt(question, question_num, total)
164
+ print(prompt_text, end="")
165
+
166
+ while True:
167
+ response = pt_prompt("").strip()
168
+
169
+ if not response:
170
+ print(" Please enter a valid choice.")
171
+ continue
172
+
173
+ if response.lower() in ("q", "quit", "cancel", "exit"):
174
+ return None
175
+
176
+ if response.lower() == "o" or response == str(len(question.options) + 1):
177
+ # Other option selected
178
+ print(" Enter your custom answer: ", end="")
179
+ custom = pt_prompt("")
180
+ if custom.strip():
181
+ return custom.strip()
182
+ print(" Custom answer cannot be empty.")
183
+ continue
184
+
185
+ if question.multiSelect:
186
+ # Parse comma-separated numbers
187
+ try:
188
+ indices = [int(x.strip()) for x in response.split(",")]
189
+ valid_range = range(1, len(question.options) + 2)
190
+ if all(i in valid_range for i in indices):
191
+ selected = []
192
+ for i in indices:
193
+ if i == len(question.options) + 1:
194
+ # Other option
195
+ print(" Enter your custom answer: ", end="")
196
+ custom = pt_prompt("")
197
+ if custom.strip():
198
+ selected.append(custom.strip())
199
+ else:
200
+ selected.append(question.options[i - 1].label)
201
+ if selected:
202
+ return ", ".join(selected)
203
+ print(
204
+ f" Invalid selection. Enter numbers from 1 to {len(question.options) + 1}."
205
+ )
206
+ except ValueError:
207
+ print(
208
+ " Invalid input. Enter numbers separated by commas."
209
+ )
210
+ else:
211
+ # Single selection
212
+ try:
213
+ choice = int(response)
214
+ if 1 <= choice <= len(question.options):
215
+ return question.options[choice - 1].label
216
+ elif choice == len(question.options) + 1:
217
+ # Other option
218
+ print(" Enter your custom answer: ", end="")
219
+ custom = pt_prompt("")
220
+ if custom.strip():
221
+ return custom.strip()
222
+ print(" Custom answer cannot be empty.")
223
+ else:
224
+ print(
225
+ f" Invalid choice. Enter a number from 1 to {len(question.options) + 1}."
226
+ )
227
+ except ValueError:
228
+ print(" Invalid input. Enter a number.")
229
+
230
+ except KeyboardInterrupt:
231
+ return None
232
+ except EOFError:
233
+ return None
234
+ except Exception as e:
235
+ logger.exception("[ask_user_question_tool] Error during prompt", extra={"error": str(e)})
236
+ return None
237
+
238
+ return await loop.run_in_executor(None, _prompt)
239
+
240
+
241
+ async def collect_answers(
242
+ questions: List[QuestionInput], initial_answers: Dict[str, str]
243
+ ) -> tuple[Dict[str, str], bool]:
244
+ """Collect answers for all questions.
245
+
246
+ Returns (answers_dict, cancelled_flag).
247
+ """
248
+ answers = dict(initial_answers)
249
+ total = len(questions)
250
+
251
+ for idx, question in enumerate(questions, start=1):
252
+ # Skip if already answered
253
+ if question.question in answers and answers[question.question]:
254
+ continue
255
+
256
+ answer = await prompt_user_for_answer(question, idx, total)
257
+ if answer is None:
258
+ return answers, True # Cancelled
259
+ answers[question.question] = answer
260
+
261
+ return answers, False
262
+
263
+
264
+ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutput]):
265
+ """Tool for asking the user questions interactively."""
266
+
267
+ @property
268
+ def name(self) -> str:
269
+ return TOOL_NAME
270
+
271
+ async def description(self) -> str:
272
+ return (
273
+ "Asks the user multiple choice questions to gather information, "
274
+ "clarify ambiguity, understand preferences, make decisions or offer them choices."
275
+ )
276
+
277
+ @property
278
+ def input_schema(self) -> type[AskUserQuestionToolInput]:
279
+ return AskUserQuestionToolInput
280
+
281
+ async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
282
+ return ASK_USER_QUESTION_PROMPT
283
+
284
+ def user_facing_name(self) -> str:
285
+ return ""
286
+
287
+ def is_read_only(self) -> bool:
288
+ return True
289
+
290
+ def is_concurrency_safe(self) -> bool:
291
+ return True
292
+
293
+ def needs_permissions(
294
+ self, input_data: Optional[AskUserQuestionToolInput] = None # noqa: ARG002
295
+ ) -> bool:
296
+ return False
297
+
298
+ async def validate_input(
299
+ self,
300
+ input_data: AskUserQuestionToolInput,
301
+ context: Optional[ToolUseContext] = None, # noqa: ARG002
302
+ ) -> ValidationResult:
303
+ """Validate that question texts and option labels are unique."""
304
+ seen_questions: set[str] = set()
305
+
306
+ for question in input_data.questions:
307
+ if question.question in seen_questions:
308
+ return ValidationResult(
309
+ result=False, message="Question texts must be unique"
310
+ )
311
+ seen_questions.add(question.question)
312
+
313
+ option_labels: set[str] = set()
314
+ for option in question.options:
315
+ if option.label in option_labels:
316
+ return ValidationResult(
317
+ result=False,
318
+ message=f'Option labels for "{question.question}" must be unique',
319
+ )
320
+ option_labels.add(option.label)
321
+
322
+ return ValidationResult(result=True)
323
+
324
+ def render_result_for_assistant(self, output: AskUserQuestionToolOutput) -> str:
325
+ """Render the tool output for the AI assistant."""
326
+ if output.cancelled:
327
+ return "User declined to answer your questions."
328
+
329
+ if not output.answers:
330
+ return "User did not provide any answers."
331
+
332
+ serialized = ", ".join(
333
+ f'"{question}"="{answer}"' for question, answer in output.answers.items()
334
+ )
335
+ return (
336
+ f"User has answered your questions: {serialized}. "
337
+ "You can now continue with the user's answers in mind."
338
+ )
339
+
340
+ def render_tool_use_message(
341
+ self, input_data: AskUserQuestionToolInput, verbose: bool = False # noqa: ARG002
342
+ ) -> str:
343
+ """Render the tool use message for display."""
344
+ question_count = len(input_data.questions)
345
+ if question_count == 1:
346
+ return f"Asking user: {input_data.questions[0].question}"
347
+ return f"Asking user {question_count} questions"
348
+
349
+ async def call(
350
+ self,
351
+ input_data: AskUserQuestionToolInput,
352
+ context: ToolUseContext,
353
+ ) -> AsyncGenerator[ToolOutput, None]:
354
+ """Execute the tool to ask user questions."""
355
+ questions = input_data.questions
356
+ initial_answers = input_data.answers or {}
357
+
358
+ # Pause UI spinner before user interaction
359
+ if context.pause_ui:
360
+ try:
361
+ context.pause_ui()
362
+ except Exception:
363
+ logger.debug("[ask_user_question_tool] Failed to pause UI")
364
+
365
+ try:
366
+ # Display introduction
367
+ loop = asyncio.get_running_loop()
368
+
369
+ def _print_intro() -> None:
370
+ print("\n" + "=" * 60)
371
+ print("I need a few answers to proceed:")
372
+ print("=" * 60)
373
+
374
+ await loop.run_in_executor(None, _print_intro)
375
+
376
+ # Collect answers
377
+ answers, cancelled = await collect_answers(questions, initial_answers)
378
+
379
+ if cancelled:
380
+ output = AskUserQuestionToolOutput(
381
+ questions=questions,
382
+ answers=answers,
383
+ cancelled=True,
384
+ )
385
+ yield ToolResult(
386
+ data=output,
387
+ result_for_assistant=self.render_result_for_assistant(output),
388
+ )
389
+ return
390
+
391
+ # Display summary
392
+ def _print_summary() -> None:
393
+ print("\n" + "-" * 40)
394
+ print("Your answers:")
395
+ for q, a in answers.items():
396
+ print(f" - {q}")
397
+ print(f" -> {a}")
398
+ print("-" * 40 + "\n")
399
+
400
+ await loop.run_in_executor(None, _print_summary)
401
+
402
+ output = AskUserQuestionToolOutput(
403
+ questions=questions,
404
+ answers=answers,
405
+ cancelled=False,
406
+ )
407
+ yield ToolResult(
408
+ data=output,
409
+ result_for_assistant=self.render_result_for_assistant(output),
410
+ )
411
+
412
+ except Exception as exc:
413
+ logger.exception(
414
+ "[ask_user_question_tool] Error collecting answers",
415
+ extra={"error": str(exc)},
416
+ )
417
+ output = AskUserQuestionToolOutput(
418
+ questions=questions,
419
+ answers={},
420
+ cancelled=True,
421
+ )
422
+ yield ToolResult(
423
+ data=output,
424
+ result_for_assistant="Error while collecting user answers: " + str(exc),
425
+ )
426
+
427
+ finally:
428
+ # Resume UI spinner after user interaction
429
+ if context.resume_ui:
430
+ try:
431
+ context.resume_ui()
432
+ except Exception:
433
+ logger.debug("[ask_user_question_tool] Failed to resume UI")
@@ -14,6 +14,9 @@ import uuid
14
14
  from dataclasses import dataclass, field
15
15
  from typing import Any, Dict, List, Optional
16
16
 
17
+ import atexit
18
+
19
+ from ripperdoc.utils.shell_utils import build_shell_command, find_suitable_shell
17
20
  from ripperdoc.utils.log import get_logger
18
21
 
19
22
 
@@ -43,6 +46,13 @@ _tasks_lock = threading.Lock()
43
46
  _background_loop: Optional[asyncio.AbstractEventLoop] = None
44
47
  _background_thread: Optional[threading.Thread] = None
45
48
  _loop_lock = threading.Lock()
49
+ _shutdown_registered = False
50
+ def _safe_log_exception(message: str, **extra: Any) -> None:
51
+ """Log an exception but never let logging failures bubble up."""
52
+ try:
53
+ logger.exception(message, extra=extra)
54
+ except Exception:
55
+ pass
46
56
 
47
57
 
48
58
  def _ensure_background_loop() -> asyncio.AbstractEventLoop:
@@ -74,9 +84,18 @@ def _ensure_background_loop() -> asyncio.AbstractEventLoop:
74
84
 
75
85
  _background_loop = loop
76
86
  _background_thread = thread
87
+ _register_shutdown_hook()
77
88
  return loop
78
89
 
79
90
 
91
+ def _register_shutdown_hook() -> None:
92
+ global _shutdown_registered
93
+ if _shutdown_registered:
94
+ return
95
+ atexit.register(shutdown_background_shell)
96
+ _shutdown_registered = True
97
+
98
+
80
99
  def _submit_to_background_loop(coro: Any) -> concurrent.futures.Future:
81
100
  """Run a coroutine on the background loop and return a thread-safe future."""
82
101
  loop = _ensure_background_loop()
@@ -153,24 +172,15 @@ async def _start_background_command(
153
172
  command: str, timeout: Optional[float] = None, shell_executable: Optional[str] = None
154
173
  ) -> str:
155
174
  """Launch a background shell command on the dedicated loop."""
156
- if shell_executable:
157
- process = await asyncio.create_subprocess_exec(
158
- shell_executable,
159
- "-c",
160
- command,
161
- stdout=asyncio.subprocess.PIPE,
162
- stderr=asyncio.subprocess.PIPE,
163
- stdin=asyncio.subprocess.DEVNULL,
164
- start_new_session=False,
165
- )
166
- else:
167
- process = await asyncio.create_subprocess_shell(
168
- command,
169
- stdout=asyncio.subprocess.PIPE,
170
- stderr=asyncio.subprocess.PIPE,
171
- stdin=asyncio.subprocess.DEVNULL,
172
- start_new_session=False,
173
- )
175
+ selected_shell = shell_executable or find_suitable_shell()
176
+ argv = build_shell_command(selected_shell, command)
177
+ process = await asyncio.create_subprocess_exec(
178
+ *argv,
179
+ stdout=asyncio.subprocess.PIPE,
180
+ stderr=asyncio.subprocess.PIPE,
181
+ stdin=asyncio.subprocess.DEVNULL,
182
+ start_new_session=False,
183
+ )
174
184
 
175
185
  task_id = f"bash_{uuid.uuid4().hex[:8]}"
176
186
  record = BackgroundTask(
@@ -295,3 +305,79 @@ def list_background_tasks() -> List[str]:
295
305
  """Return known background task ids."""
296
306
  with _tasks_lock:
297
307
  return list(_tasks.keys())
308
+
309
+
310
+ async def _shutdown_loop(loop: asyncio.AbstractEventLoop) -> None:
311
+ """Drain running background processes before stopping the loop."""
312
+ with _tasks_lock:
313
+ tasks = list(_tasks.values())
314
+ _tasks.clear()
315
+
316
+ for task in tasks:
317
+ try:
318
+ task.killed = True
319
+ with contextlib.suppress(ProcessLookupError):
320
+ task.process.kill()
321
+ try:
322
+ with contextlib.suppress(ProcessLookupError):
323
+ await asyncio.wait_for(task.process.wait(), timeout=1.5)
324
+ except asyncio.TimeoutError:
325
+ with contextlib.suppress(ProcessLookupError, PermissionError):
326
+ task.process.kill()
327
+ with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
328
+ await asyncio.wait_for(task.process.wait(), timeout=0.5)
329
+ task.exit_code = task.process.returncode or -1
330
+ except Exception:
331
+ _safe_log_exception(
332
+ "Error shutting down background task",
333
+ task_id=task.id,
334
+ command=task.command,
335
+ )
336
+ finally:
337
+ await _finalize_reader_tasks(task.reader_tasks)
338
+ task.done_event.set()
339
+
340
+ current = asyncio.current_task()
341
+ pending = [t for t in asyncio.all_tasks(loop) if t is not current]
342
+ for pending_task in pending:
343
+ pending_task.cancel()
344
+ if pending:
345
+ with contextlib.suppress(Exception):
346
+ await asyncio.gather(*pending, return_exceptions=True)
347
+
348
+ with contextlib.suppress(Exception):
349
+ await loop.shutdown_asyncgens()
350
+
351
+
352
+ def shutdown_background_shell() -> None:
353
+ """Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings."""
354
+ global _background_loop, _background_thread
355
+
356
+ loop = _background_loop
357
+ thread = _background_thread
358
+
359
+ if not loop or loop.is_closed():
360
+ _background_loop = None
361
+ _background_thread = None
362
+ return
363
+
364
+ try:
365
+ if loop.is_running():
366
+ try:
367
+ fut = asyncio.run_coroutine_threadsafe(_shutdown_loop(loop), loop)
368
+ fut.result(timeout=3)
369
+ except Exception:
370
+ logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
371
+ try:
372
+ loop.call_soon_threadsafe(loop.stop)
373
+ except Exception:
374
+ logger.debug("Failed to stop background loop", exc_info=True)
375
+ else:
376
+ loop.run_until_complete(_shutdown_loop(loop))
377
+ finally:
378
+ if thread and thread.is_alive():
379
+ thread.join(timeout=2)
380
+ with contextlib.suppress(Exception):
381
+ loop.close()
382
+ _background_loop = None
383
+ _background_thread = None
@@ -49,6 +49,7 @@ from ripperdoc.utils.permissions.tool_permission_utils import (
49
49
  from ripperdoc.utils.permissions import PermissionDecision
50
50
  from ripperdoc.utils.sandbox_utils import create_sandbox_wrapper, is_sandbox_available
51
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
52
53
  from ripperdoc.utils.log import get_logger
53
54
 
54
55
  logger = get_logger()
@@ -151,6 +152,15 @@ build projects, run tests, and interact with the file system."""
151
152
 
152
153
  async def prompt(self, safe_mode: bool = False) -> str:
153
154
  sandbox_available = is_sandbox_available()
155
+ try:
156
+ current_shell = find_suitable_shell()
157
+ except Exception as exc: # pragma: no cover - defensive guard
158
+ current_shell = f"Unavailable ({exc})"
159
+
160
+ shell_info = (
161
+ f"Current shell used for execution: {current_shell}\n"
162
+ f"- Override via RIPPERDOC_SHELL or RIPPERDOC_SHELL_PATH env vars, or pass shellExecutable input.\n"
163
+ )
154
164
 
155
165
  read_only_section = ""
156
166
  if sandbox_available:
@@ -235,6 +245,8 @@ build projects, run tests, and interact with the file system."""
235
245
  f"""\
236
246
  Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
237
247
 
248
+ {shell_info}
249
+
238
250
  Before executing the command, please follow these steps:
239
251
 
240
252
  1. Directory Verification:
@@ -486,6 +498,23 @@ build projects, run tests, and interact with the file system."""
486
498
  """Execute the bash command."""
487
499
 
488
500
  effective_command, auto_background = self._detect_auto_background(input_data.command)
501
+ try:
502
+ resolved_shell = input_data.shell_executable or find_suitable_shell()
503
+ except Exception as exc: # pragma: no cover - defensive guard
504
+ error_output = BashToolOutput(
505
+ stdout="",
506
+ stderr=f"Failed to select shell: {exc}",
507
+ exit_code=-1,
508
+ command=effective_command,
509
+ sandbox=bool(input_data.sandbox),
510
+ is_error=True,
511
+ )
512
+ yield ToolResult(
513
+ data=error_output,
514
+ result_for_assistant=self.render_result_for_assistant(error_output),
515
+ )
516
+ return
517
+
489
518
  timeout_ms = input_data.timeout or DEFAULT_TIMEOUT_MS
490
519
  if MAX_BASH_TIMEOUT_MS:
491
520
  timeout_ms = min(timeout_ms, MAX_BASH_TIMEOUT_MS)
@@ -544,18 +573,9 @@ build projects, run tests, and interact with the file system."""
544
573
  should_background = False
545
574
 
546
575
  async def _spawn_process() -> asyncio.subprocess.Process:
547
- if input_data.shell_executable:
548
- return await asyncio.create_subprocess_exec(
549
- input_data.shell_executable,
550
- "-c",
551
- final_command,
552
- stdout=asyncio.subprocess.PIPE,
553
- stderr=asyncio.subprocess.PIPE,
554
- stdin=asyncio.subprocess.DEVNULL,
555
- start_new_session=False,
556
- )
557
- return await asyncio.create_subprocess_shell(
558
- final_command,
576
+ argv = build_shell_command(resolved_shell, final_command)
577
+ return await asyncio.create_subprocess_exec(
578
+ *argv,
559
579
  stdout=asyncio.subprocess.PIPE,
560
580
  stderr=asyncio.subprocess.PIPE,
561
581
  stdin=asyncio.subprocess.DEVNULL,
@@ -592,7 +612,7 @@ build projects, run tests, and interact with the file system."""
592
612
  else (timeout_seconds if timeout_seconds > 0 else None)
593
613
  )
594
614
  task_id = await start_background_command(
595
- final_command, timeout=bg_timeout, shell_executable=input_data.shell_executable
615
+ final_command, timeout=bg_timeout, shell_executable=resolved_shell
596
616
  )
597
617
 
598
618
  output = BashToolOutput(