python-agent-harness 1.5.4.7__tar.gz → 1.5.4.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 (94) hide show
  1. {python_agent_harness-1.5.4.7/python_agent_harness.egg-info → python_agent_harness-1.5.4.8}/PKG-INFO +1 -1
  2. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/pyproject.toml +1 -1
  3. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/__init__.py +1 -1
  4. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/client.py +38 -13
  5. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/planmode.py +3 -1
  6. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/session.py +1 -1
  7. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/edit.py +1 -1
  8. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/glob.py +2 -2
  9. python_agent_harness-1.5.4.8/python_agent_harness/tools/glob_mac.py +115 -0
  10. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/grep.py +13 -1
  11. python_agent_harness-1.5.4.8/python_agent_harness/tools/grep_mac.py +209 -0
  12. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/insert.py +2 -0
  13. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/write.py +3 -1
  14. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8/python_agent_harness.egg-info}/PKG-INFO +1 -1
  15. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_client.py +17 -2
  16. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_filesystem.py +1 -1
  17. python_agent_harness-1.5.4.7/python_agent_harness/tools/glob_mac.py +0 -97
  18. python_agent_harness-1.5.4.7/python_agent_harness/tools/grep_mac.py +0 -92
  19. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/LICENSE +0 -0
  20. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/README.md +0 -0
  21. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/__main__.py +0 -0
  22. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/agent.py +0 -0
  23. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/cli.py +0 -0
  24. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/commands.py +0 -0
  25. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/config.py +0 -0
  26. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/context_manager.py +0 -0
  27. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/diffrender.py +0 -0
  28. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/mcp/__init__.py +0 -0
  29. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/mcp/client.py +0 -0
  30. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/mcp/config.py +0 -0
  31. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/mcp/manager.py +0 -0
  32. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/models.py +0 -0
  33. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/persistence.py +0 -0
  34. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/agent.md +0 -0
  35. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/build-switch.md +0 -0
  36. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/commands/explain.md +0 -0
  37. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/compact.md +0 -0
  38. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/initialize.md +0 -0
  39. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/plan-mode.md +0 -0
  40. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/plan.md +0 -0
  41. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/review.md +0 -0
  42. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/subagent.md +0 -0
  43. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/summary.md +0 -0
  44. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  45. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts/title.md +0 -0
  46. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/prompts.py +0 -0
  47. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/subagent.py +0 -0
  48. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/token_estimator.py +0 -0
  49. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tool_runner.py +0 -0
  50. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/__init__.py +0 -0
  51. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/agent_tool.py +0 -0
  52. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/base.py +0 -0
  53. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/bash.py +0 -0
  54. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/diffapply.py +0 -0
  55. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/edit_mac.py +0 -0
  56. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/filesystem.py +0 -0
  57. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/mcp.py +0 -0
  58. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/mkdir.py +0 -0
  59. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/planexit.py +0 -0
  60. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/question.py +0 -0
  61. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/read.py +0 -0
  62. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/skill.py +0 -0
  63. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tools/todo.py +0 -0
  64. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tui/__init__.py +0 -0
  65. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tui/commands.py +0 -0
  66. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tui/core.py +0 -0
  67. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tui/input.py +0 -0
  68. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness/tui/render.py +0 -0
  69. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  70. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  71. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness.egg-info/entry_points.txt +0 -0
  72. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness.egg-info/requires.txt +0 -0
  73. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/python_agent_harness.egg-info/top_level.txt +0 -0
  74. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/setup.cfg +0 -0
  75. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_agents_md.py +0 -0
  76. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_cli.py +0 -0
  77. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_commands.py +0 -0
  78. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_concurrent_subagents.py +0 -0
  79. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_config.py +0 -0
  80. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_context_rules.py +0 -0
  81. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_diffrender.py +0 -0
  82. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_invariants.py +0 -0
  83. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_mcp.py +0 -0
  84. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_models.py +0 -0
  85. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_persistence.py +0 -0
  86. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_planmode.py +0 -0
  87. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_prompts.py +0 -0
  88. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_scenarios.py +0 -0
  89. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_session.py +0 -0
  90. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_subagent.py +0 -0
  91. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_subagent_isolation.py +0 -0
  92. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_todos_scope.py +0 -0
  93. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.8}/tests/test_token_estimator.py +0 -0
  94. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.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.4.7
3
+ Version: 1.5.4.8
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author: huming
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.4.7"
7
+ version = "1.5.4.8"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -5,7 +5,7 @@ from .mcp.manager import MCPManager
5
5
  from .models import AgentMode, Message, ToolCall, ToolSpec
6
6
  from .session import Session
7
7
 
8
- __version__ = "1.5.4.7"
8
+ __version__ = "1.5.4.8"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -277,12 +277,16 @@ class Client:
277
277
 
