hx-cli 0.2.9__tar.gz → 0.2.10__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.10}/CHANGELOG.md +18 -1
  2. {hx_cli-0.2.9 → hx_cli-0.2.10}/PKG-INFO +9 -2
  3. {hx_cli-0.2.9 → hx_cli-0.2.10}/README.md +8 -1
  4. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/agents/subagent.py +6 -0
  6. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/cli.py +3 -1
  7. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/context.py +33 -12
  8. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/paths.py +44 -3
  9. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/commands.py +8 -3
  10. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/runtime.py +11 -13
  11. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/pickers.py +21 -2
  12. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_commands.py +73 -0
  13. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_extend.py +50 -1
  14. {hx_cli-0.2.9 → hx_cli-0.2.10}/.github/workflows/ci.yml +0 -0
  15. {hx_cli-0.2.9 → hx_cli-0.2.10}/.gitignore +0 -0
  16. {hx_cli-0.2.9 → hx_cli-0.2.10}/examples/hooks/check.sh +0 -0
  17. {hx_cli-0.2.9 → hx_cli-0.2.10}/install.sh +0 -0
  18. {hx_cli-0.2.9 → hx_cli-0.2.10}/pyproject.toml +0 -0
  19. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/agents/__init__.py +0 -0
  20. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/agents/definitions.py +0 -0
  21. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/__init__.py +0 -0
  22. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/__init__.py +0 -0
  23. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/browser.py +0 -0
  24. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/callback.py +0 -0
  25. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/codex.py +0 -0
  26. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/devin.py +0 -0
  27. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/oauth/pkce.py +0 -0
  28. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/resolve.py +0 -0
  29. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/auth/store.py +0 -0
  30. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/config.py +0 -0
  31. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/__init__.py +0 -0
  32. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/checkpoints.py +0 -0
  33. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/compaction.py +0 -0
  34. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/events.py +0 -0
  35. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/images.py +0 -0
  36. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/lateinject.py +0 -0
  37. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/loop.py +0 -0
  38. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/messages.py +0 -0
  39. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/session.py +0 -0
  40. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/title.py +0 -0
  41. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/core/usage.py +0 -0
  42. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/docs.py +0 -0
  43. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/frontmatter.py +0 -0
  44. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/git.py +0 -0
  45. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/hooks/__init__.py +0 -0
  46. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/hooks/engine.py +0 -0
  47. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/hooks/spec.py +0 -0
  48. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/keys.py +0 -0
  49. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/mcp/__init__.py +0 -0
  50. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/mcp/client.py +0 -0
  51. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/mcp/manager.py +0 -0
  52. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/mcp/oauth.py +0 -0
  53. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/net.py +0 -0
  54. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/permissions/__init__.py +0 -0
  55. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/permissions/engine.py +0 -0
  56. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/permissions/parser.py +0 -0
  57. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/permissions/sandbox.py +0 -0
  58. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/__init__.py +0 -0
  59. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/base.py +0 -0
  60. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/codex.py +0 -0
  61. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/codex_catalogue.py +0 -0
  62. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/devin.py +0 -0
  63. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/devin_catalogue.py +0 -0
  64. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/devin_wire.py +0 -0
  65. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/models.py +0 -0
  66. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/openrouter.py +0 -0
  67. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/protowire.py +0 -0
  68. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/registry.py +0 -0
  69. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/providers/responses_codec.py +0 -0
  70. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/skills/__init__.py +0 -0
  71. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/skills/loader.py +0 -0
  72. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/skills/runtime.py +0 -0
  73. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/__init__.py +0 -0
  74. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/ansi.py +0 -0
  75. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/buffer.py +0 -0
  76. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/component.py +0 -0
  77. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/demo.py +0 -0
  78. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/editor.py +0 -0
  79. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/keydecode.py +0 -0
  80. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/loop.py +0 -0
  81. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/markdown.py +0 -0
  82. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/modifiers.py +0 -0
  83. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/primitives.py +0 -0
  84. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/sanitize.py +0 -0
  85. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/screen.py +0 -0
  86. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/syntax.py +0 -0
  87. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/terminal.py +0 -0
  88. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/undo.py +0 -0
  89. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/width.py +0 -0
  90. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/term/word_nav.py +0 -0
  91. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/__init__.py +0 -0
  92. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/anchors.py +0 -0
  93. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/base.py +0 -0
  94. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/bash.py +0 -0
  95. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/edit.py +0 -0
  96. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/glob.py +0 -0
  97. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/grep.py +0 -0
  98. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/output.py +0 -0
  99. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/read.py +0 -0
  100. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/registry.py +0 -0
  101. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/symbols.py +0 -0
  102. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/task.py +0 -0
  103. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/todo.py +0 -0
  104. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/websearch.py +0 -0
  105. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tools/write.py +0 -0
  106. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/trace.py +0 -0
  107. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/__init__.py +0 -0
  108. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/app.py +0 -0
  109. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/clipboard.py +0 -0
  110. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/format.py +0 -0
  111. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/fuzzy.py +0 -0
  112. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/glyphs.py +0 -0
  113. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/killring.py +0 -0
  114. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/limits.py +0 -0
  115. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/paint.py +0 -0
  116. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/renderers.py +0 -0
  117. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/roles.py +0 -0
  118. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/theme.py +0 -0
  119. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/theme_json.py +0 -0
  120. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/themes/ansi.json +0 -0
  121. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/themes/dark.json +0 -0
  122. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/themes/light.json +0 -0
  123. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/__init__.py +0 -0
  124. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/blocks.py +0 -0
  125. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/dialog.py +0 -0
  126. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/login.py +0 -0
  127. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/permission.py +0 -0
  128. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/prompt.py +0 -0
  129. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/status.py +0 -0
  130. {hx_cli-0.2.9 → hx_cli-0.2.10}/src/hx/tui/views/transcript.py +0 -0
  131. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/__init__.py +0 -0
  132. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/conftest.py +0 -0
  133. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/__init__.py +0 -0
  134. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/conftest.py +0 -0
  135. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/fixtures/echo_server.py +0 -0
  136. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/report.py +0 -0
  137. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/stub.py +0 -0
  138. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/terminal.py +0 -0
  139. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_chat.py +0 -0
  140. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_cli.py +0 -0
  141. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_input.py +0 -0
  142. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_print.py +0 -0
  143. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/e2e/test_tools.py +0 -0
  144. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/test_live.py +0 -0
  145. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/test_live_codex.py +0 -0
  146. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/test_live_devin.py +0 -0
  147. {hx_cli-0.2.9 → hx_cli-0.2.10}/tests/test_live_tavily.py +0 -0
  148. {hx_cli-0.2.9 → hx_cli-0.2.10}/uv.lock +0 -0
@@ -28,6 +28,22 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.10] - 2026-09-29
32
+
33
+ ### Fixed
34
+
35
+ - `AGENTS.md` at the repository root now applies when HX is started in a
36
+ subdirectory, followed by each `AGENTS.md` down to that directory.
37
+ - Subagents started with `Task` now follow `~/.hx/AGENTS.md` and the project's
38
+ `AGENTS.md`, as the main session does.
39
+ - A saved `/effort` level shows on the status bar from the first screen of every
40
+ new session, not only after `/effort` is run again.
41
+ - `ctrl+l` switches the model and saves it for later sessions, as `/model` does;
42
+ it used to relabel the status bar only.
43
+ - `/model`, `ctrl+l` and `/effort` open with the cursor on the choice in force.
44
+
45
+ ---
46
+
31
47
  ## [0.2.9] - 2026-09-27
32
48
 
33
49
  ### Added
@@ -742,7 +758,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
742
758
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
743
759
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
744
760
 
745
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.9...HEAD
761
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.10...HEAD
762
+ [0.2.10]: https://github.com/aletisunil/hx/compare/v0.2.9...v0.2.10
746
763
  [0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
747
764
  [0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
748
765
  [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.10
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.10"
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
 
@@ -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:
@@ -107,7 +107,10 @@ async def cmd_model(ctx: CommandContext, args: str) -> None:
107
107
  from hx.providers.models import ModelRegistry
108
108
  from hx.tui.views.pickers import ModelPicker
109
109
 
110
- registry: ModelRegistry = ctx.app.models
110
+ registry: ModelRegistry | None = ctx.app.models
111
+ if registry is None:
112
+ ctx.app.notice("No model registry is loaded.", "warning")
113
+ return
111
114
  models = _reachable(ctx, registry.all())
112
115
  if not models:
113
116
  # A recorded failure replaces the remedy rather than trailing it:
@@ -780,7 +783,9 @@ async def cmd_mode(ctx: CommandContext, args: str) -> None:
780
783
 
781
784
 
782
785
  INIT_PROMPT = """\
783
- Write an AGENTS.md for this project, at its root.
786
+ Write an AGENTS.md in {directory}, the directory HX was started in. Any AGENTS.md
787
+ between the repository root and there is loaded before it, so leave out what
788
+ those already say.
784
789
 
785
790
  Read enough of the codebase to be accurate. Cover: what the project is, how to
786
791
  build, test and lint it, the layout of the source tree, and any conventions a
@@ -792,7 +797,7 @@ If AGENTS.md already exists, improve it rather than replacing it wholesale."""
792
797
 
793
798
  async def cmd_init(ctx: CommandContext, args: str) -> None:
794
799
  """``/init`` - generate an AGENTS.md describing this project."""
795
- await ctx.app.submit_to_model(INIT_PROMPT)
800
+ await ctx.app.submit_to_model(INIT_PROMPT.format(directory=ctx.settings.cwd))
796
801
 
797
802
 
798
803
  async def cmd_configure(ctx: CommandContext, args: str) -> None:
@@ -785,17 +785,9 @@ class HXSession:
785
785
  await self._run_command(f"/{chosen}")
786
786
 
787
787
  async def _open_models(self) -> None:
788
- from hx.tui.views.pickers import ModelPicker
789
-
790
- registry = self.extra.get("models")
791
- if registry is None:
792
- self._notice("no model registry is loaded", "warning")
793
- return
794
- current = getattr(getattr(self.loop, "model_info", None), "id", "") or ""
795
- chosen = await self.ask(ModelPicker(list(registry.all()), current))
796
- if chosen:
797
- self.view.dock.status.set_model(chosen)
798
- self._notice(f"model set to {chosen}")
788
+ # The same path as `/model`: a picker that only relabelled the status
789
+ # bar left the session on the old model and the choice unsaved.
790
+ await self._run_command("/model")
799
791
 
800
792
  def _commands(self) -> list[Any]:
801
793
  return list(self.commands.all()) if self.commands is not None else []
@@ -963,7 +955,8 @@ class HXSession:
963
955
  asyncio.create_task(self._open_commands()) # noqa: RUF006
964
956
  return
965
957
  if name == "ctrl+l":
966
- asyncio.create_task(self._open_models()) # noqa: RUF006
958
+ # Tracked like a typed `/model`, so an interrupt cancels it too.
959
+ self._side = asyncio.create_task(self._open_models())
967
960
  return
968
961
  # Only here, on the way to the prompt: nothing else binds shift+enter,
969
962
  # so an approval or a picker has to keep the enter it was sent. Not
@@ -1334,8 +1327,13 @@ class HXSession:
1334
1327
  )
1335
1328
  model = getattr(self.loop, "model_info", None)
1336
1329
  if model is not None:
1337
- status.set_model(getattr(model, "id", "") or "")
1330
+ model_id = getattr(model, "id", "") or ""
1331
+ status.set_model(model_id, subscription=bool(getattr(model, "is_subscription", False)))
1338
1332
  status.set_context(0, getattr(model, "context_window", 0) or 0)
1333
+ # The saved effort is in force from the first turn; a bar that only
1334
+ # shows it once /effort is run again reads as the choice not sticking.
1335
+ if self.models is not None:
1336
+ status.set_effort(self.models.displayed_effort(model_id))
1339
1337
 
1340
1338
 
1341
1339
  async def run_session(
@@ -61,6 +61,13 @@ class Picker(Widget, Framed):
61
61
  two-hundred model catalogue, with two thirds of a tall window empty
62
62
  underneath."""
63
63
  self._refresh()
64
+ # Open on what is already in force, so enter alone keeps it and the
65
+ # arrow and the ✓ do not point at different rows.
66
+ current = self.current_value()
67
+ self.selected = next(
68
+ (index for index, (value, _cells) in enumerate(self._rows) if value == current),
69
+ 0,
70
+ )
64
71
 
65
72
  # -- the room this has to work with ------------------------------------
66
73
 
@@ -260,7 +267,11 @@ class ModelPicker(Picker):
260
267
  # The same matcher `/model <query>` uses, so typing here narrows
261
268
  # exactly the way typing there does.
262
269
  matches = match_models(self.models, query)
263
- return [(model.id, self._cells(model)) for model in matches[: self.LIMIT]]
270
+ shown = matches[: self.LIMIT]
271
+ # The model in force stays listed however far down it sorts, or the
272
+ # picker opens on some other row with no ✓ anywhere.
273
+ shown += [model for model in matches[self.LIMIT :] if model.id == self.current]
274
+ return [(model.id, self._cells(model)) for model in shown]
264
275
 
265
276
  def _cells(self, model: Any) -> list[str]:
266
277
  mode = str(model.cache_mode)
@@ -310,7 +321,15 @@ class EffortPicker(Picker):
310
321
  super().__init__()
311
322
 
312
323
  def current_value(self) -> str | None:
313
- return self.current or DEFAULT_EFFORT_ROW
324
+ if self.current is None:
325
+ return DEFAULT_EFFORT_ROW
326
+ if self.current in self.info.reasoning_levels:
327
+ return self.current
328
+ # Clamped for this model: the level it actually runs at is the one in
329
+ # force, and a row that matches nothing would open on "default".
330
+ from hx.providers.codex_catalogue import resolve_effort
331
+
332
+ return resolve_effort(self.current, self.info)
314
333
 
315
334
  def rows(self, query: str) -> list[tuple[str, list[str]]]:
316
335
  default_level = self.info.default_reasoning_level or "the model's default"
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import re
7
+ import time
6
8
 
7
9
  from tests.e2e.conftest import HX, SANDBOX, SANDBOX_STATUS
8
10
  from tests.e2e.stub import GPT5, Stub, call, say
@@ -86,6 +88,77 @@ def test_model_picker_switches_the_model(hx: HX, stub: Stub) -> None:
86
88
  assert stub.requests[0].model == GPT5
87
89
 
88
90
 
91
+ def test_ctrl_l_switches_the_model_and_remembers_it(hx: HX, stub: Stub) -> None:
92
+ """ctrl+l opens the model picker on the model in force; choosing another routes the next
93
+ turn to it and saves it for the next session, exactly as /model does."""
94
+ stub.script(say("Answered by GPT-5."))
95
+ term = hx.tui()
96
+ term.press("ctrl+l")
97
+ screen = term.wait_for("Select model")
98
+ term.snapshot("ctrl+l model picker")
99
+ assert re.search(r"→ ✓ anthropic/claude-sonnet-4\.5\s", screen), "opens on the current model"
100
+
101
+ term.type("gpt")
102
+ term.wait_for(lambda s: "deepseek" not in s)
103
+ term.press("enter")
104
+ term.wait_for("Model set to openai/gpt-5")
105
+ term.submit("who are you?")
106
+ term.wait_for("Answered by GPT-5.")
107
+ assert stub.requests[0].model == GPT5
108
+ saved = json.loads((hx.hx_home / "settings.json").read_text())
109
+ assert saved["models"]["model"] == GPT5
110
+
111
+
112
+ def test_effort_carries_into_every_new_session(hx: HX) -> None:
113
+ """A saved reasoning effort is in force - and on the status bar - from the first screen of
114
+ a new session, through /clear and a restart; the picker opens on it, and a new pick
115
+ replaces it for the sessions after."""
116
+ codex = "openai-codex/gpt-5.6-terra"
117
+ (hx.hx_home / "auth.json").write_text(
118
+ json.dumps(
119
+ {
120
+ "openai-codex": {
121
+ "type": "oauth",
122
+ "access": "e2e-access",
123
+ "refresh": "e2e-refresh",
124
+ "expires": time.time() + 30 * 86_400,
125
+ "account_id": "acct-e2e",
126
+ }
127
+ }
128
+ )
129
+ )
130
+ (hx.hx_home / "auth.json").chmod(0o600)
131
+ # Fresh, so nothing reaches for the real Codex catalogue at startup.
132
+ (hx.hx_home / "models.json").write_text(json.dumps({"fetched_at": time.time(), "models": []}))
133
+ (hx.hx_home / "settings.json").write_text(json.dumps({"models": {"reasoning_effort": "high"}}))
134
+ env = {"HX_MODEL": codex}
135
+
136
+ term = hx.tui(env=env)
137
+ term.wait_for(lambda s: s.splitlines()[-1].rstrip().endswith("gpt-5.6-terra (sub) · high"))
138
+ term.snapshot("a new session starts at the saved effort")
139
+
140
+ term.submit("/effort")
141
+ screen = term.wait_for("Reasoning effort for gpt-5.6-terra")
142
+ term.snapshot("/effort opens on the level in force")
143
+ assert re.search(r"→ ✓ high\s*$", screen, re.MULTILINE)
144
+ term.press("down")
145
+ term.press("enter")
146
+ term.wait_for("Reasoning effort set to xhigh.")
147
+ assert term.lines()[-1].rstrip().endswith("gpt-5.6-terra (sub) · xhigh")
148
+ saved = json.loads((hx.hx_home / "settings.json").read_text())
149
+ assert saved["models"]["reasoning_effort"] == "xhigh"
150
+
151
+ term.submit("/clear")
152
+ term.wait_for(lambda s: "Reasoning effort set" not in s)
153
+ assert term.lines()[-1].rstrip().endswith("gpt-5.6-terra (sub) · xhigh")
154
+ term.press("ctrl+d")
155
+ term.wait_exit()
156
+
157
+ again = hx.tui(env=env)
158
+ again.wait_for(lambda s: s.splitlines()[-1].rstrip().endswith("gpt-5.6-terra (sub) · xhigh"))
159
+ again.snapshot("the next session starts at the new effort")
160
+
161
+
89
162
  def test_ctrl_p_palette_runs_a_command(hx: HX) -> None:
90
163
  """ctrl+p opens the palette; filtering and enter runs the command."""
91
164
  term = hx.tui(rows=40)
@@ -7,7 +7,7 @@ import os
7
7
  import sys
8
8
  from pathlib import Path
9
9
 
10
- from tests.e2e.conftest import HX
10
+ from tests.e2e.conftest import HX, git
11
11
  from tests.e2e.stub import GPT5, SONNET, Stub, call, say
12
12
 
13
13
  ECHO_SERVER = Path(__file__).parent / "fixtures" / "echo_server.py"
@@ -105,6 +105,55 @@ def test_blank_unreadable_and_shared_agents_md_are_harmless(hx: HX, stub: Stub)
105
105
  assert "# Project instructions" not in system
106
106
 
107
107
 
108
+ def test_agents_md_governs_a_session_started_deep_in_the_repo_and_its_subagents(
109
+ hx: HX, stub: Stub
110
+ ) -> None:
111
+ """Started in a package two levels below the repository root, HX still loads the root's
112
+ AGENTS.md, then the package's own after it, and nothing from outside the repository; a
113
+ subagent the model delegates the work to is held to the same rules."""
114
+ git(hx.project, "init", "-q")
115
+ write(hx.project.parent / "AGENTS.md", "OUTSIDE-RULE: not this repository's.\n")
116
+ write(hx.project / "AGENTS.md", "ROOT-RULE: never create unit tests; verify end to end.\n")
117
+ write(hx.project / "services" / "AGENTS.md", "")
118
+ write(hx.project / "services" / "billing" / "AGENTS.md", "PKG-RULE: money is integer cents.\n")
119
+ pricing = write(
120
+ hx.project / "services" / "billing" / "pricing.py",
121
+ "def subtotal(items):\n return sum(i['cents'] * i['qty'] for i in items)\n",
122
+ )
123
+ stub.script(
124
+ call(
125
+ "Task",
126
+ subagent_type="general",
127
+ prompt="Add apply_discount(total, percent) to pricing.py.",
128
+ description="add discount",
129
+ ),
130
+ call("Read", file_path=str(pricing)),
131
+ say("REPORT: added apply_discount; no test files, per AGENTS.md."),
132
+ say("Done, no test files added."),
133
+ )
134
+ result = hx.run(
135
+ "-p", "add a discount helper", "--mode", "bypass", cwd=pricing.parent, check=True
136
+ )
137
+ assert result.stdout.strip() == "Done, no test files added."
138
+
139
+ parent, child, child_after_read, _parent_after = stub.requests
140
+ for request in (parent, child, child_after_read):
141
+ system = request.system
142
+ assert "OUTSIDE-RULE" not in system
143
+ root = system.index("# Project instructions (AGENTS.md)")
144
+ package = system.index("# Project instructions (services/billing/AGENTS.md)")
145
+ assert root < system.index("ROOT-RULE: never create unit tests") < package
146
+ assert package < system.index("PKG-RULE: money is integer cents.")
147
+ assert "Project instructions (services/AGENTS.md)" not in system, "blank is skipped"
148
+ assert system.count("Follow them over your own defaults") == 1
149
+ assert child.system.startswith("Carry out the task"), "the subagent keeps its own prompt"
150
+
151
+ listed = hx.run("prompt", cwd=pricing.parent, check=True).stderr
152
+ assert f"[instructions] {hx.project / 'AGENTS.md'}\n" in listed
153
+ assert f"[instructions] {pricing.parent / 'AGENTS.md'}\n" in listed
154
+ assert str(hx.project.parent / "AGENTS.md") not in listed
155
+
156
+
108
157
  def test_skill_is_indexed_then_loaded(hx: HX, stub: Stub) -> None:
109
158
  """Only a skill's name and description are sent up front; calling Skill loads its body."""
110
159
  write(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes