hx-cli 0.2.8__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.8 → hx_cli-0.2.10}/CHANGELOG.md +36 -1
  2. {hx_cli-0.2.8 → hx_cli-0.2.10}/PKG-INFO +16 -2
  3. {hx_cli-0.2.8 → hx_cli-0.2.10}/README.md +15 -1
  4. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/agents/subagent.py +6 -0
  6. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/cli.py +18 -5
  7. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/context.py +66 -11
  8. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/loop.py +4 -1
  9. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/paths.py +77 -1
  10. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/commands.py +17 -8
  11. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/format.py +0 -15
  12. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/runtime.py +12 -14
  13. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/views/pickers.py +23 -3
  14. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/test_cli.py +10 -1
  15. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/test_commands.py +101 -0
  16. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/test_extend.py +129 -1
  17. {hx_cli-0.2.8 → hx_cli-0.2.10}/.github/workflows/ci.yml +0 -0
  18. {hx_cli-0.2.8 → hx_cli-0.2.10}/.gitignore +0 -0
  19. {hx_cli-0.2.8 → hx_cli-0.2.10}/examples/hooks/check.sh +0 -0
  20. {hx_cli-0.2.8 → hx_cli-0.2.10}/install.sh +0 -0
  21. {hx_cli-0.2.8 → hx_cli-0.2.10}/pyproject.toml +0 -0
  22. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/agents/__init__.py +0 -0
  23. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/agents/definitions.py +0 -0
  24. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/auth/__init__.py +0 -0
  25. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/auth/oauth/__init__.py +0 -0
  26. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/auth/oauth/browser.py +0 -0
  27. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/auth/oauth/callback.py +0 -0
  28. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/auth/oauth/codex.py +0 -0
  29. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/auth/oauth/devin.py +0 -0
  30. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/auth/oauth/pkce.py +0 -0
  31. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/auth/resolve.py +0 -0
  32. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/auth/store.py +0 -0
  33. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/config.py +0 -0
  34. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/__init__.py +0 -0
  35. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/checkpoints.py +0 -0
  36. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/compaction.py +0 -0
  37. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/events.py +0 -0
  38. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/images.py +0 -0
  39. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/lateinject.py +0 -0
  40. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/messages.py +0 -0
  41. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/session.py +0 -0
  42. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/title.py +0 -0
  43. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/core/usage.py +0 -0
  44. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/docs.py +0 -0
  45. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/frontmatter.py +0 -0
  46. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/git.py +0 -0
  47. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/hooks/__init__.py +0 -0
  48. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/hooks/engine.py +0 -0
  49. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/hooks/spec.py +0 -0
  50. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/keys.py +0 -0
  51. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/mcp/__init__.py +0 -0
  52. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/mcp/client.py +0 -0
  53. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/mcp/manager.py +0 -0
  54. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/mcp/oauth.py +0 -0
  55. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/net.py +0 -0
  56. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/permissions/__init__.py +0 -0
  57. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/permissions/engine.py +0 -0
  58. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/permissions/parser.py +0 -0
  59. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/permissions/sandbox.py +0 -0
  60. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/__init__.py +0 -0
  61. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/base.py +0 -0
  62. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/codex.py +0 -0
  63. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/codex_catalogue.py +0 -0
  64. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/devin.py +0 -0
  65. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/devin_catalogue.py +0 -0
  66. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/devin_wire.py +0 -0
  67. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/models.py +0 -0
  68. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/openrouter.py +0 -0
  69. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/protowire.py +0 -0
  70. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/registry.py +0 -0
  71. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/providers/responses_codec.py +0 -0
  72. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/skills/__init__.py +0 -0
  73. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/skills/loader.py +0 -0
  74. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/skills/runtime.py +0 -0
  75. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/__init__.py +0 -0
  76. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/ansi.py +0 -0
  77. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/buffer.py +0 -0
  78. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/component.py +0 -0
  79. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/demo.py +0 -0
  80. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/editor.py +0 -0
  81. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/keydecode.py +0 -0
  82. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/loop.py +0 -0
  83. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/markdown.py +0 -0
  84. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/modifiers.py +0 -0
  85. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/primitives.py +0 -0
  86. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/sanitize.py +0 -0
  87. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/screen.py +0 -0
  88. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/syntax.py +0 -0
  89. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/terminal.py +0 -0
  90. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/undo.py +0 -0
  91. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/width.py +0 -0
  92. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/term/word_nav.py +0 -0
  93. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/__init__.py +0 -0
  94. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/anchors.py +0 -0
  95. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/base.py +0 -0
  96. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/bash.py +0 -0
  97. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/edit.py +0 -0
  98. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/glob.py +0 -0
  99. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/grep.py +0 -0
  100. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/output.py +0 -0
  101. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/read.py +0 -0
  102. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/registry.py +0 -0
  103. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/symbols.py +0 -0
  104. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/task.py +0 -0
  105. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/todo.py +0 -0
  106. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/websearch.py +0 -0
  107. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tools/write.py +0 -0
  108. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/trace.py +0 -0
  109. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/__init__.py +0 -0
  110. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/app.py +0 -0
  111. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/clipboard.py +0 -0
  112. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/fuzzy.py +0 -0
  113. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/glyphs.py +0 -0
  114. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/killring.py +0 -0
  115. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/limits.py +0 -0
  116. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/paint.py +0 -0
  117. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/renderers.py +0 -0
  118. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/roles.py +0 -0
  119. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/theme.py +0 -0
  120. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/theme_json.py +0 -0
  121. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/themes/ansi.json +0 -0
  122. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/themes/dark.json +0 -0
  123. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/themes/light.json +0 -0
  124. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/views/__init__.py +0 -0
  125. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/views/blocks.py +0 -0
  126. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/views/dialog.py +0 -0
  127. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/views/login.py +0 -0
  128. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/views/permission.py +0 -0
  129. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/views/prompt.py +0 -0
  130. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/views/status.py +0 -0
  131. {hx_cli-0.2.8 → hx_cli-0.2.10}/src/hx/tui/views/transcript.py +0 -0
  132. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/__init__.py +0 -0
  133. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/conftest.py +0 -0
  134. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/__init__.py +0 -0
  135. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/conftest.py +0 -0
  136. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/fixtures/echo_server.py +0 -0
  137. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/report.py +0 -0
  138. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/stub.py +0 -0
  139. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/terminal.py +0 -0
  140. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/test_chat.py +0 -0
  141. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/test_input.py +0 -0
  142. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/test_print.py +0 -0
  143. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/e2e/test_tools.py +0 -0
  144. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/test_live.py +0 -0
  145. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/test_live_codex.py +0 -0
  146. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/test_live_devin.py +0 -0
  147. {hx_cli-0.2.8 → hx_cli-0.2.10}/tests/test_live_tavily.py +0 -0
  148. {hx_cli-0.2.8 → hx_cli-0.2.10}/uv.lock +0 -0
@@ -28,6 +28,39 @@ 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
+
47
+ ## [0.2.9] - 2026-09-27
48
+
49
+ ### Added
50
+
51
+ - `~/.hx/AGENTS.md` holds your own instructions and is loaded into every
52
+ session in every project, ahead of the project's `AGENTS.md`. The first
53
+ session creates it empty, ready to fill in. `hx prompt` and `/prompt` now
54
+ name each `AGENTS.md` in force, and `/prompt` says when one has changed on
55
+ disk since the session started.
56
+
57
+ ### Fixed
58
+
59
+ - An `AGENTS.md` or system-prompt override that is not valid UTF-8 is skipped
60
+ like an unreadable one, instead of stopping HX from starting.
61
+
62
+ ---
63
+
31
64
  ## [0.2.8] - 2026-09-27
32
65
 
33
66
  ### Changed
@@ -725,7 +758,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
725
758
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
726
759
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
727
760
 
728
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.8...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
763
+ [0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
729
764
  [0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
730
765
  [0.2.7]: https://github.com/aletisunil/hx/compare/v0.2.6...v0.2.7
731
766
  [0.2.6]: https://github.com/aletisunil/hx/compare/v0.2.5...v0.2.6
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.8
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
@@ -753,6 +753,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
753
753
  | `~/.hx/keybindings.json` | your key overrides |
754
754
  | `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
755
755
  | `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
756
+ | `~/.hx/AGENTS.md` | your instructions, loaded into every session in every project; created empty |
756
757
  | `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
757
758
  | `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
758
759
  | `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
@@ -761,12 +762,25 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
761
762
  | `./.hx/mcp.json` | project MCP servers |
762
763
  | `./.hx/system-prompt.md`, `./.hx/system-prompt-append.md` | project prompt overrides |
763
764
  | `./.hx/skills/`, `./.hx/agents/` | project skills and agents |
764
- | `./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 |
765
766
 
766
767
  `AGENTS.md` is the place for things a newcomer would get wrong: how to run the
767
768
  tests, conventions, what not to touch. `/init` writes a first draft. It is
768
769
  loaded once per session and frozen, so it costs one prefix, not one per turn.
769
770
 
771
+ `~/.hx/AGENTS.md` is the same thing for you rather than the project: how you
772
+ like commits written, tools you always want used, in every repository. It is
773
+ loaded first and the project's `AGENTS.md` after it, so where the two disagree
774
+ the project has the last word. The first session creates it empty, ready to
775
+ fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
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
+
770
784
  ---
771
785
 
772
786
  ## Safety
@@ -713,6 +713,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
713
713
  | `~/.hx/keybindings.json` | your key overrides |
714
714
  | `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
715
715
  | `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
716
+ | `~/.hx/AGENTS.md` | your instructions, loaded into every session in every project; created empty |
716
717
  | `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
717
718
  | `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
718
719
  | `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
@@ -721,12 +722,25 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
721
722
  | `./.hx/mcp.json` | project MCP servers |
722
723
  | `./.hx/system-prompt.md`, `./.hx/system-prompt-append.md` | project prompt overrides |
723
724
  | `./.hx/skills/`, `./.hx/agents/` | project skills and agents |
724
- | `./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 |
725
726
 
726
727
  `AGENTS.md` is the place for things a newcomer would get wrong: how to run the
727
728
  tests, conventions, what not to touch. `/init` writes a first draft. It is
728
729
  loaded once per session and frozen, so it costs one prefix, not one per turn.
729
730
 
731
+ `~/.hx/AGENTS.md` is the same thing for you rather than the project: how you
732
+ like commits written, tools you always want used, in every repository. It is
733
+ loaded first and the project's `AGENTS.md` after it, so where the two disagree
734
+ the project has the last word. The first session creates it empty, ready to
735
+ fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
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
+
730
744
  ---
731
745
 
732
746
  ## Safety
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.8"
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"
@@ -346,7 +346,12 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
346
346
  from hx.config import load_settings
347
347
  from hx.core.checkpoints import CheckpointStore
348
348
  from hx.core.compaction import Compactor
349
- from hx.core.context import ContextBuilder, build_project_context, load_system_prompt
349
+ from hx.core.context import (
350
+ ContextBuilder,
351
+ build_project_context,
352
+ load_instructions,
353
+ load_system_prompt,
354
+ )
350
355
  from hx.core.events import EventBus
351
356
  from hx.core.lateinject import Injection, InjectionRegistry
352
357
  from hx.core.loop import AgentLoop
@@ -430,6 +435,8 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
430
435
  settings.cwd,
431
436
  keep_recent_turns=settings.context.keep_recent_turns,
432
437
  )
438
+ instructions = load_instructions(settings.cwd)
439
+ project_context = build_project_context(settings.cwd, instructions)
433
440
 
434
441
  checkpoints = CheckpointStore(session, session_checkpoints_dir(session.meta.session_id))
435
442
  tools = build_default_registry(shell, jobs, tracker, todos, bus_holder, auth, checkpoints)
@@ -458,6 +465,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
458
465
  parent_session_id=session.meta.session_id,
459
466
  parent_usage=session.usage,
460
467
  hooks=hooks,
468
+ project_context=project_context,
461
469
  )
462
470
  tools.register(TaskTool(subagents))
463
471
 
@@ -478,7 +486,8 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
478
486
  settings=settings,
479
487
  model_info=model_info,
480
488
  skills_index=build_index(list(skills.values())) or None,
481
- project_context=build_project_context(settings.cwd),
489
+ project_context=project_context,
490
+ instructions=instructions,
482
491
  hooks=hooks,
483
492
  )
484
493
 
@@ -842,15 +851,17 @@ def run_print_command(parsed: ParsedArgs) -> int:
842
851
  def run_prompt_command(parsed: ParsedArgs) -> int:
843
852
  """``hx prompt`` - print the system prompt this directory resolves to.
844
853
 
845
- The prompt goes to stdout so it can be piped or diffed; where it came from
846
- goes to stderr so it never contaminates that output.
854
+ The prompt goes to stdout so it can be piped or diffed; where it came from,
855
+ and which AGENTS.md files ride alongside it, goes to stderr so it never
856
+ contaminates that output.
847
857
  """
848
858
  from hx.config import load_settings
849
- from hx.core.context import resolve_system_prompt
859
+ from hx.core.context import load_instructions, resolve_system_prompt
850
860
 
851
861
  try:
852
862
  settings = load_settings(parsed.cwd, parsed.overrides)
853
863
  resolved = resolve_system_prompt(settings.cwd, settings.prompt)
864
+ instructions = load_instructions(settings.cwd)
854
865
  except Exception as exc:
855
866
  return _report(exc)
856
867
 
@@ -858,6 +869,8 @@ def run_prompt_command(parsed: ParsedArgs) -> int:
858
869
  print(f"[source] {resolved.source}", file=sys.stderr)
859
870
  for append in resolved.appends:
860
871
  print(f"[append] {append}", file=sys.stderr)
872
+ for loaded in instructions:
873
+ print(f"[instructions] {loaded.path}", file=sys.stderr)
861
874
  return 0
862
875
 
863
876
 
@@ -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 AGENTS.md, cwd, git branch, top-level listing
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) ---
@@ -238,11 +239,63 @@ def _message_text(message: Message) -> str:
238
239
  return "\n".join(parts)
239
240
 
240
241
 
241
- def build_project_context(cwd: Path) -> str:
242
- """Static per-session project preamble: AGENTS.md contents, cwd, git branch, listing.
242
+ @dataclass(slots=True)
243
+ class Instructions:
244
+ """One AGENTS.md in force: what it is, where it lives, what it says."""
245
+
246
+ heading: str
247
+ path: Path
248
+ text: str
249
+
250
+
251
+ def load_instructions(cwd: Path) -> list[Instructions]:
252
+ """Every AGENTS.md in force, least specific first.
253
+
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.
259
+ """
260
+ from hx.paths import project_instructions_files, project_root, tilde, user_instructions_file
261
+
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
+
271
+ found: list[Instructions] = []
272
+ seen: set[Path] = set()
273
+ for heading, path in candidates:
274
+ body = _read_prompt_file(path)
275
+ if not body or path.resolve() in seen:
276
+ continue
277
+ seen.add(path.resolve())
278
+ found.append(Instructions(heading, path, body))
279
+ return found
280
+
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
+
293
+ def build_project_context(cwd: Path, instructions: list[Instructions] | None = None) -> str:
294
+ """Static per-session project preamble: cwd, git branch, listing, AGENTS.md contents.
243
295
 
244
296
  Computed once at startup and then frozen - refreshing it mid-session would
245
- invalidate the prefix.
297
+ invalidate the prefix. Pass ``instructions`` to fold in the AGENTS.md files
298
+ already loaded, so the caller can remember exactly which ones are in force.
246
299
  """
247
300
  lines = [f"Working directory: {cwd}"]
248
301
 
@@ -256,9 +309,11 @@ def build_project_context(cwd: Path) -> str:
256
309
  if entries:
257
310
  lines.append("Top level: " + ", ".join(entries[:60]))
258
311
 
259
- agents_md = cwd / "AGENTS.md"
260
- if agents_md.is_file():
261
- lines.append(f"\n# Project instructions (AGENTS.md)\n\n{agents_md.read_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}")
262
317
 
263
318
  return "\n".join(lines)
264
319
 
@@ -367,12 +422,12 @@ def resolve_system_prompt(cwd: Path, prompt: PromptSettings | None = None) -> Re
367
422
  def _read_prompt_file(path: Path) -> str:
368
423
  """Contents of a prompt override file, or ``""`` when it is absent or empty.
369
424
 
370
- An unreadable file is treated as absent: a permissions problem on an
371
- optional override must not stop the session from starting.
425
+ An unreadable file - no permission, or not UTF-8 - is treated as absent: a
426
+ problem with an optional override must not stop the session from starting.
372
427
  """
373
428
  try:
374
- return path.read_text().strip() if path.is_file() else ""
375
- except OSError:
429
+ return path.read_text(encoding="utf-8").strip() if path.is_file() else ""
430
+ except (OSError, UnicodeDecodeError):
376
431
  return ""
377
432
 
378
433
 
@@ -18,7 +18,7 @@ import time
18
18
  from dataclasses import dataclass, replace
19
19
  from typing import TYPE_CHECKING, Any, ClassVar
20
20
 
21
- from hx.core.context import AssembledContext
21
+ from hx.core.context import AssembledContext, Instructions
22
22
  from hx.core.events import (
23
23
  CompactionFinished,
24
24
  CompactionStarted,
@@ -103,6 +103,7 @@ class AgentLoop:
103
103
  active_skills: ActiveSkills | None = None,
104
104
  skills_index: str | None = None,
105
105
  project_context: str | None = None,
106
+ instructions: list[Instructions] | None = None,
106
107
  hooks: HookEngine | None = None,
107
108
  ) -> None:
108
109
  self.provider = provider
@@ -118,6 +119,8 @@ class AgentLoop:
118
119
  self.active_skills = active_skills
119
120
  self.skills_index = skills_index
120
121
  self.project_context = project_context
122
+ self.instructions = instructions or []
123
+ """The AGENTS.md files folded into ``project_context``, least specific first."""
121
124
  self.hooks = hooks
122
125
  self._cancelled = False
123
126
  self._turn_index = 0
@@ -30,6 +30,20 @@ def user_home() -> Path:
30
30
  return Path(override).expanduser() if override else Path.home() / ".hx"
31
31
 
32
32
 
33
+ def tilde(path: str | Path) -> str:
34
+ """``path`` with the home directory written as ``~``.
35
+
36
+ Compared as text, not resolved: a session records the path it resolved
37
+ when it was made, and the directory may be gone by the time it is drawn.
38
+ """
39
+ text, home = str(path), str(Path.home())
40
+ if text == home:
41
+ return "~"
42
+ if text.startswith(home.rstrip("/") + "/"):
43
+ return "~" + text[len(home.rstrip("/")) :]
44
+ return text
45
+
46
+
33
47
  def user_settings_file() -> Path:
34
48
  return user_home() / "settings.json"
35
49
 
@@ -198,7 +212,69 @@ def project_system_prompt_append_file(cwd: Path | None = None) -> Path:
198
212
  return project_dir(cwd) / "system-prompt-append.md"
199
213
 
200
214
 
215
+ def user_instructions_file() -> Path:
216
+ """Your ``AGENTS.md``: instructions that follow you into every project."""
217
+ return user_home() / "AGENTS.md"
218
+
219
+
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])]
264
+
265
+
201
266
  def ensure_user_dirs() -> None:
202
- """Create the user-level directory skeleton if it does not exist."""
267
+ """Create the user-level skeleton if it does not exist.
268
+
269
+ That includes an empty ``AGENTS.md``, so there is a file to find and fill
270
+ in; empty, it adds nothing to the prompt. An existing one - including a
271
+ symlink, dangling or not - is never touched, and failing to create it never
272
+ stops a session: the file is optional.
273
+ """
203
274
  for path in (user_home(), sessions_dir(), user_skills_dir(), user_agents_dir(), logs_dir()):
204
275
  path.mkdir(parents=True, exist_ok=True)
276
+ try:
277
+ with user_instructions_file().open("x"):
278
+ pass
279
+ except OSError:
280
+ pass
@@ -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:
@@ -507,7 +510,7 @@ async def cmd_title(ctx: CommandContext, args: str) -> None:
507
510
 
508
511
  async def cmd_prompt(ctx: CommandContext, args: str) -> None:
509
512
  """``/prompt`` - the system prompt this session is actually running with."""
510
- from hx.core.context import resolve_system_prompt
513
+ from hx.core.context import load_instructions, resolve_system_prompt
511
514
 
512
515
  in_use = ctx.app.loop.context.system_prompt
513
516
  lines = [in_use.rstrip()]
@@ -516,10 +519,15 @@ async def cmd_prompt(ctx: CommandContext, args: str) -> None:
516
519
  lines.append(f"\nSource: {resolved.source}")
517
520
  for append in resolved.appends:
518
521
  lines.append(f"Appended: {append}")
522
+ in_force = ctx.app.loop.instructions
523
+ for loaded in in_force:
524
+ lines.append(f"Instructions: {loaded.path}")
525
+ # The prompt and AGENTS.md are read once at startup to keep the cache prefix
526
+ # stable, so an edit since then is real but not yet in force.
519
527
  if resolved.text.strip() != in_use.strip():
520
- # The prompt is read once at startup to keep the cache prefix stable, so
521
- # an override edited since then is real but not yet in force.
522
528
  lines.append("An override has changed on disk since startup; it applies next run.")
529
+ if load_instructions(ctx.settings.cwd) != in_force:
530
+ lines.append("An AGENTS.md has changed on disk since startup; it applies next run.")
523
531
  ctx.app.notice("\n".join(lines))
524
532
 
525
533
 
@@ -741,8 +749,7 @@ async def cmd_permissions(ctx: CommandContext, args: str) -> None:
741
749
  lines.append("")
742
750
  lines.append('No rules configured. Add them under "permissions" in .hx/settings.json.')
743
751
 
744
- from hx.paths import project_local_settings_file
745
- from hx.tui.format import tilde
752
+ from hx.paths import project_local_settings_file, tilde
746
753
 
747
754
  # Where, and why there: a grant is machine-local, so it is kept per project
748
755
  # under the user's home instead of being dropped into the checkout.
@@ -776,7 +783,9 @@ async def cmd_mode(ctx: CommandContext, args: str) -> None:
776
783
 
777
784
 
778
785
  INIT_PROMPT = """\
779
- 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.
780
789
 
781
790
  Read enough of the codebase to be accurate. Cover: what the project is, how to
782
791
  build, test and lint it, the layout of the source tree, and any conventions a
@@ -788,7 +797,7 @@ If AGENTS.md already exists, improve it rather than replacing it wholesale."""
788
797
 
789
798
  async def cmd_init(ctx: CommandContext, args: str) -> None:
790
799
  """``/init`` - generate an AGENTS.md describing this project."""
791
- await ctx.app.submit_to_model(INIT_PROMPT)
800
+ await ctx.app.submit_to_model(INIT_PROMPT.format(directory=ctx.settings.cwd))
792
801
 
793
802
 
794
803
  async def cmd_configure(ctx: CommandContext, args: str) -> None:
@@ -11,7 +11,6 @@ from __future__ import annotations
11
11
 
12
12
  import re
13
13
  from collections.abc import Sequence
14
- from pathlib import Path
15
14
 
16
15
  from hx.term.sanitize import plain_text
17
16
  from hx.term.width import cell_width, truncate_to_width
@@ -76,17 +75,3 @@ def one_line(text: str, width: int) -> str:
76
75
  if cell_width(flat) <= width:
77
76
  return flat
78
77
  return truncate_to_width(flat, max(0, width - cell_width(ELLIPSIS))) + ELLIPSIS
79
-
80
-
81
- def tilde(path: str | Path) -> str:
82
- """``path`` with the home directory written as ``~``.
83
-
84
- Compared as text, not resolved: a session records the path it resolved
85
- when it was made, and the directory may be gone by the time it is drawn.
86
- """
87
- text, home = str(path), str(Path.home())
88
- if text == home:
89
- return "~"
90
- if text.startswith(home.rstrip("/") + "/"):
91
- return "~" + text[len(home.rstrip("/")) :]
92
- return text
@@ -26,10 +26,10 @@ from hx.core.messages import (
26
26
  )
27
27
  from hx.core.usage import format_tokens
28
28
  from hx.git import BranchWatcher
29
+ from hx.paths import tilde
29
30
  from hx.term.loop import TuiRunner
30
31
  from hx.term.modifiers import ShiftEnter
31
32
  from hx.term.terminal import Terminal
32
- from hx.tui.format import tilde
33
33
  from hx.tui.renderers import ToolCall
34
34
  from hx.tui.views.blocks import (
35
35
  AssistantMessage,
@@ -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(