python-agent-harness 1.5.5.6__tar.gz → 1.5.5.8__tar.gz

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 (105) hide show
  1. {python_agent_harness-1.5.5.6/python_agent_harness.egg-info → python_agent_harness-1.5.5.8}/PKG-INFO +1 -1
  2. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/pyproject.toml +1 -1
  3. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/__init__.py +1 -1
  4. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/diffrender.py +22 -1
  5. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/session.py +45 -12
  6. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/diffapply.py +70 -14
  7. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/edit.py +54 -9
  8. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/insert.py +11 -2
  9. python_agent_harness-1.5.5.8/python_agent_harness/tools/lsp.py +273 -0
  10. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/write.py +13 -2
  11. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8/python_agent_harness.egg-info}/PKG-INFO +1 -1
  12. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_diffrender.py +16 -1
  13. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_filesystem.py +177 -2
  14. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_invariants.py +96 -36
  15. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_scenarios.py +6 -1
  16. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_session.py +56 -0
  17. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_subagent.py +6 -0
  18. python_agent_harness-1.5.5.6/python_agent_harness/tools/lsp.py +0 -243
  19. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/LICENSE +0 -0
  20. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/README.md +0 -0
  21. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/__main__.py +0 -0
  22. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/agent.py +0 -0
  23. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/cli.py +0 -0
  24. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/client.py +0 -0
  25. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/commands.py +0 -0
  26. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/config.py +0 -0
  27. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/context_manager.py +0 -0
  28. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/lsp/__init__.py +0 -0
  29. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/lsp/client.py +0 -0
  30. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/lsp/config.py +0 -0
  31. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/lsp/manager.py +0 -0
  32. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/mcp/__init__.py +0 -0
  33. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/mcp/client.py +0 -0
  34. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/mcp/config.py +0 -0
  35. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/mcp/manager.py +0 -0
  36. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/models.py +0 -0
  37. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/persistence.py +0 -0
  38. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/planmode.py +0 -0
  39. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/agent.md +0 -0
  40. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  41. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/build-switch.md +0 -0
  42. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/commands/explain.md +0 -0
  43. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/compact.md +0 -0
  44. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/initialize.md +0 -0
  45. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/plan-mode.md +0 -0
  46. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/plan.md +0 -0
  47. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/review.md +0 -0
  48. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/subagent.md +0 -0
  49. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/summary.md +0 -0
  50. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  51. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts/title.md +0 -0
  52. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/prompts.py +0 -0
  53. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/subagent.py +0 -0
  54. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/token_estimator.py +0 -0
  55. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tool_runner.py +0 -0
  56. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/__init__.py +0 -0
  57. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/agent_tool.py +0 -0
  58. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/base.py +0 -0
  59. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/bash.py +0 -0
  60. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/bash_win.py +0 -0
  61. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/edit_mac.py +0 -0
  62. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/edit_win.py +0 -0
  63. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/filesystem.py +0 -0
  64. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/glob.py +0 -0
  65. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/glob_mac.py +0 -0
  66. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/glob_win.py +0 -0
  67. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/grep.py +0 -0
  68. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/grep_mac.py +0 -0
  69. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/grep_win.py +0 -0
  70. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/mcp.py +0 -0
  71. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/mkdir.py +0 -0
  72. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/planexit.py +0 -0
  73. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/question.py +0 -0
  74. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/read.py +0 -0
  75. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/skill.py +0 -0
  76. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tools/todo.py +0 -0
  77. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tui/__init__.py +0 -0
  78. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tui/commands.py +0 -0
  79. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tui/core.py +0 -0
  80. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tui/input.py +0 -0
  81. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness/tui/render.py +0 -0
  82. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  83. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  84. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness.egg-info/entry_points.txt +0 -0
  85. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness.egg-info/requires.txt +0 -0
  86. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/python_agent_harness.egg-info/top_level.txt +0 -0
  87. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/setup.cfg +0 -0
  88. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_agents_md.py +0 -0
  89. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_cli.py +0 -0
  90. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_client.py +0 -0
  91. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_commands.py +0 -0
  92. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_concurrent_subagents.py +0 -0
  93. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_config.py +0 -0
  94. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_context_rules.py +0 -0
  95. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_custom_agents.py +0 -0
  96. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_lsp.py +0 -0
  97. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_mcp.py +0 -0
  98. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_models.py +0 -0
  99. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_persistence.py +0 -0
  100. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_planmode.py +0 -0
  101. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_prompts.py +0 -0
  102. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_subagent_isolation.py +0 -0
  103. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_todos_scope.py +0 -0
  104. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_token_estimator.py +0 -0
  105. {python_agent_harness-1.5.5.6 → python_agent_harness-1.5.5.8}/tests/test_tools_misc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.6