278
278
  A blocked ``iter_lines()`` read must be interrupted so the agent
279
279
  loop can stop promptly. Closing the pool alone is NOT enough:
280
- on Linux, ``close()`` from another thread cannot wake a ``recv``
281
- that is already blocked in the kernel. So we first
282
- ``shutdown(SHUT_RDWR)`` every in-flight connection socket (which
283
- does wake the blocked read, turning it into a connection error
284
- the loop treats as a cancel) and then close the pool. A fresh
285
- client is swapped in for the next request.
280
+ ``httpx.Client.close()`` from another thread cannot force-close a
281
+ socket whose fd is parked in a kernel ``recv``. So we first
282
+ reach into every in-flight connection socket and both
283
+ ``shutdown(SHUT_RDWR)`` it (wakes the blocked read on Linux) and
284
+ ``close()`` the raw fd (the reliable wake on macOS/BSD, where
285
+ ``shutdown`` alone may not rouse a parked ``recv``); the woken
286
+ read surfaces as a connection error the loop treats as a cancel.
287
+ We then close the pool and swap in a fresh client for the next
288
+ request. See ``_abort_inflight_sockets`` for the platform
289
+ details.
286
290
  """
287
291
  self._aborted = True
288
292
  old = self._http
@@ -819,15 +823,31 @@ def _default_api_key() -> str | None:
819
823
 
820
824
 
821
825
  def _abort_inflight_sockets(client: httpx.Client) -> None:
822
- """Wake any blocked stream reads by shutting down live pool sockets.
826
+ """Wake any blocked stream reads on every live pool socket.
823
827
 
824
828
  Reaches through httpx/httpcore internals (transport -> pool ->
825
- connection -> network stream -> raw socket) and calls
826
- ``shutdown(SHUT_RDWR)`` on each live connection. On Linux this is
827
- the only reliable way to interrupt a ``recv`` already blocked in the
828
- kernel from another thread — ``close()`` cannot do it. Shutting
829
- down an idle socket is harmless (the pool is closed right after
830
- anyway); any failure is ignored (best effort).
829
+ connection -> network stream -> raw socket) and, for each live
830
+ connection, both ``shutdown(SHUT_RDWR)`` AND ``close()`` the raw
831
+ socket.
832
+
833
+ Both steps are needed for cross-platform reliability when another
834
+ thread is already blocked in ``recv``:
835
+
836
+ - On Linux, ``shutdown(SHUT_RDWR)`` reliably wakes the blocked
837
+ ``recv`` (turning it into a clean EOF / connection error), while
838
+ httpx's own ``close()`` cannot — it can't force-close a socket
839
+ whose fd is parked in a kernel read.
840
+ - On macOS/BSD (notably CI's macos-latest on py3.11), ``shutdown``
841
+ from another thread is NOT guaranteed to wake a ``recv`` already
842
+ parked in the kernel; the read stays blocked until the fd itself
843
+ is closed. ``socket.close()`` closes the fd directly and does
844
+ wake it, so we always follow the shutdown with an explicit
845
+ close.
846
+
847
+ Closing the fd out from under httpcore is safe here: ``abort()``
848
+ swaps in a fresh pool immediately and closes the old one under
849
+ ``contextlib.suppress`` right after this call, so a double-close is
850
+ harmless. Every step is best-effort; any failure is ignored.
831
851
  """
832
852
  pool = getattr(getattr(client, "_transport", None), "_pool", None)
833
853
  for conn in getattr(pool, "_connections", None) or ():
@@ -837,8 +857,13 @@ def _abort_inflight_sockets(client: httpx.Client) -> None:
837
857
  except Exception: # noqa: BLE001 - best effort
838
858
  sock = None
839
859
  if sock is not None:
860
+ # shutdown first (wakes the recv on Linux without touching
861
+ # the fd), then close the fd (the reliable wake on
862
+ # macOS/BSD, where shutdown alone may not).
840
863
  with contextlib.suppress(OSError):
841
864
  sock.shutdown(_socket.SHUT_RDWR)
865
+ with contextlib.suppress(OSError):
866
+ sock.close()
842
867
 
843
868
 
844
869
  def _iter_sse(lines: Iterator[str]) -> Iterator[str]:
@@ -112,4 +112,6 @@ class PlanMode:
112
112
  return self.mode == AgentMode.PLAN
113
113
 
114
114
  def plan_reminder(self) -> str:
115
- return config.PLAN_MODE_SUBAGENT_REMINDER % (self.plan_file or self.plan_file_path())
115
+ return config.PLAN_MODE_SUBAGENT_REMINDER.replace(
116
+ "%s", self.plan_file or self.plan_file_path()
117
+ )
@@ -444,7 +444,7 @@ class Session:
444
444
  )
