python-agent-harness 1.5.6.4__tar.gz → 1.5.6.6__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.6}/PKG-INFO +2 -2
  2. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/README.md +1 -1
  3. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/core/agent.py +6 -2
  6. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/core/context_manager.py +9 -6
  7. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/core/models.py +16 -6
  8. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/core/token_estimator.py +4 -1
  9. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/entry/cli.py +12 -4
  10. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/entry/headless.py +39 -72
  11. python_agent_harness-1.5.6.6/python_agent_harness/entry/restore.py +149 -0
  12. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/entry/server.py +10 -13
  13. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/agent.md +0 -15
  14. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/core.py +18 -4
  15. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/subagent.md +0 -14
  16. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/session/config.py +75 -0
  17. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/session/session.py +49 -8
  18. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/bash.py +107 -1
  19. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/grep.py +14 -6
  20. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/grep_mac.py +13 -61
  21. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tui/commands.py +25 -105
  22. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tui/render.py +32 -7
  23. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6/python_agent_harness.egg-info}/PKG-INFO +2 -2
  24. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness.egg-info/SOURCES.txt +1 -0
  25. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/tests/test_scenarios.py +58 -0
  26. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/LICENSE +0 -0
  27. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/__main__.py +0 -0
  28. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/core/__init__.py +0 -0
  29. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/core/planmode.py +0 -0
  30. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/core/subagent.py +0 -0
  31. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/core/tool_runner.py +0 -0
  32. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/entry/__init__.py +0 -0
  33. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/entry/controller.py +0 -0
  34. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/entry/view.py +0 -0
  35. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/io/__init__.py +0 -0
  36. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/io/attachments.py +0 -0
  37. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/io/diffrender.py +0 -0
  38. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/io/persistence.py +0 -0
  39. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/io/text_filter.py +0 -0
  40. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/llm/__init__.py +0 -0
  41. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/llm/client.py +0 -0
  42. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/mcp/__init__.py +0 -0
  43. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/mcp/client.py +0 -0
  44. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/mcp/config.py +0 -0
  45. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/mcp/manager.py +0 -0
  46. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/__init__.py +0 -0
  47. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  48. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/build-switch.md +0 -0
  49. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/commands/explain.md +0 -0
  50. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/compact.md +0 -0
  51. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/initialize.md +0 -0
  52. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/plan-mode.md +0 -0
  53. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/plan.md +0 -0
  54. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/review.md +0 -0
  55. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/summary.md +0 -0
  56. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  57. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/prompts/title.md +0 -0
  58. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/session/__init__.py +0 -0
  59. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/session/commands.py +0 -0
  60. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/__init__.py +0 -0
  61. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/agent_tool.py +0 -0
  62. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/base.py +0 -0
  63. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/diffapply.py +0 -0
  64. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/edit.py +0 -0
  65. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/edit_mac.py +0 -0
  66. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/filesystem.py +0 -0
  67. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/glob.py +0 -0
  68. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/glob_mac.py +0 -0
  69. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/mcp.py +0 -0
  70. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/planexit.py +0 -0
  71. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/question.py +0 -0
  72. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/read.py +0 -0
  73. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/skill.py +0 -0
  74. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/todo.py +0 -0
  75. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tools/write.py +0 -0
  76. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tui/__init__.py +0 -0
  77. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tui/core.py +0 -0
  78. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness/tui/input.py +0 -0
  79. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  80. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness.egg-info/entry_points.txt +0 -0
  81. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness.egg-info/requires.txt +0 -0
  82. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/python_agent_harness.egg-info/top_level.txt +0 -0
  83. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/setup.cfg +0 -0
  84. {python_agent_harness-1.5.6.4 → python_agent_harness-1.5.6.6}/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.6
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.6"
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.6"
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:
@@ -65,10 +65,11 @@ class ContextManager:
65
65
  def compact(self) -> bool:
66
66
  """Compact the conversation; return True on success.
67
67
 
68
- On success the history is replaced by the summary frame followed
69
- by every real user prompt (nudges and other harness-injected
70
- messages excluded), so the model keeps the actual requests; the
71
- last prompt is the resume request for the next round.
68
+ On success the history is replaced by the summary frame, every
69
+ real user prompt (nudges and other harness-injected messages
70
+ excluded), and a trailing resume message telling the model what
71
+ is already done — the summary and the prompts alone end on a
72
+ bare user request, which invites redoing finished work.
72
73
  """
73
74
  loop = self.loop
74
75
  prompts = user_prompt_texts(loop.messages)
@@ -87,8 +88,10 @@ class ContextManager:
87
88
  # stays untouched): it is part of the user turn, never a
88
89
  # system message. Every real user prompt (nudges and other
89
90
  # harness-injected messages excluded) is preserved verbatim
90
- # after the frame, so the model keeps the actual requests.
91
- loop.messages = compacted_messages(summary, prompts)
91
+ # after the frame, so the model keeps the actual requests,
92
+ # and the trailing resume message states that the summarised
93
+ # work is done (injected flag: TUI and filters skip it).
94
+ loop.messages = compacted_messages(summary, prompts, resume=True)
92
95
  # The shared conversation now is the compacted one: mirror it
93
96
  # onto session.last_messages so the TUI (renders from it) and
94
97
  # a later manual /compact start from the summary, not the old
@@ -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.
@@ -69,21 +69,6 @@ The user will primarily request you perform software engineering tasks. This inc
69
69
  - Batch independent tool calls in a single response to maximize parallelism.
70
70
 
71
71
  # Tool usage instructions
72
- **Specialized Tools vs. Shell Commands (CRITICAL):**
73
- - NEVER use `Bash` for file operations with grep, find, ls, cat, head, tail, sed or awk.
74
- - ALWAYS use: `Glob`, `Grep`, `Read`, `Edit`, `Write`
75
- - Reserve `Bash` EXCLUSIVELY for: git, npm, docker, cargo, make, system services and other non-file commands
76
- - Using bash for file operations violates the tool hierarchy and creates technical debt
77
-
78
- **Tool Selection Hierarchy:**
79
- - File search by name → Use `Glob` (NOT find or ls)
80
- - Directory listing → Use `Glob` with glob pattern `"*"` (not ls)
81
- - Content search → Use `Grep` (NOT grep or rg)
82
- - Read files → Use `Read` (NOT cat/head/tail)
83
- - Edit files → Use `Edit` (NOT sed/awk)
84
- - Write files → Use `Write` (NOT echo >/cat <<EOF)
85
- - System operations → Use `Bash` (for git, npm, docker, etc.)
86
-
87
72
  {{TOOL_INSTRUCTIONS}}
88
73
 
89
74
  # Code References
@@ -500,7 +500,11 @@ def user_prompt_texts(messages: list) -> list[str]:
500
500
  (``remember_user_text`` excludes reminders for title generation —
501
501
  a different purpose, so the filters differ.)
502
502
  """
503
- nudge = config.NUDGE_MESSAGE
503
+ # Harness-injected one-liners replayed verbatim on every compaction
504
+ # would accumulate: the completion nudge, and the resume message the
505
+ # automatic compaction appends (a bare "keep going" carries no task
506
+ # content — the summary already covers the progress it refers to).
507
+ injected = {config.NUDGE_MESSAGE, config.RESUME_MESSAGE}
504
508
  is_reminder: list[bool] = []
505
509
  last_reminder = -1
506
510
  for msg in messages:
@@ -523,7 +527,7 @@ def user_prompt_texts(messages: list) -> list[str]:
523
527
  if _message_role(msg) != "user":
524
528
  continue
525
529
  text = _message_text(msg)
526
- if not text or text == nudge:
530
+ if not text or text in injected:
527
531
  continue
528
532
  if text.startswith(config.COMPACT_HEADER):
529
533
  continue
@@ -587,19 +591,29 @@ def compact_summary(
587
591
  return summary or None
588
592
 
589
593
 
590
- def compacted_messages(summary: str, prompts: list[str]) -> list[Message]:
594
+ def compacted_messages(summary: str, prompts: list[str], resume: bool = False) -> list[Message]:
591
595
  """The post-compaction history: the summary frame as a user message
592
596
  followed by every preserved user prompt, oldest first.
593
597
 
594
598
  The summary lives in the user turn (the system prompt is passed
595
599
  separately and stays untouched); *prompts* are the real user
596
600
  requests (see ``user_prompt_texts``) that must survive compaction.
601
+
602
+ *resume* appends ``config.RESUME_MESSAGE`` (flagged ``injected``, so
603
+ the TUI and the filters skip it) after the prompts: without it the
604
+ rebuilt history ends with a bare user request and the model may redo
605
+ work the summary already records. Only the automatic in-loop
606
+ compaction passes it — the manual /compact command waits for the
607
+ next user message instead.
597
608
  """
598
609
  frame = (config.COMPACT_HEADER + summary + config.COMPACT_SEPARATOR).strip()
599
- return [
610
+ messages = [
600
611
  Message(role="user", content=frame),
601
612
  *[Message(role="user", content=p) for p in prompts],
602
613
  ]
614
+ if resume:
615
+ messages.append(Message(role="user", content=config.RESUME_MESSAGE, injected=True))
616
+ return messages
603
617
 
604
618
 
605
619
  # ---- custom agent discovery -----------------------------------------------
@@ -20,26 +20,12 @@ You are an autonomous subagent. Your role is to independently complete well-defi
20
20
  You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
21
21
 
22
22
  # Tool usage instructions
23
- **Specialized Tools vs. Shell Commands (CRITICAL):**
24
- - NEVER use `Bash` for file operations with grep, find, ls, cat, head, tail, sed or awk.
25
- - ALWAYS use: `Glob`, `Grep`, `Read`, `Edit`, `Write`
26
- - Reserve `Bash` EXCLUSIVELY for: git, npm, docker, cargo, make, system services and other non-file commands
27
- - Using bash for file operations violates the tool hierarchy and creates technical debt
28
23
 
29
24
  **Parallel Tool Execution:**
30
25
  - Call multiple tools in a single response when tasks are independent
31
26
  - Never use placeholders or guess missing parameters
32
27
  - Maximize parallel execution to improve efficiency
33
28
 
34
- **Tool Selection Hierarchy:**
35
- - File search by name → Use `Glob` (NOT find or ls)
36
- - Directory listing → Use `Glob` with glob pattern `"*"` (not ls)
37
- - Content search → Use `Grep` (NOT grep or rg)
38
- - Read files → Use `Read` (NOT cat/head/tail)
39
- - Edit files → Use `Edit` (NOT sed/awk)
40
- - Write files → Use `Write` (NOT echo >/cat <<EOF)
41
- - System operations → Use `Bash` (for git, npm, docker, etc.)
42
-
43
29
  {{TOOL_INSTRUCTIONS}}
44
30
 
45
31
  # Output Requirements