python-agent-harness 1.5.6.4__tar.gz → 1.5.6.5__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 (84) hide show
  1. {python_agent_harness-1.5.6.4/python_agent_harness.egg-info → python_agent_harness-1.5.6.5}/PKG-INFO +2 -2
  2. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/README.md +1 -1
  3. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/agent.py +6 -2
  6. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/models.py +16 -6
  7. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/token_estimator.py +4 -1
  8. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/cli.py +12 -4
  9. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/headless.py +39 -72
  10. python_agent_harness-1.5.6.5/python_agent_harness/entry/restore.py +149 -0
  11. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/server.py +10 -13
  12. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/session/config.py +63 -0
  13. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/session/session.py +41 -2
  14. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/bash.py +107 -1
  15. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/grep.py +14 -6
  16. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/grep_mac.py +13 -61
  17. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/commands.py +25 -105
  18. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/render.py +26 -3
  19. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5/python_agent_harness.egg-info}/PKG-INFO +2 -2
  20. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/SOURCES.txt +1 -0
  21. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/tests/test_scenarios.py +58 -0
  22. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/LICENSE +0 -0
  23. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/__main__.py +0 -0
  24. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/__init__.py +0 -0
  25. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/context_manager.py +0 -0
  26. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/planmode.py +0 -0
  27. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/subagent.py +0 -0
  28. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/core/tool_runner.py +0 -0
  29. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/__init__.py +0 -0
  30. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/controller.py +0 -0
  31. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/view.py +0 -0
  32. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/io/__init__.py +0 -0
  33. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/io/attachments.py +0 -0
  34. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/io/diffrender.py +0 -0
  35. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/io/persistence.py +0 -0
  36. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/io/text_filter.py +0 -0
  37. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/llm/__init__.py +0 -0
  38. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/llm/client.py +0 -0
  39. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/__init__.py +0 -0
  40. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/client.py +0 -0
  41. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/config.py +0 -0
  42. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/manager.py +0 -0
  43. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/__init__.py +0 -0
  44. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/agent.md +0 -0
  45. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  46. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/build-switch.md +0 -0
  47. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/commands/explain.md +0 -0
  48. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/compact.md +0 -0
  49. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/core.py +0 -0
  50. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/initialize.md +0 -0
  51. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/plan-mode.md +0 -0
  52. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/plan.md +0 -0
  53. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/review.md +0 -0
  54. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/subagent.md +0 -0
  55. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/summary.md +0 -0
  56. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  57. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/title.md +0 -0
  58. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/session/__init__.py +0 -0
  59. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/session/commands.py +0 -0
  60. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/__init__.py +0 -0
  61. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/agent_tool.py +0 -0
  62. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/base.py +0 -0
  63. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/diffapply.py +0 -0
  64. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/edit.py +0 -0
  65. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/edit_mac.py +0 -0
  66. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/filesystem.py +0 -0
  67. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/glob.py +0 -0
  68. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/glob_mac.py +0 -0
  69. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/mcp.py +0 -0
  70. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/planexit.py +0 -0
  71. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/question.py +0 -0
  72. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/read.py +0 -0
  73. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/skill.py +0 -0
  74. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/todo.py +0 -0
  75. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/write.py +0 -0
  76. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/__init__.py +0 -0
  77. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/core.py +0 -0
  78. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/input.py +0 -0
  79. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  80. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/entry_points.txt +0 -0
  81. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/requires.txt +0 -0
  82. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/top_level.txt +0 -0
  83. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/setup.cfg +0 -0
  84. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.5}/tests/test_invariants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.6.4
3
+ Version: 1.5.6.5
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
@@ -211,7 +211,7 @@ Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core beh
211
211
  | `shell.txt` | `Bash` tool + `agent.md` Git/GitHub guidance |
212
212
  | `plan-enter.txt` / `plan-exit.txt` | `PlanExit` tool |
213
213
  | `initialize.txt` / `review.txt` / `explain` | `initialize.md` / `review.md` / `commands/explain.md` |
214
- | compaction / summary / title | `compact.md` / `summary.md` / `title.md` |
214
+ | `compaction` / `summary` / `title` | `compact.md` / `summary.md` / `title.md` |
215
215
  | `AGENTS.md` handling | `prompts.py` (`find_agents_md_files`, `load_context_files`) |
216
216
 
217
217
  ## Development
@@ -177,7 +177,7 @@ Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core beh
177
177
  | `shell.txt` | `Bash` tool + `agent.md` Git/GitHub guidance |
178
178
  | `plan-enter.txt` / `plan-exit.txt` | `PlanExit` tool |
179
179
  | `initialize.txt` / `review.txt` / `explain` | `initialize.md` / `review.md` / `commands/explain.md` |
180
- | compaction / summary / title | `compact.md` / `summary.md` / `title.md` |
180
+ | `compaction` / `summary` / `title` | `compact.md` / `summary.md` / `title.md` |
181
181
  | `AGENTS.md` handling | `prompts.py` (`find_agents_md_files`, `load_context_files`) |
182
182
 
183
183
  ## Development
@@ -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.4"
7
+ version = "1.5.6.5"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -7,7 +7,7 @@ from .mcp.config import MCPConfig, MCPServerConfig
7
7
  from .mcp.manager import MCPManager
8
8
  from .session.session import Session
9
9
 
10
- __version__ = "1.5.6.4"
10
+ __version__ = "1.5.6.5"
11
11
 
12
12
  __all__ = [
13
13
  "Client",
@@ -471,8 +471,9 @@ class AgentLoop:
471
471
  # after successful responses, so without this the
472
472
  # interrupted turn would never reach the session
473
473
  # file — the next turn's save would overwrite it
474
- # without ever containing it.
475
- session.auto_save(salvaged, self.system)
474
+ # without ever containing it. flush bypasses the
475
+ # debounce so the partial turn is not lost.
476
+ session.flush_auto_save(salvaged)
476
477
  # The title is generated on the first save, not
477
478
  # only on clean completion — an interrupted session
478
479
  # still gets a meaningful name (one-shot; no-op when
@@ -484,6 +485,9 @@ class AgentLoop:
484
485
  # no-op when the title already exists or generation
485
486
  # is in flight)
486
487
  session.generate_session_title()
488
+ # The per-round auto_save is debounced; flush at run
489
+ # end so the final state always reaches the file.
490
+ session.flush_auto_save(salvaged)
487
491
  return self.result
488
492
 
489
493
  def _next_state(self) -> str:
@@ -69,6 +69,15 @@ class ImagePart:
69
69
  path: str | None = None
70
70
  url: str | None = None
71
71
 
72
+ def __post_init__(self) -> None:
73
+ # to_api() runs on every round (token estimation + API payload),
74
+ # so the base64 data URL is encoded once, not per call.
75
+ self._data_url: str | None = None
76
+ if self.data is not None:
77
+ self._data_url = (
78
+ f"data:{self.media_type};base64,{base64.b64encode(self.data).decode('ascii')}"
79
+ )
80
+
72
81
  @classmethod
73
82
  def from_url(cls, url: str) -> ImagePart:
74
83
  return cls(url=url)
@@ -76,13 +85,9 @@ class ImagePart:
76
85
  def to_api(self) -> dict[str, Any]:
77
86
  if self.url:
78
87
  return {"type": "image_url", "image_url": {"url": self.url}}
79
- if self.data is None:
88
+ if self._data_url is None:
80
89
  raise ValueError("ImagePart requires either data or url")
81
- b64 = base64.b64encode(self.data).decode("ascii")
82
- return {
83
- "type": "image_url",
84
- "image_url": {"url": f"data:{self.media_type};base64,{b64}"},
85
- }
90
+ return {"type": "image_url", "image_url": {"url": self._data_url}}
86
91
 
87
92
 
88
93
  # Type alias for the individual parts a Message.content list may contain.
@@ -155,6 +160,11 @@ class Message:
155
160
  injected: bool = False # harness-injected (nudge/plan/build-switch), not user input
156
161
 
157
162
  def to_api(self) -> dict[str, Any]:
163
+ # Not cached: the per-message dict is cheap to rebuild and
164
+ # caching it (returning the same object each call) would make a
165
+ # mutation by any caller corrupt later rounds and token
166
+ # estimation. The one expensive step — base64-encoding an image
167
+ # — is cached on ``ImagePart`` instead (see ImagePart.__post_init__).
158
168
  d: dict[str, Any] = {"role": self.role}
159
169
  if self.content is not None:
160
170
  content = self._api_content()
@@ -45,7 +45,10 @@ def estimate_tokens(text: str) -> int:
45
45
  """Estimate tokens in TEXT: Latin ~4 chars/token, CJK ~2 chars/token."""
46
46
  if not text:
47
47
  return 0
48
- cjk = len(_CJK_RE.findall(text))
48
+ # subn counts CJK chars in one C-speed pass without allocating the
49
+ # per-match list findall would build (this runs on the full payload
50
+ # every round).
51
+ cjk = _CJK_RE.subn("", text)[1]
49
52
  latin = len(text) - cjk
50
53
  return round(latin / 4.0 + cjk / 2.0)
51
54
 
@@ -272,12 +272,20 @@ def cmd_serve(args: argparse.Namespace) -> int:
272
272
  def cmd_config(args: argparse.Namespace) -> int:
273
273
  path = config._config_path(args.path)
274
274
  if args.init:
275
- path.parent.mkdir(parents=True, exist_ok=True)
276
- if path.exists():
275
+ # 0o700 dir / 0o600 file: the config holds the API key, so it must
276
+ # not be group/world-readable (mode applies to a NEW leaf dir only;
277
+ # an existing directory keeps its permissions).
278
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
279
+ template = config.CONFIG_TEMPLATE.format(path=str(path).replace("\\", "\\\\"))
280
+ try:
281
+ # O_EXCL: create-only, atomically -- no exists()/write race, and
282
+ # a pre-planted symlink at PATH is refused instead of followed.
283
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
284
+ except FileExistsError:
277
285
  print(f"config already exists: {path} (delete it first to overwrite)")
278
286
  return 1
279
- template = config.CONFIG_TEMPLATE.format(path=str(path).replace("\\", "\\\\"))
280
- path.write_text(template, encoding="utf-8")
287
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
288
+ f.write(template)
281
289
  print(f"wrote config template: {path}")
282
290
  return 0
283
291
  settings = config.load_llm_config(args.path)
@@ -137,6 +137,25 @@ def _cancelled_now(session: Any) -> bool:
137
137
  return event is not None and event.is_set() is True
138
138
 
139
139
 
140
+ def usage_snapshot(session: Any) -> dict[str, Any] | None:
141
+ """Cumulative input/output tokens and rounds of the current run.
142
+
143
+ Reads the CURRENT ``usage_totals`` (``Controller.submit`` swaps in a
144
+ fresh dict per run, so a reference captured before submit would
145
+ always snapshot zeros). None when the session keeps no totals.
146
+ """
147
+ totals = getattr(session, "usage_totals", None)
148
+ if not isinstance(totals, dict):
149
+ return None
150
+ usage: dict[str, Any] = {"input": 0, "output": 0, "rounds": 0}
151
+ with totals.get("_lock", threading.Lock()):
152
+ for key in ("input", "output", "rounds"):
153
+ value = totals.get(key)
154
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
155
+ usage[key] = int(value)
156
+ return usage
157
+
158
+
140
159
  def final_answer_text(session: Session) -> str:
141
160
  """The run's final assistant answer, TUI-filtered.
142
161
 
@@ -285,69 +304,25 @@ def restore_session(controller: Controller, spec: str, err: TextIO) -> bool:
285
304
  file, and restores the saved agent/model. Returns False (with a
286
305
  message on *err*) when no matching session exists.
287
306
  """
288
- from ..io.persistence import (
289
- SessionPersistence,
290
- find_session_by_title,
291
- parse_saved_body,
292
- title_from_filename,
293
- )
294
-
295
- if not spec or spec in ("--latest", "latest"):
296
- path = SessionPersistence.latest_session()
297
- elif os.path.isfile(os.path.expanduser(spec)):
298
- path = os.path.expanduser(spec)
299
- else:
300
- path = find_session_by_title(spec)
307
+ from .restore import apply_saved_session, load_saved_session, resolve_session_path
308
+
309
+ path = resolve_session_path(spec)
301
310
  if not path or not os.path.isfile(path):
302
311
  err.write(f"no session found: {spec or 'latest'}\n")
303
312
  return False
304
- # surrogateescape must mirror SessionPersistence.save: the saved body
305
- # may carry lone surrogates (the tools read non-UTF-8 files that way),
306
- # and a strict decode raised UnicodeDecodeError here.
307
- with open(path, encoding="utf-8", errors="surrogateescape") as fh:
308
- text = fh.read()
309
- meta = SessionPersistence.parse_metadata(text)
310
- body = SessionPersistence.strip_metadata(text)
311
- messages = parse_saved_body(body)
312
- controller.store.file_path = path
313
- title = title_from_filename(path)
314
- if title:
315
- controller.store.title = title
316
- agent = meta.get("python-agent-harness--agent")
317
- if agent:
318
- success, msg = controller.switch_agent(agent)
319
- if not success:
320
- controller.switch_agent("default")
313
+ try:
314
+ saved = load_saved_session(path)
315
+ except OSError as e:
316
+ err.write(f"cannot read {path}: {e}\n")
317
+ return False
318
+
319
+ def report(level: str, msg: str) -> None:
320
+ # headless output is quiet on success: only problems reach stderr
321
+ if level == "warning":
321
322
  err.write(f"warning: {msg}\n")
322
- model = meta.get("python-agent-harness--model")
323
- if model and model != controller.model:
324
- if model == controller.llm_settings.get("model"):
325
- # The saved model is the session-start default: the
326
- # "default" pseudo-profile restores exactly those settings.
327
- controller.switch_model("default")
328
- else:
329
- success, _ = controller.switch_model(model)
330
- if not success:
331
- profile = next(
332
- (
333
- name
334
- for name, p in controller.model_profiles.items()
335
- if p.get("model") == model
336
- ),
337
- None,
338
- )
339
- if profile:
340
- success, _ = controller.switch_model(profile)
341
- if not success:
342
- err.write(
343
- f"warning: model {model!r} has no matching profile, keeping current model\n"
344
- )
345
- # Replace conversation history: a new generation, so a stale worker
346
- # from a previous run can't clobber the restored session.
347
- controller.run_generation += 1
348
- controller.last_messages = messages
349
- controller.clear_todos()
350
- err.write(f"restored: {os.path.basename(path)} ({len(messages)} messages)\n")
323
+
324
+ apply_saved_session(controller, saved, report)
325
+ err.write(f"restored: {os.path.basename(path)} ({len(saved.messages)} messages)\n")
351
326
  return True
352
327
 
353
328
 
@@ -679,20 +654,12 @@ def run_headless_jsonl(
679
654
  """The terminal result line: snapshots usage and model so every
680
655
  result line — including early failures — carries the same
681
656
  fields for the driving process."""
682
- usage: dict[str, Any] | None = None
683
- # Read the CURRENT usage_totals, not a reference captured before
684
- # submit(): Controller.submit swaps in a fresh dict per run, so
685
- # a pre-submit reference would always snapshot zeros.
686
- totals = getattr(session, "usage_totals", None)
687
- if isinstance(totals, dict):
688
- usage = {"input": 0, "output": 0, "rounds": 0}
689
- with totals.get("_lock", threading.Lock()):
690
- for key in ("input", "output", "rounds"):
691
- value = totals.get(key)
692
- if isinstance(value, (int, float)) and not isinstance(value, bool):
693
- usage[key] = int(value)
694
657
  view.emit_result(
695
- answer, errors=errors, usage=usage, model=session.model, cancelled=cancelled
658
+ answer,
659
+ errors=errors,
660
+ usage=usage_snapshot(session),
661
+ model=session.model,
662
+ cancelled=cancelled,
696
663
  )
697
664
 
698
665
  if restore is not None and not restore_session(controller, restore, view.err):
@@ -0,0 +1,149 @@
1
+ """Shared session-restore logic for the TUI's /restore and headless --restore.
2
+
3
+ Both front ends resolve a saved session the same way (path, title
4
+ substring, or latest), parse it the same way, and apply it to a
5
+ ``Controller`` the same way: repoint the session store, restore the
6
+ saved agent and model, and replace the conversation history. Only the
7
+ reporting differs, so callers pass a ``report(level, message)``
8
+ callback ("ok" for confirmations, "warning" for problems).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING
17
+
18
+ from ..core.models import Message
19
+ from ..io import persistence
20
+
21
+ if TYPE_CHECKING:
22
+ from .controller import Controller
23
+
24
+ AGENT_KEY = "python-agent-harness--agent"
25
+ MODEL_KEY = "python-agent-harness--model"
26
+ PROJECT_KEY = "python-agent-harness--project-dir"
27
+ ROUND_TIMES_KEY = "python-agent-harness--round-times"
28
+
29
+ Report = Callable[[str, str], None]
30
+
31
+
32
+ @dataclass
33
+ class SavedSession:
34
+ """A parsed session file: its path, metadata and conversation."""
35
+
36
+ path: str
37
+ meta: dict[str, str] = field(default_factory=dict)
38
+ messages: list[Message] = field(default_factory=list)
39
+
40
+ @property
41
+ def round_times(self) -> list[float]:
42
+ """Persisted round timestamps; [] when absent or malformed."""
43
+ raw = self.meta.get(ROUND_TIMES_KEY)
44
+ if not raw:
45
+ return []
46
+ try:
47
+ return [float(x) for x in raw.split()]
48
+ except ValueError:
49
+ return []
50
+
51
+
52
+ def resolve_session_path(spec: str) -> str | None:
53
+ """Map SPEC (empty/"latest"/"--latest", a file path, or a title
54
+ substring) to a session file path, or None when nothing matches.
55
+
56
+ The result is not guaranteed to exist: callers check ``isfile``.
57
+ """
58
+ if not spec or spec in ("--latest", "latest"):
59
+ return persistence.SessionPersistence.latest_session()
60
+ expanded = os.path.expanduser(spec)
61
+ if os.path.isfile(expanded):
62
+ return expanded
63
+ return persistence.find_session_by_title(spec)
64
+
65
+
66
+ def load_saved_session(path: str) -> SavedSession:
67
+ """Read and parse the session file at PATH. Raises OSError.
68
+
69
+ surrogateescape mirrors ``SessionPersistence.save``: the saved body
70
+ may carry lone surrogates (non-UTF-8 file content), which a strict
71
+ decode would reject.
72
+ """
73
+ with open(path, encoding="utf-8", errors="surrogateescape") as fh:
74
+ text = fh.read()
75
+ store = persistence.SessionPersistence
76
+ meta = store.parse_metadata(text)
77
+ messages = persistence.parse_saved_body(store.strip_metadata(text))
78
+ return SavedSession(path=path, meta=meta, messages=messages)
79
+
80
+
81
+ def restore_agent(controller: Controller, agent: str | None, report: Report) -> None:
82
+ """Switch to the saved AGENT; fall back to the default agent (so no
83
+ stale tool exclusions linger) when it no longer exists."""
84
+ if not agent:
85
+ return
86
+ try:
87
+ success, msg = controller.switch_agent(agent)
88
+ if success:
89
+ report("ok", msg)
90
+ else:
91
+ controller.switch_agent("default")
92
+ report("warning", msg)
93
+ except Exception as e: # noqa: BLE001 - a bad agent must not abort the restore
94
+ report("warning", f"could not restore agent: {e}")
95
+
96
+
97
+ def restore_model(controller: Controller, model: str | None, report: Report) -> None:
98
+ """Switch back to the saved raw MODEL name through a profile.
99
+
100
+ Profile switching also fixes base_url/api_key/temperature, which a
101
+ bare model-name change would leave pointing at the wrong endpoint.
102
+ Order: the "default" pseudo-profile when MODEL is the session-start
103
+ model, else MODEL as a profile name, else a profile whose model
104
+ matches. Without a match the current model stays active.
105
+ """
106
+ if not model or model == controller.model:
107
+ return
108
+ try:
109
+ if model == controller.llm_settings.get("model"):
110
+ _, msg = controller.switch_model("default")
111
+ report("ok", msg)
112
+ return
113
+ success, msg = controller.switch_model(model)
114
+ if not success:
115
+ profile = next(
116
+ (name for name, p in controller.model_profiles.items() if p.get("model") == model),
117
+ None,
118
+ )
119
+ if profile:
120
+ success, msg = controller.switch_model(profile)
121
+ if success:
122
+ report("ok", msg)
123
+ else:
124
+ report("warning", f"model {model!r} has no matching profile, keeping current model")
125
+ except Exception as e: # noqa: BLE001 - a bad model must not abort the restore
126
+ report("warning", f"could not restore model: {e}")
127
+
128
+
129
+ def apply_saved_session(controller: Controller, saved: SavedSession, report: Report) -> None:
130
+ """Make SAVED the controller's current session.
131
+
132
+ The conversation history is replaced in BOTH places the runtime
133
+ reads it: ``conversation_history`` (what the next run sends) and
134
+ ``last_messages`` (what the views render). ``run_generation`` is
135
+ bumped so a worker still winding down from a cancelled run cannot
136
+ clobber the restored history with its salvaged one.
137
+ """
138
+ store = controller.store
139
+ store.round_times = saved.round_times
140
+ store.file_path = saved.path
141
+ title = persistence.title_from_filename(saved.path)
142
+ if title:
143
+ store.title = title
144
+ restore_agent(controller, saved.meta.get(AGENT_KEY), report)
145
+ restore_model(controller, saved.meta.get(MODEL_KEY), report)
146
+ controller.run_generation += 1
147
+ controller.conversation_history = list(saved.messages)
148
+ controller.last_messages = list(saved.messages)
149
+ controller.clear_todos()
@@ -130,7 +130,14 @@ from typing import Any, TextIO
130
130
  from ..core.models import api_safe
131
131
  from ..session.session import Session
132
132
  from .controller import Controller
133
- from .headless import PROTOCOL_VERSION, JsonlView, _restorable_handler, final_answer_text
133
+ from .headless import (
134
+ PROTOCOL_VERSION,
135
+ JsonlView,
136
+ _cancelled_now,
137
+ _restorable_handler,
138
+ final_answer_text,
139
+ usage_snapshot,
140
+ )
134
141
 
135
142
  DEFAULT_ANSWER_TIMEOUT = 0.0 # wait forever for a host answer
136
143
 
@@ -581,8 +588,7 @@ class AgentServer:
581
588
  # -- run execution ------------------------------------------------------------
582
589
 
583
590
  def _cancelled_now(self) -> bool:
584
- event = getattr(self.session, "cancel_event", None)
585
- return event is not None and event.is_set() is True
591
+ return _cancelled_now(self.session)
586
592
 
587
593
  def _final_answer(self) -> str:
588
594
  """The run's final assistant answer, TUI-filtered (headless shape).
@@ -595,16 +601,7 @@ class AgentServer:
595
601
 
596
602
  def _usage_snapshot(self) -> dict[str, Any] | None:
597
603
  """Cumulative input/output tokens and rounds of the current run."""
598
- totals = getattr(self.session, "usage_totals", None)
599
- if not isinstance(totals, dict):
600
- return None
601
- usage: dict[str, Any] = {"input": 0, "output": 0, "rounds": 0}
602
- with totals.get("_lock", threading.Lock()):
603
- for key in ("input", "output", "rounds"):
604
- value = totals.get(key)
605
- if isinstance(value, (int, float)) and not isinstance(value, bool):
606
- usage[key] = int(value)
607
- return usage
604
+ return usage_snapshot(self.session)
608
605
 
609
606
  def _execute_run(self, prompt: str, run_id: str) -> None:
610
607
  """Run one prompt to completion, streaming lines as they happen.
@@ -53,6 +53,14 @@ CALIBRATION_MAX = 3.0
53
53
  SESSION_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
54
54
  SESSION_SUBDIR = "python-agent-harness/sessions"
55
55
  AUTO_SAVE_SESSION = True
56
+ # auto_save is called after every round; the full-conversation rewrite is
57
+ # debounced so a long run does not rewrite the whole file each round.
58
+ # A save is skipped while fewer than AUTO_SAVE_MIN_INTERVAL seconds have
59
+ # passed since the last one AND the message count has not grown by
60
+ # AUTO_SAVE_MIN_GROWTH_MSGS. flush_auto_save() bypasses the debounce
61
+ # (run end, cancel, compaction).
62
+ AUTO_SAVE_MIN_INTERVAL = 2.0
63
+ AUTO_SAVE_MIN_GROWTH_MSGS = 8
56
64
 
57
65
  # ---- LLM interaction logs ---------------------------------------------------
58
66
  LLM_LOG_ENABLED = False
@@ -111,6 +119,36 @@ BASH_TIMEOUT_SILENCE: float | None = 120.0
111
119
  # than this regardless of output. None disables the cap (default).
112
120
  BASH_TIMEOUT_MAX: float | None = None
113
121
 
122
+ # Environment variables removed from every Bash command's environment
123
+ # (case-insensitive fnmatch patterns over the variable NAME). The child
124
+ # would otherwise inherit the harness's whole environment, and a single
125
+ # `printenv` would put API keys and cloud credentials into the model's
126
+ # context (and the provider's logs). Names listed in the config file's
127
+ # ``bash.env_passthrough`` are kept regardless, and so are variables whose
128
+ # value is an existing absolute path (a pointer to a credentials file, e.g.
129
+ # GOOGLE_APPLICATION_CREDENTIALS, is not itself a secret) or a trivial
130
+ # flag (0/1/true/false/...). A denylist, not an allowlist: commands keep
131
+ # PATH, toolchain, proxy and SSH-agent settings.
132
+ BASH_ENV_SECRET_PATTERNS: tuple[str, ...] = (
133
+ "*API_KEY*",
134
+ "*APIKEY*",
135
+ "*_KEY",
136
+ "*PRIVATE_KEY*",
137
+ "*SECRET*",
138
+ "*TOKEN",
139
+ "*PASSWORD*",
140
+ "*PASSWD*",
141
+ "*_PASS",
142
+ "*_PWD",
143
+ "*_PAT",
144
+ "*CREDENTIAL*",
145
+ "*COOKIE*",
146
+ )
147
+
148
+ # Names that match a secret pattern above but never hold a secret
149
+ # (public keys are public by definition); checked first.
150
+ BASH_ENV_NOT_SECRET_PATTERNS: tuple[str, ...] = ("*PUBLIC_KEY*", "*PUBKEY*")
151
+
114
152
  # ---- LLM client ----------------------------------------------------------------
115
153
  DEFAULT_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
116
154
  DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5-mini")
@@ -252,6 +290,10 @@ CONFIG_TEMPLATE = """\
252
290
  "timeout": null
253
291
  }},
254
292
  "default_agent": null,
293
+ "bash": {{
294
+ "_comment": "Secret-looking environment variables (names matching *API_KEY*, *SECRET*, *TOKEN, *PASSWORD*, ... see BASH_ENV_SECRET_PATTERNS in config.py) are removed from Bash commands' environment so `printenv` cannot leak them to the model (variables holding an existing file path are kept). List variable names here to pass specific ones through anyway, e.g. [\\"GH_TOKEN\\"] for `gh`.",
295
+ "env_passthrough": []
296
+ }},
255
297
  "paths": {{
256
298
  "_comment": "Optional overrides for context and skill directories. Absolute paths or ~ expansion supported.",
257
299
  "context_path": null,
@@ -497,6 +539,27 @@ def load_paths_config(path: str | os.PathLike | None = None) -> dict:
497
539
  return settings
498
540
 
499
541
 
542
+ def load_bash_env_passthrough(path: str | os.PathLike | None = None) -> frozenset[str]:
543
+ """Names from ``bash.env_passthrough``: secret-looking environment
544
+ variables the user explicitly lets Bash commands see (e.g. GH_TOKEN
545
+ for `gh`).
546
+
547
+ Fails closed: a missing/broken file or a malformed section yields
548
+ an empty set, so secrets stay stripped.
549
+ """
550
+ try:
551
+ data = _read_config(path)
552
+ except ValueError:
553
+ return frozenset()
554
+ section = data.get("bash")
555
+ if not isinstance(section, dict):
556
+ return frozenset()
557
+ names = section.get("env_passthrough")
558
+ if not isinstance(names, list):
559
+ return frozenset()
560
+ return frozenset(n.strip().upper() for n in names if isinstance(n, str) and n.strip())
561
+
562
+
500
563
  def load_mcp_config(path: str | os.PathLike | None = None) -> MCPConfig:
501
564
  """Load MCP server settings from the config file's ``mcp`` object.
502
565
 
@@ -195,6 +195,10 @@ class Session:
195
195
  self.todos: list[dict] = []
196
196
  self.pending_user_prompts: list[str] = []
197
197
  self._save_error: str | None = None
198
+ # auto_save debounce state (see config.AUTO_SAVE_*): the last
199
+ # successful save's wall time and conversation length
200
+ self._last_save_at: float | None = None
201
+ self._last_save_len: int = 0
198
202
  self.last_messages: list = []
199
203
  self.cancel_event = threading.Event()
200
204
  # Named LLM profiles for runtime switching via /model command
@@ -680,6 +684,39 @@ class Session:
680
684
  def auto_save(self, messages: list, system: str | None) -> None:
681
685
  if not config.AUTO_SAVE_SESSION:
682
686
  return
687
+ # Debounce the full-conversation rewrite: auto_save runs after
688
+ # every round, but the file only needs refreshing when enough
689
+ # time has passed AND the conversation grew. The growth check
690
+ # is on the message count (cheap) before the expensive
691
+ # _conversation_text serialization.
692
+ now = time.monotonic()
693
+ # getattr: test doubles built via __new__ skip __init__ and never
694
+ # set the debounce state; treat a missing state as "never saved".
695
+ last = getattr(self, "_last_save_at", None)
696
+ if last is not None:
697
+ elapsed = now - last
698
+ grew = (
699
+ len(messages) - getattr(self, "_last_save_len", 0)
700
+ >= config.AUTO_SAVE_MIN_GROWTH_MSGS
701
+ )
702
+ # skip while inside the min interval and the conversation has
703
+ # not grown enough; a big round (grew) or the interval
704
+ # elapsing forces a save
705
+ if elapsed < config.AUTO_SAVE_MIN_INTERVAL and not grew:
706
+ return
707
+ self._do_save(messages)
708
+
709
+ def flush_auto_save(self, messages: list) -> None:
710
+ """Save immediately, bypassing the auto_save debounce.
711
+
712
+ Called at run end / cancel / compaction, where the conversation
713
+ must reach the session file regardless of the debounce window.
714
+ """
715
+ if not config.AUTO_SAVE_SESSION:
716
+ return
717
+ self._do_save(messages)
718
+
719
+ def _do_save(self, messages: list) -> None:
683
720
  text = self._conversation_text(messages)
684
721
  # retry once: transient failures (NFS hiccup, brief lock) clear
685
722
  # on the second attempt; permanent ones (disk full, read-only)
@@ -689,6 +726,8 @@ class Session:
689
726
  try:
690
727
  self.store.save(text)
691
728
  self._save_error = None
729
+ self._last_save_at = time.monotonic()
730
+ self._last_save_len = len(messages)
692
731
  return
693
732
  except (OSError, UnicodeError) as e:
694
733
  # UnicodeError too: a save failure must never escape into
@@ -983,7 +1022,7 @@ class Session:
983
1022
  # preserved verbatim after the frame, so the model keeps
984
1023
  # the actual requests.
985
1024
  self.last_messages = compacted_messages(summary, user_prompt_texts(messages))
986
- self.auto_save(self.last_messages, self.system_prompt)
1025
+ self.flush_auto_save(self.last_messages)
987
1026
  self.notify("compact")
988
1027
  return True, "Session compacted successfully."
989
1028
  except Exception as e: # noqa: BLE001 - compaction failure is non-fatal
@@ -1019,7 +1058,7 @@ class Session:
1019
1058
  if not summary:
1020
1059
  return "Summary failed: empty response."
1021
1060
  self.last_messages.append(Msg(role="assistant", content=summary))
1022
- self.auto_save(self.last_messages, self.system_prompt)
1061
+ self.flush_auto_save(self.last_messages)
1023
1062
  return "Summary appended."
1024
1063
 
1025
1064