python-agent-harness 1.5.5.1__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.1/python_agent_harness.egg-info → python_agent_harness-1.5.5.3}/PKG-INFO +15 -3
  2. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/README.md +2 -1
  3. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/pyproject.toml +19 -5
  4. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/agent.py +13 -2
  6. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/cli.py +3 -3
  7. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/client.py +16 -4
  8. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/config.py +14 -4
  9. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/persistence.py +1 -7
  10. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/__init__.py +17 -4
  11. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/bash.py +5 -3
  12. python_agent_harness-1.5.5.3/python_agent_harness/tools/bash_win.py +232 -0
  13. python_agent_harness-1.5.5.3/python_agent_harness/tools/edit_win.py +56 -0
  14. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/glob.py +2 -2
  15. python_agent_harness-1.5.5.3/python_agent_harness/tools/glob_win.py +90 -0
  16. python_agent_harness-1.5.5.3/python_agent_harness/tools/grep_win.py +136 -0
  17. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/commands.py +9 -4
  18. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/core.py +4 -0
  19. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/input.py +61 -11
  20. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/render.py +52 -17
  21. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3/python_agent_harness.egg-info}/PKG-INFO +15 -3
  22. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/SOURCES.txt +4 -0
  23. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_cli.py +1 -0
  24. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_client.py +7 -3
  25. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_commands.py +2 -1
  26. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_concurrent_subagents.py +6 -0
  27. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_custom_agents.py +4 -0
  28. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_filesystem.py +346 -18
  29. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_invariants.py +5 -0
  30. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_mcp.py +1 -0
  31. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_persistence.py +6 -9
  32. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_planmode.py +6 -1
  33. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_scenarios.py +5 -0
  34. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_session.py +19 -7
  35. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_subagent.py +1 -0
  36. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_subagent_isolation.py +57 -0
  37. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_todos_scope.py +6 -0
  38. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_tools_misc.py +5 -0
  39. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/LICENSE +0 -0
  40. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/__main__.py +0 -0
  41. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/commands.py +0 -0
  42. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/context_manager.py +0 -0
  43. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/diffrender.py +0 -0
  44. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/__init__.py +0 -0
  45. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/client.py +0 -0
  46. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/config.py +0 -0
  47. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/manager.py +0 -0
  48. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/models.py +0 -0
  49. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/planmode.py +0 -0
  50. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/agent.md +0 -0
  51. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  52. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/build-switch.md +0 -0
  53. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/commands/explain.md +0 -0
  54. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/compact.md +0 -0
  55. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/initialize.md +0 -0
  56. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/plan-mode.md +0 -0
  57. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/plan.md +0 -0
  58. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/review.md +0 -0
  59. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/subagent.md +0 -0
  60. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/summary.md +0 -0
  61. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  62. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/title.md +0 -0
  63. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts.py +0 -0
  64. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/session.py +0 -0
  65. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/subagent.py +0 -0
  66. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/token_estimator.py +0 -0
  67. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tool_runner.py +0 -0
  68. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/agent_tool.py +0 -0
  69. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/base.py +0 -0
  70. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/diffapply.py +0 -0
  71. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/edit.py +0 -0
  72. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/edit_mac.py +0 -0
  73. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/filesystem.py +0 -0
  74. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/glob_mac.py +0 -0
  75. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/grep.py +0 -0
  76. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/grep_mac.py +0 -0
  77. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/insert.py +0 -0
  78. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/mcp.py +0 -0
  79. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/mkdir.py +0 -0
  80. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/planexit.py +0 -0
  81. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/question.py +0 -0
  82. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/read.py +0 -0
  83. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/skill.py +0 -0
  84. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/todo.py +0 -0
  85. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/write.py +0 -0
  86. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/__init__.py +0 -0
  87. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  88. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/entry_points.txt +0 -0
  89. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/requires.txt +0 -0
  90. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/top_level.txt +0 -0
  91. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/setup.cfg +0 -0
  92. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_agents_md.py +0 -0
  93. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_config.py +0 -0
  94. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_context_rules.py +0 -0
  95. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_diffrender.py +0 -0
  96. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_models.py +0 -0
  97. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_prompts.py +0 -0
  98. {python_agent_harness-1.5.5.1 → python_agent_harness-1.5.5.3}/tests/test_token_estimator.py +0 -0
@@ -1,9 +1,20 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.1
3
+ Version: 1.5.5.3
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
- Author: huming
5
+ Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/beacoder/python-agent-harness
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Operating System :: Microsoft :: Windows
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
7
18
  Requires-Python: >=3.11
8
19
  Description-Content-Type: text/markdown
