python-agent-harness 1.5.6.7__tar.gz → 1.5.6.9__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 (86) hide show
  1. {python_agent_harness-1.5.6.7/python_agent_harness.egg-info → python_agent_harness-1.5.6.9}/PKG-INFO +22 -3
  2. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/README.md +21 -2
  3. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/core/tool_runner.py +13 -0
  6. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/entry/cli.py +18 -5
  7. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/entry/controller.py +109 -2
  8. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/entry/headless.py +1 -41
  9. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/entry/restore.py +6 -2
  10. python_agent_harness-1.5.6.9/python_agent_harness/hooks.py +633 -0
  11. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/io/persistence.py +9 -1
  12. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/io/text_filter.py +15 -0
  13. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/core.py +7 -3
  14. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/session/config.py +41 -1
  15. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/session/session.py +28 -1
  16. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tui/commands.py +15 -6
  17. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tui/core.py +34 -1
  18. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tui/input.py +1 -1
  19. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tui/render.py +41 -4
  20. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9/python_agent_harness.egg-info}/PKG-INFO +22 -3
  21. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness.egg-info/SOURCES.txt +1 -0
  22. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/LICENSE +0 -0
  23. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/__main__.py +0 -0
  24. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/core/__init__.py +0 -0
  25. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/core/agent.py +0 -0
  26. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/core/context_manager.py +0 -0
  27. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/core/models.py +0 -0
  28. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/core/planmode.py +0 -0
  29. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/core/subagent.py +0 -0
  30. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/core/token_estimator.py +0 -0
  31. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/entry/__init__.py +0 -0
  32. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/entry/server.py +0 -0
  33. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/entry/view.py +0 -0
  34. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/io/__init__.py +0 -0
  35. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/io/attachments.py +0 -0
  36. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/io/diffrender.py +0 -0
  37. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/llm/__init__.py +0 -0
  38. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/llm/client.py +0 -0
  39. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/mcp/__init__.py +0 -0
  40. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/mcp/client.py +0 -0
  41. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/mcp/config.py +0 -0
  42. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/mcp/manager.py +0 -0
  43. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/__init__.py +0 -0
  44. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/agent.md +0 -0
  45. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  46. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/build-switch.md +0 -0
  47. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/commands/explain.md +0 -0
  48. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/compact.md +0 -0
  49. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/initialize.md +0 -0
  50. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/plan-mode.md +0 -0
  51. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/plan.md +0 -0
  52. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/review.md +0 -0
  53. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/subagent.md +0 -0
  54. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/summary.md +0 -0
  55. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  56. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/prompts/title.md +0 -0
  57. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/session/__init__.py +0 -0
  58. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/session/commands.py +0 -0
  59. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/__init__.py +0 -0
  60. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/agent_tool.py +0 -0
  61. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/base.py +0 -0
  62. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/bash.py +0 -0
  63. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/diffapply.py +0 -0
  64. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/edit.py +0 -0
  65. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/edit_mac.py +0 -0
  66. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/filesystem.py +0 -0
  67. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/glob.py +0 -0
  68. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/glob_mac.py +0 -0
  69. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/grep.py +0 -0
  70. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/grep_mac.py +0 -0
  71. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/mcp.py +0 -0
  72. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/planexit.py +0 -0
  73. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/question.py +0 -0
  74. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/read.py +0 -0
  75. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/skill.py +0 -0
  76. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/todo.py +0 -0
  77. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tools/write.py +0 -0
  78. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tui/__init__.py +0 -0
  79. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness/tui/mouse.py +0 -0
  80. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  81. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness.egg-info/entry_points.txt +0 -0
  82. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness.egg-info/requires.txt +0 -0
  83. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/python_agent_harness.egg-info/top_level.txt +0 -0
  84. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/setup.cfg +0 -0
  85. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/tests/test_invariants.py +0 -0
  86. {python_agent_harness-1.5.6.7 → python_agent_harness-1.5.6.9}/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.7
3
+ Version: 1.5.6.9
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
@@ -72,7 +72,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
72
72
  ## Features
73
73
 
74
74
  - **FSM-driven execution** — explicit `WAIT` / `TOOL` / `TRET` / `SUPERVISE` / `DONE` / `ERRS` / `ABRT` states. Completion supervision nudges the model when it stops early; failed tool calls are sanitized. Transient API failures (`429` / `5xx`) retry with exponential backoff. Auth-expired codes (configurable via `AUTH_REFRESH_STATUS_CODES` in `config.py`; defaults to `[401, 502]`) trigger automatic API key re-read from config/env.
75
- - **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at 70% usage.
75
+ - **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at 80% usage.
76
76
  - **Image & text attachments** — attach with `@path` (e.g. `@screenshot.png`, `@README.md`): images (PNG/JPEG/GIF/WebP, up to 20 MB) become multimodal `image_url` parts; text files are inlined. Web URLs supported for images.
77
77
  - **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Edit` (incl. unified diffs), `Write`, `Bash`, `Skill`, `Question`, `PlanExit`. Read-only tool rounds (`Read`, `Glob`, `Grep`, `Skill`) dispatch concurrently; async tools (`Bash`, `Agent`) can also run concurrently. Results always delivered in emitted order.
78
78
  - **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
@@ -81,6 +81,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
81
81
  - **MCP support** — optional via `[mcp]` extra. MCP tools become ordinary agent tools (`mcp__<server>__<tool>`). Supports `stdio`, `streamable-http`, and `sse` transports.
82
82
  - **Slash commands** — built-in `/init`, `/review`, `/explain`, etc., plus custom commands from `prompts/commands/*.md`.
83
83
  - **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent files live in `prompts/agents/*.md` and support `name:` and `exclude_tools:` frontmatter.
84
+ - **Hooks** — shell commands on `tool.before` / `tool.after` / `agent.start` / `agent.end`: block risky tool calls, feed linter output back to the model, add per-turn context, notify on completion. See [Hooks](#hooks).
84
85
  - **Embeddable runtime boundaries** — `headless --json` for one-shot CI/scripting; `serve` for hosting apps (resident process, bidirectional protocol: multi-turn memory, mid-run Q&A, protocol-level cancel).
85
86
 
86
87
  ## Usage
@@ -140,7 +141,7 @@ Agent files are markdown (`.md`) in `prompts/agents/`. Each becomes a switchable
140
141
  - `name:` — override the agent name (defaults to file stem)
141
142
  - `exclude_tools:` — list of tool names to hide. Matches by exact name, glob (`mcp__git__*`), or `__`-delimited prefix (`mcp__git` hides `mcp__git__list_repos` but `Write` does NOT hide `TodoWrite`).
142
143
 
143
- **Commands vs Agents:** Commands (`/review`) are one-shot with a hardcoded kickoff and reset after run. Agents (`/agent reviewer`) persist until the next `/agent`, let the user type their own prompt, and stay active after `Ctrl-C`.
144
+ **Commands vs Agents:** Commands (`/review`) are one-shot with a hardcoded kickoff and reset after run. Agents (`/agent reviewer`) persist until the next `/agent`, let the user type their own prompt, and stay active after `Ctrl-C`. The active agent is always shown in the status bar (`[BUILD] @reviewer ...`, `@default` for the built-in).
144
145
 
145
146
  ## Configuration
146
147
 
@@ -193,10 +194,28 @@ All LLM settings live in `~/.config/python-agent-harness/config.json`. Environme
193
194
  - **`paths.context_path` / `paths.skill_path`** — locations for context files and skills. Default: project-local `contexts/` and `skills/`.
194
195
  - **`mcp.servers`** — MCP server config (requires `[mcp]` extra). Each supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, `enabled`.
195
196
  - **`headless` / `serve`** — optional budget limits: `max_rounds` (LLM rounds) and `timeout` (wall-clock seconds). `--max-rounds` / `--timeout` override per call; `<= 0` or `null` disables. Unset = unlimited.
197
+ - **`hooks`** — shell-command hooks; see [Hooks](#hooks).
196
198
  - **Precedence** — code defaults < config file < `OPENAI_*` env vars. Sub-agents also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
197
199
  - **Custom config** — `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG` env var.
198
200
  - **LLM logging** — request/response bodies logged as JSON to `/tmp/python-agent-harness-<date>-<id>.json`. Set `LLM_LOG_DIR` to change. Path printed at startup.
199
201
 
202
+ ### Hooks
203
+
204
+ Shell commands run at four points of a run, configured under `hooks` (`config --init` writes inactive examples under `hooks._examples`; `config` lists the active ones):
205
+
206
+ ```json
207
+ "hooks": {
208
+ "tool.before": [{ "matcher": "Bash", "command": "~/bin/guard.sh", "fail_closed": true }],
209
+ "tool.after": [{ "matcher": "Edit|Write", "command": "ruff check -q . >&2 || exit 2" }],
210
+ "agent.start": [{ "command": "git status --short" }],
211
+ "agent.end": [{ "command": "notify-send 'run finished'" }]
212
+ }
213
+ ```
214
+
215
+ - **Behavior** — the event arrives as JSON on stdin. Exit `2` from `tool.before` blocks the call (stderr is the reason); from `tool.after` it appends stderr to the result as feedback. `agent.start` stdout becomes turn context; `agent.end` is notify-only and gets `status` + `answer`; `tool.after` skips results discarded by `Ctrl-C`.
216
+ - **Keys** — `command` (required), `matcher` (regex on the full tool name), `timeout` (default 30 s), `fail_closed` (block if the hook itself fails), `env_passthrough` (secret env vars the hook may see; others are stripped as for Bash).
217
+ - **Safety** — failures are logged, not fed to the model (except a `fail_closed` reason), and can't crash the agent; `Ctrl-C` stops running hooks; output is capped. The payload is model-written: never `eval` it, and never print secrets (hook output reaches the model).
218
+
200
219
  ## Inspired by opencode
201
220
 
202
221
  Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core behaviors have been ported. The goal: retain its practical coding-agent workflow while keeping the implementation small and easy to customize.
@@ -38,7 +38,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
38
38
  ## Features
39
39
 
40
40
  - **FSM-driven execution** — explicit `WAIT` / `TOOL` / `TRET` / `SUPERVISE` / `DONE` / `ERRS` / `ABRT` states. Completion supervision nudges the model when it stops early; failed tool calls are sanitized. Transient API failures (`429` / `5xx`) retry with exponential backoff. Auth-expired codes (configurable via `AUTH_REFRESH_STATUS_CODES` in `config.py`; defaults to `[401, 502]`) trigger automatic API key re-read from config/env.
41
- - **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at 70% usage.
41
+ - **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at 80% usage.
42
42
  - **Image & text attachments** — attach with `@path` (e.g. `@screenshot.png`, `@README.md`): images (PNG/JPEG/GIF/WebP, up to 20 MB) become multimodal `image_url` parts; text files are inlined. Web URLs supported for images.
43
43
  - **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Edit` (incl. unified diffs), `Write`, `Bash`, `Skill`, `Question`, `PlanExit`. Read-only tool rounds (`Read`, `Glob`, `Grep`, `Skill`) dispatch concurrently; async tools (`Bash`, `Agent`) can also run concurrently. Results always delivered in emitted order.
44
44
  - **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
@@ -47,6 +47,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
47
47
  - **MCP support** — optional via `[mcp]` extra. MCP tools become ordinary agent tools (`mcp__<server>__<tool>`). Supports `stdio`, `streamable-http`, and `sse` transports.
48
48
  - **Slash commands** — built-in `/init`, `/review`, `/explain`, etc., plus custom commands from `prompts/commands/*.md`.
49
49
  - **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent files live in `prompts/agents/*.md` and support `name:` and `exclude_tools:` frontmatter.
50
+ - **Hooks** — shell commands on `tool.before` / `tool.after` / `agent.start` / `agent.end`: block risky tool calls, feed linter output back to the model, add per-turn context, notify on completion. See [Hooks](#hooks).
50
51
  - **Embeddable runtime boundaries** — `headless --json` for one-shot CI/scripting; `serve` for hosting apps (resident process, bidirectional protocol: multi-turn memory, mid-run Q&A, protocol-level cancel).
51
52
 
52
53
  ## Usage
@@ -106,7 +107,7 @@ Agent files are markdown (`.md`) in `prompts/agents/`. Each becomes a switchable
106
107
  - `name:` — override the agent name (defaults to file stem)
107
108
  - `exclude_tools:` — list of tool names to hide. Matches by exact name, glob (`mcp__git__*`), or `__`-delimited prefix (`mcp__git` hides `mcp__git__list_repos` but `Write` does NOT hide `TodoWrite`).
108
109
 
109
- **Commands vs Agents:** Commands (`/review`) are one-shot with a hardcoded kickoff and reset after run. Agents (`/agent reviewer`) persist until the next `/agent`, let the user type their own prompt, and stay active after `Ctrl-C`.
110
+ **Commands vs Agents:** Commands (`/review`) are one-shot with a hardcoded kickoff and reset after run. Agents (`/agent reviewer`) persist until the next `/agent`, let the user type their own prompt, and stay active after `Ctrl-C`. The active agent is always shown in the status bar (`[BUILD] @reviewer ...`, `@default` for the built-in).
110
111
 
111
112
  ## Configuration
112
113
 
@@ -159,10 +160,28 @@ All LLM settings live in `~/.config/python-agent-harness/config.json`. Environme
159
160
  - **`paths.context_path` / `paths.skill_path`** — locations for context files and skills. Default: project-local `contexts/` and `skills/`.
160
161
  - **`mcp.servers`** — MCP server config (requires `[mcp]` extra). Each supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, `enabled`.
161
162
  - **`headless` / `serve`** — optional budget limits: `max_rounds` (LLM rounds) and `timeout` (wall-clock seconds). `--max-rounds` / `--timeout` override per call; `<= 0` or `null` disables. Unset = unlimited.
163
+ - **`hooks`** — shell-command hooks; see [Hooks](#hooks).
162
164
  - **Precedence** — code defaults < config file < `OPENAI_*` env vars. Sub-agents also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
163
165
  - **Custom config** — `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG` env var.
164
166
  - **LLM logging** — request/response bodies logged as JSON to `/tmp/python-agent-harness-<date>-<id>.json`. Set `LLM_LOG_DIR` to change. Path printed at startup.
165
167
 
168
+ ### Hooks
169
+
170
+ Shell commands run at four points of a run, configured under `hooks` (`config --init` writes inactive examples under `hooks._examples`; `config` lists the active ones):
171
+
172
+ ```json
173
+ "hooks": {
174
+ "tool.before": [{ "matcher": "Bash", "command": "~/bin/guard.sh", "fail_closed": true }],
175
+ "tool.after": [{ "matcher": "Edit|Write", "command": "ruff check -q . >&2 || exit 2" }],
176
+ "agent.start": [{ "command": "git status --short" }],
177
+ "agent.end": [{ "command": "notify-send 'run finished'" }]
178
+ }
179
+ ```
180
+
181
+ - **Behavior** — the event arrives as JSON on stdin. Exit `2` from `tool.before` blocks the call (stderr is the reason); from `tool.after` it appends stderr to the result as feedback. `agent.start` stdout becomes turn context; `agent.end` is notify-only and gets `status` + `answer`; `tool.after` skips results discarded by `Ctrl-C`.
182
+ - **Keys** — `command` (required), `matcher` (regex on the full tool name), `timeout` (default 30 s), `fail_closed` (block if the hook itself fails), `env_passthrough` (secret env vars the hook may see; others are stripped as for Bash).
183
+ - **Safety** — failures are logged, not fed to the model (except a `fail_closed` reason), and can't crash the agent; `Ctrl-C` stops running hooks; output is capped. The payload is model-written: never `eval` it, and never print secrets (hook output reaches the model).
184
+
166
185
  ## Inspired by opencode
167
186
 
168
187
  Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core behaviors have been ported. The goal: retain its practical coding-agent workflow while keeping the implementation small and easy to customize.
@@ -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.7"
7
+ version = "1.5.6.9"
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.7"
10
+ __version__ = "1.5.6.9"
11
11
 
12
12
  __all__ = [
13
13
  "Client",
@@ -97,11 +97,24 @@ class ToolRunner:
97
97
  missing = [k for k in required if k not in args]
98
98
  if missing:
99
99
  return f"Error: {call.name} is missing required argument(s): {', '.join(missing)}"
100
+ hooks = getattr(loop.session, "hooks", None) # test doubles may lack it
101
+ if hooks is not None:
102
+ blocked = hooks.before_tool(call.name, args, call.id, not loop.top_level)
103
+ if isinstance(blocked, str): # not just "not None": hooks may be replaced
104
+ return blocked
100
105
  return loop.session.execute_tool(call.name, args, call_id=call.id)
101
106
 
102
107
  def deliver_tool_result(self, p: ToolCall, result: str) -> None:
103
108
  """Append one tool result message for call P (parent thread only)."""
104
109
  loop = self.loop
110
+ # tool.after: the result is final here for sync AND async tools,
111
+ # and delivery is sequential in call order on the loop's thread.
112
+ # HookRunner never raises (its own boundary): no handling here.
113
+ hooks = getattr(loop.session, "hooks", None) # test doubles may lack it
114
+ if hooks is not None:
115
+ final = hooks.after_tool(p.name, p.arguments, p.id, not loop.top_level, result)
116
+ if isinstance(final, str): # a replaced hooks object may return anything
117
+ result = final
105
118
  p.result = result
106
119
  if hasattr(loop.session, "take_diff"):
107
120
  p.diff = loop.session.take_diff(p.id)
@@ -45,6 +45,9 @@ def make_session(
45
45
  settings = config.load_llm_config(config_path)
46
46
  paths = config.load_paths_config(config_path)
47
47
  mcp_config = config.load_mcp_config(config_path)
48
+ # validated before anything is built: an invalid hooks section stops
49
+ # startup (a configured guard must never be silently dropped)
50
+ hook_specs = config.load_hooks(config_path)
48
51
  model = model or settings["model"]
49
52
  # resolve sub-agent overrides against the EFFECTIVE main settings
50
53
  # (so a CLI/caller model override is inherited too when the
@@ -139,13 +142,10 @@ def make_session(
139
142
  llm_settings=llm_settings,
140
143
  config_path=config_path,
141
144
  )
145
+ session.install_hooks(hook_specs)
142
146
  if default_agent and default_agent != RESERVED_AGENT_NAME:
143
147
  ok, msg = session.switch_agent(default_agent)
144
- if ok:
145
- # record the applied agent so saved sessions restore it;
146
- # switch_agent("default") clears it again
147
- session.store.agent = default_agent
148
- else:
148
+ if not ok:
149
149
  # A typo'd/missing default_agent must not silently start the
150
150
  # session with the built-in prompt — record the config error
151
151
  # so the TUI can surface it (see Tui.run).
@@ -319,6 +319,19 @@ def cmd_config(args: argparse.Namespace) -> int:
319
319
  )
320
320
  else:
321
321
  print('mcp: (none configured — add an "mcp" section to the config file)')
322
+ try:
323
+ hooks = config.load_hooks(args.path)
324
+ except ValueError as e:
325
+ print(f"hooks: INVALID — {e}")
326
+ else:
327
+ if hooks:
328
+ print("hooks:")
329
+ for h in hooks:
330
+ matcher = f" [{h.matcher.pattern}]" if h.matcher else ""
331
+ flags = ", fail_closed" if h.fail_closed else ""
332
+ print(f" {h.event}{matcher}: {h.command} (timeout={h.timeout:g}s{flags})")
333
+ else:
334
+ print('hooks: (none configured — add a "hooks" section to the config file)')
322
335
  print(
323
336
  "subagent_llm: (inherits main)"
324
337
  if subagent_settings == settings
@@ -23,8 +23,11 @@ from dataclasses import dataclass
23
23
  from typing import Any
24
24
 
25
25
  from ..core.agent import run_agent_loop
26
- from ..core.models import ImagePart, Message, TextPart
26
+ from ..core.models import ImagePart, Message, TextPart, api_safe
27
+ from ..hooks import RunStatus
27
28
  from ..io.attachments import AttachmentError, parse_at_references
29
+ from ..io.text_filter import strip_final_check
30
+ from ..session import config
28
31
  from ..session.session import Session
29
32
  from .view import View
30
33
 
@@ -51,6 +54,45 @@ class RunHandle:
51
54
  warnings: list[str]
52
55
 
53
56
 
57
+ def final_answer_text(session: Any) -> str:
58
+ """The run's final assistant answer, TUI-filtered.
59
+
60
+ Scans the session history backwards for the last assistant message
61
+ whose text survives the filters (reasoning preamble and the
62
+ trailing [FINAL CHECK] block). Models following the
63
+ task-completion rules often end with a SEPARATE assistant message
64
+ holding only the [FINAL CHECK] block, so a check-only message
65
+ strips to empty and must be skipped in favor of the real answer
66
+ before it. Empty when no assistant message has visible text
67
+ (e.g. the run errored out).
68
+
69
+ The scan stops at THIS run's prompt -- the newest user message the
70
+ harness did not inject itself (nudges, plan reminders and mid-run
71
+ prompts are ``injected``; compaction re-adds the prompts as plain
72
+ user messages, so the run's own prompt is still the newest one).
73
+ History persists across runs (``serve``) and restored sessions
74
+ (``--restore``), so without the boundary a run that was cancelled
75
+ or died before answering reported the PREVIOUS run's answer as its
76
+ own -- and defeated serve's "run produced no answer" check.
77
+ """
78
+ for msg in reversed(session.last_messages):
79
+ role = getattr(msg, "role", None)
80
+ if role == "user" and not getattr(msg, "injected", False):
81
+ break
82
+ if role != "assistant":
83
+ continue
84
+ text = strip_final_check(msg.text_without_reasoning()).strip()
85
+ if text:
86
+ # api_safe: the answer is written to stdout (headless prints it
87
+ # verbatim; --json and serve JSON-encode it), and a lone
88
+ # surrogate from a restored non-UTF-8 session raised
89
+ # UnicodeEncodeError there — headless died before writing the
90
+ # result line, and serve's _write_line SWALLOWED it (it is a
91
+ # ValueError), reporting success with an empty stream.
92
+ return api_safe(text)
93
+ return ""
94
+
95
+
54
96
  class Controller:
55
97
  """Mediator between a ``Session`` (model) and a ``View``.
56
98
 
@@ -367,9 +409,23 @@ class Controller:
367
409
  staleness guard; the cancel event no longer blocks the
368
410
  adoption).
369
411
  """
412
+ hooks = getattr(self.session, "hooks", None) # test doubles may lack it
413
+ answer: str | None = None
414
+ crashed = False
370
415
  try:
416
+ context = hooks.agent_start(user_msg.text()) if hooks is not None else None
417
+ if isinstance(context, str) and context: # hooks may be a replaced object
418
+ # its own injected message BEFORE the user's: the request
419
+ # stays the last thing the model reads (where queued
420
+ # prompts expect it), and title generation / compaction
421
+ # skip it like other harness-injected messages
422
+ self.conversation_history.append(
423
+ Message(
424
+ role="user", content=config.HOOK_CONTEXT_HEADER + context, injected=True
425
+ )
426
+ )
371
427
  self.conversation_history.append(user_msg)
372
- run_agent_loop(
428
+ answer = run_agent_loop(
373
429
  self.session,
374
430
  messages=list(self.conversation_history),
375
431
  top_level=True,
@@ -381,9 +437,17 @@ class Controller:
381
437
  if seq == self.session.run_generation and self.session.last_messages:
382
438
  self.conversation_history = list(self.session.last_messages)
383
439
  except Exception as e: # noqa: BLE001
440
+ crashed = True
384
441
  if seq == self.session.run_generation:
385
442
  self.session.log(f"agent error: {e}")
386
443
  finally:
444
+ # every run that started gets its end event (a superseded
445
+ # one too -- the payload carries its own text). agent_end
446
+ # never raises and returns at once (its hooks run on their
447
+ # own thread); answer/status are computed inside its
448
+ # boundary, so run_finished/restore below always run
449
+ if hooks is not None:
450
+ hooks.agent_end(user_msg.text(), lambda: self._end_outcome(seq, answer, crashed))
387
451
  if seq == self.session.run_generation:
388
452
  # the run is done: the final assistant message is now
389
453
  # part of the conversation history, so the view can drop
@@ -392,3 +456,46 @@ class Controller:
392
456
  self.session.notify("run_finished")
393
457
  if restore is not None:
394
458
  restore()
459
+
460
+ def _end_outcome(
461
+ self, seq: int, raw: str | None, crashed: bool
462
+ ) -> tuple[str | None, RunStatus]:
463
+ """``(answer, status)`` for the agent.end hook."""
464
+ status = self._run_status(seq, raw, crashed)
465
+ return self._end_answer(seq, raw, status), status
466
+
467
+ def _end_answer(self, seq: int, raw: str | None, status: RunStatus) -> str | None:
468
+ """The ``answer`` for agent.end: what the user was shown.
469
+
470
+ The loop's raw result can carry the reasoning preamble, or be
471
+ only the [FINAL CHECK] block of a supervised run, so the current
472
+ run's answer is extracted like headless/serve do. An ``error``
473
+ run reports its error text instead; a superseded run's history
474
+ already belongs to the next run, so only its raw result (with
475
+ the check block dropped) is available.
476
+ """
477
+ if status == "error":
478
+ return raw
479
+ if status == "superseded":
480
+ if raw is None:
481
+ return None
482
+ return strip_final_check(raw).strip() or None
483
+ return final_answer_text(self.session) or None
484
+
485
+ def _run_status(self, seq: int, answer: str | None, crashed: bool) -> RunStatus:
486
+ """How a run ended, for the agent.end hook (see ``hooks.RunStatus``).
487
+
488
+ Derived here rather than from the loop's FSM state, which
489
+ ``run_agent_loop`` does not expose: superseded beats cancelled
490
+ (a newer run clears the cancel flag); an ``Error: ...`` result
491
+ is how the loop reports a failed LLM request (and how the TUI
492
+ recognizes failures).
493
+ """
494
+ if seq != self.session.run_generation:
495
+ return "superseded"
496
+ cancel_event = getattr(self.session, "cancel_event", None)
497
+ if cancel_event is not None and cancel_event.is_set():
498
+ return "cancelled"
499
+ if crashed or (answer is not None and answer.startswith("Error:")):
500
+ return "error"
501
+ return "done"
@@ -23,9 +23,8 @@ import threading
23
23
  from typing import Any, TextIO
24
24
 
25
25
  from ..core.models import api_safe
26
- from ..io.text_filter import strip_final_check
27
26
  from ..session.session import Session
28
- from .controller import Controller
27
+ from .controller import Controller, final_answer_text # noqa: F401 (re-exported)
29
28
  from .view import FilteredDeltaStream
30
29
 
31
30
  # Exec-stream protocol version: rides on every ``start`` and ``result``
@@ -156,45 +155,6 @@ def usage_snapshot(session: Any) -> dict[str, Any] | None:
156
155
  return usage
157
156
 
158
157
 
159
- def final_answer_text(session: Session) -> str:
160
- """The run's final assistant answer, TUI-filtered.
161
-
162
- Scans the session history backwards for the last assistant message
163
- whose text survives the filters (reasoning preamble and the
164
- trailing [FINAL CHECK] block). Models following the
165
- task-completion rules often end with a SEPARATE assistant message
166
- holding only the [FINAL CHECK] block, so a check-only message
167
- strips to empty and must be skipped in favor of the real answer
168
- before it. Empty when no assistant message has visible text
169
- (e.g. the run errored out).
170
-
171
- The scan stops at THIS run's prompt -- the newest user message the
172
- harness did not inject itself (nudges, plan reminders and mid-run
173
- prompts are ``injected``; compaction re-adds the prompts as plain
174
- user messages, so the run's own prompt is still the newest one).
175
- History persists across runs (``serve``) and restored sessions
176
- (``--restore``), so without the boundary a run that was cancelled
177
- or died before answering reported the PREVIOUS run's answer as its
178
- own -- and defeated serve's "run produced no answer" check.
179
- """
180
- for msg in reversed(session.last_messages):
181
- role = getattr(msg, "role", None)
182
- if role == "user" and not getattr(msg, "injected", False):
183
- break
184
- if role != "assistant":
185
- continue
186
- text = strip_final_check(msg.text_without_reasoning()).strip()
187
- if text:
188
- # api_safe: the answer is written to stdout (headless prints it
189
- # verbatim; --json and serve JSON-encode it), and a lone
190
- # surrogate from a restored non-UTF-8 session raised
191
- # UnicodeEncodeError there — headless died before writing the
192
- # result line, and serve's _write_line SWALLOWED it (it is a
193
- # ValueError), reporting success with an empty stream.
194
- return api_safe(text)
195
- return ""
196
-
197
-
198
158
  def _restorable_handler(previous: Any) -> Any:
199
159
  """A handler value ``signal.signal`` will accept back.
200
160
 
@@ -79,8 +79,12 @@ def load_saved_session(path: str) -> SavedSession:
79
79
 
80
80
 
81
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."""
82
+ """Switch to the saved AGENT; keep the current agent when the session
83
+ was saved without one (pre-agent-era file), and fall back to the
84
+ default agent (so no stale tool exclusions linger) when the saved
85
+ name no longer exists. A session saved on the default agent records
86
+ ``default`` explicitly (see SessionPersistence.metadata_block), so it
87
+ still switches back even when a custom agent is active now."""
84
88
  if not agent:
85
89
  return
86
90
  try: