python-agent-harness 1.5.6.2__tar.gz → 1.5.6.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 (90) hide show
  1. {python_agent_harness-1.5.6.2/python_agent_harness.egg-info → python_agent_harness-1.5.6.3}/PKG-INFO +1 -1
  2. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/pyproject.toml +1 -1
  3. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/__init__.py +1 -1
  4. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/models.py +37 -5
  5. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/headless.py +21 -4
  6. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/server.py +19 -2
  7. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/persistence.py +10 -1
  8. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/client.py +209 -30
  9. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/agent.md +3 -3
  10. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/session/session.py +5 -1
  11. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/edit.py +43 -11
  12. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/commands.py +4 -2
  13. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/render.py +35 -5
  14. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3/python_agent_harness.egg-info}/PKG-INFO +1 -1
  15. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/LICENSE +0 -0
  16. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/README.md +0 -0
  17. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/__main__.py +0 -0
  18. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/__init__.py +0 -0
  19. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/agent.py +0 -0
  20. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/context_manager.py +0 -0
  21. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/planmode.py +0 -0
  22. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/subagent.py +0 -0
  23. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/token_estimator.py +0 -0
  24. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/core/tool_runner.py +0 -0
  25. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/__init__.py +0 -0
  26. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/cli.py +0 -0
  27. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/controller.py +0 -0
  28. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/view.py +0 -0
  29. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/__init__.py +0 -0
  30. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/attachments.py +0 -0
  31. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/diffrender.py +0 -0
  32. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/io/text_filter.py +0 -0
  33. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/llm/__init__.py +0 -0
  34. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/llm/client.py +0 -0
  35. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/__init__.py +0 -0
  36. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/config.py +0 -0
  37. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/manager.py +0 -0
  38. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/__init__.py +0 -0
  39. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/client.py +0 -0
  40. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/config.py +0 -0
  41. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/manager.py +0 -0
  42. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/__init__.py +0 -0
  43. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  44. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/build-switch.md +0 -0
  45. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/commands/explain.md +0 -0
  46. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/compact.md +0 -0
  47. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/core.py +0 -0
  48. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/initialize.md +0 -0
  49. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/plan-mode.md +0 -0
  50. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/plan.md +0 -0
  51. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/review.md +0 -0
  52. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/subagent.md +0 -0
  53. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/summary.md +0 -0
  54. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  55. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/title.md +0 -0
  56. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/session/__init__.py +0 -0
  57. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/session/commands.py +0 -0
  58. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/session/config.py +0 -0
  59. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/__init__.py +0 -0
  60. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/agent_tool.py +0 -0
  61. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/base.py +0 -0
  62. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/bash.py +0 -0
  63. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/diffapply.py +0 -0
  64. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/edit_mac.py +0 -0
  65. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/filesystem.py +0 -0
  66. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/glob.py +0 -0
  67. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/glob_mac.py +0 -0
  68. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/grep.py +0 -0
  69. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/grep_mac.py +0 -0
  70. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/insert.py +0 -0
  71. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/lsp.py +0 -0
  72. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/mcp.py +0 -0
  73. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/mkdir.py +0 -0
  74. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/planexit.py +0 -0
  75. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/question.py +0 -0
  76. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/read.py +0 -0
  77. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/skill.py +0 -0
  78. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/todo.py +0 -0
  79. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/write.py +0 -0
  80. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/__init__.py +0 -0
  81. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/core.py +0 -0
  82. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/input.py +0 -0
  83. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  84. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  85. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/entry_points.txt +0 -0
  86. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/requires.txt +0 -0
  87. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/top_level.txt +0 -0
  88. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/setup.cfg +0 -0
  89. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/tests/test_invariants.py +0 -0
  90. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.3}/tests/test_scenarios.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.6.2
3
+ Version: 1.5.6.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.6.2"
7
+ version = "1.5.6.3"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -8,7 +8,7 @@ from .mcp.config import MCPConfig, MCPServerConfig
8
8
  from .mcp.manager import MCPManager
9
9
  from .session.session import Session
10
10
 
11
- __version__ = "1.5.6.2"
11
+ __version__ = "1.5.6.3"
12
12
 