9
20
  License-File: LICENSE
@@ -31,6 +42,7 @@ FSM-driven execution · OpenAI-compatible · built for daily use and easy custom
31
42
  [![CI](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml)
32
43
  [![PyPI](https://img.shields.io/pypi/v/python-agent-harness.svg)](https://pypi.org/project/python-agent-harness/)
33
44
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
45
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/python-agent-harness?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/python-agent-harness)
34
46
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
35
47
 
36
48
  </div>
@@ -265,7 +277,7 @@ python_agent_harness/
265
277
 
266
278
  ## Development
267
279
 
268
- 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.
269
281
 
270
282
  ```sh
271
283
  make test # unit tests
@@ -8,6 +8,7 @@ FSM-driven execution · OpenAI-compatible · built for daily use and easy custom
8
8
  [![CI](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml)
9
9
  [![PyPI](https://img.shields.io/pypi/v/python-agent-harness.svg)](https://pypi.org/project/python-agent-harness/)
10
10
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
11
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/python-agent-harness?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/python-agent-harness)
11
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
13
 
13
14
  </div>
@@ -242,7 +243,7 @@ python_agent_harness/
242
243
 
243
244
  ## Development
244
245
 
245
- 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.
246
247
 
247
248
  ```sh
248
249
  make test # unit tests
@@ -4,21 +4,35 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.5.1"
7
+ version = "1.5.5.3"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
+ authors = [
10
+ {name = "Huming Chen", email = "chenhuming@gmail.com"}
11
+ ]
12
+ license = "MIT"
9
13
  readme = "README.md"
10
14
  requires-python = ">=3.11"
11
- authors = [
12
- { name = "huming" }
15
+ classifiers = [
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: Microsoft :: Windows",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14"
13
26
  ]
14
- license = 'MIT'
15
- license-files = ["LICENSE"]
16
27
  dependencies = [
17
28
  "rich>=13.0",
18
29
  "httpx>=0.27",
19
30
  "prompt_toolkit>=3.0",
20
31
  ]
21
32
 
33
+ [project.urls]
34
+ Homepage = "https://github.com/beacoder/python-agent-harness"
35
+
22
36
  [project.optional-dependencies]
23
37
  # MCP (Model Context Protocol) client support: exposes MCP server tools to
24
38
  # the agent as namespaced mcp__<server>__<tool> tools. Install with:
@@ -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.1"
8
+ __version__ = "1.5.5.3"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -637,14 +637,25 @@ def run_agent_loop(
637
637
 
638
638
  ``client`` (when given) overrides the session's client for this
639
639
  run — the per-invocation sub-agent clone."""
640
- return AgentLoop(
640
+ loop = AgentLoop(
641
641
  session,
642
642
  messages=messages,
643
643
  top_level=top_level,
644
644
  system=system,
645
645
  max_rounds=max_rounds,
646
646
  client=client,
647
- ).run()
647
+ )
648
+ result = loop.run()
649
+ # Notify run_done only for a successfully completed top-level run:
650
+ # - top_level: sub-agents share the parent's session (notify_fn
651
+ # included) and must not mark the parent run finished while it is
652
+ # still executing tools
653
+ # - DONE only: ERRS already notifies "error" with the detail, and
654
+ # ABRT is cancellation/supersession (TUI sets that status). DONE
655
+ # also implies not cancelled — the FSM routes cancelled to ABRT.
656
+ if loop.top_level and loop.state == AgentLoop.DONE:
657
+ session.notify("run_done")
658
+ return result
648
659
 
649
660
 
650
661
  class Supervisor:
@@ -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 = {
@@ -170,18 +170,12 @@ class SessionPersistence:
170
170
  lines = [";; Local Variables:"]
171
171
  pairs = [
172
172
  ("python-agent-harness--project-dir", self.project_dir),
173
- ("gptel-model", self.model),
174
- ("gptel-system-prompt", self.system_prompt),
175
- ("gptel-temperature", self.temperature),
176
- ("gptel-max-tokens", self.max_tokens),
173
+ ("python-agent-harness--model", self.model),
177
174
  ]
178
175
  for name, value in pairs:
179
176
  if value is None:
180
177
  continue
181
178
  lines.append(f";; {name}: {value!r}")
182
- if self.tool_names:
183
- names = " ".join(f'"{n}"' for n in self.tool_names)
184
- lines.append(f";; gptel--tool-names: ({names})")
185
179
  if self.round_times:
186
180
  stamps = " ".join(repr(float(t)) for t in self.round_times)
187
181
  lines.append(f";; python-agent-harness--round-times: {stamps}")
@@ -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")