3
+ Version: 1.5.5.8
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.5.6"
7
+ version = "1.5.5.8"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -7,7 +7,7 @@ from .mcp.manager import MCPManager
7
7
  from .models import AgentMode, Message, ToolCall, ToolSpec
8
8
  from .session import Session
9
9
 
10
- __version__ = "1.5.5.6"
10
+ __version__ = "1.5.5.8"
11
11
 
12
12
  __all__ = [
13
13
  "Client",
@@ -3,18 +3,39 @@
3
3
  ``unified_diff`` builds a standard unified diff between two file
4
4
  contents (used by Edit/Write to record what actually changed).
5
5
  ``render_diff`` turns that text into a red/green ``rich`` renderable
6
- suitable for the TUI's tool-output panel.
6
+ suitable for the TUI's tool-output panel. ``sanitize_for_display``
7
+ makes a recorded diff printable when it carries surrogate-escaped
8
+ bytes from a non-UTF-8 file.
7
9
  """
8
10
 
9
11
  from __future__ import annotations
10
12
 
11
13
  import difflib
14
+ import re
12
15
 
13
16
  from rich.console import Group
14
17
  from rich.text import Text
15
18
 
16
19
  MAX_DIFF_LINES = 400 # truncation cap for the rendered (not stored) diff
17
20
 
21
+ # Lone surrogates (U+DC80-U+DCFF plus any other surrogate code point):
22
+ # what a file read with errors="surrogateescape" produces for bytes that
23
+ # are not valid UTF-8.
24
+ _SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
25
+
26
+
27
+ def sanitize_for_display(text: str) -> str:
28
+ """Replace lone surrogates in TEXT with U+FFFD.
29
+
30
+ Files are read with ``errors="surrogateescape"`` so invalid bytes
31
+ survive a read/write round trip; those bytes appear as lone
32
+ surrogates in the string, which cannot be encoded to the terminal
33
+ (``print`` would raise ``UnicodeEncodeError``). A recorded diff is
34
+ display-only, so the surrogates are replaced here — the file on
35
+ disk is never touched.
36
+ """
37
+ return _SURROGATE_RE.sub("\ufffd", text)
38
+
18
39
 
19
40
  def unified_diff(
20
41
  old_content: str,
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Any
18
18
 
19
19
  from . import config
20
20
  from .client import Client, LLMClient
21
+ from .diffrender import sanitize_for_display
21
22
  from .mcp.config import MCPConfig
22
23
  from .mcp.manager import MCPManager
23
24
  from .models import AgentMode
@@ -327,11 +328,16 @@ class Session:
327
328
  return result
328
329
 
329
330
  def record_diff(self, diff_text: str) -> None:
330
- """Attach a unified diff to the tool call currently executing."""
331
+ """Attach a unified diff to the tool call currently executing.
332
+
333
+ Sanitized for display: file content read with
334
+ ``errors="surrogateescape"`` (non-UTF-8 files) carries lone
335
+ surrogates that a terminal cannot encode.
336
+ """
331
337
  call_id = getattr(self._active_call, "call_id", None)
332
338
  if call_id and diff_text:
333
339
  with self._tool_diffs_lock:
334
- self._tool_diffs[call_id] = diff_text
340
+ self._tool_diffs[call_id] = sanitize_for_display(diff_text)
335
341
 
336
342
  def take_diff(self, call_id: str) -> str | None:
337
343
  """Pop and return the diff recorded for CALL_ID, if any."""
@@ -350,23 +356,50 @@ class Session:
350
356
  "Error: blocked by plan mode (read-only phase); "
351
357
  "Bash is disabled — use Read/Glob/Grep for read-only access"
352
358
  )
359
+ if name == "Edit" and args.get("old_str") is None and args.get("diff") is not False:
360
+ # diff/patch mode: the `path` argument is NOT enough — the
361
+ # patch applies to the paths inside the diff itself, and the
362
+ # macOS/Windows Python applier honors absolute `+++` targets.
363
+ content = args.get("new_str")
364
+ if isinstance(content, str) and content.strip():
365
+ return self._plan_diff_verdict(args)
366
+ # contentless diff call: Edit rejects a missing/invalid
367
+ # new_str before any patch engine runs, so the plain path
368
+ # check below is sufficient
353
369
  path = self._tool_path(name, args)
354
370
  if path and path != self.plan_mode.plan_file:
355
- # Edit diff-mode (no old_str, diff not explicitly False) runs
356
- # `patch` which can write to arbitrary files via relative paths
357
- # in the diff content — block it even if the target path looks
358
- # innocent, because patch follows paths within the diff.
359
- if name == "Edit" and args.get("old_str") is None and args.get("diff") is not False:
360
- return (
361
- "Error: blocked by plan mode (read-only phase); "
362
- "diff/patch mode cannot target files other than the plan "
363
- "file — use string replacement (old_str/new_str) instead"
364
- )
365
371
  return (
366
372
  "Error: blocked by plan mode (read-only phase); only the plan file may be modified"
367
373
  )
368
374
  return None
369
375
 
376
+ def _plan_diff_verdict(self, args: dict[str, Any]) -> str | None:
377
+ """Allow an Edit diff only when every section targets the plan file.
378
+
379
+ Resolution mirrors the Edit backends (same cwd/fallback rules,
380
+ same helpers as the built-in Python applier), so the sections
381
+ approved here are exactly the files the backend would write. A
382
+ diff that cannot be parsed into file sections is refused (fail
383
+ closed): an unverifiable patch must never reach a patch engine.
384
+ """
385
+ from .tools.diffapply import diff_targets
386
+ from .tools.edit import _patch_cwd, _strip_diff_fence
387
+
388
+ plan_file = self.plan_mode.plan_file
389
+ raw = str(args.get("path", ""))
390
+ path = os.path.realpath(os.path.abspath(raw))
391
+ cwd = _patch_cwd(raw, path)
392
+ text = str(args.get("new_str"))
393
+ text = text if text.endswith("\n") else text + "\n"
394
+ targets = diff_targets(_strip_diff_fence(text), cwd, path if os.path.isfile(path) else None)
395
+ if plan_file and targets is not None and all(target == plan_file for target in targets):
396
+ return None
397
+ return (
398
+ "Error: blocked by plan mode (read-only phase); "
399
+ "diff/patch mode cannot target files other than the plan "
400
+ "file — use string replacement (old_str/new_str) instead"
401
+ )
402
+
370
403
  def _tool_path(self, name: str, args: dict[str, Any]) -> str | None:
371
404
  if name == "Write":
372
405
  return os.path.realpath(
@@ -53,9 +53,10 @@ class _Hunk:
53
53
 
54
54
 
55
55
  class _Section:
56
- __slots__ = ("new_path", "hunks")
56
+ __slots__ = ("old_path", "new_path", "hunks")
57
57
 
58
- def __init__(self, new_path: str, hunks: list[_Hunk]) -> None:
58
+ def __init__(self, old_path: str, new_path: str, hunks: list[_Hunk]) -> None:
59
+ self.old_path = old_path
59
60
  self.new_path = new_path
60
61
  self.hunks = hunks
61
62
 
@@ -82,6 +83,8 @@ def _parse(diff_text: str) -> list[_Section]:
82
83
  i += 1
83
84
  continue
84
85
  new_path = lines[i + 1][len("+++") :].strip()
86
+ # git-style "a/... b/..." header, old path on the `---` line
87
+ old_path = lines[i][len("---") :].strip()
85
88
  i += 2
86
89
  hunks: list[_Hunk] = []
87
90
  while i < n:
@@ -108,7 +111,7 @@ def _parse(diff_text: str) -> list[_Section]:
108
111
  if _section_header_at(lines, i):
109
112
  break
110
113
  i += 1 # stray line (e.g. "diff --git", "index ...") — skip
111
- sections.append(_Section(new_path, hunks))
114
+ sections.append(_Section(old_path, new_path, hunks))
112
115
  return sections
113
116
 
114
117
 
@@ -157,8 +160,36 @@ def _match_hunk(hunk: _Hunk, file_lines: list[str]) -> int | None:
157
160
  return None
158
161
 
159
162
 
160
- def _apply_hunk(hunk: _Hunk, pos: int, file_lines: list[str]) -> None:
161
- """Replace the matched region with the hunk's new lines in place."""
163
+ def _file_line_ending(file_lines: list[str]) -> str:
164
+ """The file's line-ending convention: ``"\\r\\n"``, ``"\\r"`` or ``"\\n"``.
165
+
166
+ Decided by the first ``"\\n"``-terminated line, so a classic-Mac
167
+ ``"\\r"`` terminator (or a stray carriage return, which universal
168
+ newline reading treats as a terminator) never flips a normal file's
169
+ convention. ``"\\r"`` is returned only when the file has no
170
+ ``"\\n"`` terminator at all. Files without any terminator (a
171
+ single unterminated line) default to ``"\\n"``.
172
+ """
173
+ saw_cr_only = False
174
+ for line in file_lines:
175
+ if line.endswith("\r\n"):
176
+ return "\r\n"
177
+ if line.endswith("\n"):
178
+ return "\n"
179
+ if line.endswith("\r"):
180
+ saw_cr_only = True
181
+ return "\r" if saw_cr_only else "\n"
182
+
183
+
184
+ def _apply_hunk(hunk: _Hunk, pos: int, file_lines: list[str], ending: str) -> None:
185
+ """Replace the matched region with the hunk's new lines in place.
186
+
187
+ Context lines keep the file's original bytes; added lines adopt the
188
+ file's line-ending convention (*ending*) — the diff's own ``+``
189
+ lines usually carry LF (the model / git), and letting them through
190
+ verbatim would leave a CRLF file with mixed endings. A line marked
191
+ ``\`` gets no terminator at all.
192
+ """
162
193
  old_count = sum(1 for kind, _, _ in hunk.body if kind in (" ", "-"))
163
194
  new_block: list[str] = []
164
195
  p = pos
@@ -169,12 +200,8 @@ def _apply_hunk(hunk: _Hunk, pos: int, file_lines: list[str]) -> None:
169
200
  elif kind == "-":
170
201
  p += 1
171
202
  else: # "+"
172
- if no_newline:
173
- new_block.append(content.rstrip("\n"))
174
- elif content.endswith("\n"):
175
- new_block.append(content)
176
- else:
177
- new_block.append(content + "\n")
203
+ body = content.rstrip("\r\n")
204
+ new_block.append(body if no_newline else body + ending)
178
205
  file_lines[pos : pos + old_count] = new_block
179
206
 
180
207
 
@@ -199,8 +226,12 @@ def _apply_section(section: _Section, cwd: str, fallback_path: str | None) -> tu
199
226
  target = _resolve_target(section.new_path, cwd, fallback_path)
200
227
  if not os.path.isfile(target):
201
228
  return False, f"target file does not exist: {target}"
229
+ # surrogateescape + newline="": invalid UTF-8 bytes and the file's own
230
+ # line endings survive untouched for every line the diff does not
231
+ # change (errors="replace" used to persist U+FFFD for such bytes, and
232
+ # universal newlines rewrote the whole file's endings).
202
233
  try:
203
- with open(target, encoding="utf-8", errors="replace") as f:
234
+ with open(target, encoding="utf-8", errors="surrogateescape", newline="") as f:
204
235
  file_lines = f.readlines()
205
236
  except OSError as e:
206
237
  return False, f"cannot read {target}: {e}"
@@ -216,10 +247,11 @@ def _apply_section(section: _Section, cwd: str, fallback_path: str | None) -> tu
216
247
  )
217
248
  plan.append((hunk, pos))
218
249
  new_lines = list(file_lines)
250
+ ending = _file_line_ending(file_lines)
219
251
  for hunk, pos in reversed(plan): # bottom-up: earlier positions stay valid
220
- _apply_hunk(hunk, pos, new_lines)
252
+ _apply_hunk(hunk, pos, new_lines, ending)
221
253
  try:
222
- with open(target, "w", encoding="utf-8") as f:
254
+ with open(target, "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
223
255
  f.writelines(new_lines)
224
256
  except OSError as e:
225
257
  return False, f"cannot write {target}: {e}"
@@ -244,3 +276,27 @@ def apply_unified_diff(
244
276
  if not ok:
245
277
  return False, msg
246
278
  return True, f"applied {len(sections)} file section(s)"
279
+
280
+
281
+ def diff_targets(diff_text: str, cwd: str, fallback_path: str | None = None) -> list[str] | None:
282
+ """Paths a patch could plausibly touch for this diff.
283
+
284
+ Includes every section's ``---`` (old) AND ``+++`` (new) path,
285
+ resolved exactly like the applier resolves the new path. The
286
+ built-in applier only ever writes the new path, but external patch
287
+ engines (GNU patch) may consult the old path too, so a caller
288
+ gating writes must treat both as in scope. Returns ``None`` when
289
+ the text yields no file section — the caller decides whether an
290
+ unverifiable diff is acceptable (the plan-mode guard treats it as a
291
+ refusal).
292
+ """
293
+ sections = _parse(diff_text)
294
+ if not sections:
295
+ return None
296
+ targets: list[str] = []
297
+ for section in sections:
298
+ for path in (section.old_path, section.new_path):
299
+ resolved = os.path.realpath(_resolve_target(path, cwd, fallback_path))
300
+ if resolved not in targets:
301
+ targets.append(resolved)
302
+ return targets
@@ -18,6 +18,41 @@ import subprocess
18
18
  from ..diffrender import unified_diff
19
19
  from .base import Tool, ToolContext
20
20
 
21
+ # Any line ending, as a single compiled pattern (CRLF first so a CRLF is
22
+ # never rewritten as two endings).
23
+ _NEWLINE_RE = re.compile(r"\r\n|\r|\n")
24
+
25
+
26
+ def _uses_crlf(text: str) -> bool:
27
+ """Whether the FIRST line ending in TEXT is CRLF.
28
+
29
+ Files are read with ``newline=""`` so their endings are preserved
30
+ byte-for-byte; this detector decides how model-provided text (which
31
+ is almost always LF) must be translated to match the file's own
32
+ convention instead of silently rewriting it.
33
+ """
34
+ idx = text.find("\n")
35
+ return idx > 0 and text[idx - 1] == "\r"
36
+
37
+
38
+ def _to_crlf(text: str) -> str:
39
+ """Rewrite every line ending in TEXT to CRLF."""
40
+ return _NEWLINE_RE.sub("\r\n", text)
41
+
42
+
43
+ def _patch_cwd(raw: str, path: str) -> str:
44
+ """The working directory a diff applies in (Emacs `file-name-directory`).
45
+
46
+ A trailing-slash directory path -> that directory itself, so a
47
+ multi-file diff applies to files within it; otherwise the file's
48
+ parent. Shared by `Edit.run` and the plan-mode diff guard
49
+ (``Session._plan_diff_verdict``) so both always agree on where a
50
+ patch's section paths resolve.
51
+ """
52
+ if raw.endswith("/") or raw.endswith(os.sep):
53
+ return path or "/"
54
+ return os.path.dirname(path) or "/"
55
+
21
56
 
22
57
  class Edit(Tool):
23
58
  name = "Edit"
@@ -73,13 +108,11 @@ class Edit(Tool):
73
108
  # gptel: string mode when `diff` is false OR `old_str` is provided.
74
109
  if diffp is False or old is not None:
75
110
  return self._string_replace(path, old, new_str, ctx)
76
- # Diff mode runs `patch` in Emacs `file-name-directory' of the path:
77
- # a trailing-slash directory path -> that directory itself, so a
78
- # multi-file diff applies to files within it; otherwise the parent.
79
- if raw.endswith("/") or raw.endswith(os.sep):
80
- cwd = path or "/"
81
- else:
82
- cwd = os.path.dirname(path) or "/"
111
+ # Diff mode runs `patch` in Emacs `file-name-directory' of the path
112
+ # (see _patch_cwd): a trailing-slash directory path -> that
113
+ # directory itself, so a multi-file diff applies to files within
114
+ # it; otherwise the parent.
115
+ cwd = _patch_cwd(raw, path)
83
116
  return self._apply_patch(path, cwd, new_str, ctx)
84
117
 
85
118
  def _string_replace(self, path: str, old: str | None, new_str: str, ctx: ToolContext) -> str:
@@ -89,11 +122,23 @@ class Edit(Tool):
89
122
  )
90
123
  if old is None:
91
124
  return "Error: old_str is required for non-diff edits"
125
+ if not isinstance(old, str) or not isinstance(new_str, str):
126
+ return "Error: old_str and new_str must be strings"
127
+ # surrogateescape: invalid UTF-8 bytes (Latin-1, GBK, ...) survive
128
+ # the read/rewrite round trip — errors="replace" would silently
129
+ # persist U+FFFD for every such byte. newline="": the file's own
130
+ # line endings are preserved instead of being rewritten to LF.
92
131
  try:
93
- with open(path, encoding="utf-8", errors="replace") as f:
132
+ with open(path, encoding="utf-8", errors="surrogateescape", newline="") as f:
94
133
  content = f.read()
95
134
  except OSError as e:
96
135
  return f"Error: cannot read {path}: {e}"
136
+ if _uses_crlf(content):
137
+ # the file uses CRLF; the model's old_str/new_str are nearly
138
+ # always LF, so translate them to the file's convention —
139
+ # the match still works and the edit never mixes endings
140
+ old = _to_crlf(old)
141
+ new_str = _to_crlf(new_str)
97
142
  count = content.count(old)
98
143
  if count == 0:
99
144
  return f'Error: Could not find old_str "{old[:20]}" in file {path}'
@@ -104,7 +149,7 @@ class Edit(Tool):
104
149
  )