13
13
  __all__ = [
14
14
  "Client",
@@ -5,9 +5,30 @@ from __future__ import annotations
5
5
  import base64
6
6
  import enum
7
7
  import json
8
+ import re
8
9
  from dataclasses import dataclass, field
9
10
  from typing import Any, Union
10
11
 
12
+ # Lone surrogates (\ud800-\udfff) reach a str when a file is decoded with
13
+ # errors="surrogateescape" — which every tool deliberately does, so a
14
+ # non-UTF-8 source file round-trips instead of becoming U+FFFD. Such a
15
+ # string cannot be encoded to UTF-8, so anything that leaves the process
16
+ # (a JSON request body, a terminal write) must replace them first.
17
+ _SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
18
+
19
+
20
+ def api_safe(text: str) -> str:
21
+ """TEXT with lone surrogates replaced by U+FFFD, safe to serialize.
22
+
23
+ The tools read files with ``errors="surrogateescape"``, so a
24
+ conversation can carry lone surrogates from a non-UTF-8 (Latin-1,
25
+ GBK, ...) source. A request body containing one cannot be encoded
26
+ to UTF-8 — ``httpx`` raised ``UnicodeEncodeError`` — so they are
27
+ replaced at the OUTBOUND boundary only: the stored history, the
28
+ session file and the file on disk keep the exact bytes.
29
+ """
30
+ return _SURROGATE_RE.sub("\ufffd", text)
31
+
11
32
 
12
33
  class AgentMode(enum.Enum):
13
34
  BUILD = "build"
@@ -136,7 +157,10 @@ class Message:
136
157
  def to_api(self) -> dict[str, Any]:
137
158
  d: dict[str, Any] = {"role": self.role}
138
159
  if self.content is not None:
139
- d["content"] = self._api_content()
160
+ content = self._api_content()
161
+ # outbound boundary: a lone surrogate cannot be encoded into
162
+ # the JSON body (see api_safe)
163
+ d["content"] = api_safe(content) if isinstance(content, str) else content
140
164
  if self.tool_calls:
141
165
  d["tool_calls"] = [
142
166
  {
@@ -144,9 +168,13 @@ class Message:
144
168
  "type": "function",
145
169
  "function": {
146
170
  "name": tc.name,
147
- "arguments": tc.arguments
148
- if isinstance(tc.arguments, str)
149
- else json.dumps(tc.arguments, ensure_ascii=False),
171
+ # ensure_ascii=False keeps non-ASCII readable;
172
+ # api_safe then removes the unencodable surrogates
173
+ "arguments": api_safe(
174
+ tc.arguments
175
+ if isinstance(tc.arguments, str)
176
+ else json.dumps(tc.arguments, ensure_ascii=False)
177
+ ),
150
178
  },
151
179
  }
152
180
  for tc in self.tool_calls
@@ -188,7 +216,11 @@ class Message:
188
216
  @staticmethod
189
217
  def _part_to_api(part: ContentPart) -> Any:
190
218
  """Serialize one content part to its provider-specific dict."""
191
- if isinstance(part, (TextPart, ImagePart)):
219
+ if isinstance(part, TextPart):
220
+ # text parts are a second carrier of file content into the
221
+ # payload, so they need the same outbound treatment
222
+ return {"type": "text", "text": api_safe(part.text)}
223
+ if isinstance(part, ImagePart):
192
224
  return part.to_api()
193
225
  return part
194
226
 
@@ -22,6 +22,7 @@ import sys
22
22
  import threading
23
23
  from typing import Any, TextIO
24
24
 
25
+ from ..core.models import api_safe
25
26
  from ..io.text_filter import strip_final_check
26
27
  from ..session.session import Session
27
28
  from .controller import Controller
@@ -166,7 +167,13 @@ def final_answer_text(session: Session) -> str:
166
167
  continue
167
168
  text = strip_final_check(msg.text_without_reasoning()).strip()
168
169
  if text:
169
- return text
170
+ # api_safe: the answer is written to stdout (headless prints it
171
+ # verbatim; --json and serve JSON-encode it), and a lone
172
+ # surrogate from a restored non-UTF-8 session raised
173
+ # UnicodeEncodeError there — headless died before writing the
174
+ # result line, and serve's _write_line SWALLOWED it (it is a
175
+ # ValueError), reporting success with an empty stream.
176
+ return api_safe(text)
170
177
  return ""
171
178
 
172
179
 
@@ -295,7 +302,10 @@ def restore_session(controller: Controller, spec: str, err: TextIO) -> bool:
295
302
  if not path or not os.path.isfile(path):
296
303
  err.write(f"no session found: {spec or 'latest'}\n")
297
304
  return False
298
- with open(path, encoding="utf-8") as fh:
305
+ # surrogateescape must mirror SessionPersistence.save: the saved body
306
+ # may carry lone surrogates (the tools read non-UTF-8 files that way),
307
+ # and a strict decode raised UnicodeDecodeError here.
308
+ with open(path, encoding="utf-8", errors="surrogateescape") as fh:
299
309
  text = fh.read()
300
310
  meta = SessionPersistence.parse_metadata(text)
301
311
  body = SessionPersistence.strip_metadata(text)
@@ -417,7 +427,10 @@ def run_headless(
417
427
  return 1
418
428
  answer = final_answer_text(session)
419
429
  if answer:
420
- view.out.write(answer + "\n")
430
+ # api_safe: the answer carries whatever the conversation carried
431
+ # (a restored non-UTF-8 session), and a strict-UTF-8 stdout
432
+ # raised UnicodeEncodeError here — losing the result line.
433
+ view.out.write(api_safe(answer) + "\n")
421
434
  view.out.flush()
422
435
  return 1 if view.errors else 0
423
436
 
@@ -512,7 +525,11 @@ class JsonlView(HeadlessView):
512
525
  line = {"seq": self._seq, "protocol": PROTOCOL_VERSION, **payload}
513
526
  if self.run_id is not None:
514
527
  line["run_id"] = self.run_id
515
- self.out.write(json.dumps(line, ensure_ascii=False, default=str) + "\n")
528
+ # api_safe at the sink: the JSON body is written to the driver's
529
+ # stdout, and a lone surrogate (restored non-UTF-8 session) made
530
+ # the write raise UnicodeEncodeError — the run then ended without
531
+ # its terminal `result` line.
532
+ self.out.write(api_safe(json.dumps(line, ensure_ascii=False, default=str)) + "\n")
516
533
  self.out.flush()
517
534
 
518
535
  def _emit(self, payload: dict[str, Any]) -> None:
@@ -127,6 +127,7 @@ import time
127
127
  import uuid
128
128
  from typing import Any, TextIO
129
129
 
130
+ from ..core.models import api_safe
130
131
  from ..session.session import Session
131
132
  from .controller import Controller
132
133
  from .headless import PROTOCOL_VERSION, JsonlView, _restorable_handler, final_answer_text
@@ -530,7 +531,11 @@ class AgentServer:
530
531
  live run lines get ``seq``/``run_id`` via the view's ``_write``.
531
532
  """
532
533
  text = (
533
- json.dumps({"protocol": PROTOCOL_VERSION, **payload}, ensure_ascii=False, default=str)
534
+ api_safe(
535
+ json.dumps(
536
+ {"protocol": PROTOCOL_VERSION, **payload}, ensure_ascii=False, default=str
537
+ )
538
+ )
534
539
  + "\n"
535
540
  )
536
541
  view = self.view
@@ -892,7 +897,19 @@ class AgentServer:
892
897
  def _read_ops(self) -> None:
893
898
  """The reader loop proper: dispatch ops until EOF/shutdown."""
894
899
  while not self._stopped.is_set():
895
- line = self.inp.readline()
900
+ try:
901
+ line = self.inp.readline()
902
+ except (UnicodeDecodeError, ValueError, OSError) as e:
903
+ # Unreadable input must never take the resident process
904
+ # down. A single non-UTF-8 byte on a strict-UTF-8 stdin
905
+ # (the common C.UTF-8/LANG=en_US.UTF-8 container setup)
906
+ # raised out of serve_forever, which skipped the run drain
907
+ # below: an in-flight run then emitted NO result line and
908
+ # its token usage was lost. Report it as a protocol error
909
+ # and stop reading, so the drain still runs. ValueError
910
+ # covers a stdin closed underneath us.
911
+ self._error("protocol", f"cannot read op: {e}")
912
+ break
896
913
  if not line: # EOF: the host closed the pipe
897
914
  break
898
915
  line = line.strip()
@@ -195,7 +195,16 @@ class SessionPersistence:
195
195
  session_dir().mkdir(parents=True, exist_ok=True)
196
196
  content = conversation_text.rstrip("\n") + "\n\n" + self.metadata_block() + "\n"
197
197
  tmp = path + ".tmp"
198
- Path(tmp).write_text(content, encoding="utf-8")
198
+ # surrogateescape: the tools deliberately read files with it
199
+ # (see atomic_write_text / Edit._string_replace), so a
200
+ # conversation can legitimately carry lone surrogates from a
201
+ # non-UTF-8 source file. A strict write raised
202
+ # UnicodeEncodeError, which is NOT an OSError, so it escaped
203
+ # auto_save's handler: the turn was never saved and a stray
204
+ # <name>.md.tmp was left behind. The matching reads
205
+ # (headless restore_session, TUI /restore and /sessions) use
206
+ # the same error handler so the bytes round-trip.
207
+ Path(tmp).write_text(content, encoding="utf-8", errors="surrogateescape")
199
208
  os.replace(tmp, path)
200
209
  return path
201
210
 
@@ -13,6 +13,7 @@ import os
13
13
  import queue
14
14
  import subprocess
15
15
  import threading
16
+ import time
16
17
  from pathlib import Path
17
18
  from typing import Any
18
19
 
@@ -21,6 +22,13 @@ class LSPError(RuntimeError):
21
22
  pass
22
23
 
23
24
 
25
+ # Upper bound for one framed JSON-RPC message. A Content-Length header is
26
+ # untrusted input: a bogus or malicious value must not make the reader
27
+ # reserve gigabytes. Real LSP responses (even documentSymbol on a huge
28
+ # file) are orders of magnitude smaller.
29
+ MAX_MESSAGE_BYTES = 64 * 1024 * 1024
30
+
31
+
24
32
  class LSPClient:
25
33
  def __init__(self, command: list[str], root: str, language_id: str, timeout: float = 30.0):
26
34
  self.command = command
@@ -39,15 +47,75 @@ class LSPClient:
39
47
  self._capabilities: dict[str, Any] = {}
40
48
  self.position_encoding = "utf-16"
41
49
  self._closed = False
50
+ # Set when the reader thread exits for ANY reason other than an
51
+ # explicit close(): the transport can no longer deliver responses,
52
+ # so the client must count as dead even while the child process is
53
+ # still running (a framing error, a crashed/hung server). Without
54
+ # this, `alive` kept reporting True and `manager.get_client` never
55
+ # replaced the broken client -- every later LSP call then burned
56
+ # the full request timeout for the rest of the session.
57
+ self._reader_dead = False
58
+ # Identity of the CURRENT reader thread's generation. A reader
59
+ # superseded by start() can finish after the new one is installed;
60
+ # without this counter it would mark the fresh (healthy) transport
61
+ # dead in its finally block -- the proc identity check alone does
62
+ # not cover the window before the new proc is assigned.
63
+ self._reader_gen = 0
64
+ # True while start() is running its initialize handshake. start()
65
+ # reaches request(), which calls start_if_needed(): a server that
66
+ # dies immediately leaves `alive` False, so without this flag each
67
+ # level re-entered start() -- spawning another process -- until
68
+ # RecursionError instead of a clean "failed to start" error.
69
+ self._starting = False
42
70
 
43
71
  @property
44
72
  def alive(self) -> bool:
45
- return self.proc is not None and self.proc.poll() is None and not self._closed
73
+ return (
74
+ self.proc is not None
75
+ and self.proc.poll() is None
76
+ and not self._closed
77
+ and not self._reader_dead
78
+ )
46
79
 
47
80
  def start(self) -> None:
48
- if self.alive:
81
+ """Start the server if it is not already running.
82
+
83
+ Re-entrancy-safe: ``_start()`` performs the initialize handshake
84
+ through ``request()``, which calls ``start_if_needed()``. A server
85
+ that dies immediately (reader EOF before the request is written)
86
+ leaves ``alive`` False, so without the ``_starting`` guard every
87
+ level re-entered ``start()`` and spawned another process until
88
+ RecursionError. With the guard the inner call is a no-op and the
89
+ handshake fails fast with a clean LSPError.
90
+ """
91
+ if self.alive or self._starting:
49
92
  return
93
+ self._starting = True
94
+ try:
95
+ self._start()
96
+ finally:
97
+ self._starting = False
98
+
99
+ def _start(self) -> None:
100
+ # Invalidate any superseded reader FIRST: _release_proc() below
101
+ # unblocks it, and if it reached its finally block while the
102
+ # generation still matched it would mark this fresh transport dead
103
+ # (and fail its waiters) before the replacement is installed.
104
+ self._reader_gen += 1
105
+ # A previous child can still be running when only the reader died
106
+ # (framing error / crashed transport): release it before the pipes
107
+ # are replaced, or the process and its descriptors leak.
108
+ if self.proc is not None:
109
+ self._release_proc()
50
110
  self._closed = False
111
+ self._reader_dead = False
112
+ # A fresh server has no documents open: forget the previous
113
+ # session's bookkeeping, or open_document() would send didChange
114
+ # for a document this process never opened.
115
+ with self._state_lock:
116
+ self._opened.clear()
117
+ self._texts.clear()
118
+ self.position_encoding = "utf-16"
51
119
  try:
52
120
  self.proc = subprocess.Popen(
53
121
  self.command,
@@ -59,8 +127,9 @@ class LSPClient:
59
127
  )
60
128
  except OSError as e:
61
129
  raise LSPError(f"failed to start LSP server {' '.join(self.command)!r}: {e}") from e
130
+ gen = self._reader_gen
62
131
  self._reader = threading.Thread(
63
- target=self._read_loop, daemon=True, name=f"lsp-reader:{self.command[0]}"
132
+ target=self._read_loop, args=(gen,), daemon=True, name=f"lsp-reader:{self.command[0]}"
64
133
  )
65
134
  self._reader.start()
66
135
  try:
@@ -111,7 +180,7 @@ class LSPClient:
111
180
  message["params"] = params
112
181
  try:
113
182
  self._send(message)
114
- kind, value = waiter.get(timeout=self.timeout)
183
+ kind, value = self._wait_response(waiter, method)
115
184
  except queue.Empty as e:
116
185
  with self._state_lock:
117
186
  self._pending.pop(request_id, None)
@@ -130,6 +199,27 @@ class LSPClient:
130
199
  raise LSPError(f"LSP {method} failed ({code}): {msg}")
131
200
  return value
132
201
 
202
+ def _wait_response(self, waiter: queue.Queue, method: str) -> tuple[str, Any]:
203
+ """Wait for WAITER, failing fast once the transport is dead.
204
+
205
+ The reader drains every waiter when it exits, but a request that
206
+ registers AFTER that drain finds an empty pending map and could
207
+ only wait out the full request timeout for a response nobody can
208
+ deliver. Polling in slices lets the wait end as soon as the
209
+ transport is known to be dead. Raises ``queue.Empty`` on a real
210
+ timeout so the caller keeps its single timeout message.
211
+ """
212
+ deadline = time.monotonic() + self.timeout
213
+ while True:
214
+ remaining = deadline - time.monotonic()
215
+ if remaining <= 0:
216
+ raise queue.Empty
217
+ try:
218
+ return waiter.get(timeout=min(0.1, remaining))
219
+ except queue.Empty:
220
+ if self._reader_dead:
221
+ raise LSPError(f"LSP server exited while waiting for {method}") from None
222
+
133
223
  def notify(self, method: str, params: Any = None) -> None:
134
224
  self.start_if_needed()
135
225
  message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
@@ -149,6 +239,7 @@ class LSPClient:
149
239
  # write can deadlock the reader thread (which needs _state_lock to
150
240
  # deliver responses/diagnostics) if the pipe buffer fills.
151
241
  with self._state_lock:
242
+ gen = self._reader_gen
152
243
  version = self._opened.get(uri, 0)
153
244
  if version:
154
245
  if self._texts.get(uri) == text:
@@ -174,6 +265,16 @@ class LSPClient:
174
265
  }
175
266
  }
176
267
  self.notify(method, params)
268
+ if self._reader_gen != gen:
269
+ # The transport restarted between the bookkeeping above and the
270
+ # send: start() cleared _opened, and this notification either
271
+ # went to a server that is gone or to a replacement that never
272
+ # received didOpen. Drop the stale claim so the next call
273
+ # re-opens the document against the current server instead of
274
+ # sending didChange for a document it does not know.
275
+ with self._state_lock:
276
+ self._opened.pop(uri, None)
277
+ self._texts.pop(uri, None)
177
278
 
178
279
  def close_document(self, uri: str) -> None:
179
280
  with self._state_lock:
@@ -192,28 +293,20 @@ class LSPClient:
192
293
  if self._closed:
193
294
  return
194
295
  proc = self.proc
195
- was_alive = proc is not None and proc.poll() is None
296
+ # The graceful path (shutdown request + exit notification) goes
297
+ # through request()/notify(), which call start_if_needed() — and
298
+ # with the reader dead `alive` is False, so closing would SPAWN A
299
+ # FRESH SERVER just to shut it down, leaking the old process.
300
+ # When the transport is already broken there is nobody to talk to:
301
+ # go straight to terminate.
302
+ was_alive = proc is not None and proc.poll() is None and not self._reader_dead
196
303
  if was_alive:
197
304
  with contextlib.suppress(Exception):
198
305
  self.request("shutdown", None)
199
306
  with contextlib.suppress(Exception):
200
307
  self.notify("exit", None)
201
308
  self._closed = True
202
- proc = self.proc
203
- if proc is not None and proc.poll() is None:
204
- with contextlib.suppress(Exception):
205
- proc.terminate()
206
- proc.wait(timeout=2)
207
- with contextlib.suppress(Exception):
208
- proc.kill()
209
- # Close the pipe file objects so their file descriptors are
210
- # released (subprocess does not close them on terminate/kill —
211
- # leaving them open leaks fds and emits ResourceWarning).
212
- if proc is not None:
213
- for stream in (proc.stdin, proc.stdout):
214
- if stream is not None:
215
- with contextlib.suppress(Exception):
216
- stream.close()
309
+ self._release_proc()
217
310
  with self._state_lock:
218
311
  with contextlib.suppress(queue.Full):
219
312
  for waiter in self._pending.values():
@@ -222,6 +315,33 @@ class LSPClient:
222
315
  self._opened.clear()
223
316
  self._texts.clear()
224
317
 
318
+ def _release_proc(self) -> None:
319
+ """Terminate + reap the current server process and close its pipes.
320
+
321
+ Shared by ``close()`` (explicit teardown) and ``start()`` (a
322
+ restart after the reader died): in the latter case the child may
323
+ still be running, and the pipes MUST be released either way --
324
+ ``subprocess`` does not close them on terminate/kill, so leaving
325
+ them open leaks descriptors and emits ResourceWarning.
326
+ """
327
+ proc = self.proc
328
+ if proc is None:
329
+ return
330
+ if proc.poll() is None:
331
+ with contextlib.suppress(Exception):
332
+ proc.terminate()
333
+ proc.wait(timeout=2)
334
+ with contextlib.suppress(Exception):
335
+ proc.kill()
336
+ # Reap the killed child so it does not linger as a zombie
337
+ # until subprocess's global cleanup runs.
338
+ with contextlib.suppress(Exception):
339
+ proc.wait(timeout=2)
340
+ for stream in (getattr(proc, "stdin", None), getattr(proc, "stdout", None)):
341
+ if stream is not None:
342
+ with contextlib.suppress(Exception):
343
+ stream.close()
344
+
225
345
  def _send(self, message: dict[str, Any]) -> None:
226
346
  data = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
227
347
  packet = b"Content-Length: " + str(len(data)).encode("ascii") + b"\r\n\r\n" + data
@@ -235,16 +355,24 @@ class LSPClient:
235
355
  except OSError as e:
236
356
  raise LSPError(f"failed writing to LSP server: {e}") from e
237
357
 
238
- def _read_loop(self) -> None:
358
+ def _read_loop(self, gen: int | None = None) -> None:
359
+ proc = self.proc
239
360
  try:
240
- proc = self.proc
241
361
  if proc is None or proc.stdout is None:
242
362
  return
243
363
  stream = proc.stdout
244
364
  while not self._closed:
245
365
  headers: dict[str, str] = {}
246
366
  while True:
247
- line = stream.readline()
367
+ try:
368
+ line = stream.readline()
369
+ except (ValueError, OSError):
370
+ # close() may release the pipes while this thread
371
+ # is parked in a read; that surfaces here as a
372
+ # closed-file/most-unblocked error. Treat it as
373
+ # EOF instead of letting a traceback escape a
374
+ # daemon thread.
375
+ return
248
376
  if not line:
249
377
  return
250
378
  line = line.rstrip(b"\r\n")
@@ -259,8 +387,14 @@ class LSPClient:
259
387
  length = int(headers.get("content-length", "0"))
260
388
  except ValueError:
261
389
  continue
262
- payload = stream.read(length)
263
- if len(payload) != length:
390
+ if length < 0 or length > MAX_MESSAGE_BYTES:
391
+ # Unusable framing: a bogus/huge header must not make
392
+ # read() reserve gigabytes, and a negative one can
393
+ # never be satisfied. Stop reading; the finally block
394
+ # marks the transport dead so the server is replaced.
395
+ return
396
+ payload = self._read_exactly(stream, length)
397
+ if payload is None:
264
398
  return
265
399
  try:
266
400
  message = json.loads(payload.decode("utf-8"))
@@ -268,12 +402,57 @@ class LSPClient:
268
402
  continue
269
403
  self._handle_message(message)
270
404
  finally:
271
- with self._state_lock:
272
- pending = list(self._pending.values())
273
- self._pending.clear()
274
- with contextlib.suppress(queue.Full):
405
+ # Mark the transport dead for every exit path except an
406
+ # explicit close(): `alive` must go False so the manager
407
+ # replaces this client instead of sending requests into a
408
+ # pipe nobody reads (which only produced timeouts). Both
409
+ # guards matter: a superseded reader (start() installed a
410
+ # replacement) must not kill the fresh transport, and a
411
+ # concurrent close() already owns the flag.
412
+ current = (gen is None or gen == self._reader_gen) and self.proc is proc
413
+ if current:
414
+ self._reader_dead = True
415
+ # Only the CURRENT reader owns this client's waiters: a
416
+ # superseded reader exiting during a restart must not fail
417
+ # requests that belong to the replacement transport.
418
+ with self._state_lock:
419
+ pending = list(self._pending.values())
420
+ self._pending.clear()
275
421
  for waiter in pending:
276
- waiter.put_nowait(("error", {"message": "LSP server exited"}))
422
+ # Per waiter: one already-filled queue must not stop
423
+ # the rest from being notified.
424
+ with contextlib.suppress(queue.Full):
425
+ waiter.put_nowait(("error", {"message": "LSP server exited"}))
426
+
427
+ @staticmethod
428
+ def _read_exactly(stream: Any, length: int) -> bytes | None:
429
+ """Read exactly LENGTH bytes from STREAM; None on EOF first.
430
+
431
+ ``read(n)`` on a pipe-backed raw stream returns UP TO n bytes: it
432
+ reports what a single ``read(2)`` delivered, which for a message
433
+ larger than the pipe buffer is a partial message. Treating that
434
+ short read as EOF killed the reader thread permanently -- one
435
+ large documentSymbol/hover/findReferences result silently
436
+ disabled LSP for the rest of the session. Accumulate until the
437
+ framed length is satisfied; only a genuinely empty read (every
438
+ writer gone) is EOF.
439
+ """
440
+ if length == 0:
441
+ return b""
442
+ chunks: list[bytes] = []
443
+ remaining = length
444
+ while remaining:
445
+ try:
446
+ chunk = stream.read(remaining)
447
+ except (ValueError, OSError):
448
+ return None # pipe closed under us (see the readline guard)
449
+ if not chunk:
450
+ return None
451
+ chunks.append(chunk)
452
+ # Clamp: a stream that over-delivers must not make `remaining`
453
+ # negative (a negative read() means "until EOF", i.e. a loop).
454
+ remaining = max(0, remaining - len(chunk))
455
+ return b"".join(chunks)
277
456
 
278
457
  def _handle_message(self, message: dict[str, Any]) -> None:
279
458
  if "id" in message and ("result" in message or "error" in message):
@@ -56,9 +56,9 @@ The user will primarily request you perform software engineering tasks. This inc
56
56
  - Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
57
57
  - Implement the solution using all tools available to you
58
58
  - Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
59
- - VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
60
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
61
-
59
+ - VERY IMPORTANT:
60
+ - When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
61
+ - NEVER commit changes unless the user explicitly asks you to.
62
62
  - Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
63
63
 
64
64
  # Tool usage policy
@@ -698,7 +698,11 @@ class Session:
698
698
  self.store.save(text)
699
699
  self._save_error = None
700
700
  return
701
- except OSError as e:
701
+ except (OSError, UnicodeError) as e:
702
+ # UnicodeError too: a save failure must never escape into
703
+ # the agent loop (it unwound the run and lost the turn).
704
+ # The writer uses surrogateescape now, so this is a
705
+ # defensive net rather than the primary fix.
702
706
  self._save_error = str(e)
703
707
  if attempt == 1:
704
708
  time.sleep(0.2)
@@ -65,14 +65,16 @@ class Edit(Tool):
65
65
 
66
66
  **When NOT to use `Edit`:**
67
67
  - Creating brand new files → use `Write`
68
- - You haven't read the file yet → must `Read` first (tool will error)
69
- - The old_string is not unique and you want to replace all occurrences → use `replace_all: true`
68
+ - You haven't read the file yet → must `Read` first
69
+ - The old_string is not unique and you want to replace all occurrences → pass `replace_all: true`
70
70
 
71
71
  **How to use `Edit`:**
72
- - MUST `Read` the file first (required, tool will error otherwise)
73
- - Provide exact `old_string` to match (including proper inion from file content, not line number prefixes)
72
+ - MUST `Read` the file first
73
+ - Provide exact `old_string` to match (including proper indentation from the file content, not line number prefixes)
74
74
  - Provide `new_string` as replacement (must be different from old_string)
75
- - The edit will FAIL if old_string is not unique
75
+ - The edit will FAIL if old_string is not unique — add context, or set `replace_all: true`
76
+ - `replace_all: true` replaces EVERY occurrence (use it deliberately: it is not
77
+ scoped to one region)
76
78
  - Preserve exact indentation from the file content (ignore line number prefixes from `Read` output)
77
79
  - Always prefer editing existing files over creating new ones
78
80
  """
@@ -107,6 +109,13 @@ class Edit(Tool):
107
109
  "description": "Original string to replace. If providing a unified diff, this should be false",
108
110
  },
109
111
  "new_str": {"type": "string", "description": "Replacement string OR unified diff text"},
112
+ "replace_all": {
113
+ "type": "boolean",
114
+ "description": (
115
+ "Replace EVERY occurrence of old_str instead of failing when it "
116
+ "is not unique. Default false (a non-unique match is an error)."
117
+ ),
118
+ },
110
119
  "diff": {
111
120
  "type": "boolean",
112
121
  "description": "Whether the replacement is a string or a diff. `true` for a diff, `false` otherwise.",
@@ -127,7 +136,13 @@ class Edit(Tool):
127
136
  diffp = args.get("diff")
128
137
  # gptel: string mode when `diff` is false OR `old_str` is provided.
129
138
  if diffp is False or old is not None:
130
- return self._string_replace(path, old, new_str, ctx)
139
+ # `is True`, not bool(): a model that sends the string "false"
140
+ # (or 0) must not be read as "replace every occurrence" --
141
+ # bool("false") is True, which would silently mass-replace.
142
+ # Anything unrecognised fails closed, like `diffp is False`.
143
+ return self._string_replace(
144
+ path, old, new_str, ctx, replace_all=args.get("replace_all") is True
145
+ )
131
146
  # Diff mode runs `patch` in Emacs `file-name-directory' of the path
132
147
  # (see _patch_cwd): a trailing-slash directory path -> that
133
148
  # directory itself, so a multi-file diff applies to files within
@@ -135,7 +150,15 @@ class Edit(Tool):
135
150
  cwd = _patch_cwd(raw, path)
136
151
  return self._apply_patch(path, cwd, new_str, ctx)
137
152
 
138
- def _string_replace(self, path: str, old: str | None, new_str: str, ctx: ToolContext) -> str:
153
+ def _string_replace(
154
+ self,
155
+ path: str,
156
+ old: str | None,
157
+ new_str: str,
158
+ ctx: ToolContext,
159
+ *,
160
+ replace_all: bool = False,
161
+ ) -> str:
139
162
  if os.path.isdir(path):
140
163
  return (
141
164
  f"Error: String replacement is intended for single files, not directories ({path})"
@@ -144,6 +167,10 @@ class Edit(Tool):
144
167
  return "Error: old_str is required for non-diff edits"
145
168
  if not isinstance(old, str) or not isinstance(new_str, str):
146
169
  return "Error: old_str and new_str must be strings"
170
+ if not old:
171
+ # `str.count("")` is len+1 and `replace("", x)` splices x
172
+ # between every character — never a meaningful edit.
173
+ return "Error: old_str must not be empty"
147
174
  # surrogateescape: invalid UTF-8 bytes (Latin-1, GBK, ...) survive
148
175
  # the read/rewrite round trip — errors="replace" would silently
149
176
  # persist U+FFFD for every such byte. newline="": the file's own
@@ -162,12 +189,12 @@ class Edit(Tool):
162
189
  count = content.count(old)
163
190
  if count == 0:
164
191
  return f'Error: Could not find old_str "{old[:20]}" in file {path}'
165
- if count > 1:
192
+ if count > 1 and not replace_all:
166
193
  return (
167
- "Error: Match is not unique. Consider providing more context "
168
- "for the replacement, or a unified diff"
194
+ f"Error: Match is not unique ({count} occurrences). Consider providing more "
195
+ "context for the replacement, passing replace_all: true, or using a unified diff"
169
196
  )
170
- new = content.replace(old, new_str, 1)
197
+ new = content.replace(old, new_str) if replace_all else content.replace(old, new_str, 1)
171
198
  from .filesystem import atomic_write_text
172
199
 
173
200
  try:
@@ -177,6 +204,11 @@ class Edit(Tool):
177
204
  diff_text = unified_diff(content, new, path)
178
205
  if diff_text:
179
206
  ctx.record_diff(diff_text)
207
+ if replace_all and count > 1:
208
+ return (
209
+ f"Successfully replaced all {count} occurrences of {old[:20]} (truncated) "
210
+ f"with {new_str[:20]} (truncated)"
211
+ )
180
212
  return f"Successfully replaced {old[:20]} (truncated) with {new_str[:20]} (truncated)"
181
213
 
182
214
  def _apply_patch(self, path: str, cwd: str, diff: str, ctx: ToolContext) -> str:
@@ -628,7 +628,8 @@ class CommandMixin:
628
628
  return
629
629
  for f in files:
630
630
  try:
631
- with open(f, encoding="utf-8") as fh:
631
+ # surrogateescape mirrors SessionPersistence.save (see there)
632
+ with open(f, encoding="utf-8", errors="surrogateescape") as fh:
632
633
  text = fh.read()
633
634
  except OSError:
634
635
  continue
@@ -672,7 +673,8 @@ class CommandMixin:
672
673
  self.console.print(f"[red]file not found: {safe_markup(path)}[/red]")
673
674
  return
674
675
  try:
675
- with open(path, encoding="utf-8") as fh:
676
+ # surrogateescape mirrors SessionPersistence.save (see there)
677
+ with open(path, encoding="utf-8", errors="surrogateescape") as fh:
676
678
  text = fh.read()
677
679
  except OSError as e:
678
680
  self.console.print(f"[red]cannot read {safe_markup(path)}: {safe_markup(e)}[/red]")
@@ -20,7 +20,7 @@ from rich.panel import Panel
20
20
  from rich.table import Table
21
21
  from rich.text import Text
22
22
 
23
- from ..core.models import ImagePart, display_args
23
+ from ..core.models import ImagePart, api_safe, display_args
24
24
  from ..io.diffrender import render_diff
25
25
  from ..io.text_filter import strip_final_check, strip_reasoning
26
26
  from ..prompts import _is_mode_reminder_text
@@ -66,8 +66,13 @@ def sanitize_terminal(text: str) -> str:
66
66
  what actually ran), spoof hyperlinks, or -- on terminals that honor
67
67
  OSC 52 -- write the user's clipboard. Tabs and newlines survive;
68
68
  colors in command output are dropped along with everything else.
69
+
70
+ Lone surrogates (a non-UTF-8 file read with ``surrogateescape``)
71
+ become U+FFFD: Rich would hand them to the terminal verbatim and the
72
+ UTF-8 encoder raised ``UnicodeEncodeError`` — killing the TUI while
73
+ rendering a restored session.
69
74
  """
70
- return _TERMINAL_CONTROL.sub("", text)
75
+ return _TERMINAL_CONTROL.sub("", api_safe(text))
71
76
 
72
77
 
73
78
  def safe_markup(value: object) -> str:
@@ -465,14 +470,39 @@ class RenderMixin:
465
470
 
466
471
  @staticmethod
467
472
  def _est_lines(row: Any, width: int) -> int:
468
- """Rough wrapped-line estimate for a row (used for the budget)."""
473
+ """Estimated wrapped-line count for ROW at WIDTH (budget math).
474
+
475
+ The estimate must never be a LOWER bound: ``_apply_budget`` keeps
476
+ the newest rows by subtracting estimates from a fixed budget, so
477
+ under-estimating a tall row lets it through, the frame overflows,
478
+ and rich's ``Live`` crops it from the BOTTOM -- hiding exactly the
479
+ newest rows the budget exists to protect.
480
+
481
+ Two shapes made the old per-type estimate collapse to 1 line:
482
+
483
+ - multi-line ``Text`` (the old formula divided the total length by
484
+ the width, so 20 short lines counted as ~2 and were billed as 1);
485
+ - ``Group`` (a rendered diff is one ``Text`` per diff line, so a
486
+ 300-line Edit diff was counted as a single line and disabled the
487
+ budget entirely).
488
+
489
+ Both now count real newlines, and ``Group`` recurses into its
490
+ renderables.
491
+ """
492
+ width = max(1, width)
493
+
494
+ def _wrapped(text: str) -> int:
495
+ return sum(max(1, math.ceil(cell_len(line) / width)) for line in text.split("\n"))
496
+
469
497
  if isinstance(row, Text):
470
- return max(1, math.ceil(len(row.plain) / max(1, width)))
498
+ return _wrapped(row.plain)
471
499
  if isinstance(row, Markdown):
472
- return max(1, math.ceil(len(row.markup) / max(1, width)))
500
+ return _wrapped(row.markup)
473
501
  if isinstance(row, Panel):
474
502
  inner = getattr(row.renderable, "renderables", None)
475
503
  return 3 + (len(inner) if isinstance(inner, (list, tuple)) else 1)
504
+ if isinstance(row, Group):
505
+ return sum(RenderMixin._est_lines(child, width) for child in row.renderables)
476
506
  return 1
477
507
 
478
508
  def _render_frame(self) -> Group:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.6.2
3
+ Version: 1.5.6.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