ripperdoc 0.2.2__py3-none-any.whl → 0.2.3__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 (51) 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/cost_cmd.py +5 -0
  5. ripperdoc/cli/commands/doctor_cmd.py +12 -4
  6. ripperdoc/cli/commands/memory_cmd.py +6 -13
  7. ripperdoc/cli/commands/models_cmd.py +36 -6
  8. ripperdoc/cli/commands/resume_cmd.py +4 -2
  9. ripperdoc/cli/commands/status_cmd.py +1 -1
  10. ripperdoc/cli/ui/rich_ui.py +102 -2
  11. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  12. ripperdoc/core/agents.py +13 -5
  13. ripperdoc/core/config.py +9 -1
  14. ripperdoc/core/providers/__init__.py +31 -0
  15. ripperdoc/core/providers/anthropic.py +136 -0
  16. ripperdoc/core/providers/base.py +187 -0
  17. ripperdoc/core/providers/gemini.py +172 -0
  18. ripperdoc/core/providers/openai.py +142 -0
  19. ripperdoc/core/query.py +331 -141
  20. ripperdoc/core/query_utils.py +64 -23
  21. ripperdoc/core/tool.py +5 -3
  22. ripperdoc/sdk/client.py +12 -1
  23. ripperdoc/tools/background_shell.py +54 -18
  24. ripperdoc/tools/bash_tool.py +33 -13
  25. ripperdoc/tools/file_edit_tool.py +13 -0
  26. ripperdoc/tools/file_read_tool.py +16 -0
  27. ripperdoc/tools/file_write_tool.py +13 -0
  28. ripperdoc/tools/glob_tool.py +5 -1
  29. ripperdoc/tools/ls_tool.py +14 -10
  30. ripperdoc/tools/multi_edit_tool.py +12 -0
  31. ripperdoc/tools/notebook_edit_tool.py +12 -0
  32. ripperdoc/tools/todo_tool.py +1 -3
  33. ripperdoc/tools/tool_search_tool.py +8 -4
  34. ripperdoc/utils/file_watch.py +134 -0
  35. ripperdoc/utils/git_utils.py +36 -38
  36. ripperdoc/utils/json_utils.py +1 -2
  37. ripperdoc/utils/log.py +3 -4
  38. ripperdoc/utils/memory.py +1 -3
  39. ripperdoc/utils/message_compaction.py +2 -6
  40. ripperdoc/utils/messages.py +9 -13
  41. ripperdoc/utils/output_utils.py +1 -3
  42. ripperdoc/utils/prompt.py +17 -0
  43. ripperdoc/utils/session_usage.py +7 -0
  44. ripperdoc/utils/shell_utils.py +159 -0
  45. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.3.dist-info}/METADATA +1 -1
  46. ripperdoc-0.2.3.dist-info/RECORD +95 -0
  47. ripperdoc-0.2.2.dist-info/RECORD +0 -86
  48. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.3.dist-info}/WHEEL +0 -0
  49. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.3.dist-info}/entry_points.txt +0 -0
  50. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.3.dist-info}/licenses/LICENSE +0 -0
  51. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.3.dist-info}/top_level.txt +0 -0
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import re
7
- from typing import Any, Dict, List, Optional, Union
7
+ from typing import Any, Dict, List, Mapping, Optional, Union
8
8
  from uuid import uuid4
9
9
 
10
10
  from json_repair import repair_json
@@ -26,17 +26,25 @@ from ripperdoc.utils.messages import (
26
26
  logger = get_logger()
27
27
 
28
28
 
29
- def _safe_int(value: Any) -> int:
29
+ def _safe_int(value: object) -> int:
30
30
  """Best-effort int conversion for usage counters."""
31
31
  try:
32
32
  if value is None:
33
33
  return 0
34
- return int(value)
34
+ if isinstance(value, bool):
35
+ return int(value)
36
+ if isinstance(value, (int, float)):
37
+ return int(value)
38
+ if isinstance(value, str):
39
+ return int(value)
40
+ if hasattr(value, "__int__"):
41
+ return int(value) # type: ignore[arg-type]
42
+ return 0
35
43
  except (TypeError, ValueError):
36
44
  return 0
37
45
 
38
46
 
39
- def _get_usage_field(usage: Any, field: str) -> int:
47
+ def _get_usage_field(usage: Optional[Mapping[str, Any] | object], field: str) -> int:
40
48
  """Fetch a usage field from either a dict or object."""
41
49
  if usage is None:
42
50
  return 0
@@ -45,7 +53,7 @@ def _get_usage_field(usage: Any, field: str) -> int:
45
53
  return _safe_int(getattr(usage, field, 0))
46
54
 
47
55
 
48
- def anthropic_usage_tokens(usage: Any) -> Dict[str, int]:
56
+ def anthropic_usage_tokens(usage: Optional[Mapping[str, Any] | object]) -> Dict[str, int]:
49
57
  """Extract token counts from an Anthropic response usage payload."""
50
58
  return {
51
59
  "input_tokens": _get_usage_field(usage, "input_tokens"),
@@ -55,7 +63,7 @@ def anthropic_usage_tokens(usage: Any) -> Dict[str, int]:
55
63
  }
56
64
 
57
65
 
58
- def openai_usage_tokens(usage: Any) -> Dict[str, int]:
66
+ def openai_usage_tokens(usage: Optional[Mapping[str, Any] | object]) -> Dict[str, int]:
59
67
  """Extract token counts from an OpenAI-compatible response usage payload."""
60
68
  prompt_details = None
61
69
  if isinstance(usage, dict):
@@ -73,8 +81,24 @@ def openai_usage_tokens(usage: Any) -> Dict[str, int]:
73
81
  }
74
82
 
75
83
 
84
+ def estimate_cost_usd(model_profile: ModelProfile, usage_tokens: Dict[str, int]) -> float:
85
+ """Compute USD cost using per-1M token pricing from the model profile."""
86
+ input_price = getattr(model_profile, "input_cost_per_million_tokens", 0.0) or 0.0
87
+ output_price = getattr(model_profile, "output_cost_per_million_tokens", 0.0) or 0.0
88
+
89
+ total_input_tokens = (
90
+ _safe_int(usage_tokens.get("input_tokens"))
91
+ + _safe_int(usage_tokens.get("cache_read_input_tokens"))
92
+ + _safe_int(usage_tokens.get("cache_creation_input_tokens"))
93
+ )
94
+ output_tokens = _safe_int(usage_tokens.get("output_tokens"))
95
+
96
+ cost = (total_input_tokens * input_price + output_tokens * output_price) / 1_000_000
97
+ return float(cost)
98
+
99
+
76
100
  def resolve_model_profile(model: str) -> ModelProfile:
77
- """Resolve a model pointer to a concrete profile or raise if missing."""
101
+ """Resolve a model pointer to a concrete profile, falling back to a safe default."""
78
102
  config = get_global_config()
79
103
  profile_name = getattr(config.model_pointers, model, None) or model
80
104
  model_profile = config.model_profiles.get(profile_name)
@@ -82,7 +106,11 @@ def resolve_model_profile(model: str) -> ModelProfile:
82
106
  fallback_profile = getattr(config.model_pointers, "main", "default")
83
107
  model_profile = config.model_profiles.get(fallback_profile)
84
108
  if not model_profile:
85
- raise ValueError(f"No model profile found for pointer: {model}")
109
+ logger.warning(
110
+ "[config] No model profile found; using built-in default profile",
111
+ extra={"model_pointer": model},
112
+ )
113
+ return ModelProfile(provider=ProviderType.OPENAI_COMPATIBLE, model="gpt-4o-mini")
86
114
  return model_profile
87
115
 
88
116
 
@@ -103,12 +131,10 @@ def _parse_text_mode_json_blocks(text: str) -> Optional[List[Dict[str, Any]]]:
103
131
  if not text or not isinstance(text, str):
104
132
  return None
105
133
 
106
- code_blocks = re.findall(
107
- r"```(?:\s*json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE
108
- )
134
+ code_blocks = re.findall(r"```(?:\s*json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE)
109
135
  candidates = [blk.strip() for blk in code_blocks if blk.strip()]
110
136
 
111
- def _normalize_blocks(parsed: Any) -> Optional[List[Dict[str, Any]]]:
137
+ def _normalize_blocks(parsed: object) -> Optional[List[Dict[str, Any]]]:
112
138
  raw_blocks = parsed if isinstance(parsed, list) else [parsed]
113
139
  normalized: List[Dict[str, Any]] = []
114
140
  for raw in raw_blocks:
@@ -204,7 +230,9 @@ def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
204
230
 
205
231
  schema_json = ""
206
232
  try:
207
- schema_json = json.dumps(tool.input_schema.model_json_schema(), ensure_ascii=False, indent=2)
233
+ schema_json = json.dumps(
234
+ tool.input_schema.model_json_schema(), ensure_ascii=False, indent=2
235
+ )
208
236
  except (AttributeError, TypeError, ValueError) as exc:
209
237
  logger.debug(
210
238
  "[tool_prompt] Failed to render input_schema",
@@ -218,7 +246,12 @@ def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
218
246
 
219
247
  example_blocks = [
220
248
  {"type": "text", "text": "好的,我来帮你查看一下README.md文件"},
221
- {"type": "tool_use", "tool_use_id": "tool_id_000001", "tool": "View", "input": {"file_path": "README.md"}},
249
+ {
250
+ "type": "tool_use",
251
+ "tool_use_id": "tool_id_000001",
252
+ "tool": "View",
253
+ "input": {"file_path": "README.md"},
254
+ },
222
255
  ]
223
256
  lines.append("Example:")
224
257
  lines.append("```json")
@@ -228,7 +261,9 @@ def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
228
261
  return "\n".join(lines)
229
262
 
230
263
 
231
- def text_mode_history(messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]]) -> List[Union[UserMessage, AssistantMessage]]:
264
+ def text_mode_history(
265
+ messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
266
+ ) -> List[Union[UserMessage, AssistantMessage]]:
232
267
  """Convert a message history into text-only form for text mode."""
233
268
 
234
269
  def _normalize_block(block: Any) -> Optional[Dict[str, Any]]:
@@ -269,9 +304,13 @@ def text_mode_history(messages: List[Union[UserMessage, AssistantMessage, Progre
269
304
  if isinstance(content, list):
270
305
  normalized_blocks = []
271
306
  for block in content:
272
- block_type = getattr(block, "type", None) or (block.get("type") if isinstance(block, dict) else None)
273
- block_text = getattr(block, "text", None) if hasattr(block, "text") else (
274
- block.get("text") if isinstance(block, dict) else None
307
+ block_type = getattr(block, "type", None) or (
308
+ block.get("type") if isinstance(block, dict) else None
309
+ )
310
+ block_text = (
311
+ getattr(block, "text", None)
312
+ if hasattr(block, "text")
313
+ else (block.get("text") if isinstance(block, dict) else None)
275
314
  )
276
315
  if block_type == "text" and isinstance(block_text, str):
277
316
  parsed_nested = _parse_text_mode_json_blocks(block_text)
@@ -287,7 +326,9 @@ def text_mode_history(messages: List[Union[UserMessage, AssistantMessage, Progre
287
326
  elif isinstance(content, str):
288
327
  parsed_blocks = _parse_text_mode_json_blocks(content)
289
328
  if parsed_blocks:
290
- text_content = f"```json\n{json.dumps(parsed_blocks, ensure_ascii=False, indent=2)}\n```"
329
+ text_content = (
330
+ f"```json\n{json.dumps(parsed_blocks, ensure_ascii=False, indent=2)}\n```"
331
+ )
291
332
  else:
292
333
  text_content = content
293
334
  else:
@@ -301,7 +342,9 @@ def text_mode_history(messages: List[Union[UserMessage, AssistantMessage, Progre
301
342
  return converted
302
343
 
303
344
 
304
- def _maybe_convert_json_block_to_tool_use(content_blocks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
345
+ def _maybe_convert_json_block_to_tool_use(
346
+ content_blocks: List[Dict[str, Any]],
347
+ ) -> List[Dict[str, Any]]:
305
348
  """Convert any text blocks containing JSON content to structured content blocks."""
306
349
  if not content_blocks:
307
350
  return content_blocks
@@ -437,9 +480,7 @@ async def build_openai_tool_schemas(tools: List[Tool[Any, Any]]) -> List[Dict[st
437
480
  return openai_tools
438
481
 
439
482
 
440
- def content_blocks_from_anthropic_response(
441
- response: Any, tool_mode: str
442
- ) -> List[Dict[str, Any]]:
483
+ def content_blocks_from_anthropic_response(response: Any, tool_mode: str) -> List[Dict[str, Any]]:
443
484
  """Normalize Anthropic response content to our internal block format."""
444
485
  blocks: List[Dict[str, Any]] = []
445
486
  for block in getattr(response, "content", []) or []:
ripperdoc/core/tool.py CHANGED
@@ -8,6 +8,7 @@ import json
8
8
  from abc import ABC, abstractmethod
9
9
  from typing import Any, AsyncGenerator, Dict, List, Optional, TypeVar, Generic, Union
10
10
  from pydantic import BaseModel, ConfigDict, Field
11
+ from ripperdoc.utils.file_watch import FileSnapshot
11
12
  from ripperdoc.utils.log import get_logger
12
13
 
13
14
 
@@ -39,7 +40,8 @@ class ToolUseContext(BaseModel):
39
40
  safe_mode: bool = False
40
41
  verbose: bool = False
41
42
  permission_checker: Optional[Any] = None
42
- read_file_timestamps: Dict[str, float] = {}
43
+ read_file_timestamps: Dict[str, float] = Field(default_factory=dict)
44
+ file_state_cache: Dict[str, "FileSnapshot"] = Field(default_factory=dict)
43
45
  tool_registry: Optional[Any] = None
44
46
  abort_signal: Optional[Any] = None
45
47
  model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -202,7 +204,7 @@ async def build_tool_description(
202
204
  except Exception:
203
205
  logger.exception(
204
206
  "[tool] Failed to build input example section",
205
- extra={"tool": getattr(tool, 'name', None)},
207
+ extra={"tool": getattr(tool, "name", None)},
206
208
  )
207
209
  return description_text
208
210
 
@@ -221,7 +223,7 @@ def tool_input_examples(tool: Tool[Any, Any], limit: int = 5) -> List[Dict[str,
221
223
  except Exception:
222
224
  logger.exception(
223
225
  "[tool] Failed to format tool input example",
224
- extra={"tool": getattr(tool, 'name', None)},
226
+ extra={"tool": getattr(tool, "name", None)},
225
227
  )
226
228
  continue
227
229
  return results
ripperdoc/sdk/client.py CHANGED
@@ -19,11 +19,13 @@ from typing import (
19
19
  List,
20
20
  Optional,
21
21
  Sequence,
22
+ Tuple,
22
23
  Union,
23
24
  )
24
25
 
25
26
  from ripperdoc.core.default_tools import get_default_tools
26
27
  from ripperdoc.core.query import QueryContext, query as _core_query
28
+ from ripperdoc.core.permissions import PermissionResult
27
29
  from ripperdoc.core.system_prompt import build_system_prompt
28
30
  from ripperdoc.core.tool import Tool
29
31
  from ripperdoc.tools.task_tool import TaskTool
@@ -42,7 +44,16 @@ from ripperdoc.utils.mcp import (
42
44
  )
43
45
 
44
46
  MessageType = Union[UserMessage, AssistantMessage, ProgressMessage]
45
- PermissionChecker = Callable[[Tool[Any, Any], Any], Union[Awaitable[Any], Any]]
47
+ PermissionChecker = Callable[
48
+ [Tool[Any, Any], Any],
49
+ Union[
50
+ PermissionResult,
51
+ Dict[str, Any],
52
+ Tuple[bool, Optional[str]],
53
+ bool,
54
+ Awaitable[Union[PermissionResult, Dict[str, Any], Tuple[bool, Optional[str]], bool]],
55
+ ],
56
+ ]
46
57
  QueryRunner = Callable[
47
58
  [
48
59
  List[MessageType],
@@ -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,7 @@ _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
46
50
 
47
51
 
48
52
  def _ensure_background_loop() -> asyncio.AbstractEventLoop:
@@ -74,9 +78,18 @@ def _ensure_background_loop() -> asyncio.AbstractEventLoop:
74
78
 
75
79
  _background_loop = loop
76
80
  _background_thread = thread
81
+ _register_shutdown_hook()
77
82
  return loop
78
83
 
79
84
 
85
+ def _register_shutdown_hook() -> None:
86
+ global _shutdown_registered
87
+ if _shutdown_registered:
88
+ return
89
+ atexit.register(shutdown_background_shell)
90
+ _shutdown_registered = True
91
+
92
+
80
93
  def _submit_to_background_loop(coro: Any) -> concurrent.futures.Future:
81
94
  """Run a coroutine on the background loop and return a thread-safe future."""
82
95
  loop = _ensure_background_loop()
@@ -153,24 +166,15 @@ async def _start_background_command(
153
166
  command: str, timeout: Optional[float] = None, shell_executable: Optional[str] = None
154
167
  ) -> str:
155
168
  """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
- )
169
+ selected_shell = shell_executable or find_suitable_shell()
170
+ argv = build_shell_command(selected_shell, command)
171
+ process = await asyncio.create_subprocess_exec(
172
+ *argv,
173
+ stdout=asyncio.subprocess.PIPE,
174
+ stderr=asyncio.subprocess.PIPE,
175
+ stdin=asyncio.subprocess.DEVNULL,
176
+ start_new_session=False,
177
+ )
174
178
 
175
179
  task_id = f"bash_{uuid.uuid4().hex[:8]}"
176
180
  record = BackgroundTask(
@@ -295,3 +299,35 @@ def list_background_tasks() -> List[str]:
295
299
  """Return known background task ids."""
296
300
  with _tasks_lock:
297
301
  return list(_tasks.keys())
302
+
303
+
304
+ def shutdown_background_shell() -> None:
305
+ """Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings."""
306
+ global _background_loop, _background_thread
307
+
308
+ loop = _background_loop
309
+ with _tasks_lock:
310
+ tasks = list(_tasks.values())
311
+ _tasks.clear()
312
+
313
+ for task in tasks:
314
+ try:
315
+ task.killed = True
316
+ task.process.kill()
317
+ except Exception:
318
+ pass
319
+ for reader in task.reader_tasks:
320
+ if loop and loop.is_running():
321
+ loop.call_soon_threadsafe(reader.cancel)
322
+ task.done_event.set()
323
+
324
+ if loop and loop.is_running():
325
+ try:
326
+ loop.call_soon_threadsafe(loop.stop)
327
+ except Exception:
328
+ pass
329
+ if _background_thread and _background_thread.is_alive():
330
+ _background_thread.join(timeout=2)
331
+
332
+ _background_loop = None
333
+ _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(
@@ -16,6 +16,7 @@ from ripperdoc.core.tool import (
16
16
  ValidationResult,
17
17
  )
18
18
  from ripperdoc.utils.log import get_logger
19
+ from ripperdoc.utils.file_watch import record_snapshot
19
20
 
20
21
  logger = get_logger()
21
22
 
@@ -185,6 +186,18 @@ match exactly (including whitespace and indentation)."""
185
186
  with open(input_data.file_path, "w", encoding="utf-8") as f:
186
187
  f.write(new_content)
187
188
 
189
+ try:
190
+ record_snapshot(
191
+ input_data.file_path,
192
+ new_content,
193
+ getattr(context, "file_state_cache", {}),
194
+ )
195
+ except Exception:
196
+ logger.exception(
197
+ "[file_edit_tool] Failed to record file snapshot",
198
+ extra={"file_path": input_data.file_path},
199
+ )
200
+
188
201
  # Generate diff for display
189
202
  import difflib
190
203
 
@@ -16,6 +16,7 @@ from ripperdoc.core.tool import (
16
16
  ValidationResult,
17
17
  )
18
18
  from ripperdoc.utils.log import get_logger
19
+ from ripperdoc.utils.file_watch import record_snapshot
19
20
 
20
21
  logger = get_logger()
21
22
 
@@ -143,6 +144,21 @@ and limit to read only a portion of the file."""
143
144
 
144
145
  content = "".join(selected_lines)
145
146
 
147
+ # Remember what we read so we can detect user edits later.
148
+ try:
149
+ record_snapshot(
150
+ input_data.file_path,
151
+ content,
152
+ getattr(context, "file_state_cache", {}),
153
+ offset=offset,
154
+ limit=limit,
155
+ )
156
+ except Exception:
157
+ logger.exception(
158
+ "[file_read_tool] Failed to record file snapshot",
159
+ extra={"file_path": input_data.file_path},
160
+ )
161
+
146
162
  output = FileReadToolOutput(
147
163
  content=content,
148
164
  file_path=input_data.file_path,
@@ -17,6 +17,7 @@ from ripperdoc.core.tool import (
17
17
  ValidationResult,
18
18
  )
19
19
  from ripperdoc.utils.log import get_logger
20
+ from ripperdoc.utils.file_watch import record_snapshot
20
21
 
21
22
  logger = get_logger()
22
23
 
@@ -125,6 +126,18 @@ NEVER write new files unless explicitly required by the user."""
125
126
 
126
127
  bytes_written = len(input_data.content.encode("utf-8"))
127
128
 
129
+ try:
130
+ record_snapshot(
131
+ input_data.file_path,
132
+ input_data.content,
133
+ getattr(context, "file_state_cache", {}),
134
+ )
135
+ except Exception:
136
+ logger.exception(
137
+ "[file_write_tool] Failed to record file snapshot",
138
+ extra={"file_path": input_data.file_path},
139
+ )
140
+
128
141
  output = FileWriteToolOutput(
129
142
  file_path=input_data.file_path,
130
143
  bytes_written=bytes_written,
@@ -112,7 +112,11 @@ class GlobTool(Tool[GlobToolInput, GlobToolOutput]):
112
112
  rendered_path = ""
113
113
  if input_data.path:
114
114
  candidate_path = Path(input_data.path)
115
- absolute_path = candidate_path if candidate_path.is_absolute() else (base_path / candidate_path).resolve()
115
+ absolute_path = (
116
+ candidate_path
117
+ if candidate_path.is_absolute()
118
+ else (base_path / candidate_path).resolve()
119
+ )
116
120
 
117
121
  try:
118
122
  relative_path = absolute_path.relative_to(base_path)
@@ -142,22 +142,22 @@ def _should_skip(
142
142
  path: Path,
143
143
  root_path: Path,
144
144
  patterns: list[str],
145
- ignore_map: Optional[Dict[Optional[Path], List[str]]] = None
145
+ ignore_map: Optional[Dict[Optional[Path], List[str]]] = None,
146
146
  ) -> bool:
147
147
  name = path.name
148
148
  if name.startswith("."):
149
149
  return True
150
150
  if "__pycache__" in path.parts:
151
151
  return True
152
-
152
+
153
153
  # Check against ignore patterns
154
154
  if ignore_map and should_ignore_path(path, root_path, ignore_map):
155
155
  return True
156
-
156
+
157
157
  # Also check against direct patterns for backward compatibility
158
158
  if patterns and _matches_ignore(path, root_path, patterns):
159
159
  return True
160
-
160
+
161
161
  return False
162
162
 
163
163
 
@@ -346,7 +346,9 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
346
346
  try:
347
347
  root_path = _resolve_directory_path(input_data.path)
348
348
  except Exception:
349
- return ValidationResult(result=False, message=f"Unable to resolve path: {input_data.path}")
349
+ return ValidationResult(
350
+ result=False, message=f"Unable to resolve path: {input_data.path}"
351
+ )
350
352
 
351
353
  if not root_path.is_absolute():
352
354
  return ValidationResult(result=False, message=f"Path is not absolute: {root_path}")
@@ -392,7 +394,9 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
392
394
  return f'path: "{input_data.path}"{ignore_display}'
393
395
 
394
396
  try:
395
- relative_path = _relative_path_for_display(resolved_path, base_path) or resolved_path.as_posix()
397
+ relative_path = (
398
+ _relative_path_for_display(resolved_path, base_path) or resolved_path.as_posix()
399
+ )
396
400
  except Exception:
397
401
  relative_path = str(resolved_path)
398
402
 
@@ -431,18 +435,18 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
431
435
  git_root = get_git_root(root_path)
432
436
  if git_root:
433
437
  git_info["repository"] = str(git_root)
434
-
438
+
435
439
  branch = get_current_git_branch(root_path)
436
440
  if branch:
437
441
  git_info["branch"] = branch
438
-
442
+
439
443
  commit_hash = get_git_commit_hash(root_path)
440
444
  if commit_hash:
441
445
  git_info["commit"] = commit_hash
442
-
446
+
443
447
  is_clean = is_working_directory_clean(root_path)
444
448
  git_info["clean"] = "yes" if is_clean else "no (uncommitted changes)"
445
-
449
+
446
450
  tracked, untracked = get_git_status_files(root_path)
447
451
  if tracked or untracked:
448
452
  status_info = []
@@ -18,6 +18,7 @@ from ripperdoc.core.tool import (
18
18
  ValidationResult,
19
19
  )
20
20
  from ripperdoc.utils.log import get_logger
21
+ from ripperdoc.utils.file_watch import record_snapshot
21
22
 
22
23
  logger = get_logger()
23
24
 
@@ -360,6 +361,17 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
360
361
  file_path.parent.mkdir(parents=True, exist_ok=True)
361
362
  try:
362
363
  file_path.write_text(updated_content, encoding="utf-8")
364
+ try:
365
+ record_snapshot(
366
+ str(file_path),
367
+ updated_content,
368
+ getattr(context, "file_state_cache", {}),
369
+ )
370
+ except Exception:
371
+ logger.exception(
372
+ "[multi_edit_tool] Failed to record file snapshot",
373
+ extra={"file_path": str(file_path)},
374
+ )
363
375
  except Exception as exc:
364
376
  logger.exception(
365
377
  "[multi_edit_tool] Error writing edited file",
@@ -20,6 +20,7 @@ from ripperdoc.core.tool import (
20
20
  ValidationResult,
21
21
  )
22
22
  from ripperdoc.utils.log import get_logger
23
+ from ripperdoc.utils.file_watch import record_snapshot
23
24
 
24
25
 
25
26
  logger = get_logger()
@@ -272,6 +273,17 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
272
273
  )
273
274
 
274
275
  path.write_text(json.dumps(nb_json, indent=1), encoding="utf-8")
276
+ try:
277
+ record_snapshot(
278
+ input_data.notebook_path,
279
+ json.dumps(nb_json, indent=1),
280
+ getattr(context, "file_state_cache", {}),
281
+ )
282
+ except Exception:
283
+ logger.exception(
284
+ "[notebook_edit_tool] Failed to record file snapshot",
285
+ extra={"file_path": input_data.notebook_path},
286
+ )
275
287
 
276
288
  output = NotebookEditOutput(
277
289
  new_source=new_source,