105
150
  new = content.replace(old, new_str, 1)
106
151
  try:
107
- with open(path, "w", encoding="utf-8") as f:
152
+ with open(path, "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
108
153
  f.write(new)
109
154
  except OSError as e:
110
155
  return f"Error: {e}"
@@ -10,6 +10,7 @@ import os
10
10
 
11
11
  from ..diffrender import unified_diff
12
12
  from .base import Tool, ToolContext
13
+ from .edit import _to_crlf, _uses_crlf
13
14
 
14
15
 
15
16
  class Insert(Tool):
@@ -33,16 +34,24 @@ class Insert(Tool):
33
34
 
34
35
  def run(self, args: dict, ctx: ToolContext) -> str:
35
36
  path = os.path.realpath(os.path.abspath(args["path"]))
37
+ # surrogateescape + newline="": preserve invalid UTF-8 bytes and
38
+ # the file's own line endings (see Edit._string_replace)
36
39
  try:
37
- with open(path, encoding="utf-8") as f:
40
+ with open(path, encoding="utf-8", errors="surrogateescape", newline="") as f:
38
41
  old_content = f.read()
39
42
  lines = old_content.splitlines(keepends=True)
40
43
  except OSError as e:
41
44
  return f"Error: cannot read {path}: {e}"
42
45
  ln = int(args["line_number"])
43
46
  new_str = args["new_str"]
47
+ if not isinstance(new_str, str):
48
+ return "Error: new_str must be a string"
44
49
  if not new_str.endswith("\n"):
45
50
  new_str += "\n"
51
+ if _uses_crlf(old_content):
52
+ # match the file's convention: inserting LF into a CRLF file
53
+ # would leave it with mixed line endings
54
+ new_str = _to_crlf(new_str)
46
55
  if ln < -1:
47
56
  return f"Error: line_number {ln} is invalid (use 0 for beginning, -1 for end)"
48
57
  if ln == -1 or ln >= len(lines):
@@ -53,7 +62,7 @@ class Insert(Tool):
53
62
  lines.insert(ln, new_str)
54
63
  new_content = "".join(lines)
55
64
  try:
56
- with open(path, "w", encoding="utf-8") as f:
65
+ with open(path, "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
57
66
  f.write(new_content)
58
67
  except OSError as e:
59
68
  return f"Error: {e}"