445
445
  if approved:
446
446
  self.switch_to_build()
447
- msg = config.PLAN_EXIT_APPROVED_MESSAGE % (self.plan_mode.plan_file or "")
447
+ msg = config.PLAN_EXIT_APPROVED_MESSAGE.replace("%s", self.plan_mode.plan_file or "")
448
448
  self.pending_user_prompts.append(msg)
449
449
  return (
450
450
  "User approved switching to build agent. You are now in "
@@ -90,7 +90,7 @@ class Edit(Tool):
90
90
  if old is None:
91
91
  return "Error: old_str is required for non-diff edits"
92
92
  try:
93
- with open(path, encoding="utf-8") as f:
93
+ with open(path, encoding="utf-8", errors="replace") as f:
94
94
  content = f.read()
95
95
  except OSError as e:
96
96
  return f"Error: cannot read {path}: {e}"
@@ -107,7 +107,7 @@ class GlobTool(Tool):
107
107
  return f"Error: {e}"
108
108
  if proc.returncode != 0:
109
109
  # Failure banner is prepended to whatever git emitted.
110
- banner = f"Glob failed with exit code {proc.returncode}\n.STDOUT:\n\n"
110
+ banner = f"Glob failed with exit code {proc.returncode}\nSTDOUT:\n\n"
111
111
  return _spool(banner + (proc.stdout or "") + (proc.stderr or ""), "glob")
112
112
  return _git_glob_results(proc.stdout, git_root, base, depth)
113
113
 
@@ -141,7 +141,7 @@ class GlobTool(Tool):
141
141
  return f"Error: {e}"
142
142
  out = proc.stdout
143
143
  if proc.returncode != 0:
144
- out = f"Glob failed with exit code {proc.returncode}\n.STDOUT:\n\n" + out
144
+ out = f"Glob failed with exit code {proc.returncode}\nSTDOUT:\n\n" + out
145
145
  return _spool(out, "glob")
146
146
 
147
147
 
@@ -0,0 +1,115 @@
1
+ """macOS Glob tool: ``git ls-files`` inside git repos, ``find`` outside.
2
+
3
+ Apple ships neither GNU ``tree`` nor a compatible equivalent, so the
4
+ non-git fallback in :class:`GlobTool` (which shells out to ``tree``)
5
+ fails on a stock macOS install. ``GlobMac`` replaces that fallback
6
+ with a hybrid approach: ``find`` (C binary, pre-installed on macOS)
7
+ handles directory traversal and pattern matching, while Python handles
8
+ mtime sorting — which ``find`` does not support.
9
+
10
+ This is significantly faster than the previous pure-Python
11
+ :meth:`pathlib.Path.rglob` approach on large directory trees, while
12
+ producing identical results.
13
+
14
+ Only the non-git fallback differs; the git path, the tool name, and
15
+ the result format are inherited unchanged so callers, the tool
16
+ registry, and the plan-mode write guard are platform-independent.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import subprocess
23
+
24
+ from .base import ToolContext
25
+ from .filesystem import _natnump, _spool
26
+ from .glob import GlobTool
27
+
28
+
29
+ class GlobMac(GlobTool):
30
+ """Glob with a hybrid ``find`` + Python-sort non-git fallback for macOS."""
31
+
32
+ def _find_fallback(self, pattern: str, base: str, depth: object) -> str:
33
+ """Use ``find`` for traversal + matching, Python for mtime sort.
34
+
35
+ ``find`` (C binary) walks the directory tree and applies pattern
36
+ matching natively, which is significantly faster than
37
+ :meth:`pathlib.Path.rglob` on large trees. Results are then
38
+ sorted by modification time (newest first) in Python, matching
39
+ the ``tree --sort=mtime`` order of the Linux fallback. Hidden
40
+ directories (``.git``, etc.) are skipped via ``-path */.* -prune``.
41
+ Symlinks are followed (``-L``), matching ``tree -l`` and the old
42
+ :meth:`pathlib.Path.rglob` behavior.
43
+ """
44
+ cmd = [
45
+ "find",
46
+ "-L",
47
+ base,
48
+ "-path",
49
+ "*/.*",
50
+ "-prune",
51
+ "-o",
52
+ "-type",
53
+ "f",
54
+ "-iname",
55
+ pattern,
56
+ "-print",
57
+ ]
58
+ if _natnump(depth):
59
+ cmd[3:3] = ["-maxdepth", str(depth)]
60
+ try:
61
+ proc = subprocess.run(
62
+ cmd,
63
+ capture_output=True,
64
+ text=True,
65
+ encoding="utf-8",
66
+ errors="replace",
67
+ timeout=60,
68
+ )
69
+ except (OSError, subprocess.TimeoutExpired) as e:
70
+ return f"Error: {e}"
71
+ lines = [line for line in proc.stdout.splitlines() if line]
72
+ if not lines:
73
+ if proc.returncode != 0:
74
+ out = f"Glob failed with exit code {proc.returncode}\n"
75
+ out += proc.stderr or ""
76
+ return _spool(out, "glob")
77
+ return ""
78
+
79
+ matches: list[tuple[float, str]] = []
80
+ for path in lines:
81
+ try:
82
+ mtime = os.path.getmtime(path)
83
+ except OSError:
84
+ mtime = 0.0
85
+ matches.append((mtime, path))
86
+
87
+ matches.sort(key=lambda t: t[0], reverse=True)
88
+ out = "\n".join(path for _, path in matches)
89
+ if not out:
90
+ return ""
91
+ return _spool(out + "\n", "glob")
92
+
93
+ def run(self, args: dict, ctx: ToolContext) -> str:
94
+ pattern = args.get("pattern") or ""
95
+ if not pattern:
96
+ return "Error: pattern must not be empty"
97
+ path = args.get("path")
98
+ if path:
99
+ if not (os.path.isdir(path) and os.access(path, os.R_OK)):
100
+ return f"Error: path {path} is not readable"
101
+ else:
102
+ path = ctx.cwd
103
+ base = os.path.realpath(path)
104
+ depth = args.get("depth")
105
+
106
+ from .filesystem import _git_root
107
+
108
+ git_root = _git_root(base)
109
+
110
+ if git_root:
111
+ # The git path is identical to the parent — delegate.
112
+ return super().run(args, ctx)
113
+
114
+ # Non-git: hybrid find + Python sort fallback instead of `tree`.
115
+ return self._find_fallback(pattern, base, depth)
@@ -49,7 +49,7 @@ class Grep(Tool):
49
49
  if context is not None:
50
50
  context = max(0, min(15, int(context)))
51
51
 
52
- git_root = _git_root(path) if os.path.isdir(path) else None
52
+ git_root = _git_root(path)
53
53
  if git_root:
54
54
  rel = os.path.relpath(path, git_root)
55
55
  pathspec = rel
@@ -84,6 +84,18 @@ class Grep(Tool):
84
84
  proc = None
85
85
  if proc is not None and proc.returncode in (0, 1):
86
86
  return _grep_out(proc, "git")
87
+ return self._fallback_rg_grep(regex, path, glob, context)
88
+
89
+ def _fallback_rg_grep(
90
+ self, regex: str, path: str, glob: str | None, context: int | None
91
+ ) -> str:
92
+ """rg → grep fallback chain (no git grep).
93
+
94
+ Shared by :class:`Grep` (after git grep -P fails) and
95
+ :class:`GrepMac` (after git grep -E fails). Extracted here so
96
+ the Mac variant can skip the parent's ``git grep -P`` attempt
97
+ without duplicating the rg/grep logic.
98
+ """
87
99
  if shutil.which("rg"):
88
100
  cmd = [
89
101
  "rg",
@@ -0,0 +1,209 @@
1
+ """macOS Grep tool: ``git grep -E`` instead of ``git grep -P``.
2
+
3
+ Apple's default Git (installed via Xcode Command Line Tools) is built
4
+ without PCRE support, so ``git grep -P`` (Perl-compatible regex) fails
5
+ with::
6
+
7
+ fatal: cannot use Perl-compatible regexes when not compiled with USE_LIBPCRE
8
+
9
+ The Linux :class:`Grep` tool uses ``-P`` as the first-choice backend;
10
+ when that fails on macOS the fallback chain (``rg`` → ``grep``) still
11
+ works, but:
12
+
13
+ - ``rg`` (ripgrep) is not installed by default on macOS.
14
+ - BSD ``grep`` supports only POSIX ERE, losing Perl features
15
+ (``\\d``, ``\\w``, lookaheads).
16
+ - Two unnecessary failed attempts add latency before reaching a
17
+ working backend.
18
+
19
+ ``GrepMac`` replaces ``-P`` with ``-E`` (POSIX extended regex) in the
20
+ ``git grep`` command, so the git path — the fastest and most common
21
+ backend — works on stock macOS. PCRE character class shorthands
22
+ (``\\d``, ``\\w``, ``\\s`` and their negations) are translated to
23
+ POSIX bracket expressions so the LLM's regexes produce the same
24
+ matches they would under ``-P``. The translation is context-aware:
25
+ it skips shorthands inside existing character classes (``[\\d.]``)
26
+ and shorthands preceded by a literal backslash (``\\\\d``), so the
27
+ regex structure is never corrupted. ``\\b`` (word boundary) is
28
+ translated to ``[[:<:]]`` / ``[[:>:]]`` on macOS git, which supports
29
+ these GNU-compatible extensions.
30
+
31
+ Features with no POSIX equivalent (lookaheads, backreferences) are
32
+ left as-is — they will cause a syntax error, which is better than
33
+ silently matching wrong content.
34
+
35
+ The fallback chain skips the parent's ``git grep -P`` attempt (guaranteed
36
+ to fail on macOS) and goes directly to ``rg`` / ``grep`` via the shared
37
+ :meth:`Grep._fallback_rg_grep` method.
38
+
39
+ Only the ``git grep`` regex flag differs; the tool name and result
40
+ format are inherited so callers, the tool registry, and the plan-mode
41
+ write guard are platform-independent.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import os
47
+ import re
48
+ import subprocess
49
+
50
+ from .base import ToolContext
51
+ from .filesystem import _git_root
52
+ from .grep import Grep, _grep_out
53
+
54
+ _PCRE_CLASS_MAP: dict[str, str] = {
55
+ r"\d": "[0-9]",
56
+ r"\D": "[^0-9]",
57
+ r"\w": "[A-Za-z0-9_]",
58
+ r"\W": "[^A-Za-z0-9_]",
59
+ r"\s": "[ \t\n\r\f\v]",
60
+ r"\S": "[^ \t\n\r\f\v]",
61
+ }
62
+
63
+ # Match \d, \D, \w, \W, \s, \S, or \b that is NOT preceded by an
64
+ # odd number of backslashes (i.e. not escaped). The lookbehind
65
+ # (?<!\\) ensures we don't match a shorthand that is itself escaped.
66
+ # The ``prefix`` group captures any preceding pairs of literal
67
+ # backslashes so they can be preserved in the replacement — without
68
+ # this, re.sub would discard them along with the full match.
69
+ _PCRE_SHORTHAND_RE = re.compile(r"(?<!\\)(?P<prefix>(?:\\\\)*)(?P<shorthand>\\[dDwWsSb])")
70
+
71
+
72
+ def _is_escaped(s: str, pos: int) -> bool:
73
+ """True if the character at *pos* is preceded by an odd number of backslashes."""
74
+ count = 0
75
+ j = pos - 1
76
+ while j >= 0 and s[j] == "\\":
77
+ count += 1
78
+ j -= 1
79
+ return count % 2 == 1
80
+
81
+
82
+ def _find_char_class_end(s: str, start: int) -> int | None:
83
+ """Find the closing ``]`` of a character class starting at *start*.
84
+
85
+ Handles ``[]]`` (``]`` as first char) and ``[^]]``. Returns the
86
+ index of the closing ``]`` or ``None`` if unterminated.
87
+ """
88
+ i = start + 1
89
+ if i < len(s) and s[i] == "^":
90
+ i += 1
91
+ if i < len(s) and s[i] == "]":
92
+ i += 1
93
+ while i < len(s):
94
+ if s[i] == "\\" and i + 1 < len(s):
95
+ i += 2
96
+ continue
97
+ if s[i] == "]":
98
+ return i
99
+ i += 1
100
+ return None
101
+
102
+
103
+ def _pcre_to_ere(regex: str) -> str:
104
+ """Translate PCRE character class shorthands to POSIX ERE.
105
+
106
+ Translates ``\\d``, ``\\w``, ``\\s``, their negations, and ``\\b``
107
+ (word boundary) to POSIX-compatible equivalents. The translation is
108
+ context-aware:
109
+
110
+ - Shorthands **inside** an existing character class (``[\\d.]``) are
111
+ left untouched — replacing them would create nested ``[]`` which
112
+ is invalid in POSIX ERE. ``git grep -E`` on macOS handles
113
+ ``\\d`` inside ``[]`` natively, so this is safe.
114
+ - Shorthands preceded by a literal backslash (``\\\\d``) are left
115
+ untouched — they represent a literal backslash + letter, not a
116
+ character class.
117
+
118
+ Features with no POSIX equivalent (lookaheads, backreferences) are
119
+ left as-is so they produce a clear syntax error instead of silently
120
+ matching wrong content.
121
+ """
122
+ # Find all character class spans so we can skip shorthands inside them.
123
+ char_class_spans: list[tuple[int, int]] = []
124
+ i = 0
125
+ while i < len(regex):
126
+ if regex[i] == "[" and not _is_escaped(regex, i):
127
+ end = _find_char_class_end(regex, i)
128
+ if end is not None:
129
+ char_class_spans.append((i, end))
130
+ i = end + 1
131
+ continue
132
+ i += 1
133
+
134
+ def _in_char_class(pos: int) -> bool:
135
+ return any(start < pos < end for start, end in char_class_spans)
136
+
137
+ def _replace(m: re.Match) -> str:
138
+ if _in_char_class(m.start()):
139
+ return m.group(0)
140
+ prefix = m.group("prefix")
141
+ shorthand = m.group("shorthand")
142
+ if shorthand == r"\b":
143
+ pos = m.start("shorthand")
144
+ after = regex[pos + 2] if pos + 2 < len(regex) else ""
145
+ before = regex[pos - 1] if pos > 0 else ""
146
+ if before and (before.isalnum() or before == "_"):
147
+ return prefix + "[[:>:]]"
148
+ if after and (after.isalnum() or after == "_"):
149
+ return prefix + "[[:<:]]"
150
+ return prefix + "[[:<:]]"
151
+ return prefix + _PCRE_CLASS_MAP.get(shorthand, shorthand)
152
+
153
+ return _PCRE_SHORTHAND_RE.sub(_replace, regex)
154
+
155
+
156
+ class GrepMac(Grep):
157
+ """Grep with ``git grep -E`` for macOS (no PCRE dependency)."""
158
+
159
+ def run(self, args: dict, ctx: ToolContext) -> str:
160
+ regex = args["regex"]
161
+ path = os.path.realpath(args["path"])
162
+ if not os.path.isdir(path) and not os.path.isfile(path):
163
+ return f"Error: path {args['path']} is not readable"
164
+ glob = args.get("glob")
165
+ context = args.get("context_lines")
166
+ if context is not None:
167
+ context = max(0, min(15, int(context)))
168
+
169
+ git_root = _git_root(path)
170
+ if git_root:
171
+ rel = os.path.relpath(path, git_root)
172
+ pathspec = rel
173
+ if glob and os.path.isdir(path):
174
+ pathspec = os.path.join(rel, glob).replace(os.sep, "/")
175
+ ere_regex = _pcre_to_ere(regex)
176
+ cmd = [
177
+ "git",
178
+ "grep",
179
+ "--line-number",
180
+ "--no-color",
181
+ "--max-count=1000",
182
+ "--untracked",
183
+ "-E",
184
+ "-e",
185
+ ere_regex,
186
+ "--",
187
+ pathspec,
188
+ ]
189
+ if context:
190
+ cmd = cmd[:3] + [f"-C{context}"] + cmd[3:]
191
+ try:
192
+ proc = subprocess.run(
193
+ cmd,
194
+ cwd=git_root,
195
+ capture_output=True,
196
+ text=True,
197
+ encoding="utf-8",
198
+ errors="replace",
199
+ timeout=60,
200
+ )
201
+ except (OSError, subprocess.TimeoutExpired):
202
+ proc = None
203
+ if proc is not None and proc.returncode in (0, 1):
204
+ return _grep_out(proc, "git")
205
+
206
+ # Non-git or git grep -E failed: skip the parent's git grep -P
207
+ # attempt (guaranteed to fail on macOS) and go directly to the
208
+ # shared rg/grep fallback chain.
209
+ return self._fallback_rg_grep(regex, path, glob, context)
@@ -43,6 +43,8 @@ class Insert(Tool):
43
43
  new_str = args["new_str"]
44
44
  if not new_str.endswith("\n"):
45
45
  new_str += "\n"
46
+ if ln < -1:
47
+ return f"Error: line_number {ln} is invalid (use 0 for beginning, -1 for end)"
46
48
  if ln == -1 or ln >= len(lines):
47
49
  lines.append(new_str)
48
50
  elif ln == 0:
@@ -26,7 +26,9 @@ class Write(Tool):
26
26
  def run(self, args: dict, ctx: ToolContext) -> str:
27
27
  dir_path = args.get("path") or "."
28
28
  filename = args.get("filename") or ""
29
- content = args.get("content") or ""
29
+ content = args.get("content")
30
+ if content is None:
31
+ return "Error: Required argument `content' missing"
30
32
  # LLM may put the full file path in "filename" or in "path"
31
33
  if filename:
32
34
  path = os.path.realpath(os.path.abspath(os.path.join(dir_path, filename)))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.4.7
3
+ Version: 1.5.4.8
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author: huming
6
6
  License-Expression: MIT
@@ -812,7 +812,7 @@ class TestClientAbort(unittest.TestCase):
812
812
 
813
813
  class TestAbortInflightSockets(unittest.TestCase):
814
814
  """_abort_inflight_sockets wakes live pool sockets via
815
- shutdown(SHUT_RDWR) and tolerates broken internals."""
815
+ shutdown(SHUT_RDWR) + close() and tolerates broken internals."""
816
816
 
817
817
  def _fake_client_with_stream(self, stream):
818
818
  conn = mock.Mock()
@@ -821,7 +821,7 @@ class TestAbortInflightSockets(unittest.TestCase):
821
821
  client._transport._pool._connections = [conn]
822
822
  return client
823
823
 
824
- def test_shuts_down_live_socket(self):
824
+ def test_shuts_down_and_closes_live_socket(self):
825
825
  import socket as _socket
826
826
 
827
827
  from python_agent_harness.client import _abort_inflight_sockets
@@ -830,7 +830,22 @@ class TestAbortInflightSockets(unittest.TestCase):
830
830
  stream = mock.Mock()
831
831
  stream.get_extra_info.return_value = sock
832
832
  _abort_inflight_sockets(self._fake_client_with_stream(stream))
833
+ # shutdown wakes the recv on Linux; close() is the reliable wake
834
+ # on macOS/BSD where shutdown alone may leave a parked recv stuck
833
835
  sock.shutdown.assert_called_once_with(_socket.SHUT_RDWR)
836
+ sock.close.assert_called_once_with()
837
+
838
+ def test_close_still_runs_when_shutdown_raises(self):
839
+ """A shutdown OSError must not skip the fd close: on macOS the
840
+ close is the step that actually wakes the blocked read."""
841
+ from python_agent_harness.client import _abort_inflight_sockets
842
+
843
+ sock = mock.Mock()
844
+ sock.shutdown.side_effect = OSError("not connected")
845
+ stream = mock.Mock()
846
+ stream.get_extra_info.return_value = sock
847
+ _abort_inflight_sockets(self._fake_client_with_stream(stream)) # no raise
848
+ sock.close.assert_called_once_with()
834
849
 
835
850
  def test_empty_pool_is_noop(self):
836
851
  from python_agent_harness.client import _abort_inflight_sockets
@@ -1606,7 +1606,7 @@ class TestGrepMac(unittest.TestCase):
1606
1606
  with (
1607
1607
  mock.patch("shutil.which", return_value="/usr/bin/rg"),
1608
1608
  mock.patch(
1609
- "python_agent_harness.tools.grep_mac.subprocess.run",
1609
+ "python_agent_harness.tools.grep.subprocess.run",
1610
1610
  return_value=proc,
1611
1611
  ),
1612
1612
  ):
@@ -1,97 +0,0 @@
1
- """macOS Glob tool: ``git ls-files`` inside git repos, ``pathlib`` outside.
2
-
3
- Apple ships neither GNU ``tree`` nor a compatible equivalent, so the
4
- non-git fallback in :class:`GlobTool` (which shells out to ``tree``)
5
- fails on a stock macOS install. ``GlobMac`` replaces that fallback
6
- with :meth:`pathlib.Path.rglob`, which is pure-Python, requires no
7
- external binary, and produces identical results.
8
-
9
- Only the non-git fallback differs; the git path, the tool name, and
10
- the result format are inherited unchanged so callers, the tool
11
- registry, and the plan-mode write guard are platform-independent.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import fnmatch
17
- import os
18
- from pathlib import Path
19
-
20
- from .base import ToolContext
21
- from .filesystem import _natnump, _spool
22
- from .glob import GlobTool
23
-
24
-
25
- class GlobMac(GlobTool):
26
- """Glob with a pure-Python non-git fallback for macOS."""
27
-
28
- def _tree_fallback(self, pattern: str, base: str, depth: object) -> str:
29
- """Walk *base* with :func:`pathlib.Path.rglob` instead of ``tree``.
30
-
31
- Results are absolute paths sorted by modification time (newest
32
- first), matching the ``tree --sort=mtime`` order of the Linux
33
- fallback. Hidden directories (``.git``, etc.) are skipped.
34
- """
35
- root = Path(base)
36
- matches: list[tuple[float, str]] = []
37
- if _natnump(depth):
38
- # Depth-limited: walk manually so we can count levels.
39
- for dirpath, dirnames, filenames in os.walk(base):
40
- # Skip hidden directories (mirrors tree's -I .git and
41
- # the general expectation that dotfiles are excluded).
42
- dirnames[:] = [d for d in dirnames if not d.startswith(".")]
43
- rel = os.path.relpath(dirpath, base)
44
- level = 0 if rel == "." else rel.count(os.sep) + 1
45
- if level >= depth:
46
- dirnames.clear()
47
- continue
48
- for name in filenames:
49
- if fnmatch.fnmatch(name.lower(), pattern.lower()):
50
- full = os.path.join(dirpath, name)
51
- try:
52
- mtime = os.path.getmtime(full)
53
- except OSError:
54
- mtime = 0.0
55
- matches.append((mtime, full))
56
- else:
57
- # Unlimited depth: rglob is simpler.
58
- for p in root.rglob("*"):
59
- if any(part.startswith(".") for part in p.relative_to(root).parts):
60
- continue
61
- if p.is_file() and fnmatch.fnmatch(p.name.lower(), pattern.lower()):
62
- try:
63
- mtime = p.stat().st_mtime
64
- except OSError:
65
- mtime = 0.0
66
- matches.append((mtime, str(p)))
67
-
68
- # Sort newest-first (like tree --sort=mtime).
69
- matches.sort(key=lambda t: t[0], reverse=True)
70
- out = "\n".join(path for _, path in matches)
71
- if not out:
72
- return ""
73
- return _spool(out + "\n", "glob")
74
-
75
- def run(self, args: dict, ctx: ToolContext) -> str:
76
- pattern = args.get("pattern") or ""
77
- if not pattern:
78
- return "Error: pattern must not be empty"
79
- path = args.get("path")
80
- if path:
81
- if not (os.path.isdir(path) and os.access(path, os.R_OK)):
82
- return f"Error: path {path} is not readable"
83
- else:
84
- path = ctx.cwd
85
- base = os.path.realpath(path)
86
- depth = args.get("depth")
87
-
88
- from .filesystem import _git_root
89
-
90
- git_root = _git_root(base)
91
-
92
- if git_root:
93
- # The git path is identical to the parent — delegate.
94
- return super().run(args, ctx)
95
-
96
- # Non-git: pure-Python fallback instead of `tree`.
97
- return self._tree_fallback(pattern, base, depth)
@@ -1,92 +0,0 @@
1
- """macOS Grep tool: ``git grep -E`` instead of ``git grep -P``.
2
-
3
- Apple's default Git (installed via Xcode Command Line Tools) is built
4
- without PCRE support, so ``git grep -P`` (Perl-compatible regex) fails
5
- with::
6
-
7
- fatal: cannot use Perl-compatible regexes when not compiled with USE_LIBPCRE
8
-
9
- The Linux :class:`Grep` tool uses ``-P`` as the first-choice backend;
10
- when that fails on macOS the fallback chain (``rg`` → ``grep``) still
11
- works, but:
12
-
13
- - ``rg`` (ripgrep) is not installed by default on macOS.
14
- - BSD ``grep`` supports only POSIX ERE, losing Perl features
15
- (``\\d``, ``\\w``, lookaheads).
16
- - Two unnecessary failed attempts add latency before reaching a
17
- working backend.
18
-
19
- ``GrepMac`` replaces ``-P`` with ``-E`` (POSIX extended regex) in the
20
- ``git grep`` command, so the git path — the fastest and most common
21
- backend — works on stock macOS. The rest of the fallback chain (rg,
22
- grep) is inherited unchanged.
23
-
24
- Only the ``git grep`` regex flag differs; the tool name and result
25
- format are inherited so callers, the tool registry, and the plan-mode
26
- write guard are platform-independent.
27
- """
28
-
29
- from __future__ import annotations
30
-
31
- import os
32
- import subprocess
33
-
34
- from .base import ToolContext
35
- from .filesystem import _git_root
36
- from .grep import Grep, _grep_out
37
-
38
-
39
- class GrepMac(Grep):
40
- """Grep with ``git grep -E`` for macOS (no PCRE dependency)."""
41
-
42
- def run(self, args: dict, ctx: ToolContext) -> str:
43
- regex = args["regex"]
44
- path = os.path.realpath(args["path"])
45
- if not os.path.isdir(path) and not os.path.isfile(path):
46
- return f"Error: path {args['path']} is not readable"
47
- glob = args.get("glob")
48
- context = args.get("context_lines")
49
- if context is not None:
50
- context = max(0, min(15, int(context)))
51
-
52
- git_root = _git_root(path) if os.path.isdir(path) else None
53
- if git_root:
54
- rel = os.path.relpath(path, git_root)
55
- pathspec = rel
56
- if glob and os.path.isdir(path):
57
- pathspec = os.path.join(rel, glob).replace(os.sep, "/")
58
- # -E (extended regex) instead of -P (Perl regex): Apple's
59
- # Git is built without PCRE, so -P fails on stock macOS.
60
- cmd = [
61
- "git",
62
- "grep",
63
- "--line-number",
64
- "--no-color",
65
- "--max-count=1000",
66
- "--untracked",
67
- "-E",
68
- "-e",
69
- regex,
70
- "--",
71
- pathspec,
72
- ]
73
- if context:
74
- cmd = cmd[:3] + [f"-C{context}"] + cmd[3:]
75
- try:
76
- proc = subprocess.run(
77
- cmd,
78
- cwd=git_root,
79
- capture_output=True,
80
- text=True,
81
- encoding="utf-8",
82
- errors="replace",
83
- timeout=60,
84
- )
85
- except (OSError, subprocess.TimeoutExpired):
86
- proc = None
87
- if proc is not None and proc.returncode in (0, 1):
88
- return _grep_out(proc, "git")
89
-
90
- # Non-git or git grep failed: delegate to the parent's rg/grep
91
- # fallback chain (identical on both platforms).
92
- return super().run(args, ctx)