hx-cli 0.2.9__tar.gz → 0.2.11__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.
- {hx_cli-0.2.9 → hx_cli-0.2.11}/CHANGELOG.md +37 -1
- {hx_cli-0.2.9 → hx_cli-0.2.11}/PKG-INFO +9 -2
- {hx_cli-0.2.9 → hx_cli-0.2.11}/README.md +8 -1
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/agents/subagent.py +6 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/cli.py +3 -1
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/context.py +33 -12
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/events.py +3 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/loop.py +118 -19
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/messages.py +58 -1
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/paths.py +44 -3
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/bash.py +23 -5
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/commands.py +8 -3
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/renderers.py +5 -3
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/runtime.py +21 -18
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/blocks.py +17 -1
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/pickers.py +21 -2
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/stub.py +33 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/terminal.py +11 -2
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_commands.py +73 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_extend.py +50 -1
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_tools.py +91 -1
- {hx_cli-0.2.9 → hx_cli-0.2.11}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/.gitignore +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/install.sh +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/pyproject.toml +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/config.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/docs.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/git.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/keys.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/net.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/trace.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/conftest.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/test_live.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.9 → hx_cli-0.2.11}/uv.lock +0 -0
|
@@ -28,6 +28,40 @@ the project follows [semantic versioning](https://semver.org/).
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
+
## [0.2.11] - 2026-10-01
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- `esc` while a tool runs no longer breaks the session: the next question is
|
|
36
|
+
answered instead of every later turn failing. A session that was interrupted
|
|
37
|
+
or killed mid-tool before this release also carries on after `hx resume`.
|
|
38
|
+
- `esc` during a `Bash` command stops the command. The next `Bash` call gets its
|
|
39
|
+
own output and exit code, not what was left of the one stopped.
|
|
40
|
+
- An interrupted call keeps what it printed, on screen and for the model, and
|
|
41
|
+
reads "Interrupted after <time>" instead of looking like a failure.
|
|
42
|
+
- A tool that finished keeps its result when `esc` lands while its
|
|
43
|
+
`PostToolUse` hook runs.
|
|
44
|
+
- `Bash` shows a short line such as "Starting..." as soon as it is printed,
|
|
45
|
+
rather than only when more output follows.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## [0.2.10] - 2026-09-29
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- `AGENTS.md` at the repository root now applies when HX is started in a
|
|
54
|
+
subdirectory, followed by each `AGENTS.md` down to that directory.
|
|
55
|
+
- Subagents started with `Task` now follow `~/.hx/AGENTS.md` and the project's
|
|
56
|
+
`AGENTS.md`, as the main session does.
|
|
57
|
+
- A saved `/effort` level shows on the status bar from the first screen of every
|
|
58
|
+
new session, not only after `/effort` is run again.
|
|
59
|
+
- `ctrl+l` switches the model and saves it for later sessions, as `/model` does;
|
|
60
|
+
it used to relabel the status bar only.
|
|
61
|
+
- `/model`, `ctrl+l` and `/effort` open with the cursor on the choice in force.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
31
65
|
## [0.2.9] - 2026-09-27
|
|
32
66
|
|
|
33
67
|
### Added
|
|
@@ -742,7 +776,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
742
776
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
743
777
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
744
778
|
|
|
745
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
779
|
+
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.11...HEAD
|
|
780
|
+
[0.2.11]: https://github.com/aletisunil/hx/compare/v0.2.10...v0.2.11
|
|
781
|
+
[0.2.10]: https://github.com/aletisunil/hx/compare/v0.2.9...v0.2.10
|
|
746
782
|
[0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
|
|
747
783
|
[0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
|
|
748
784
|
[0.2.7]: https://github.com/aletisunil/hx/compare/v0.2.6...v0.2.7
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.11
|
|
4
4
|
Summary: HX - an agent harness for the terminal
|
|
5
5
|
Project-URL: Homepage, https://hx.sunilaleti.dev
|
|
6
6
|
Project-URL: Documentation, https://hx.sunilaleti.dev
|
|
@@ -762,7 +762,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
762
762
|
| `./.hx/mcp.json` | project MCP servers |
|
|
763
763
|
| `./.hx/system-prompt.md`, `./.hx/system-prompt-append.md` | project prompt overrides |
|
|
764
764
|
| `./.hx/skills/`, `./.hx/agents/` | project skills and agents |
|
|
765
|
-
|
|
|
765
|
+
| `AGENTS.md` | project instructions: the repository root's, then each one down to where HX started |
|
|
766
766
|
|
|
767
767
|
`AGENTS.md` is the place for things a newcomer would get wrong: how to run the
|
|
768
768
|
tests, conventions, what not to touch. `/init` writes a first draft. It is
|
|
@@ -774,6 +774,13 @@ loaded first and the project's `AGENTS.md` after it, so where the two disagree
|
|
|
774
774
|
the project has the last word. The first session creates it empty, ready to
|
|
775
775
|
fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
|
|
776
776
|
|
|
777
|
+
Started below the repository root - in `services/billing`, say - HX still loads
|
|
778
|
+
the root's `AGENTS.md`, then every `AGENTS.md` on the way down to that directory,
|
|
779
|
+
the nearest last, so a package can refine the repository's rules. A submodule
|
|
780
|
+
counts as part of the repository around it. Nothing above the repository root is
|
|
781
|
+
read; outside a repository only the current directory is.
|
|
782
|
+
Subagents run under the same files as the session that started them.
|
|
783
|
+
|
|
777
784
|
---
|
|
778
785
|
|
|
779
786
|
## Safety
|
|
@@ -722,7 +722,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
722
722
|
| `./.hx/mcp.json` | project MCP servers |
|
|
723
723
|
| `./.hx/system-prompt.md`, `./.hx/system-prompt-append.md` | project prompt overrides |
|
|
724
724
|
| `./.hx/skills/`, `./.hx/agents/` | project skills and agents |
|
|
725
|
-
|
|
|
725
|
+
| `AGENTS.md` | project instructions: the repository root's, then each one down to where HX started |
|
|
726
726
|
|
|
727
727
|
`AGENTS.md` is the place for things a newcomer would get wrong: how to run the
|
|
728
728
|
tests, conventions, what not to touch. `/init` writes a first draft. It is
|
|
@@ -734,6 +734,13 @@ loaded first and the project's `AGENTS.md` after it, so where the two disagree
|
|
|
734
734
|
the project has the last word. The first session creates it empty, ready to
|
|
735
735
|
fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
|
|
736
736
|
|
|
737
|
+
Started below the repository root - in `services/billing`, say - HX still loads
|
|
738
|
+
the root's `AGENTS.md`, then every `AGENTS.md` on the way down to that directory,
|
|
739
|
+
the nearest last, so a package can refine the repository's rules. A submodule
|
|
740
|
+
counts as part of the repository around it. Nothing above the repository root is
|
|
741
|
+
read; outside a repository only the current directory is.
|
|
742
|
+
Subagents run under the same files as the session that started them.
|
|
743
|
+
|
|
737
744
|
---
|
|
738
745
|
|
|
739
746
|
## Safety
|
|
@@ -59,6 +59,7 @@ class SubagentRunner:
|
|
|
59
59
|
parent_session_id: str | None = None,
|
|
60
60
|
parent_usage: object | None = None,
|
|
61
61
|
hooks: HookEngine | None = None,
|
|
62
|
+
project_context: str | None = None,
|
|
62
63
|
) -> None:
|
|
63
64
|
self.definitions = definitions
|
|
64
65
|
self.provider = provider
|
|
@@ -72,6 +73,10 @@ class SubagentRunner:
|
|
|
72
73
|
self.hooks = hooks
|
|
73
74
|
"""Shared with the parent: a hook that guards Bash must guard a subagent's
|
|
74
75
|
Bash too, or the guard is one delegation away from being bypassed."""
|
|
76
|
+
self.project_context = project_context
|
|
77
|
+
"""The parent's project preamble, AGENTS.md included. A subagent edits the
|
|
78
|
+
same project under the same rules; without it "never add unit tests" is
|
|
79
|
+
one delegation away from being ignored."""
|
|
75
80
|
self._active: dict[str, str] = {}
|
|
76
81
|
|
|
77
82
|
async def run(self, agent_type: str, prompt: str, description: str) -> SubagentResult:
|
|
@@ -160,6 +165,7 @@ class SubagentRunner:
|
|
|
160
165
|
bus=self.bus,
|
|
161
166
|
settings=self.settings,
|
|
162
167
|
model_info=self.models.get_or_default(model) if self.models else None,
|
|
168
|
+
project_context=self.project_context,
|
|
163
169
|
hooks=self.hooks,
|
|
164
170
|
)
|
|
165
171
|
loop.origin = f"{definition.name} subagent"
|
|
@@ -436,6 +436,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
436
436
|
keep_recent_turns=settings.context.keep_recent_turns,
|
|
437
437
|
)
|
|
438
438
|
instructions = load_instructions(settings.cwd)
|
|
439
|
+
project_context = build_project_context(settings.cwd, instructions)
|
|
439
440
|
|
|
440
441
|
checkpoints = CheckpointStore(session, session_checkpoints_dir(session.meta.session_id))
|
|
441
442
|
tools = build_default_registry(shell, jobs, tracker, todos, bus_holder, auth, checkpoints)
|
|
@@ -464,6 +465,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
464
465
|
parent_session_id=session.meta.session_id,
|
|
465
466
|
parent_usage=session.usage,
|
|
466
467
|
hooks=hooks,
|
|
468
|
+
project_context=project_context,
|
|
467
469
|
)
|
|
468
470
|
tools.register(TaskTool(subagents))
|
|
469
471
|
|
|
@@ -484,7 +486,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
484
486
|
settings=settings,
|
|
485
487
|
model_info=model_info,
|
|
486
488
|
skills_index=build_index(list(skills.values())) or None,
|
|
487
|
-
project_context=
|
|
489
|
+
project_context=project_context,
|
|
488
490
|
instructions=instructions,
|
|
489
491
|
hooks=hooks,
|
|
490
492
|
)
|
|
@@ -10,7 +10,8 @@ Layout, in order::
|
|
|
10
10
|
[1] system prompt static for the session
|
|
11
11
|
[2] tool schemas deterministic sort: builtins, then mcp__* alphabetical
|
|
12
12
|
[3] skills index name + description only (progressive disclosure)
|
|
13
|
-
[4] project context cwd, git branch, top-level listing, ~/.hx/AGENTS.md,
|
|
13
|
+
[4] project context cwd, git branch, top-level listing, ~/.hx/AGENTS.md,
|
|
14
|
+
each AGENTS.md from the repository root down to cwd
|
|
14
15
|
--- breakpoint A (static) ---
|
|
15
16
|
[5] conversation history
|
|
16
17
|
--- breakpoint B (rolling, before the last few turns) ---
|
|
@@ -250,20 +251,26 @@ class Instructions:
|
|
|
250
251
|
def load_instructions(cwd: Path) -> list[Instructions]:
|
|
251
252
|
"""Every AGENTS.md in force, least specific first.
|
|
252
253
|
|
|
253
|
-
``~/.hx/AGENTS.md`` follows the user into every project;
|
|
254
|
-
``AGENTS.md``
|
|
255
|
-
one has the last word. A blank or
|
|
256
|
-
|
|
254
|
+
``~/.hx/AGENTS.md`` follows the user into every project; then each
|
|
255
|
+
``AGENTS.md`` from the repository root down to ``cwd``. Where two disagree
|
|
256
|
+
the more specific one comes later and has the last word. A blank or
|
|
257
|
+
unreadable file is skipped, and a path reached twice - running from inside
|
|
258
|
+
``$HX_HOME``, say - is loaded once.
|
|
257
259
|
"""
|
|
258
|
-
from hx.paths import
|
|
260
|
+
from hx.paths import project_instructions_files, project_root, tilde, user_instructions_file
|
|
259
261
|
|
|
260
262
|
user = user_instructions_file()
|
|
263
|
+
here = cwd.resolve()
|
|
264
|
+
root = project_root(here)
|
|
265
|
+
base = root or here
|
|
266
|
+
candidates = [(f"User instructions ({tilde(user)})", user)]
|
|
267
|
+
for path in project_instructions_files(here, root):
|
|
268
|
+
where = path.relative_to(base).as_posix()
|
|
269
|
+
candidates.append((f"Project instructions ({where})", path))
|
|
270
|
+
|
|
261
271
|
found: list[Instructions] = []
|
|
262
272
|
seen: set[Path] = set()
|
|
263
|
-
for heading, path in
|
|
264
|
-
(f"User instructions ({tilde(user)})", user),
|
|
265
|
-
("Project instructions (AGENTS.md)", project_instructions_file(cwd)),
|
|
266
|
-
):
|
|
273
|
+
for heading, path in candidates:
|
|
267
274
|
body = _read_prompt_file(path)
|
|
268
275
|
if not body or path.resolve() in seen:
|
|
269
276
|
continue
|
|
@@ -272,6 +279,17 @@ def load_instructions(cwd: Path) -> list[Instructions]:
|
|
|
272
279
|
return found
|
|
273
280
|
|
|
274
281
|
|
|
282
|
+
INSTRUCTIONS_PREAMBLE = (
|
|
283
|
+
"The sections below are AGENTS.md files: standing instructions from the user "
|
|
284
|
+
"and this project, already loaded in full - there is no need to read them "
|
|
285
|
+
"again. Follow them over your own defaults and habits. Where two disagree, "
|
|
286
|
+
"the later, more specific one wins."
|
|
287
|
+
)
|
|
288
|
+
"""Said once, ahead of the files. Without it a rule like "no unit tests" reads
|
|
289
|
+
as background next to a request to make something "well covered", and the
|
|
290
|
+
model re-reads the file it was already given to find out whether it counts."""
|
|
291
|
+
|
|
292
|
+
|
|
275
293
|
def build_project_context(cwd: Path, instructions: list[Instructions] | None = None) -> str:
|
|
276
294
|
"""Static per-session project preamble: cwd, git branch, listing, AGENTS.md contents.
|
|
277
295
|
|
|
@@ -291,8 +309,11 @@ def build_project_context(cwd: Path, instructions: list[Instructions] | None = N
|
|
|
291
309
|
if entries:
|
|
292
310
|
lines.append("Top level: " + ", ".join(entries[:60]))
|
|
293
311
|
|
|
294
|
-
|
|
295
|
-
|
|
312
|
+
loaded = load_instructions(cwd) if instructions is None else instructions
|
|
313
|
+
if loaded:
|
|
314
|
+
lines.append(f"\n{INSTRUCTIONS_PREAMBLE}")
|
|
315
|
+
for entry in loaded:
|
|
316
|
+
lines.append(f"\n# {entry.heading}\n\n{entry.text}")
|
|
296
317
|
|
|
297
318
|
return "\n".join(lines)
|
|
298
319
|
|
|
@@ -67,6 +67,9 @@ class ToolCallFinished(Event):
|
|
|
67
67
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
68
68
|
"""Structured detail the renderer draws with - an edit's diff, a command's
|
|
69
69
|
exit code. Presentation only: the model never sees it."""
|
|
70
|
+
interrupted: bool = False
|
|
71
|
+
"""The user stopped the turn while this call ran. Whatever it streamed is
|
|
72
|
+
kept: how far a command got is what the user wants to see."""
|
|
70
73
|
|
|
71
74
|
|
|
72
75
|
@dataclass(slots=True)
|
|
@@ -42,7 +42,9 @@ from hx.core.messages import (
|
|
|
42
42
|
ToolResultBlock,
|
|
43
43
|
ToolUseBlock,
|
|
44
44
|
UserTurn,
|
|
45
|
+
answer_unanswered_calls,
|
|
45
46
|
assistant_message,
|
|
47
|
+
interrupted_result,
|
|
46
48
|
tool_result_message,
|
|
47
49
|
user_message,
|
|
48
50
|
)
|
|
@@ -50,7 +52,7 @@ from hx.core.session import Environment
|
|
|
50
52
|
from hx.core.usage import TurnUsage, compute_cost
|
|
51
53
|
from hx.hooks.spec import HookOutcome
|
|
52
54
|
from hx.providers.base import ProviderError, ProviderRequest, StreamDelta, StreamEnd
|
|
53
|
-
from hx.tools.base import ToolContext
|
|
55
|
+
from hx.tools.base import ToolContext, ToolResult
|
|
54
56
|
|
|
55
57
|
if TYPE_CHECKING:
|
|
56
58
|
from collections.abc import Awaitable, Callable, Sequence
|
|
@@ -225,6 +227,27 @@ class AgentLoop:
|
|
|
225
227
|
self.bus.publish(TurnFinished(self._turn_index, stop_reason))
|
|
226
228
|
|
|
227
229
|
calls = message.tool_uses()
|
|
230
|
+
if calls and self._cancelled:
|
|
231
|
+
# Stopped between the answer and its tools: none of them ran,
|
|
232
|
+
# and the transcript has to say so before the turn ends - as
|
|
233
|
+
# does the screen, which a resumed session draws them on too.
|
|
234
|
+
for call in calls:
|
|
235
|
+
self.bus.publish(
|
|
236
|
+
ToolCallStarted(
|
|
237
|
+
tool_use_id=call.id,
|
|
238
|
+
name=self._display_name(call.name),
|
|
239
|
+
input=call.input,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
self.bus.publish(
|
|
243
|
+
ToolCallFinished(
|
|
244
|
+
tool_use_id=call.id,
|
|
245
|
+
is_error=True,
|
|
246
|
+
summary="interrupted",
|
|
247
|
+
interrupted=True,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
produced.append(self._answer_calls(calls, {}))
|
|
228
251
|
if not calls or self._cancelled:
|
|
229
252
|
# A steer that arrived during the last stretch is the next thing
|
|
230
253
|
# the user said, so the conversation continues rather than
|
|
@@ -234,10 +257,15 @@ class AgentLoop:
|
|
|
234
257
|
await self._name_session()
|
|
235
258
|
return TurnResult(stop_reason, produced)
|
|
236
259
|
|
|
237
|
-
results =
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
260
|
+
results: dict[str, ToolResultBlock] = {}
|
|
261
|
+
try:
|
|
262
|
+
await self._execute_tools(calls, results)
|
|
263
|
+
finally:
|
|
264
|
+
# Also on interrupt. The calls are already in the transcript,
|
|
265
|
+
# and a call left without a result is rejected by every route on
|
|
266
|
+
# the next request - so every later turn in the session would
|
|
267
|
+
# fail the same way. What finished keeps its real result.
|
|
268
|
+
produced.append(self._answer_calls(calls, results))
|
|
241
269
|
|
|
242
270
|
self.bus.publish(
|
|
243
271
|
ErrorRaised(message=f"Stopped after {self.MAX_TURNS} turns", recoverable=True)
|
|
@@ -459,7 +487,9 @@ class AgentLoop:
|
|
|
459
487
|
)
|
|
460
488
|
|
|
461
489
|
async def _build_request(self) -> ProviderRequest:
|
|
462
|
-
messages =
|
|
490
|
+
messages = answer_unanswered_calls(
|
|
491
|
+
await self.injections.apply(self.session.active_messages())
|
|
492
|
+
)
|
|
463
493
|
if self.model_info is None or not self.model_info.supports_images:
|
|
464
494
|
from hx.core.images import without_images
|
|
465
495
|
|
|
@@ -570,31 +600,48 @@ class AgentLoop:
|
|
|
570
600
|
)
|
|
571
601
|
)
|
|
572
602
|
|
|
573
|
-
async def _execute_tools(
|
|
603
|
+
async def _execute_tools(
|
|
604
|
+
self, calls: list[ToolUseBlock], results: dict[str, ToolResultBlock]
|
|
605
|
+
) -> None:
|
|
574
606
|
"""Permission-check, then run. Read-only calls are gathered concurrently;
|
|
575
607
|
mutating calls run in emission order.
|
|
576
608
|
|
|
609
|
+
Each result lands in ``results`` the moment its call finishes, so an
|
|
610
|
+
interrupt keeps what had already run.
|
|
611
|
+
|
|
577
612
|
A denied call becomes an ``is_error`` result rather than an exception, so
|
|
578
613
|
the model can react instead of the turn dying.
|
|
579
614
|
"""
|
|
580
|
-
results: dict[str, ToolResultBlock] = {}
|
|
581
615
|
concurrent: list[ToolUseBlock] = []
|
|
582
616
|
|
|
617
|
+
async def run(call: ToolUseBlock) -> None:
|
|
618
|
+
results[call.id] = await self._run_one(call, results)
|
|
619
|
+
|
|
583
620
|
for call in calls:
|
|
584
621
|
if self._runs_serially(call):
|
|
585
622
|
for pending in concurrent:
|
|
586
|
-
|
|
623
|
+
await run(pending)
|
|
587
624
|
concurrent.clear()
|
|
588
|
-
|
|
625
|
+
await run(call)
|
|
589
626
|
else:
|
|
590
627
|
concurrent.append(call)
|
|
591
628
|
|
|
592
629
|
if concurrent:
|
|
593
|
-
|
|
594
|
-
for call, result in zip(concurrent, gathered, strict=True):
|
|
595
|
-
results[call.id] = result
|
|
630
|
+
await asyncio.gather(*(run(c) for c in concurrent))
|
|
596
631
|
|
|
597
|
-
|
|
632
|
+
def _answer_calls(
|
|
633
|
+
self, calls: list[ToolUseBlock], results: dict[str, ToolResultBlock]
|
|
634
|
+
) -> Message:
|
|
635
|
+
"""Append the results for ``calls``, in the order the model asked for them.
|
|
636
|
+
|
|
637
|
+
A call with no result was cut off by the user, before it started or
|
|
638
|
+
while it ran, and is answered as exactly that.
|
|
639
|
+
"""
|
|
640
|
+
message = tool_result_message(
|
|
641
|
+
[results.get(call.id) or interrupted_result(call.id) for call in calls]
|
|
642
|
+
)
|
|
643
|
+
self.session.append(message)
|
|
644
|
+
return message
|
|
598
645
|
|
|
599
646
|
def _runs_serially(self, call: ToolUseBlock) -> bool:
|
|
600
647
|
try:
|
|
@@ -609,7 +656,10 @@ class AgentLoop:
|
|
|
609
656
|
"""Attribute a subagent's tool calls so they do not read as the parent's."""
|
|
610
657
|
return name if self.origin is None else f"{self.origin} > {name}"
|
|
611
658
|
|
|
612
|
-
async def _run_one(
|
|
659
|
+
async def _run_one(
|
|
660
|
+
self, call: ToolUseBlock, results: dict[str, ToolResultBlock]
|
|
661
|
+
) -> ToolResultBlock:
|
|
662
|
+
"""Run one call. Interrupted, it leaves its answer in ``results``."""
|
|
613
663
|
started = time.monotonic()
|
|
614
664
|
|
|
615
665
|
# PreToolUse runs before the call is announced. A hook that rewrites the
|
|
@@ -626,7 +676,40 @@ class AgentLoop:
|
|
|
626
676
|
tool_use_id=call.id, name=self._display_name(call.name), input=call.input
|
|
627
677
|
)
|
|
628
678
|
)
|
|
679
|
+
streamed: list[str] = []
|
|
680
|
+
try:
|
|
681
|
+
return await self._run_announced(call, pre, started, streamed, results)
|
|
682
|
+
except asyncio.CancelledError:
|
|
683
|
+
if call.id in results:
|
|
684
|
+
# The tool had finished and only its hooks were cut off.
|
|
685
|
+
raise
|
|
686
|
+
cap = self.settings.context.tool_output_char_cap
|
|
687
|
+
results[call.id] = interrupted_result(call.id, "".join(streamed)[-cap:])
|
|
688
|
+
# The call is on screen as running; left alone it would spin on
|
|
689
|
+
# under the "Interrupted" notice for the rest of the session.
|
|
690
|
+
self.bus.publish(
|
|
691
|
+
ToolCallFinished(
|
|
692
|
+
tool_use_id=call.id,
|
|
693
|
+
is_error=True,
|
|
694
|
+
duration_ms=(time.monotonic() - started) * 1000,
|
|
695
|
+
summary="interrupted",
|
|
696
|
+
interrupted=True,
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
raise
|
|
629
700
|
|
|
701
|
+
async def _run_announced(
|
|
702
|
+
self,
|
|
703
|
+
call: ToolUseBlock,
|
|
704
|
+
pre: HookOutcome,
|
|
705
|
+
started: float,
|
|
706
|
+
streamed: list[str],
|
|
707
|
+
results: dict[str, ToolResultBlock],
|
|
708
|
+
) -> ToolResultBlock:
|
|
709
|
+
"""The part of :meth:`_run_one` after the call is on screen.
|
|
710
|
+
|
|
711
|
+
What the tool prints is kept in ``streamed`` for an interrupt to report.
|
|
712
|
+
"""
|
|
630
713
|
if pre.blocked:
|
|
631
714
|
refusal = ToolResultBlock(
|
|
632
715
|
tool_use_id=call.id,
|
|
@@ -657,18 +740,34 @@ class AgentLoop:
|
|
|
657
740
|
)
|
|
658
741
|
return denied
|
|
659
742
|
|
|
743
|
+
def progress(chunk: str) -> None:
|
|
744
|
+
streamed.append(chunk)
|
|
745
|
+
self._emit_progress(call.id, chunk)
|
|
746
|
+
|
|
660
747
|
ctx = ToolContext(
|
|
661
748
|
cwd=self.settings.cwd,
|
|
662
749
|
session_id=self.session.meta.session_id,
|
|
663
750
|
tool_use_id=call.id,
|
|
664
751
|
settings=self.settings,
|
|
665
|
-
emit_progress=
|
|
752
|
+
emit_progress=progress,
|
|
666
753
|
)
|
|
667
754
|
result = await self.tools.call(call.name, call.input, ctx)
|
|
668
755
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
756
|
+
try:
|
|
757
|
+
post = await self._fire_hooks(
|
|
758
|
+
lambda h: h.post_tool_use(call.name, call.input, result.content, result.is_error)
|
|
759
|
+
)
|
|
760
|
+
except asyncio.CancelledError:
|
|
761
|
+
# The tool finished and its effects are real: its own result stands,
|
|
762
|
+
# rather than telling the model a write that happened never did.
|
|
763
|
+
results[call.id] = self._finish(call, result, HookOutcome(), started)
|
|
764
|
+
raise
|
|
765
|
+
return self._finish(call, result, post, started)
|
|
766
|
+
|
|
767
|
+
def _finish(
|
|
768
|
+
self, call: ToolUseBlock, result: ToolResult, post: HookOutcome, started: float
|
|
769
|
+
) -> ToolResultBlock:
|
|
770
|
+
"""Announce a call the tool finished, and its result for the model."""
|
|
672
771
|
content = result.content
|
|
673
772
|
if post.blocked:
|
|
674
773
|
content = f"{content}\n\nA hook rejected this result: {post.reason}"
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import time
|
|
11
11
|
from collections.abc import Sequence
|
|
12
|
-
from dataclasses import dataclass, field
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
13
|
from enum import StrEnum
|
|
14
14
|
from typing import Any, Literal
|
|
15
15
|
|
|
@@ -238,3 +238,60 @@ def assistant_message(blocks: list[ContentBlock], model: str | None = None) -> M
|
|
|
238
238
|
def tool_result_message(results: list[ToolResultBlock]) -> Message:
|
|
239
239
|
"""Tool results are carried on a ``user``-role message, matching the provider wire format."""
|
|
240
240
|
return Message(role="user", content=list(results))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
INTERRUPTED = "Interrupted by the user before this call finished."
|
|
244
|
+
_OUTPUT_SO_FAR = "\n\nOutput before the interrupt:\n"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def interrupted_result(tool_use_id: str, output: str = "") -> ToolResultBlock:
|
|
248
|
+
"""The answer for a call the user cut off, before it started or while it ran.
|
|
249
|
+
|
|
250
|
+
What it printed before then goes with it: how far a command got is what
|
|
251
|
+
the model needs to decide whether to run it again.
|
|
252
|
+
"""
|
|
253
|
+
content = f"{INTERRUPTED}{_OUTPUT_SO_FAR}{output}" if output.strip() else INTERRUPTED
|
|
254
|
+
return ToolResultBlock(tool_use_id=tool_use_id, content=content, is_error=True)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def interrupted_output(result: ToolResultBlock) -> str | None:
|
|
258
|
+
"""What an interrupted call printed, or ``None`` when ``result`` is not one."""
|
|
259
|
+
if not (result.is_error and result.content.startswith(INTERRUPTED)):
|
|
260
|
+
return None
|
|
261
|
+
return result.content.removeprefix(INTERRUPTED).removeprefix(_OUTPUT_SO_FAR)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def answer_unanswered_calls(messages: Sequence[Message]) -> list[Message]:
|
|
265
|
+
"""``messages`` with every tool call answered by the message after it.
|
|
266
|
+
|
|
267
|
+
The loop answers every call it starts, interrupted or not, so a request is
|
|
268
|
+
never built while a call waits. A transcript can still hold a call with no
|
|
269
|
+
result: written before the loop did that, or by a process killed mid-tool.
|
|
270
|
+
Every route rejects such a call - on every request after it, since it never
|
|
271
|
+
leaves the history - so the missing results are filled in as interrupted.
|
|
272
|
+
Only the request is repaired; the transcript on disk keeps what happened.
|
|
273
|
+
"""
|
|
274
|
+
repaired: list[Message] = []
|
|
275
|
+
waiting: list[ToolUseBlock] = []
|
|
276
|
+
for message in [*messages, None]:
|
|
277
|
+
if waiting:
|
|
278
|
+
results = {r.tool_use_id: r for r in message.tool_results()} if message else {}
|
|
279
|
+
if any(call.id not in results for call in waiting):
|
|
280
|
+
answers = [results.get(c.id) or interrupted_result(c.id) for c in waiting]
|
|
281
|
+
ids = {call.id for call in waiting}
|
|
282
|
+
if message is not None and results:
|
|
283
|
+
# Into the results already there: the calls stay answered
|
|
284
|
+
# by one message, in the order they were made.
|
|
285
|
+
rest = [
|
|
286
|
+
b
|
|
287
|
+
for b in message.content
|
|
288
|
+
if not (isinstance(b, ToolResultBlock) and b.tool_use_id in ids)
|
|
289
|
+
]
|
|
290
|
+
message = replace(message, content=[*answers, *rest])
|
|
291
|
+
else:
|
|
292
|
+
repaired.append(tool_result_message(answers))
|
|
293
|
+
if message is None:
|
|
294
|
+
break
|
|
295
|
+
repaired.append(message)
|
|
296
|
+
waiting = message.tool_uses() if message.role == "assistant" else []
|
|
297
|
+
return repaired
|
|
@@ -217,9 +217,50 @@ def user_instructions_file() -> Path:
|
|
|
217
217
|
return user_home() / "AGENTS.md"
|
|
218
218
|
|
|
219
219
|
|
|
220
|
-
def
|
|
221
|
-
"""The
|
|
222
|
-
|
|
220
|
+
def project_root(cwd: Path | None = None) -> Path | None:
|
|
221
|
+
"""The repository ``cwd`` sits in: the nearest directory holding ``.git``.
|
|
222
|
+
|
|
223
|
+
``.git`` is a directory in a clone and a file in a worktree or submodule.
|
|
224
|
+
A submodule is part of the repository around it, so the walk carries on
|
|
225
|
+
past one to the superproject. ``None`` outside a repository.
|
|
226
|
+
"""
|
|
227
|
+
start = (cwd or Path.cwd()).resolve()
|
|
228
|
+
submodule: Path | None = None
|
|
229
|
+
for directory in (start, *start.parents):
|
|
230
|
+
marker = directory / ".git"
|
|
231
|
+
if marker.is_dir() or (marker.is_file() and not _is_submodule(marker)):
|
|
232
|
+
return directory
|
|
233
|
+
if marker.is_file() and submodule is None:
|
|
234
|
+
submodule = directory
|
|
235
|
+
return submodule
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _is_submodule(marker: Path) -> bool:
|
|
239
|
+
"""Whether a ``.git`` file points into a superproject's ``.git/modules``."""
|
|
240
|
+
try:
|
|
241
|
+
text = marker.read_text(encoding="utf-8", errors="replace")
|
|
242
|
+
except OSError:
|
|
243
|
+
return False
|
|
244
|
+
return text.startswith("gitdir:") and "/modules/" in text.replace("\\", "/")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def project_instructions_files(cwd: Path, root: Path | None) -> list[Path]:
|
|
248
|
+
"""Every ``AGENTS.md`` that governs ``cwd``, least specific first.
|
|
249
|
+
|
|
250
|
+
From the repository root down to ``cwd``, one candidate per directory, so
|
|
251
|
+
starting HX in ``src/app`` still finds the root's file - and ``src/app``'s
|
|
252
|
+
own, which comes last and so has the last word. Outside a repository only
|
|
253
|
+
``cwd`` itself is looked at: walking up from an arbitrary directory would
|
|
254
|
+
pick up whatever happens to sit in its parents.
|
|
255
|
+
|
|
256
|
+
``root`` is :func:`project_root` of ``cwd``, passed in because the caller
|
|
257
|
+
needs it too and finding it walks the file system.
|
|
258
|
+
"""
|
|
259
|
+
here = cwd.resolve()
|
|
260
|
+
if root is None:
|
|
261
|
+
return [here / "AGENTS.md"]
|
|
262
|
+
chain = [here, *here.parents]
|
|
263
|
+
return [directory / "AGENTS.md" for directory in reversed(chain[: chain.index(root) + 1])]
|
|
223
264
|
|
|
224
265
|
|
|
225
266
|
def ensure_user_dirs() -> None:
|
|
@@ -143,6 +143,13 @@ class PersistentShell:
|
|
|
143
143
|
if not interrupted or not await self._drain_stale_sentinel():
|
|
144
144
|
await self.restart()
|
|
145
145
|
yield "[hx] shell did not recover from the interrupt; restarted\n"
|
|
146
|
+
except asyncio.CancelledError:
|
|
147
|
+
# The user stopped the turn. The command must stop with it, and
|
|
148
|
+
# its sentinel must not be left for the next command to read -
|
|
149
|
+
# the same recovery as a timeout, without the output to report.
|
|
150
|
+
if not await self.interrupt() or not await self._drain_stale_sentinel():
|
|
151
|
+
await self.restart()
|
|
152
|
+
raise
|
|
146
153
|
|
|
147
154
|
self._duration_ms = (time.monotonic() - started) * 1000
|
|
148
155
|
|
|
@@ -178,11 +185,14 @@ class PersistentShell:
|
|
|
178
185
|
yield head
|
|
179
186
|
return
|
|
180
187
|
|
|
181
|
-
# Hold back
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
# Hold back only what could be the start of the sentinel, so it is
|
|
189
|
+
# never emitted as output. Holding back a sentinel's length of
|
|
190
|
+
# anything hid a short line - "Starting..." before a long build -
|
|
191
|
+
# until the command ended.
|
|
192
|
+
held = _sentinel_prefix_at_end(buffer, self._sentinel)
|
|
193
|
+
if len(buffer) > held:
|
|
194
|
+
yield buffer[: len(buffer) - held]
|
|
195
|
+
buffer = buffer[len(buffer) - held :]
|
|
186
196
|
|
|
187
197
|
async def _drain_stale_sentinel(self, timeout_seconds: float = 5.0) -> bool:
|
|
188
198
|
"""Consume output up to the sentinel owed by an interrupted command."""
|
|
@@ -564,6 +574,14 @@ class KillShellTool(Tool):
|
|
|
564
574
|
return ToolResult(content=f"Killed {job_id}.", summary=f"killed {job_id}")
|
|
565
575
|
|
|
566
576
|
|
|
577
|
+
def _sentinel_prefix_at_end(buffer: str, sentinel: str) -> int:
|
|
578
|
+
"""Length of the longest end of ``buffer`` that ``sentinel`` starts with."""
|
|
579
|
+
for size in range(min(len(buffer), len(sentinel) - 1), 0, -1):
|
|
580
|
+
if sentinel.startswith(buffer[-size:]):
|
|
581
|
+
return size
|
|
582
|
+
return 0
|
|
583
|
+
|
|
584
|
+
|
|
567
585
|
def _parse_trailer(tail: str, previous: Path) -> tuple[int, Path]:
|
|
568
586
|
"""Read the ``<status> <pwd>`` the sentinel is followed by.
|
|
569
587
|
|