python-agent-harness 1.5.4.7__tar.gz → 1.5.4.9__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.9}/PKG-INFO +1 -1
  2. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/pyproject.toml +1 -1
  3. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/__init__.py +1 -1
  4. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/client.py +100 -23
  5. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/planmode.py +3 -1
  6. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/session.py +1 -1
  7. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/edit.py +1 -1
  8. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/glob.py +2 -2
  9. python_agent_harness-1.5.4.9/python_agent_harness/tools/glob_mac.py +115 -0
  10. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/grep.py +13 -1
  11. python_agent_harness-1.5.4.9/python_agent_harness/tools/grep_mac.py +209 -0
  12. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/insert.py +2 -0
  13. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/write.py +3 -1
  14. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9/python_agent_harness.egg-info}/PKG-INFO +1 -1
  15. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_client.py +18 -2
  16. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/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.9}/LICENSE +0 -0
  20. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/README.md +0 -0
  21. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/__main__.py +0 -0
  22. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/agent.py +0 -0
  23. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/cli.py +0 -0
  24. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/commands.py +0 -0
  25. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/config.py +0 -0
  26. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/context_manager.py +0 -0
  27. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/diffrender.py +0 -0
  28. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/mcp/__init__.py +0 -0
  29. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/mcp/client.py +0 -0
  30. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/mcp/config.py +0 -0
  31. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/mcp/manager.py +0 -0
  32. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/models.py +0 -0
  33. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/persistence.py +0 -0
  34. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/agent.md +0 -0
  35. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/build-switch.md +0 -0
  36. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/commands/explain.md +0 -0
  37. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/compact.md +0 -0
  38. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/initialize.md +0 -0
  39. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/plan-mode.md +0 -0
  40. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/plan.md +0 -0
  41. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/review.md +0 -0
  42. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/subagent.md +0 -0
  43. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/summary.md +0 -0
  44. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  45. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts/title.md +0 -0
  46. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/prompts.py +0 -0
  47. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/subagent.py +0 -0
  48. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/token_estimator.py +0 -0
  49. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tool_runner.py +0 -0
  50. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/__init__.py +0 -0
  51. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/agent_tool.py +0 -0
  52. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/base.py +0 -0
  53. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/bash.py +0 -0
  54. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/diffapply.py +0 -0
  55. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/edit_mac.py +0 -0
  56. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/filesystem.py +0 -0
  57. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/mcp.py +0 -0
  58. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/mkdir.py +0 -0
  59. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/planexit.py +0 -0
  60. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/question.py +0 -0
  61. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/read.py +0 -0
  62. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/skill.py +0 -0
  63. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tools/todo.py +0 -0
  64. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tui/__init__.py +0 -0
  65. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tui/commands.py +0 -0
  66. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tui/core.py +0 -0
  67. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tui/input.py +0 -0
  68. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness/tui/render.py +0 -0
  69. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  70. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  71. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness.egg-info/entry_points.txt +0 -0
  72. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness.egg-info/requires.txt +0 -0
  73. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/python_agent_harness.egg-info/top_level.txt +0 -0
  74. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/setup.cfg +0 -0
  75. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_agents_md.py +0 -0
  76. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_cli.py +0 -0
  77. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_commands.py +0 -0
  78. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_concurrent_subagents.py +0 -0
  79. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_config.py +0 -0
  80. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_context_rules.py +0 -0
  81. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_diffrender.py +0 -0
  82. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_invariants.py +0 -0
  83. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_mcp.py +0 -0
  84. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_models.py +0 -0
  85. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_persistence.py +0 -0
  86. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_planmode.py +0 -0
  87. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_prompts.py +0 -0
  88. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_scenarios.py +0 -0
  89. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_session.py +0 -0
  90. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_subagent.py +0 -0
  91. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_subagent_isolation.py +0 -0
  92. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_todos_scope.py +0 -0
  93. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/tests/test_token_estimator.py +0 -0
  94. {python_agent_harness-1.5.4.7 → python_agent_harness-1.5.4.9}/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.9
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.9"
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.9"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -12,6 +12,7 @@ import json
12
12
  import os
13
13
  import random
14
14
  import socket as _socket
15
+ import sys
15
16
  import threading
16
17
  import time
17
18
  import uuid
@@ -277,12 +278,16 @@ class Client:
277
278
 
278
279
  A blocked ``iter_lines()`` read must be interrupted so the agent
279
280
  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.
281
+ ``httpx.Client.close()`` from another thread cannot force-close a
282
+ socket whose fd is parked in a kernel ``recv``. So we first
283
+ reach into every in-flight connection socket and both
284
+ ``shutdown(SHUT_RDWR)`` it (wakes the blocked read on Linux) and
285
+ ``close()`` the raw fd (the reliable wake on macOS/BSD, where
286
+ ``shutdown`` alone may not rouse a parked ``recv``); the woken
287
+ read surfaces as a connection error the loop treats as a cancel.
288
+ We then close the pool and swap in a fresh client for the next
289
+ request. See ``_abort_inflight_sockets`` for the platform
290
+ details.
286
291
  """
287
292
  self._aborted = True
288
293
  old = self._http
@@ -818,27 +823,99 @@ def _default_api_key() -> str | None:
818
823
  return os.environ.get("OPENAI_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or None
819
824
 
820
825
 
826
+ def _extract_socket(conn: object) -> _socket.socket | None:
827
+ """Best-effort extraction of the raw socket from an httpcore connection.
828
+
829
+ For direct (non-proxy) connections the socket lives at
830
+ ``conn._connection._network_stream``. Proxy connections are wrapped
831
+ (ForwardHTTPConnection/TunnelHTTPConnection -> HTTPConnection ->
832
+ HTTP11Connection), so we unwrap up to 4 levels looking for the
833
+ ``_network_stream`` attribute that owns the raw socket.
834
+ """
835
+ try:
836
+ obj: object = conn
837
+ stream = None
838
+ for _ in range(4):
839
+ stream = getattr(obj, "_network_stream", None)
840
+ if stream is not None:
841
+ break
842
+ obj = getattr(obj, "_connection", None)
843
+ if obj is None:
844
+ break
845
+ return stream.get_extra_info("socket") if stream is not None else None
846
+ except Exception: # noqa: BLE001 - best effort
847
+ return None
848
+
849
+
850
+ def _shutdown_and_close(sock: _socket.socket) -> None:
851
+ """Shutdown then close a socket to wake blocked reads.
852
+
853
+ On macOS the socket is in non-blocking mode (httpcore calls
854
+ settimeout for the read timeout), so a blocked recv parks in
855
+ select(); closing the fd before that select has processed the
856
+ shutdown wakeup can lose the wakeup and leave the read parked
857
+ forever. Give the woken thread a moment to observe the EOF first.
858
+ """
859
+ with contextlib.suppress(OSError):
860
+ sock.shutdown(_socket.SHUT_RDWR)
861
+ if sys.platform == "darwin":
862
+ time.sleep(0.05)
863
+ with contextlib.suppress(OSError):
864
+ sock.close()
865
+
866
+
821
867
  def _abort_inflight_sockets(client: httpx.Client) -> None:
822
- """Wake any blocked stream reads by shutting down live pool sockets.
868
+ """Wake any blocked stream reads on every live pool socket.
823
869
 
824
870
  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).
871
+ connection -> network stream -> raw socket) and, for each live
872
+ connection, both ``shutdown(SHUT_RDWR)`` AND ``close()`` the raw
873
+ socket.
874
+
875
+ Both steps are needed for cross-platform reliability when another
876
+ thread is already blocked in ``recv``:
877
+
878
+ - On Linux, ``shutdown(SHUT_RDWR)`` reliably wakes the blocked
879
+ ``recv`` (turning it into a clean EOF / connection error), while
880
+ httpx's own ``close()`` cannot — it can't force-close a socket
881
+ whose fd is parked in a kernel read.
882
+ - On macOS/BSD (notably CI's macos-latest on py3.11), ``shutdown``
883
+ from another thread is NOT guaranteed to wake a ``recv`` already
884
+ parked in the kernel; the read stays blocked until the fd itself
885
+ is closed. ``socket.close()`` closes the fd directly and does
886
+ wake it, so we always follow the shutdown with an explicit
887
+ close.
888
+
889
+ Closing the fd out from under httpcore is safe here: ``abort()``
890
+ swaps in a fresh pool immediately and closes the old one under
891
+ ``contextlib.suppress`` right after this call, so a double-close is
892
+ harmless. Every step is best-effort; any failure is ignored.
831
893
  """
832
- pool = getattr(getattr(client, "_transport", None), "_pool", None)
833
- for conn in getattr(pool, "_connections", None) or ():
834
- try:
835
- stream = getattr(getattr(conn, "_connection", None), "_network_stream", None)
836
- sock = stream.get_extra_info("socket") if stream is not None else None
837
- except Exception: # noqa: BLE001 - best effort
838
- sock = None
839
- if sock is not None:
840
- with contextlib.suppress(OSError):
841
- sock.shutdown(_socket.SHUT_RDWR)
894
+ pools: list[object] = []
895
+ base_pool = getattr(getattr(client, "_transport", None), "_pool", None)
896
+ if base_pool is not None:
897
+ pools.append(base_pool)
898
+
899
+ if sys.platform == "darwin":
900
+ # macOS only: with proxy env vars (HTTP_PROXY/HTTPS_PROXY) set,
901
+ # httpx routes requests through proxy transports registered in
902
+ # client._mounts (pools are httpcore.HTTPProxy), leaving the
903
+ # base transport pool above empty. On Linux this never happens
904
+ # in CI and the code path above is sufficient, so this extra
905
+ # walk is deliberately restricted to macOS.
906
+ mounts = getattr(client, "_mounts", None)
907
+ if isinstance(mounts, dict):
908
+ for transport in mounts.values():
909
+ if transport is not None:
910
+ pool = getattr(transport, "_pool", None)
911
+ if pool is not None:
912
+ pools.append(pool)
913
+
914
+ for pool in pools:
915
+ for conn in getattr(pool, "_connections", None) or ():
916
+ sock = _extract_socket(conn)
917
+ if sock is not None:
918
+ _shutdown_and_close(sock)
842
919
 
843
920
 
844
921
  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.9
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author: huming
6
6
  License-Expression: MIT
@@ -812,16 +812,17 @@ 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()
819
+ conn._network_stream = None
819
820
  conn._connection._network_stream = stream
820
821
  client = mock.Mock()
821
822
  client._transport._pool._connections = [conn]
822
823
  return client
823
824
 
824
- def test_shuts_down_live_socket(self):
825
+ def test_shuts_down_and_closes_live_socket(self):
825
826
  import socket as _socket
826
827
 
827
828
  from python_agent_harness.client import _abort_inflight_sockets
@@ -830,7 +831,22 @@ class TestAbortInflightSockets(unittest.TestCase):
830
831
  stream = mock.Mock()
831
832
  stream.get_extra_info.return_value = sock
832
833
  _abort_inflight_sockets(self._fake_client_with_stream(stream))
834
+ # shutdown wakes the recv on Linux; close() is the reliable wake
835
+ # on macOS/BSD where shutdown alone may leave a parked recv stuck
833
836
  sock.shutdown.assert_called_once_with(_socket.SHUT_RDWR)
837
+ sock.close.assert_called_once_with()
838
+
839
+ def test_close_still_runs_when_shutdown_raises(self):
840
+ """A shutdown OSError must not skip the fd close: on macOS the
841
+ close is the step that actually wakes the blocked read."""
842
+ from python_agent_harness.client import _abort_inflight_sockets
843
+
844
+ sock = mock.Mock()
845
+ sock.shutdown.side_effect = OSError("not connected")
846
+ stream = mock.Mock()
847
+ stream.get_extra_info.return_value = sock
848
+ _abort_inflight_sockets(self._fake_client_with_stream(stream)) # no raise
849
+ sock.close.assert_called_once_with()
834
850
 
835
851
  def test_empty_pool_is_noop(self):
836
852
  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)