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.
Files changed (148) hide show
  1. {hx_cli-0.2.9 → hx_cli-0.2.11}/CHANGELOG.md +37 -1
  2. {hx_cli-0.2.9 → hx_cli-0.2.11}/PKG-INFO +9 -2
  3. {hx_cli-0.2.9 → hx_cli-0.2.11}/README.md +8 -1
  4. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/agents/subagent.py +6 -0
  6. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/cli.py +3 -1
  7. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/context.py +33 -12
  8. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/events.py +3 -0
  9. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/loop.py +118 -19
  10. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/messages.py +58 -1
  11. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/paths.py +44 -3
  12. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/bash.py +23 -5
  13. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/commands.py +8 -3
  14. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/renderers.py +5 -3
  15. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/runtime.py +21 -18
  16. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/blocks.py +17 -1
  17. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/pickers.py +21 -2
  18. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/stub.py +33 -0
  19. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/terminal.py +11 -2
  20. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_commands.py +73 -0
  21. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_extend.py +50 -1
  22. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_tools.py +91 -1
  23. {hx_cli-0.2.9 → hx_cli-0.2.11}/.github/workflows/ci.yml +0 -0
  24. {hx_cli-0.2.9 → hx_cli-0.2.11}/.gitignore +0 -0
  25. {hx_cli-0.2.9 → hx_cli-0.2.11}/examples/hooks/check.sh +0 -0
  26. {hx_cli-0.2.9 → hx_cli-0.2.11}/install.sh +0 -0
  27. {hx_cli-0.2.9 → hx_cli-0.2.11}/pyproject.toml +0 -0
  28. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/agents/__init__.py +0 -0
  29. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/agents/definitions.py +0 -0
  30. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/__init__.py +0 -0
  31. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/__init__.py +0 -0
  32. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/browser.py +0 -0
  33. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/callback.py +0 -0
  34. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/codex.py +0 -0
  35. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/devin.py +0 -0
  36. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/oauth/pkce.py +0 -0
  37. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/resolve.py +0 -0
  38. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/auth/store.py +0 -0
  39. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/config.py +0 -0
  40. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/__init__.py +0 -0
  41. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/checkpoints.py +0 -0
  42. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/compaction.py +0 -0
  43. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/images.py +0 -0
  44. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/lateinject.py +0 -0
  45. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/session.py +0 -0
  46. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/title.py +0 -0
  47. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/core/usage.py +0 -0
  48. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/docs.py +0 -0
  49. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/frontmatter.py +0 -0
  50. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/git.py +0 -0
  51. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/hooks/__init__.py +0 -0
  52. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/hooks/engine.py +0 -0
  53. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/hooks/spec.py +0 -0
  54. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/keys.py +0 -0
  55. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/mcp/__init__.py +0 -0
  56. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/mcp/client.py +0 -0
  57. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/mcp/manager.py +0 -0
  58. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/mcp/oauth.py +0 -0
  59. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/net.py +0 -0
  60. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/permissions/__init__.py +0 -0
  61. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/permissions/engine.py +0 -0
  62. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/permissions/parser.py +0 -0
  63. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/permissions/sandbox.py +0 -0
  64. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/__init__.py +0 -0
  65. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/base.py +0 -0
  66. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/codex.py +0 -0
  67. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/codex_catalogue.py +0 -0
  68. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/devin.py +0 -0
  69. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/devin_catalogue.py +0 -0
  70. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/devin_wire.py +0 -0
  71. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/models.py +0 -0
  72. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/openrouter.py +0 -0
  73. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/protowire.py +0 -0
  74. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/registry.py +0 -0
  75. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/providers/responses_codec.py +0 -0
  76. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/skills/__init__.py +0 -0
  77. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/skills/loader.py +0 -0
  78. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/skills/runtime.py +0 -0
  79. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/__init__.py +0 -0
  80. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/ansi.py +0 -0
  81. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/buffer.py +0 -0
  82. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/component.py +0 -0
  83. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/demo.py +0 -0
  84. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/editor.py +0 -0
  85. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/keydecode.py +0 -0
  86. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/loop.py +0 -0
  87. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/markdown.py +0 -0
  88. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/modifiers.py +0 -0
  89. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/primitives.py +0 -0
  90. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/sanitize.py +0 -0
  91. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/screen.py +0 -0
  92. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/syntax.py +0 -0
  93. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/terminal.py +0 -0
  94. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/undo.py +0 -0
  95. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/width.py +0 -0
  96. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/term/word_nav.py +0 -0
  97. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/__init__.py +0 -0
  98. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/anchors.py +0 -0
  99. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/base.py +0 -0
  100. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/edit.py +0 -0
  101. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/glob.py +0 -0
  102. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/grep.py +0 -0
  103. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/output.py +0 -0
  104. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/read.py +0 -0
  105. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/registry.py +0 -0
  106. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/symbols.py +0 -0
  107. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/task.py +0 -0
  108. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/todo.py +0 -0
  109. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/websearch.py +0 -0
  110. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tools/write.py +0 -0
  111. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/trace.py +0 -0
  112. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/__init__.py +0 -0
  113. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/app.py +0 -0
  114. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/clipboard.py +0 -0
  115. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/format.py +0 -0
  116. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/fuzzy.py +0 -0
  117. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/glyphs.py +0 -0
  118. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/killring.py +0 -0
  119. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/limits.py +0 -0
  120. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/paint.py +0 -0
  121. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/roles.py +0 -0
  122. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/theme.py +0 -0
  123. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/theme_json.py +0 -0
  124. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/themes/ansi.json +0 -0
  125. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/themes/dark.json +0 -0
  126. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/themes/light.json +0 -0
  127. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/__init__.py +0 -0
  128. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/dialog.py +0 -0
  129. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/login.py +0 -0
  130. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/permission.py +0 -0
  131. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/prompt.py +0 -0
  132. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/status.py +0 -0
  133. {hx_cli-0.2.9 → hx_cli-0.2.11}/src/hx/tui/views/transcript.py +0 -0
  134. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/__init__.py +0 -0
  135. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/conftest.py +0 -0
  136. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/__init__.py +0 -0
  137. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/conftest.py +0 -0
  138. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/fixtures/echo_server.py +0 -0
  139. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/report.py +0 -0
  140. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_chat.py +0 -0
  141. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_cli.py +0 -0
  142. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_input.py +0 -0
  143. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/e2e/test_print.py +0 -0
  144. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/test_live.py +0 -0
  145. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/test_live_codex.py +0 -0
  146. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/test_live_devin.py +0 -0
  147. {hx_cli-0.2.9 → hx_cli-0.2.11}/tests/test_live_tavily.py +0 -0
  148. {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.9...HEAD
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.9
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
- | `./AGENTS.md` | project instructions, loaded into every session |
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
- | `./AGENTS.md` | project instructions, loaded into every session |
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
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.9"
3
+ __version__ = "0.2.11"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -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=build_project_context(settings.cwd, instructions),
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, 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; the project's own
254
- ``AGENTS.md`` comes after it, so where the two disagree the more specific
255
- one has the last word. A blank or unreadable file is skipped, and running
256
- from inside ``$HX_HOME`` - where both paths name the same file - loads it once.
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 project_instructions_file, tilde, user_instructions_file
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
- for loaded in load_instructions(cwd) if instructions is None else instructions:
295
- lines.append(f"\n# {loaded.heading}\n\n{loaded.text}")
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 = await self._execute_tools(calls)
238
- result_message = tool_result_message(results)
239
- self.session.append(result_message)
240
- produced.append(result_message)
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 = await self.injections.apply(self.session.active_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(self, calls: list[ToolUseBlock]) -> list[ToolResultBlock]:
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
- results[pending.id] = await self._run_one(pending)
623
+ await run(pending)
587
624
  concurrent.clear()
588
- results[call.id] = await self._run_one(call)
625
+ await run(call)
589
626
  else:
590
627
  concurrent.append(call)
591
628
 
592
629
  if concurrent:
593
- gathered = await asyncio.gather(*(self._run_one(c) for c in concurrent))
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
- return [results[c.id] for c in calls]
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(self, call: ToolUseBlock) -> ToolResultBlock:
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=lambda chunk: self._emit_progress(call.id, chunk),
752
+ emit_progress=progress,
666
753
  )
667
754
  result = await self.tools.call(call.name, call.input, ctx)
668
755
 
669
- post = await self._fire_hooks(
670
- lambda h: h.post_tool_use(call.name, call.input, result.content, result.is_error)
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 project_instructions_file(cwd: Path | None = None) -> Path:
221
- """The project's ``AGENTS.md``, at the root of the working directory."""
222
- return (cwd or Path.cwd()) / "AGENTS.md"
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 a partial sentinel so it is never emitted as output.
182
- safe = len(buffer) - len(self._sentinel)
183
- if safe > 0:
184
- yield buffer[:safe]
185
- buffer = buffer[safe:]
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