ripperdoc 0.2.10__py3-none-any.whl → 0.3.0__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 (70) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +164 -57
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +3 -7
  5. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  6. ripperdoc/cli/commands/memory_cmd.py +2 -1
  7. ripperdoc/cli/commands/models_cmd.py +61 -5
  8. ripperdoc/cli/commands/resume_cmd.py +1 -0
  9. ripperdoc/cli/commands/skills_cmd.py +103 -0
  10. ripperdoc/cli/commands/stats_cmd.py +4 -4
  11. ripperdoc/cli/commands/status_cmd.py +10 -0
  12. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  13. ripperdoc/cli/commands/themes_cmd.py +139 -0
  14. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  15. ripperdoc/cli/ui/helpers.py +6 -3
  16. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  17. ripperdoc/cli/ui/panels.py +13 -8
  18. ripperdoc/cli/ui/rich_ui.py +451 -32
  19. ripperdoc/cli/ui/spinner.py +68 -5
  20. ripperdoc/cli/ui/tool_renderers.py +10 -9
  21. ripperdoc/cli/ui/wizard.py +18 -11
  22. ripperdoc/core/agents.py +4 -0
  23. ripperdoc/core/config.py +235 -0
  24. ripperdoc/core/default_tools.py +1 -0
  25. ripperdoc/core/hooks/llm_callback.py +0 -1
  26. ripperdoc/core/hooks/manager.py +6 -0
  27. ripperdoc/core/permissions.py +82 -5
  28. ripperdoc/core/providers/openai.py +55 -9
  29. ripperdoc/core/query.py +349 -108
  30. ripperdoc/core/query_utils.py +17 -14
  31. ripperdoc/core/skills.py +1 -0
  32. ripperdoc/core/theme.py +298 -0
  33. ripperdoc/core/tool.py +8 -3
  34. ripperdoc/protocol/__init__.py +14 -0
  35. ripperdoc/protocol/models.py +300 -0
  36. ripperdoc/protocol/stdio.py +1453 -0
  37. ripperdoc/tools/background_shell.py +49 -5
  38. ripperdoc/tools/bash_tool.py +75 -9
  39. ripperdoc/tools/file_edit_tool.py +98 -29
  40. ripperdoc/tools/file_read_tool.py +139 -8
  41. ripperdoc/tools/file_write_tool.py +46 -3
  42. ripperdoc/tools/grep_tool.py +98 -8
  43. ripperdoc/tools/lsp_tool.py +9 -15
  44. ripperdoc/tools/multi_edit_tool.py +26 -3
  45. ripperdoc/tools/skill_tool.py +52 -1
  46. ripperdoc/tools/task_tool.py +33 -8
  47. ripperdoc/utils/file_watch.py +12 -6
  48. ripperdoc/utils/image_utils.py +125 -0
  49. ripperdoc/utils/log.py +30 -3
  50. ripperdoc/utils/lsp.py +9 -3
  51. ripperdoc/utils/mcp.py +80 -18
  52. ripperdoc/utils/message_formatting.py +2 -2
  53. ripperdoc/utils/messages.py +177 -32
  54. ripperdoc/utils/pending_messages.py +50 -0
  55. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  56. ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
  57. ripperdoc/utils/platform.py +198 -0
  58. ripperdoc/utils/session_heatmap.py +1 -3
  59. ripperdoc/utils/session_history.py +2 -2
  60. ripperdoc/utils/session_stats.py +1 -0
  61. ripperdoc/utils/shell_utils.py +8 -5
  62. ripperdoc/utils/todo.py +0 -6
  63. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +49 -17
  64. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/RECORD +68 -61
  65. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  66. ripperdoc/sdk/__init__.py +0 -9
  67. ripperdoc/sdk/client.py +0 -408
  68. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  69. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  70. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
@@ -14,7 +14,7 @@ import time
14
14
  import uuid
15
15
  import weakref
16
16
  from dataclasses import dataclass, field
17
- from typing import Any, Dict, List, Optional
17
+ from typing import Any, Callable, Dict, List, Optional
18
18
 
19
19
  import atexit
20
20
 
@@ -34,6 +34,7 @@ class BackgroundTask:
34
34
  process: asyncio.subprocess.Process
35
35
  start_time: float
36
36
  timeout: Optional[float] = None
37
+ end_time: Optional[float] = None
37
38
  stdout_chunks: List[str] = field(default_factory=list)
38
39
  stderr_chunks: List[str] = field(default_factory=list)
39
40
  exit_code: Optional[int] = None
@@ -41,6 +42,7 @@ class BackgroundTask:
41
42
  timed_out: bool = False
42
43
  reader_tasks: List[asyncio.Task] = field(default_factory=list)
43
44
  done_event: asyncio.Event = field(default_factory=asyncio.Event)
45
+ completion_callbacks: List[Callable[["BackgroundTask"], None]] = field(default_factory=list)
44
46
 
45
47
 
46
48
  DEFAULT_TASK_TTL_SEC = float(os.getenv("RIPPERDOC_BASH_TASK_TTL_SEC", "3600"))
@@ -251,6 +253,7 @@ class BackgroundShellManager:
251
253
  with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
252
254
  await asyncio.wait_for(task.process.wait(), timeout=kill_timeout)
253
255
  task.exit_code = task.process.returncode or -1
256
+ task.end_time = task.end_time or _loop_time()
254
257
  except (OSError, RuntimeError, asyncio.CancelledError) as exc:
255
258
  if not isinstance(exc, asyncio.CancelledError):
256
259
  _safe_log_exception(
@@ -261,6 +264,7 @@ class BackgroundShellManager:
261
264
  finally:
262
265
  await _finalize_reader_tasks(task.reader_tasks, timeout=0.3 if force else 1.0)
263
266
  task.done_event.set()
267
+ _run_completion_callbacks(task)
264
268
 
265
269
  current = asyncio.current_task()
266
270
  pending = [t for t in asyncio.all_tasks(loop) if t is not current]
@@ -277,6 +281,7 @@ class BackgroundShellManager:
277
281
  # Module-level functions that delegate to the singleton manager
278
282
  # These maintain backward compatibility with existing code
279
283
 
284
+
280
285
  def _get_manager() -> BackgroundShellManager:
281
286
  """Get the singleton manager instance."""
282
287
  return BackgroundShellManager.get_instance()
@@ -346,6 +351,21 @@ async def _finalize_reader_tasks(reader_tasks: List[asyncio.Task], timeout: floa
346
351
  await asyncio.gather(*reader_tasks, return_exceptions=True)
347
352
 
348
353
 
354
+ def _run_completion_callbacks(task: BackgroundTask) -> None:
355
+ """Invoke completion callbacks safely for a finished task."""
356
+ if not task.completion_callbacks:
357
+ return
358
+ for callback in list(task.completion_callbacks):
359
+ try:
360
+ callback(task)
361
+ except Exception:
362
+ logger.debug(
363
+ "Background task completion callback failed",
364
+ exc_info=True,
365
+ extra={"task_id": task.id, "command": task.command},
366
+ )
367
+
368
+
349
369
  async def _monitor_task(task: BackgroundTask) -> None:
350
370
  """Wait for a background process to finish or timeout, then mark status."""
351
371
  try:
@@ -355,6 +375,7 @@ async def _monitor_task(task: BackgroundTask) -> None:
355
375
  await task.process.wait()
356
376
  with _get_tasks_lock():
357
377
  task.exit_code = task.process.returncode
378
+ task.end_time = task.end_time or _loop_time()
358
379
  except asyncio.TimeoutError:
359
380
  logger.warning(f"Background task {task.id} timed out after {task.timeout}s: {task.command}")
360
381
  with _get_tasks_lock():
@@ -363,6 +384,7 @@ async def _monitor_task(task: BackgroundTask) -> None:
363
384
  await task.process.wait()
364
385
  with _get_tasks_lock():
365
386
  task.exit_code = -1
387
+ task.end_time = task.end_time or _loop_time()
366
388
  except asyncio.CancelledError:
367
389
  return
368
390
  except (OSError, RuntimeError, ProcessLookupError) as exc:
@@ -374,14 +396,19 @@ async def _monitor_task(task: BackgroundTask) -> None:
374
396
  )
375
397
  with _get_tasks_lock():
376
398
  task.exit_code = -1
399
+ task.end_time = task.end_time or _loop_time()
377
400
  finally:
378
401
  # Ensure readers are finished before marking done.
379
402
  await _finalize_reader_tasks(task.reader_tasks)
380
403
  task.done_event.set()
404
+ _run_completion_callbacks(task)
381
405
 
382
406
 
383
407
  async def _start_background_command(
384
- command: str, timeout: Optional[float] = None, shell_executable: Optional[str] = None
408
+ command: str,
409
+ timeout: Optional[float] = None,
410
+ shell_executable: Optional[str] = None,
411
+ completion_callbacks: Optional[List[Callable[["BackgroundTask"], None]]] = None,
385
412
  ) -> str:
386
413
  """Launch a background shell command on the dedicated loop."""
387
414
  selected_shell = shell_executable or find_suitable_shell()
@@ -401,6 +428,7 @@ async def _start_background_command(
401
428
  process=process,
402
429
  start_time=_loop_time(),
403
430
  timeout=timeout,
431
+ completion_callbacks=list(completion_callbacks or []),
404
432
  )
405
433
  with _get_tasks_lock():
406
434
  _get_tasks()[task_id] = record
@@ -420,11 +448,16 @@ async def _start_background_command(
420
448
 
421
449
 
422
450
  async def start_background_command(
423
- command: str, timeout: Optional[float] = None, shell_executable: Optional[str] = None
451
+ command: str,
452
+ timeout: Optional[float] = None,
453
+ shell_executable: Optional[str] = None,
454
+ completion_callbacks: Optional[List[Callable[["BackgroundTask"], None]]] = None,
424
455
  ) -> str:
425
456
  """Launch a background shell command and return its task id."""
426
457
  future = _submit_to_background_loop(
427
- _start_background_command(command, timeout, shell_executable)
458
+ _start_background_command(
459
+ command, timeout, shell_executable, completion_callbacks=completion_callbacks
460
+ )
428
461
  )
429
462
  return await asyncio.wrap_future(future)
430
463
 
@@ -453,6 +486,7 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
453
486
 
454
487
  If consume is True, buffered stdout/stderr are cleared after reading.
455
488
  """
489
+ now = _loop_time()
456
490
  tasks = _get_tasks()
457
491
  with _get_tasks_lock():
458
492
  if task_id not in tasks:
@@ -462,6 +496,14 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
462
496
  stdout = "".join(task.stdout_chunks)
463
497
  stderr = "".join(task.stderr_chunks)
464
498
 
499
+ finished = task.exit_code is not None or task.killed or task.timed_out
500
+ if finished and task.end_time is None:
501
+ task.end_time = now
502
+ duration_ms = (
503
+ ((task.end_time or now) - task.start_time) * 1000.0 if task.start_time else None
504
+ )
505
+ age_ms = (now - task.start_time) * 1000.0 if task.start_time else None
506
+
465
507
  if consume:
466
508
  task.stdout_chunks.clear()
467
509
  task.stderr_chunks.clear()
@@ -475,7 +517,8 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
475
517
  "exit_code": task.exit_code,
476
518
  "timed_out": task.timed_out,
477
519
  "killed": task.killed,
478
- "duration_ms": (_loop_time() - task.start_time) * 1000.0,
520
+ "duration_ms": duration_ms,
521
+ "age_ms": age_ms,
479
522
  }
480
523
 
481
524
 
@@ -506,6 +549,7 @@ async def kill_background_task(task_id: str) -> bool:
506
549
 
507
550
  with _get_tasks_lock():
508
551
  task.exit_code = task.process.returncode or -1
552
+ task.end_time = task.end_time or _loop_time()
509
553
  return True
510
554
  finally:
511
555
  await _finalize_reader_tasks(task.reader_tasks)
@@ -50,6 +50,7 @@ from ripperdoc.utils.sandbox_utils import create_sandbox_wrapper, is_sandbox_ava
50
50
  from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
51
51
  from ripperdoc.utils.shell_utils import build_shell_command, find_suitable_shell
52
52
  from ripperdoc.utils.log import get_logger
53
+ from ripperdoc.utils.platform import IS_WINDOWS
53
54
 
54
55
  logger = get_logger()
55
56
 
@@ -597,6 +598,7 @@ build projects, run tests, and interact with the file system."""
597
598
  sandbox_requested: bool,
598
599
  start_time: float,
599
600
  input_data: BashToolInput,
601
+ context: Optional[ToolUseContext] = None,
600
602
  ) -> Optional[BashToolOutput]:
601
603
  """Run a command in background mode.
602
604
 
@@ -622,8 +624,50 @@ build projects, run tests, and interact with the file system."""
622
624
  if input_data.timeout is None
623
625
  else (timeout_seconds if timeout_seconds > 0 else None)
624
626
  )
627
+
628
+ completion_callbacks = None
629
+ queue = getattr(context, "pending_message_queue", None) if context else None
630
+ if queue is not None:
631
+
632
+ def _notify_completion(task: Any) -> None:
633
+ # Mirror status computation from background_shell._compute_status
634
+ if getattr(task, "killed", False):
635
+ status = "killed"
636
+ elif getattr(task, "timed_out", False):
637
+ status = "failed"
638
+ else:
639
+ exit_code = getattr(task, "exit_code", None)
640
+ status = (
641
+ "running"
642
+ if exit_code is None
643
+ else ("completed" if exit_code == 0 else "failed")
644
+ )
645
+ exit_code = getattr(task, "exit_code", None)
646
+ status_line = (
647
+ f"Background bash task {getattr(task, 'id', '')} finished with status: {status}"
648
+ )
649
+ if exit_code is not None:
650
+ status_line += f" (exit code {exit_code})"
651
+ details = [
652
+ status_line,
653
+ f"Command: {effective_command}",
654
+ "Use BashOutput with this task id to read stdout/stderr and continue.",
655
+ ]
656
+ queue.enqueue_text(
657
+ "\n".join(details),
658
+ metadata={
659
+ "source": "background_bash",
660
+ "background_task_id": getattr(task, "id", None),
661
+ },
662
+ )
663
+
664
+ completion_callbacks = [_notify_completion] # type: ignore[assignment]
665
+
625
666
  task_id = await start_background_command(
626
- final_command, timeout=bg_timeout, shell_executable=resolved_shell
667
+ final_command,
668
+ timeout=bg_timeout,
669
+ shell_executable=resolved_shell,
670
+ completion_callbacks=completion_callbacks, # type: ignore[arg-type]
627
671
  )
628
672
 
629
673
  return BashToolOutput(
@@ -889,6 +933,7 @@ build projects, run tests, and interact with the file system."""
889
933
  sandbox_requested,
890
934
  start,
891
935
  input_data,
936
+ context,
892
937
  )
893
938
  if output:
894
939
  yield ToolResult(
@@ -1021,35 +1066,56 @@ build projects, run tests, and interact with the file system."""
1021
1066
  finally:
1022
1067
  self._current_is_read_only = previous_read_only
1023
1068
  if sandbox_cleanup:
1024
- with contextlib.suppress(Exception):
1069
+ with contextlib.suppress(OSError, IOError, PermissionError):
1025
1070
  sandbox_cleanup()
1026
1071
 
1027
1072
  async def _force_kill_process(
1028
1073
  self, process: asyncio.subprocess.Process, grace_seconds: float = KILL_GRACE_SECONDS
1029
1074
  ) -> None:
1030
- """Attempt to terminate a process group and avoid hanging waits."""
1075
+ """Attempt to terminate a process group and avoid hanging waits.
1076
+
1077
+ Platform differences:
1078
+ - Unix: Uses killpg with SIGTERM/SIGKILL to terminate process groups
1079
+ - Windows: Uses process.terminate()/kill() which sends appropriate signals
1080
+ """
1031
1081
  if process.returncode is not None:
1032
1082
  return
1033
1083
 
1034
1084
  def _terminate() -> None:
1035
- if hasattr(os, "killpg"):
1036
- os.killpg(process.pid, signal.SIGTERM)
1085
+ if IS_WINDOWS:
1086
+ # Windows: use process.terminate() which is cross-platform
1087
+ process.terminate()
1088
+ elif hasattr(os, "killpg"):
1089
+ # Unix: terminate the entire process group
1090
+ try:
1091
+ os.killpg(process.pid, signal.SIGTERM)
1092
+ except (ProcessLookupError, PermissionError, OSError):
1093
+ # Fallback to single process termination
1094
+ process.terminate()
1037
1095
  else:
1038
1096
  process.terminate()
1039
1097
 
1040
1098
  def _kill() -> None:
1041
- if hasattr(os, "killpg"):
1042
- os.killpg(process.pid, signal.SIGKILL)
1099
+ if IS_WINDOWS:
1100
+ # Windows: use process.kill() which forcefully terminates
1101
+ process.kill()
1102
+ elif hasattr(os, "killpg") and hasattr(signal, "SIGKILL"):
1103
+ # Unix: forcefully kill the entire process group
1104
+ try:
1105
+ os.killpg(process.pid, signal.SIGKILL)
1106
+ except (ProcessLookupError, PermissionError, OSError):
1107
+ # Fallback to single process kill
1108
+ process.kill()
1043
1109
  else:
1044
1110
  process.kill()
1045
1111
 
1046
- with contextlib.suppress(ProcessLookupError, PermissionError):
1112
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
1047
1113
  _terminate()
1048
1114
  with contextlib.suppress(asyncio.TimeoutError):
1049
1115
  await asyncio.wait_for(process.wait(), timeout=grace_seconds)
1050
1116
  return
1051
1117
 
1052
- with contextlib.suppress(ProcessLookupError, PermissionError):
1118
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
1053
1119
  _kill()
1054
1120
  with contextlib.suppress(asyncio.TimeoutError):
1055
1121
  await asyncio.wait_for(process.wait(), timeout=grace_seconds)
@@ -19,18 +19,36 @@ from ripperdoc.core.tool import (
19
19
  ValidationResult,
20
20
  )
21
21
  from ripperdoc.utils.log import get_logger
22
+ from ripperdoc.utils.platform import HAS_FCNTL
22
23
  from ripperdoc.utils.file_watch import record_snapshot
23
24
  from ripperdoc.utils.path_ignore import check_path_for_tool
25
+ from ripperdoc.tools.file_read_tool import detect_file_encoding
24
26
 
25
- # Import fcntl for file locking on Unix systems
26
- try:
27
- import fcntl
27
+ logger = get_logger()
28
28
 
29
- HAS_FCNTL = True
30
- except ImportError:
31
- HAS_FCNTL = False
32
29
 
33
- logger = get_logger()
30
+ def determine_edit_encoding(file_path: str, new_content: str) -> str:
31
+ """Determine encoding for editing a file.
32
+
33
+ Detects the file's current encoding and verifies the new content
34
+ can be encoded with it. Falls back to UTF-8 if needed.
35
+ """
36
+ detected_encoding, _ = detect_file_encoding(file_path)
37
+
38
+ if not detected_encoding:
39
+ return "utf-8"
40
+
41
+ # Verify new content can be encoded
42
+ try:
43
+ new_content.encode(detected_encoding)
44
+ return detected_encoding
45
+ except (UnicodeEncodeError, LookupError):
46
+ logger.info(
47
+ "New content cannot be encoded with %s, falling back to UTF-8 for %s",
48
+ detected_encoding,
49
+ file_path,
50
+ )
51
+ return "utf-8"
34
52
 
35
53
 
36
54
  @contextlib.contextmanager
@@ -49,6 +67,8 @@ def _file_lock(file_handle: TextIO, exclusive: bool = True) -> Generator[None, N
49
67
  yield
50
68
  return
51
69
 
70
+ import fcntl
71
+
52
72
  lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
53
73
  try:
54
74
  fcntl.flock(file_handle.fileno(), lock_type)
@@ -221,31 +241,66 @@ match exactly (including whitespace and indentation)."""
221
241
  file_state_cache = getattr(context, "file_state_cache", {})
222
242
  file_snapshot = file_state_cache.get(abs_file_path)
223
243
 
244
+ # Detect file encoding before opening
245
+ file_encoding, _ = detect_file_encoding(abs_file_path)
246
+ if not file_encoding:
247
+ file_encoding = "utf-8"
248
+
224
249
  try:
225
250
  # Open file with exclusive lock to prevent concurrent modifications
226
251
  # Use r+ mode to get a file handle we can lock before reading
227
- with open(abs_file_path, "r+", encoding="utf-8") as f:
252
+ #
253
+ # TOCTOU mitigation strategy:
254
+ # 1. Record mtime immediately after open (pre_lock_mtime)
255
+ # 2. Acquire exclusive lock
256
+ # 3. Check mtime again after lock (post_lock_mtime)
257
+ # 4. If pre != post, file was modified in the window between open and lock
258
+ # 5. Also validate against cached snapshot timestamp
259
+ with open(abs_file_path, "r+", encoding=file_encoding) as f:
260
+ # Record mtime immediately after open, before acquiring lock
261
+ try:
262
+ pre_lock_mtime = os.fstat(f.fileno()).st_mtime
263
+ except OSError:
264
+ pre_lock_mtime = None
265
+
228
266
  with _file_lock(f, exclusive=True):
229
- # Re-check mtime AFTER acquiring lock to close TOCTOU window
230
- # This is the key fix: validate mtime while holding the lock
231
- if file_snapshot:
232
- try:
233
- current_mtime = os.fstat(f.fileno()).st_mtime
234
- if current_mtime > file_snapshot.timestamp:
235
- output = FileEditToolOutput(
236
- file_path=input_data.file_path,
237
- replacements_made=0,
238
- success=False,
239
- message="File has been modified since read, either by the user "
240
- "or by a linter. Read it again before attempting to edit it.",
241
- )
242
- yield ToolResult(
243
- data=output,
244
- result_for_assistant=self.render_result_for_assistant(output),
245
- )
246
- return
247
- except OSError:
248
- pass # fstat failed, proceed anyway
267
+ # Check mtime after acquiring lock to detect modifications
268
+ # during the window between open() and lock acquisition
269
+ try:
270
+ post_lock_mtime = os.fstat(f.fileno()).st_mtime
271
+ except OSError:
272
+ post_lock_mtime = None
273
+
274
+ # Detect modification during open->lock window
275
+ if pre_lock_mtime is not None and post_lock_mtime is not None:
276
+ if post_lock_mtime > pre_lock_mtime:
277
+ output = FileEditToolOutput(
278
+ file_path=input_data.file_path,
279
+ replacements_made=0,
280
+ success=False,
281
+ message="File was modified while acquiring lock. Please retry.",
282
+ )
283
+ yield ToolResult(
284
+ data=output,
285
+ result_for_assistant=self.render_result_for_assistant(output),
286
+ )
287
+ return
288
+
289
+ # Validate against cached snapshot timestamp
290
+ if file_snapshot and post_lock_mtime is not None:
291
+ if post_lock_mtime > file_snapshot.timestamp:
292
+ output = FileEditToolOutput(
293
+ file_path=input_data.file_path,
294
+ replacements_made=0,
295
+ success=False,
296
+ message="File has been modified since read, either by the user "
297
+ "or by a linter. Read it again before attempting to edit it.",
298
+ )
299
+ yield ToolResult(
300
+ data=output,
301
+ result_for_assistant=self.render_result_for_assistant(output),
302
+ )
303
+ return
249
304
 
250
305
  # Read content while holding the lock
251
306
  content = f.read()
@@ -292,6 +347,19 @@ match exactly (including whitespace and indentation)."""
292
347
  )
293
348
  replacements = 1
294
349
 
350
+ # Verify new content can be encoded with file's encoding
351
+ # If not, fall back to UTF-8
352
+ write_encoding = file_encoding
353
+ try:
354
+ new_content.encode(file_encoding)
355
+ except (UnicodeEncodeError, LookupError):
356
+ logger.info(
357
+ "New content cannot be encoded with %s, using UTF-8 for %s",
358
+ file_encoding,
359
+ abs_file_path,
360
+ )
361
+ write_encoding = "utf-8"
362
+
295
363
  # Atomic write: write to temp file then rename
296
364
  # This ensures the file is either fully written or not at all
297
365
  file_dir = os.path.dirname(abs_file_path)
@@ -301,7 +369,7 @@ match exactly (including whitespace and indentation)."""
301
369
  dir=file_dir, prefix=".ripperdoc_edit_", suffix=".tmp"
302
370
  )
303
371
  try:
304
- with os.fdopen(fd, "w", encoding="utf-8") as temp_f:
372
+ with os.fdopen(fd, "w", encoding=write_encoding) as temp_f:
305
373
  temp_f.write(new_content)
306
374
  # Preserve original file permissions
307
375
  original_stat = os.fstat(f.fileno())
@@ -345,6 +413,7 @@ match exactly (including whitespace and indentation)."""
345
413
  abs_file_path,
346
414
  new_content,
347
415
  getattr(context, "file_state_cache", {}),
416
+ encoding=write_encoding,
348
417
  )
349
418
  except (OSError, IOError, RuntimeError) as exc:
350
419
  logger.warning(