python-agent-harness 1.5.5.2__tar.gz → 1.5.5.4__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.4}/PKG-INFO +3 -2
  2. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/README.md +1 -1
  3. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/pyproject.toml +2 -1
  4. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/cli.py +8 -4
  6. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/client.py +16 -4
  7. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/config.py +14 -4
  8. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/persistence.py +3 -0
  9. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/session.py +8 -0
  10. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/__init__.py +17 -4
  11. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/bash.py +5 -3
  12. python_agent_harness-1.5.5.4/python_agent_harness/tools/bash_win.py +232 -0
  13. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/diffapply.py +2 -2
  14. python_agent_harness-1.5.5.4/python_agent_harness/tools/edit_win.py +56 -0
  15. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/glob.py +2 -2
  16. python_agent_harness-1.5.5.4/python_agent_harness/tools/glob_win.py +90 -0
  17. python_agent_harness-1.5.5.4/python_agent_harness/tools/grep_win.py +141 -0
  18. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tui/commands.py +78 -17
  19. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tui/input.py +61 -11
  20. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4/python_agent_harness.egg-info}/PKG-INFO +3 -2
  21. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness.egg-info/SOURCES.txt +4 -0
  22. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_client.py +7 -3
  23. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_commands.py +2 -1
  24. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_concurrent_subagents.py +4 -0
  25. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_custom_agents.py +43 -0
  26. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_filesystem.py +346 -18
  27. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_invariants.py +4 -0
  28. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_persistence.py +16 -0
  29. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_planmode.py +5 -1
  30. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_scenarios.py +4 -0
  31. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_session.py +18 -7
  32. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_tools_misc.py +1 -0
  33. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/LICENSE +0 -0
  34. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/__main__.py +0 -0
  35. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/agent.py +0 -0
  36. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/commands.py +0 -0
  37. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/context_manager.py +0 -0
  38. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/diffrender.py +0 -0
  39. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/mcp/__init__.py +0 -0
  40. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/mcp/client.py +0 -0
  41. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/mcp/config.py +0 -0
  42. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/mcp/manager.py +0 -0
  43. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/models.py +0 -0
  44. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/planmode.py +0 -0
  45. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/agent.md +0 -0
  46. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  47. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/build-switch.md +0 -0
  48. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/commands/explain.md +0 -0
  49. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/compact.md +0 -0
  50. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/initialize.md +0 -0
  51. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/plan-mode.md +0 -0
  52. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/plan.md +0 -0
  53. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/review.md +0 -0
  54. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/subagent.md +0 -0
  55. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/summary.md +0 -0
  56. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  57. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts/title.md +0 -0
  58. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/prompts.py +0 -0
  59. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/subagent.py +0 -0
  60. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/token_estimator.py +0 -0
  61. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tool_runner.py +0 -0
  62. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/agent_tool.py +0 -0
  63. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/base.py +0 -0
  64. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/edit.py +0 -0
  65. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/edit_mac.py +0 -0
  66. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/filesystem.py +0 -0
  67. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/glob_mac.py +0 -0
  68. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/grep.py +0 -0
  69. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/grep_mac.py +0 -0
  70. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/insert.py +0 -0
  71. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/mcp.py +0 -0
  72. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/mkdir.py +0 -0
  73. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/planexit.py +0 -0
  74. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/question.py +0 -0
  75. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/read.py +0 -0
  76. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/skill.py +0 -0
  77. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/todo.py +0 -0
  78. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tools/write.py +0 -0
  79. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tui/__init__.py +0 -0
  80. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tui/core.py +0 -0
  81. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness/tui/render.py +0 -0
  82. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  83. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness.egg-info/entry_points.txt +0 -0
  84. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness.egg-info/requires.txt +0 -0
  85. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/python_agent_harness.egg-info/top_level.txt +0 -0
  86. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/setup.cfg +0 -0
  87. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_agents_md.py +0 -0
  88. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_cli.py +0 -0
  89. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_config.py +0 -0
  90. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_context_rules.py +0 -0
  91. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_diffrender.py +0 -0
  92. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_mcp.py +0 -0
  93. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_models.py +0 -0
  94. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_prompts.py +0 -0
  95. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_subagent.py +0 -0
  96. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_subagent_isolation.py +0 -0
  97. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/tests/test_todos_scope.py +0 -0
  98. {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.4}/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.4
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.4"
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.4"
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
@@ -125,7 +125,11 @@ def make_session(
125
125
  )
126
126
  if default_agent and default_agent != RESERVED_AGENT_NAME:
127
127
  ok, msg = session.switch_agent(default_agent)
128
- if not ok:
128
+ if ok:
129
+ # record the applied agent so saved sessions restore it;
130
+ # switch_agent("default") clears it again
131
+ session.store.agent = default_agent
132
+ else:
129
133
  # A typo'd/missing default_agent must not silently start the
130
134
  # session with the built-in prompt — record the config error
131
135
  # so the TUI can surface it (see Tui.run).
@@ -175,7 +179,7 @@ def cmd_config(args: argparse.Namespace) -> int:
175
179
  if path.exists() and not args.force:
176
180
  print(f"config already exists: {path} (use --force to overwrite)")
177
181
  return 1
178
- template = config.CONFIG_TEMPLATE.format(path=path)
182
+ template = config.CONFIG_TEMPLATE.format(path=str(path).replace("\\", "\\\\"))
179
183
  path.write_text(template, encoding="utf-8")
180
184
  print(f"wrote config template: {path}")
181
185
  return 0
@@ -250,7 +254,7 @@ def _add_config_arg(parser: argparse.ArgumentParser, suppress: bool = False) ->
250
254
  "--config",
251
255
  metavar="PATH",
252
256
  default=argparse.SUPPRESS if suppress else None,
253
- help="path to config.json (default: ~/.config/python-agent-harness/config.json)",
257
+ help="path to config.json (default: OS config dir / python-agent-harness / config.json)",
254
258
  )
255
259
 
256
260
 
@@ -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 = {
@@ -112,9 +112,11 @@ class SessionPersistence:
112
112
  max_tokens: int | None = None,
113
113
  tool_names: list[str] | None = None,
114
114
  round_times: list[float] | None = None,
115
+ agent: str | None = None,
115
116
  ) -> None:
116
117
  self.project_dir = project_dir
117
118
  self.model = model
119
+ self.agent = agent
118
120
  self.system_prompt = system_prompt
119
121
  self.temperature = temperature
120
122
  self.max_tokens = max_tokens
@@ -171,6 +173,7 @@ class SessionPersistence:
171
173
  pairs = [
172
174
  ("python-agent-harness--project-dir", self.project_dir),
173
175
  ("python-agent-harness--model", self.model),
176
+ ("python-agent-harness--agent", self.agent),
174
177
  ]
175
178
  for name, value in pairs:
176
179
  if value is None:
@@ -162,6 +162,12 @@ class Session:
162
162
  # cache build simultaneously — double-checked locking prevents
163
163
  # redundant filesystem scans and the data race on the cache tuple
164
164
  self._skill_index_lock = threading.Lock()
165
+ # NOTE: the store does NOT record ``default_agent`` here: the
166
+ # store's agent must reflect the agent actually applied, and
167
+ # only a successful switch_agent() can guarantee that (a typo'd
168
+ # default_agent is reported as a startup warning and the session
169
+ # keeps the built-in prompt). cli.make_session applies the
170
+ # default_agent right after construction, which sets it.
165
171
  self.store = SessionPersistence(
166
172
  project_dir=project_dir,
167
173
  model=model,
@@ -731,6 +737,7 @@ class Session:
731
737
  if name == RESERVED_AGENT_NAME:
732
738
  self.system_prompt = self._default_system_prompt
733
739
  self.store.system_prompt = self._default_system_prompt
740
+ self.store.agent = None
734
741
  return True, "switched to default agent"
735
742
 
736
743
  agents = discover_agents()
@@ -749,6 +756,7 @@ class Session:
749
756
  context_path=self._configured_context_path,
750
757
  )
751
758
  self.store.system_prompt = self.system_prompt
759
+ self.store.agent = name
752
760
  return True, f"switched to {name}"
753
761
 
754
762
  # ------------------------------------------------------------------
@@ -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
@@ -48,7 +48,7 @@ class _Hunk:
48
48
  """(kind, content-without-newline) for the lines that must exist
49
49
  in the old file (' ' context and '-' removed lines)."""
50
50
  return [
51
- (kind, content.rstrip("\n")) for kind, content, _ in self.body if kind in (" ", "-")
51
+ (kind, content.rstrip("\r\n")) for kind, content, _ in self.body if kind in (" ", "-")
52
52
  ]
53
53
 
54
54
 
@@ -142,7 +142,7 @@ def _match_hunk(hunk: _Hunk, file_lines: list[str]) -> int | None:
142
142
  mismatches = 0
143
143
  ok = True
144
144
  for i, (kind, content) in enumerate(old_seq):
145
- file_content = file_lines[p + i].rstrip("\n")
145
+ file_content = file_lines[p + i].rstrip("\r\n")
146
146
  if kind == "-":
147
147
  if file_content != content:
148
148
  ok = False
@@ -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")