python-agent-harness 1.5.5.2__tar.gz → 1.5.5.3__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 (98) hide show
  1. {python_agent_harness-1.5.5.2/python_agent_harness.egg-info → python_agent_harness-1.5.5.3}/PKG-INFO +3 -2
  2. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/README.md +1 -1
  3. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/pyproject.toml +2 -1
  4. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/cli.py +3 -3
  6. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/client.py +16 -4
  7. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/config.py +14 -4
  8. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/__init__.py +17 -4
  9. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/bash.py +5 -3
  10. python_agent_harness-1.5.5.3/python_agent_harness/tools/bash_win.py +232 -0
  11. python_agent_harness-1.5.5.3/python_agent_harness/tools/edit_win.py +56 -0
  12. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/glob.py +2 -2
  13. python_agent_harness-1.5.5.3/python_agent_harness/tools/glob_win.py +90 -0
  14. python_agent_harness-1.5.5.3/python_agent_harness/tools/grep_win.py +136 -0
  15. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/commands.py +7 -2
  16. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/input.py +61 -11
  17. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3/python_agent_harness.egg-info}/PKG-INFO +3 -2
  18. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/SOURCES.txt +4 -0
  19. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_client.py +7 -3
  20. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_commands.py +2 -1
  21. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_concurrent_subagents.py +4 -0
  22. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_filesystem.py +346 -18
  23. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_invariants.py +4 -0
  24. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_planmode.py +5 -1
  25. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_scenarios.py +4 -0
  26. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_session.py +18 -7
  27. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_tools_misc.py +1 -0
  28. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/LICENSE +0 -0
  29. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/__main__.py +0 -0
  30. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/agent.py +0 -0
  31. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/commands.py +0 -0
  32. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/context_manager.py +0 -0
  33. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/diffrender.py +0 -0
  34. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/__init__.py +0 -0
  35. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/client.py +0 -0
  36. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/config.py +0 -0
  37. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/manager.py +0 -0
  38. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/models.py +0 -0
  39. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/persistence.py +0 -0
  40. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/planmode.py +0 -0
  41. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/agent.md +0 -0
  42. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  43. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/build-switch.md +0 -0
  44. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/commands/explain.md +0 -0
  45. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/compact.md +0 -0
  46. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/initialize.md +0 -0
  47. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/plan-mode.md +0 -0
  48. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/plan.md +0 -0
  49. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/review.md +0 -0
  50. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/subagent.md +0 -0
  51. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/summary.md +0 -0
  52. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  53. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/title.md +0 -0
  54. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts.py +0 -0
  55. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/session.py +0 -0
  56. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/subagent.py +0 -0
  57. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/token_estimator.py +0 -0
  58. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tool_runner.py +0 -0
  59. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/agent_tool.py +0 -0
  60. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/base.py +0 -0
  61. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/diffapply.py +0 -0
  62. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/edit.py +0 -0
  63. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/edit_mac.py +0 -0
  64. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/filesystem.py +0 -0
  65. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/glob_mac.py +0 -0
  66. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/grep.py +0 -0
  67. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/grep_mac.py +0 -0
  68. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/insert.py +0 -0
  69. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/mcp.py +0 -0
  70. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/mkdir.py +0 -0
  71. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/planexit.py +0 -0
  72. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/question.py +0 -0
  73. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/read.py +0 -0
  74. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/skill.py +0 -0
  75. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/todo.py +0 -0
  76. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/write.py +0 -0
  77. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/__init__.py +0 -0
  78. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/core.py +0 -0
  79. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/render.py +0 -0
  80. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  81. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/entry_points.txt +0 -0
  82. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/requires.txt +0 -0
  83. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/top_level.txt +0 -0
  84. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/setup.cfg +0 -0
  85. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_agents_md.py +0 -0
  86. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_cli.py +0 -0
  87. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_config.py +0 -0
  88. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_context_rules.py +0 -0
  89. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_custom_agents.py +0 -0
  90. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_diffrender.py +0 -0
  91. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_mcp.py +0 -0
  92. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_models.py +0 -0
  93. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_persistence.py +0 -0
  94. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_prompts.py +0 -0
  95. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_subagent.py +0 -0
  96. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_subagent_isolation.py +0 -0
  97. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_todos_scope.py +0 -0
  98. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_token_estimator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.2
3
+ Version: 1.5.5.3
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
@@ -9,6 +9,7 @@ Classifier: Development Status :: 5 - Production/Stable
9
9
  Classifier: Environment :: Console
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: Operating System :: MacOS
12
+ Classifier: Operating System :: Microsoft :: Windows
12
13
  Classifier: Operating System :: POSIX :: Linux
13
14
  Classifier: Programming Language :: Python :: 3.11
14
15
  Classifier: Programming Language :: Python :: 3.12
@@ -276,7 +277,7 @@ python_agent_harness/
276
277
 
277
278
  ## Development
278
279
 
279
- Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and macOS.
280
+ Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux, MacOS and Windows.
280
281
 
281
282
  ```sh
282
283
  make test # unit tests
@@ -243,7 +243,7 @@ python_agent_harness/
243
243
 
244
244
  ## Development
245
245
 
246
- Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and macOS.
246
+ Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux, MacOS and Windows.
247
247
 
248
248
  ```sh
249
249
  make test # unit tests
@@ -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.2"
7
+ version = "1.5.5.3"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -17,6 +17,7 @@ classifiers = [
17
17
  "Environment :: Console",
18
18
  "Intended Audience :: Developers",
19
19
  "Operating System :: MacOS",
20
+ "Operating System :: Microsoft :: Windows",
20
21
  "Operating System :: POSIX :: Linux",
21
22
  "Programming Language :: Python :: 3.11",
22
23
  "Programming Language :: Python :: 3.12",
@@ -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.5.2"
8
+ __version__ = "1.5.5.3"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -9,7 +9,7 @@ sessions, restore and summary/explain — are TUI slash commands only;
9
9
  they are NOT registered as CLI subcommands.
10
10
 
11
11
  Configuration (LLM etc.) is read from a JSON file, by default
12
- ~/.config/python-agent-harness/config.json; see `config --init`.
12
+ from the OS config directory; see `config --init`.
13
13
  """
14
14
 
15
15
  from __future__ import annotations
@@ -175,7 +175,7 @@ def cmd_config(args: argparse.Namespace) -> int:
175
175
  if path.exists() and not args.force:
176
176
  print(f"config already exists: {path} (use --force to overwrite)")
177
177
  return 1
178
- template = config.CONFIG_TEMPLATE.format(path=path)
178
+ template = config.CONFIG_TEMPLATE.format(path=str(path).replace("\\", "\\\\"))
179
179
  path.write_text(template, encoding="utf-8")
180
180
  print(f"wrote config template: {path}")
181
181
  return 0
@@ -250,7 +250,7 @@ def _add_config_arg(parser: argparse.ArgumentParser, suppress: bool = False) ->
250
250
  "--config",
251
251
  metavar="PATH",
252
252
  default=argparse.SUPPRESS if suppress else None,
253
- help="path to config.json (default: ~/.config/python-agent-harness/config.json)",
253
+ help="path to config.json (default: OS config dir / python-agent-harness / config.json)",
254
254
  )
255
255
 
256
256
 
@@ -13,6 +13,7 @@ import os
13
13
  import random
14
14
  import socket as _socket
15
15
  import sys
16
+ import tempfile
16
17
  import threading
17
18
  import time
18
19
  import uuid
@@ -128,7 +129,7 @@ def _llm_log_path() -> Path:
128
129
  d = Path(log_dir)
129
130
  d.mkdir(parents=True, exist_ok=True)
130
131
  return d / f"python-agent-harness-{date_str}-{session_id}.json"
131
- return Path(f"/tmp/python-agent-harness-{date_str}-{session_id}.json")
132
+ return Path(tempfile.gettempdir()) / f"python-agent-harness-{date_str}-{session_id}.json"
132
133
 
133
134
 
134
135
  def _log_llm_interaction(
@@ -196,8 +197,12 @@ def _resolve_ca_bundle() -> str | bool:
196
197
  "/etc/pki/tls/certs/ca-bundle.crt",
197
198
  "/etc/ssl/certs/ca-certificates.crt",
198
199
  "/etc/ssl/ca-bundle.pem",
200
+ # Windows (Git for Windows, conda, etc.)
201
+ os.path.join(os.environ.get("PROGRAMFILES", ""), "Git", "mingw64", "ssl", "cert.pem"),
202
+ os.path.join(os.environ.get("PROGRAMFILES(X86)", ""), "Git", "mingw64", "ssl", "cert.pem"),
203
+ os.path.join(os.environ.get("CONDA_PREFIX", ""), "ssl", "cert.pem"),
199
204
  ):
200
- if os.path.isfile(cand):
205
+ if cand and os.path.isfile(cand):
201
206
  return cand
202
207
  return True
203
208
 
@@ -877,9 +882,16 @@ def _shutdown_and_close(sock: _socket.socket) -> None:
877
882
  after, so a new socket could reuse the fd number before the
878
883
  reader observed the EOF. The grace sleep delays the fd release
879
884
  so the woken thread can observe the EOF on the old fd first.
885
+
886
+ On Windows, ``shutdown(SHUT_RDWR)`` on the client socket causes
887
+ ``WinError 10058`` on the server side when it tries to read,
888
+ producing noisy BrokenPipeError tracebacks. Skipping shutdown
889
+ and just closing the fd is sufficient — Windows ``close()``
890
+ triggers a TCP RST that reliably wakes the blocked read.
880
891
  """
881
- with contextlib.suppress(OSError):
882
- sock.shutdown(_socket.SHUT_RDWR)
892
+ if sys.platform != "win32":
893
+ with contextlib.suppress(OSError):
894
+ sock.shutdown(_socket.SHUT_RDWR)
883
895
  if sys.platform == "darwin":
884
896
  # macOS only; on Linux shutdown() alone reliably wakes the
885
897
  # blocked recv (measured: <200ms unblock, no sleeps needed).
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import sys
7
8
  from pathlib import Path
8
9
 
9
10
  from .mcp.config import MCPConfig
@@ -50,7 +51,10 @@ CALIBRATION_MIN = 0.5
50
51
  CALIBRATION_MAX = 3.0
51
52
 
52
53
  # ---- sessions ---------------------------------------------------------------
53
- SESSION_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
54
+ if sys.platform == "win32":
55
+ SESSION_DIR = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
56
+ else:
57
+ SESSION_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
54
58
  SESSION_SUBDIR = "python-agent-harness/sessions"
55
59
  AUTO_SAVE_SESSION = True
56
60
 
@@ -164,9 +168,15 @@ DEFAULT_AGENT_PROMPT_FILE = PROMPTS_DIR / "agent.md"
164
168
  DEFAULT_SUBAGENT_PROMPT_FILE = PROMPTS_DIR / "subagent.md"
165
169
 
166
170
  # ---- configuration file ---------------------------------------------------------
167
- CONFIG_DIR = (
168
- Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "python-agent-harness"
169
- )
171
+ if sys.platform == "win32":
172
+ CONFIG_DIR = (
173
+ Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
174
+ / "python-agent-harness"
175
+ )
176
+ else:
177
+ CONFIG_DIR = (
178
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "python-agent-harness"
179
+ )
170
180
  CONFIG_FILE = CONFIG_DIR / "config.json"
171
181
 
172
182
  DEFAULT_LLM: dict = {
@@ -7,11 +7,15 @@ import sys
7
7
  from .agent_tool import AgentTool
8
8
  from .base import PendingToolResult, Registry, Tool, ToolContext
9
9
  from .bash import Bash
10
+ from .bash_win import BashWindows
10
11
  from .edit import Edit
11
12
  from .edit_mac import EditMac
13
+ from .edit_win import EditWindows
12
14
  from .filesystem import GlobTool, Grep, Insert, Mkdir, Read, Write
13
15
  from .glob_mac import GlobMac
16
+ from .glob_win import GlobWindows
14
17
  from .grep_mac import GrepMac
18
+ from .grep_win import GrepWindows
15
19
  from .mcp import MCPTool, mcp_tools_from_manager, normalize_mcp_result
16
20
  from .planexit import PlanExit
17
21
  from .question import Question
@@ -25,12 +29,16 @@ __all__ = [
25
29
  "ToolContext",
26
30
  "AgentTool",
27
31
  "Bash",
32
+ "BashWindows",
28
33
  "Edit",
29
34
  "EditMac",
35
+ "EditWindows",
30
36
  "GlobMac",
31
37
  "GlobTool",
38
+ "GlobWindows",
32
39
  "Grep",
33
40
  "GrepMac",
41
+ "GrepWindows",
34
42
  "Insert",
35
43
  "MCPTool",
36
44
  "Mkdir",
@@ -50,10 +58,15 @@ def default_registry() -> Registry:
50
58
  # macOS lacks GNU `patch`, `tree`, and PCRE-enabled git — use
51
59
  # pure-Python / POSIX-compatible Mac variants that register under
52
60
  # the same tool names so callers are platform-independent.
61
+ # Windows lacks process groups, select on pipes, SIGKILL, `patch`,
62
+ # `tree`, `find`, and `grep` — Windows variants use pure-Python
63
+ # fallbacks and CREATE_NEW_PROCESS_GROUP + taskkill instead.
53
64
  _mac = sys.platform == "darwin"
54
- edit_tool = EditMac() if _mac else Edit()
55
- glob_tool = GlobMac() if _mac else GlobTool()
56
- grep_tool = GrepMac() if _mac else Grep()
65
+ _win = sys.platform == "win32"
66
+ edit_tool = EditWindows() if _win else EditMac() if _mac else Edit()
67
+ glob_tool = GlobWindows() if _win else GlobMac() if _mac else GlobTool()
68
+ grep_tool = GrepWindows() if _win else GrepMac() if _mac else Grep()
69
+ bash_tool = BashWindows() if _win else Bash()
57
70
  for tool in (
58
71
  AgentTool(),
59
72
  TodoWrite(),
@@ -64,7 +77,7 @@ def default_registry() -> Registry:
64
77
  edit_tool,
65
78
  Write(),
66
79
  Mkdir(),
67
- Bash(),
80
+ bash_tool,
68
81
  Skill(),
69
82
  Question(),
70
83
  ):
@@ -30,6 +30,7 @@ import os
30
30
  import select
31
31
  import signal
32
32
  import subprocess
33
+ import sys
33
34
  import threading
34
35
  import time
35
36
  from collections import deque
@@ -56,7 +57,7 @@ def _kill_pgid(pgid: int) -> None:
56
57
  stdout pipe open is still running in the group.
57
58
  """
58
59
  with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
59
- os.killpg(pgid, signal.SIGKILL)
60
+ os.killpg(pgid, signal.SIGKILL) # pyright: ignore[reportAttributeAccessIssue]
60
61
 
61
62
 
62
63
  def _kill_graceful(pgid: int, proc: subprocess.Popen) -> None:
@@ -66,7 +67,7 @@ def _kill_graceful(pgid: int, proc: subprocess.Popen) -> None:
66
67
  children before the hard kill.
67
68
  """
68
69
  with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
69
- os.killpg(pgid, signal.SIGTERM)
70
+ os.killpg(pgid, signal.SIGTERM) # pyright: ignore[reportAttributeAccessIssue]
70
71
  try:
71
72
  proc.wait(timeout=2)
72
73
  except subprocess.TimeoutExpired:
@@ -144,7 +145,8 @@ def _collect_output(proc: subprocess.Popen, cancel: threading.Event | None) -> t
144
145
  if stdout is None: # unreachable (stdout=PIPE), kept for the type checker
145
146
  return "", "ok"
146
147
  fd = stdout.fileno()
147
- os.set_blocking(fd, False)
148
+ if sys.platform != "win32":
149
+ os.set_blocking(fd, False) # pyright: ignore[reportAttributeAccessIssue]
148
150
  decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
149
151
  head: list[str] = []
150
152
  head_len = 0
@@ -0,0 +1,232 @@
1
+ """Windows Bash tool: process management for win32.
2
+
3
+ Windows lacks ``os.killpg``, ``os.set_blocking``, ``select.select``
4
+ on pipe fds, and ``start_new_session``. This subclass overrides
5
+ ``_execute`` to use Windows-native process creation and termination
6
+ while inheriting the shared output-truncation, timeout, and exit-code
7
+ helpers from :class:`Bash`.
8
+
9
+ Process creation uses ``CREATE_NEW_PROCESS_GROUP`` so the child is
10
+ isolated from console Ctrl-C signals (the TUI handles cancel itself).
11
+ Process termination uses ``taskkill /F /T /PID`` to kill the process
12
+ and all its children (the Windows equivalent of killing a process
13
+ group). Output is collected via a reader thread + queue, since
14
+ ``select.select`` cannot poll Windows pipes.
15
+
16
+ Only the process-creation and output-collection strategy differs;
17
+ the tool name, description, parameters, timeout semantics, and
18
+ exit-code reporting are inherited so callers, the tool registry,
19
+ and the plan-mode write guard are platform-independent.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import codecs
25
+ import contextlib
26
+ import os
27
+ import queue
28
+ import subprocess
29
+ import threading
30
+ import time
31
+ from collections import deque
32
+
33
+ from ..config import BASH_TIMEOUT_MAX, BASH_TIMEOUT_SILENCE
34
+ from ..config import MAX_OUTPUT_CHARS as _MAX_OUTPUT
35
+ from .base import PendingToolResult, ToolContext
36
+ from .bash import (
37
+ _DRAIN_GRACE,
38
+ _POLL_INTERVAL,
39
+ _READ_CHUNK,
40
+ _TAIL_LINES,
41
+ Bash,
42
+ _append_exit_code,
43
+ _assemble_truncated,
44
+ _timeout_message,
45
+ )
46
+
47
+
48
+ def _kill_process_tree(pid: int) -> None:
49
+ """Kill the process PID and all its children (Windows equivalent of
50
+ ``os.killpg`` with ``SIGKILL``).
51
+
52
+ Uses ``taskkill /F /T /PID`` which recursively terminates the
53
+ process tree. Errors are suppressed (already gone, etc.).
54
+ """
55
+ with contextlib.suppress(Exception):
56
+ subprocess.run(
57
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
58
+ stdin=subprocess.DEVNULL,
59
+ stdout=subprocess.DEVNULL,
60
+ stderr=subprocess.DEVNULL,
61
+ timeout=10,
62
+ )
63
+
64
+
65
+ def _kill_process_tree_graceful(pid: int, proc: subprocess.Popen) -> None:
66
+ """Graceful kill: terminate, then taskkill /F /T if still alive.
67
+
68
+ Windows has no SIGTERM equivalent for process trees;
69
+ ``proc.terminate()`` calls ``TerminateProcess`` on the immediate
70
+ child. If it doesn't die within 2s, ``taskkill /F /T`` force-kills
71
+ the whole tree (children included).
72
+ """
73
+ with contextlib.suppress(ProcessLookupError, OSError):
74
+ proc.terminate()
75
+ try:
76
+ proc.wait(timeout=2)
77
+ except subprocess.TimeoutExpired:
78
+ _kill_process_tree(pid)
79
+
80
+
81
+ def _collect_output_win(proc: subprocess.Popen, cancel: threading.Event | None) -> tuple[str, str]:
82
+ """Read PROC's merged output on Windows; return (text, status).
83
+
84
+ Windows lacks ``select.select`` on pipe fds and ``os.set_blocking``,
85
+ so a reader thread does blocking ``os.read`` calls and feeds chunks
86
+ into a ``queue.Queue``. The collector polls the queue with
87
+ ``_POLL_INTERVAL``, checking cancel/timeout conditions between
88
+ reads — mirroring the Unix ``_collect_output`` logic exactly.
89
+
90
+ Status is one of ``"ok"``, ``"cancelled"``, ``"timeout_silence"``,
91
+ ``"timeout_max"``. Keeps the head (first ``_MAX_OUTPUT`` chars)
92
+ and the tail (last ``_TAIL_LINES`` lines) and discards the middle,
93
+ so memory stays bounded no matter how much the process writes.
94
+ """
95
+ stdout = proc.stdout
96
+ if stdout is None:
97
+ return "", "ok"
98
+
99
+ chunk_queue: queue.Queue[bytes | None] = queue.Queue()
100
+
101
+ def _reader() -> None:
102
+ """Blocking reader thread: reads raw bytes and feeds the queue."""
103
+ try:
104
+ fd = stdout.fileno()
105
+ while True:
106
+ raw = os.read(fd, _READ_CHUNK)
107
+ if not raw:
108
+ break
109
+ chunk_queue.put(raw)
110
+ except OSError:
111
+ pass
112
+ finally:
113
+ chunk_queue.put(None)
114
+
115
+ threading.Thread(target=_reader, daemon=True).start()
116
+
117
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
118
+ head: list[str] = []
119
+ head_len = 0
120
+ tail: deque[str] = deque(maxlen=_TAIL_LINES)
121
+ pending_line = ""
122
+ total = 0
123
+ exited = False
124
+ drain_until: float | None = None
125
+ start = time.monotonic()
126
+ last_output = start
127
+
128
+ def finish() -> str:
129
+ nonlocal pending_line
130
+ if pending_line:
131
+ if len(pending_line) > _MAX_OUTPUT:
132
+ pending_line = pending_line[:_MAX_OUTPUT]
133
+ tail.append(pending_line)
134
+ head_text = "".join(head)
135
+ if total > _MAX_OUTPUT:
136
+ return _assemble_truncated(head_text, tail)
137
+ return head_text
138
+
139
+ while True:
140
+ if cancel is not None and cancel.is_set():
141
+ return "", "cancelled"
142
+ now = time.monotonic()
143
+ if not exited:
144
+ if BASH_TIMEOUT_SILENCE is not None and now - last_output >= BASH_TIMEOUT_SILENCE:
145
+ return finish(), "timeout_silence"
146
+ if BASH_TIMEOUT_MAX is not None and now - start >= BASH_TIMEOUT_MAX:
147
+ return finish(), "timeout_max"
148
+ if exited and drain_until is not None and now >= drain_until:
149
+ break
150
+ try:
151
+ raw = chunk_queue.get(timeout=_POLL_INTERVAL)
152
+ except queue.Empty:
153
+ if not exited and proc.poll() is not None:
154
+ exited = True
155
+ drain_until = time.monotonic() + _DRAIN_GRACE
156
+ continue
157
+ if raw is None:
158
+ break
159
+ chunk = decoder.decode(raw).replace("\r\n", "\n")
160
+ total += len(chunk)
161
+ last_output = time.monotonic()
162
+ if head_len < _MAX_OUTPUT:
163
+ take = chunk[: _MAX_OUTPUT - head_len]
164
+ head.append(take)
165
+ head_len += len(take)
166
+ parts = chunk.split("\n")
167
+ parts[0] = pending_line + parts[0]
168
+ pending_line = parts.pop()
169
+ for line in parts:
170
+ if len(line) > _MAX_OUTPUT:
171
+ line = line[:_MAX_OUTPUT]
172
+ tail.append(line)
173
+ return finish(), "ok"
174
+
175
+
176
+ class BashWindows(Bash):
177
+ """Bash for Windows: ``CREATE_NEW_PROCESS_GROUP`` + ``taskkill``.
178
+
179
+ Overrides :meth:`_execute` to use Windows-native process creation
180
+ (``CREATE_NEW_PROCESS_GROUP``) and termination (``taskkill /F /T``).
181
+ All shared logic — timeout messages, output truncation, exit-code
182
+ reporting, the ``PendingToolResult`` async contract — is inherited
183
+ unchanged from :class:`Bash`.
184
+ """
185
+
186
+ def _execute(self, command: str, ctx: ToolContext) -> str | PendingToolResult:
187
+ cancel = ctx.cancel_event
188
+ if cancel is not None and cancel.is_set():
189
+ return "Error: Bash command cancelled."
190
+ try:
191
+ proc = subprocess.Popen(
192
+ command,
193
+ shell=True,
194
+ stdin=subprocess.DEVNULL,
195
+ stdout=subprocess.PIPE,
196
+ stderr=subprocess.STDOUT,
197
+ bufsize=0,
198
+ cwd=ctx.cwd,
199
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
200
+ )
201
+ except OSError as e:
202
+ return f"Error: {e}"
203
+
204
+ pid = proc.pid
205
+ pending = PendingToolResult()
206
+
207
+ def deliverer() -> None:
208
+ try:
209
+ out, status = _collect_output_win(proc, cancel)
210
+ except Exception as e: # noqa: BLE001 - delivered as an error string
211
+ out = f"Error: Bash failed — {e}"
212
+ else:
213
+ if status == "cancelled":
214
+ _kill_process_tree(pid)
215
+ out = "Error: Bash command cancelled."
216
+ elif status == "timeout_silence":
217
+ _kill_process_tree_graceful(pid, proc)
218
+ out = _timeout_message(out, silence=True)
219
+ elif status == "timeout_max":
220
+ _kill_process_tree_graceful(pid, proc)
221
+ out = _timeout_message(out, silence=False)
222
+ elif status == "ok":
223
+ out = _append_exit_code(out, proc)
224
+ pending.deliver(out)
225
+ with contextlib.suppress(Exception):
226
+ if proc.stdout is not None:
227
+ proc.stdout.close()
228
+ with contextlib.suppress(Exception):
229
+ proc.wait(timeout=2)
230
+
231
+ threading.Thread(target=deliverer, daemon=True).start()
232
+ return pending
@@ -0,0 +1,56 @@
1
+ """Windows Edit tool: pure-Python diff applier (no ``patch`` binary).
2
+
3
+ Windows does not ship ``patch.exe``, so the diff/patch mode of
4
+ :class:`Edit` (which shells out to ``patch --forward``) fails on a
5
+ stock Windows install. The pure-Python applier (``diffapply.py``)
6
+ parses the diff structurally and matches hunks with fuzz, so the same
7
+ diffs apply on Windows, macOS, and Linux.
8
+
9
+ Only the diff-application strategy differs; the tool name stays
10
+ ``Edit`` so callers, the tool registry and the plan-mode write guard
11
+ are platform-independent.
12
+
13
+ The implementation is identical to :class:`EditMac` — both platforms
14
+ lack a reliable ``patch`` binary and use the same Python applier.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ from ..diffrender import unified_diff
22
+ from .base import ToolContext
23
+ from .diffapply import apply_unified_diff
24
+ from .edit import Edit, _strip_diff_fence
25
+
26
+
27
+ class EditWindows(Edit):
28
+ """Edit with a pure-Python diff/patch backend for Windows."""
29
+
30
+ def _apply_patch(self, path: str, cwd: str, diff: str, ctx: ToolContext) -> str:
31
+ text = diff if diff.endswith("\n") else diff + "\n"
32
+ text = _strip_diff_fence(text)
33
+ is_file = os.path.isfile(path)
34
+ old_content = None
35
+ if is_file:
36
+ try:
37
+ with open(path, encoding="utf-8", errors="replace") as f:
38
+ old_content = f.read()
39
+ except OSError:
40
+ old_content = None
41
+ ok, msg = apply_unified_diff(text, cwd, path if is_file else None)
42
+ if not ok:
43
+ return f"Error: Failed to apply diff to {path}.\n{msg}"
44
+ if old_content is not None and os.path.isfile(path):
45
+ try:
46
+ with open(path, encoding="utf-8", errors="replace") as f:
47
+ new_content = f.read()
48
+ diff_text = unified_diff(old_content, new_content, path)
49
+ if diff_text:
50
+ ctx.record_diff(diff_text)
51
+ except OSError:
52
+ pass
53
+ return (
54
+ f"Diff successfully applied to {path}.\n"
55
+ f"Applied with the built-in Python diff applier.\n{msg}"
56
+ )
@@ -156,10 +156,10 @@ def _git_glob_results(raw: str, git_root: str, base: str, depth: object) -> str:
156
156
  """
157
157
  lines = [line for line in raw.split("\0") if line]
158
158
  if _natnump(depth):
159
- rel_base = os.path.relpath(base, git_root)
159
+ rel_base = os.path.relpath(base, git_root).replace(os.sep, "/")
160
160
  base_depth = 0 if rel_base == "." else 1 + rel_base.count("/")
161
161
  lines = [line for line in lines if line.count("/") < base_depth + depth]
162
- out = "\n".join(os.path.join(git_root, line) for line in lines)
162
+ out = "\n".join(os.path.join(git_root, line).replace(os.sep, "/") for line in lines)
163
163
  if not out:
164
164
  return ""
165
165
  return _spool(out + "\n", "glob")
@@ -0,0 +1,90 @@
1
+ """Windows Glob tool: ``git ls-files`` inside git repos, ``pathlib`` outside.
2
+
3
+ Windows ships neither ``tree`` nor ``find``, so the non-git fallback
4
+ in :class:`GlobTool` (which shells out to ``tree``) and the macOS
5
+ variant :class:`GlobMac` (which shells out to ``find``) both fail on
6
+ a stock Windows install. ``GlobWindows`` replaces that fallback with
7
+ a pure-Python :meth:`pathlib.Path.rglob` approach: Python handles
8
+ directory traversal, pattern matching, and mtime sorting.
9
+
10
+ This is slower than the C-based ``tree``/``find`` on large directory
11
+ trees, but produces identical results and has no external dependencies
12
+ — critical on Windows where neither binary is guaranteed to exist.
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
+ from pathlib import Path
23
+
24
+ from .base import ToolContext
25
+ from .filesystem import _natnump, _spool
26
+ from .glob import GlobTool
27
+
28
+
29
+ class GlobWindows(GlobTool):
30
+ """Glob with a pure-Python ``pathlib.rglob`` non-git fallback for Windows."""
31
+
32
+ def _rglob_fallback(self, pattern: str, base: str, depth: object) -> str:
33
+ """Use ``pathlib.Path.rglob`` for traversal + matching, Python for mtime sort.
34
+
35
+ Walks the directory tree with :meth:`pathlib.Path.rglob`, filters
36
+ hidden directories (``.git``, etc.), and sorts results by
37
+ modification time (newest first), matching the ``tree --sort=mtime``
38
+ order of the Linux fallback. Symlinks are followed by default
39
+ via ``Path.rglob``.
40
+ """
41
+ root = Path(base)
42
+ max_depth = depth if _natnump(depth) else None
43
+
44
+ matches: list[tuple[float, str]] = []
45
+ try:
46
+ for p in root.rglob(pattern):
47
+ if not p.is_file():
48
+ continue
49
+ # skip hidden directories (.git, etc.)
50
+ if any(part.startswith(".") for part in p.relative_to(root).parts[:-1]):
51
+ continue
52
+ if max_depth is not None:
53
+ rel_depth = len(p.relative_to(root).parts)
54
+ if rel_depth > max_depth:
55
+ continue
56
+ try:
57
+ mtime = p.stat().st_mtime
58
+ except OSError:
59
+ mtime = 0.0
60
+ matches.append((mtime, str(p)))
61
+ except OSError as e:
62
+ return f"Error: {e}"
63
+
64
+ matches.sort(key=lambda t: t[0], reverse=True)
65
+ out = "\n".join(path for _, path in matches)
66
+ if not out:
67
+ return ""
68
+ return _spool(out + "\n", "glob")
69
+
70
+ def run(self, args: dict, ctx: ToolContext) -> str:
71
+ pattern = args.get("pattern") or ""
72
+ if not pattern:
73
+ return "Error: pattern must not be empty"
74
+ path = args.get("path")
75
+ if path:
76
+ if not (os.path.isdir(path) and os.access(path, os.R_OK)):
77
+ return f"Error: path {path} is not readable"
78
+ else:
79
+ path = ctx.cwd
80
+ base = os.path.realpath(path)
81
+ depth = args.get("depth")
82
+
83
+ from .filesystem import _git_root
84
+
85
+ git_root = _git_root(base)
86
+
87
+ if git_root:
88
+ return super().run(args, ctx)
89
+
90
+ return self._rglob_fallback(pattern, base, depth)