hx-cli 0.2.13__tar.gz → 0.2.14__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 (159) hide show
  1. {hx_cli-0.2.13 → hx_cli-0.2.14}/CHANGELOG.md +48 -1
  2. {hx_cli-0.2.13 → hx_cli-0.2.14}/PKG-INFO +14 -8
  3. {hx_cli-0.2.13 → hx_cli-0.2.14}/README.md +13 -7
  4. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/agents/subagent.py +1 -0
  6. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/cli.py +5 -5
  7. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/loop.py +39 -1
  8. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/base.py +5 -0
  9. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/claude_code.py +72 -3
  10. hx_cli-0.2.14/src/hx/providers/claude_code_catalogue.py +276 -0
  11. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/codex.py +1 -1
  12. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/models.py +256 -31
  13. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/commands.py +56 -36
  14. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/runtime.py +13 -0
  15. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/views/pickers.py +101 -14
  16. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/conftest.py +42 -4
  17. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/fake_claude.py +71 -4
  18. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/stub.py +32 -1
  19. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/test_claude_code.py +457 -7
  20. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/test_commands.py +133 -2
  21. hx_cli-0.2.13/src/hx/providers/claude_code_catalogue.py +0 -140
  22. {hx_cli-0.2.13 → hx_cli-0.2.14}/.github/workflows/ci.yml +0 -0
  23. {hx_cli-0.2.13 → hx_cli-0.2.14}/.gitignore +0 -0
  24. {hx_cli-0.2.13 → hx_cli-0.2.14}/examples/hooks/check.sh +0 -0
  25. {hx_cli-0.2.13 → hx_cli-0.2.14}/install.sh +0 -0
  26. {hx_cli-0.2.13 → hx_cli-0.2.14}/pyproject.toml +0 -0
  27. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/agents/__init__.py +0 -0
  28. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/agents/definitions.py +0 -0
  29. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/auth/__init__.py +0 -0
  30. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/auth/claude_code.py +0 -0
  31. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/auth/oauth/__init__.py +0 -0
  32. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/auth/oauth/browser.py +0 -0
  33. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/auth/oauth/callback.py +0 -0
  34. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/auth/oauth/codex.py +0 -0
  35. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/auth/oauth/devin.py +0 -0
  36. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/auth/oauth/pkce.py +0 -0
  37. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/auth/resolve.py +0 -0
  38. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/auth/store.py +0 -0
  39. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/config.py +0 -0
  40. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/__init__.py +0 -0
  41. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/checkpoints.py +0 -0
  42. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/compaction.py +0 -0
  43. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/context.py +0 -0
  44. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/events.py +0 -0
  45. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/images.py +0 -0
  46. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/lateinject.py +0 -0
  47. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/messages.py +0 -0
  48. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/session.py +0 -0
  49. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/title.py +0 -0
  50. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/core/usage.py +0 -0
  51. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/docs.py +0 -0
  52. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/frontmatter.py +0 -0
  53. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/git.py +0 -0
  54. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/hooks/__init__.py +0 -0
  55. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/hooks/engine.py +0 -0
  56. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/hooks/spec.py +0 -0
  57. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/keys.py +0 -0
  58. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/mcp/__init__.py +0 -0
  59. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/mcp/client.py +0 -0
  60. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/mcp/manager.py +0 -0
  61. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/mcp/oauth.py +0 -0
  62. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/net.py +0 -0
  63. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/paths.py +0 -0
  64. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/permissions/__init__.py +0 -0
  65. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/permissions/engine.py +0 -0
  66. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/permissions/parser.py +0 -0
  67. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/permissions/sandbox.py +0 -0
  68. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/__init__.py +0 -0
  69. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/claude_code_cli.py +0 -0
  70. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/claude_code_transcript.py +0 -0
  71. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/codex_catalogue.py +0 -0
  72. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/devin.py +0 -0
  73. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/devin_catalogue.py +0 -0
  74. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/devin_wire.py +0 -0
  75. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/openrouter.py +0 -0
  76. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/protowire.py +0 -0
  77. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/registry.py +0 -0
  78. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/providers/responses_codec.py +0 -0
  79. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/skills/__init__.py +0 -0
  80. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/skills/loader.py +0 -0
  81. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/skills/runtime.py +0 -0
  82. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/__init__.py +0 -0
  83. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/ansi.py +0 -0
  84. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/buffer.py +0 -0
  85. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/component.py +0 -0
  86. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/demo.py +0 -0
  87. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/editor.py +0 -0
  88. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/keydecode.py +0 -0
  89. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/loop.py +0 -0
  90. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/markdown.py +0 -0
  91. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/modifiers.py +0 -0
  92. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/primitives.py +0 -0
  93. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/sanitize.py +0 -0
  94. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/screen.py +0 -0
  95. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/syntax.py +0 -0
  96. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/terminal.py +0 -0
  97. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/undo.py +0 -0
  98. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/width.py +0 -0
  99. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/term/word_nav.py +0 -0
  100. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/__init__.py +0 -0
  101. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/anchors.py +0 -0
  102. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/base.py +0 -0
  103. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/bash.py +0 -0
  104. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/edit.py +0 -0
  105. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/glob.py +0 -0
  106. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/grep.py +0 -0
  107. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/output.py +0 -0
  108. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/read.py +0 -0
  109. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/registry.py +0 -0
  110. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/symbols.py +0 -0
  111. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/task.py +0 -0
  112. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/todo.py +0 -0
  113. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/websearch.py +0 -0
  114. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tools/write.py +0 -0
  115. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/trace.py +0 -0
  116. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/__init__.py +0 -0
  117. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/app.py +0 -0
  118. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/clipboard.py +0 -0
  119. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/format.py +0 -0
  120. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/fuzzy.py +0 -0
  121. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/glyphs.py +0 -0
  122. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/killring.py +0 -0
  123. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/limits.py +0 -0
  124. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/paint.py +0 -0
  125. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/renderers.py +0 -0
  126. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/roles.py +0 -0
  127. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/theme.py +0 -0
  128. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/theme_json.py +0 -0
  129. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/themes/ansi.json +0 -0
  130. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/themes/dark.json +0 -0
  131. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/themes/light.json +0 -0
  132. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/views/__init__.py +0 -0
  133. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/views/blocks.py +0 -0
  134. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/views/dialog.py +0 -0
  135. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/views/login.py +0 -0
  136. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/views/permission.py +0 -0
  137. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/views/prompt.py +0 -0
  138. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/views/status.py +0 -0
  139. {hx_cli-0.2.13 → hx_cli-0.2.14}/src/hx/tui/views/transcript.py +0 -0
  140. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/__init__.py +0 -0
  141. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/conftest.py +0 -0
  142. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/__init__.py +0 -0
  143. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/fixtures/echo_server.py +0 -0
  144. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/report.py +0 -0
  145. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/terminal.py +0 -0
  146. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/test_chat.py +0 -0
  147. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/test_cli.py +0 -0
  148. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/test_extend.py +0 -0
  149. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/test_input.py +0 -0
  150. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/test_print.py +0 -0
  151. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/test_queue.py +0 -0
  152. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/e2e/test_tools.py +0 -0
  153. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/test_live.py +0 -0
  154. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/test_live_claude_code.py +0 -0
  155. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/test_live_codex.py +0 -0
  156. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/test_live_devin.py +0 -0
  157. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/test_live_tavily.py +0 -0
  158. {hx_cli-0.2.13 → hx_cli-0.2.14}/tests/tls_proxy.py +0 -0
  159. {hx_cli-0.2.13 → hx_cli-0.2.14}/uv.lock +0 -0
@@ -28,6 +28,52 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.14] - 2026-10-04
32
+
33
+ ### Added
34
+
35
+ - When the installed Claude Code is behind its current release - and so lists older Claude
36
+ models than your subscription can run - `/model` says so under the list, with the command
37
+ that updates it (`brew upgrade --cask claude-code` for a Homebrew install, otherwise
38
+ `claude update`). After updating, the next `/model` lists the new models; no
39
+ refresh needed.
40
+
41
+ ### Changed
42
+
43
+ - The model catalogue is refreshed from inside the `/model` picker: press `ctrl+r`. The
44
+ list updates in place and keeps your filter and selection; the outcome, or why the refresh
45
+ failed, shows under the list. With no models at all, `/model` now opens anyway and says
46
+ so, so `ctrl+r` is there to fill it.
47
+
48
+ ### Removed
49
+
50
+ - `/models refresh`. It sat one letter away from `/model` and did nothing without its one
51
+ argument; use `ctrl+r` in the `/model` picker.
52
+
53
+ ### Fixed
54
+
55
+ - On the `claude-code` route, the context window is the one Claude Code reports, not a
56
+ 200k guess: Opus 5.5 runs with 1M. Claude Code lists its models without their sizes, and
57
+ what it serves depends on the plan, so a model starts at the window it was measured
58
+ serving on every plan - 1M for Opus 5.5, Sonnet 5.5, Sonnet 5 and Opus 4.7, 200k
59
+ otherwise - until its first turn reports the real window and maximum output for your
60
+ account. From then on the context gauge, the compaction threshold, the `/model` picker
61
+ and later sessions all use it, and a catalogue refresh keeps it.
62
+ - A model Claude Code lists at its long context - Fable 5.1 as `claude-fable-5-1[1m]` -
63
+ runs at it: `/model` offers `claude-code/claude-fable-5-1[1m]` at 1M, where HX used to
64
+ drop the `[1m]` and run the model at its standard context.
65
+ - A turn refused because the model, or its 1M context, needs usage credits your Claude plan
66
+ has off no longer reads "usage limit is reached. It resets at ...": HX says what needs the
67
+ credits, where to turn them on, and which model runs without them. Once they are on, the
68
+ next turn runs.
69
+ - After a catalogue refresh (`ctrl+r` in `/model`, or signing in), the session in progress
70
+ measures its context against the refreshed window, not the one it started with.
71
+ - `/model anthropic` (or typing `anthropic` in the picker) lists the Claude models your Claude
72
+ Code subscription runs, first, ahead of the per-token OpenRouter ones; `openai` does the same
73
+ for Codex. Several words match in any order: `/model anthropic sonnet`.
74
+
75
+ ---
76
+
31
77
  ## [0.2.13] - 2026-10-04
32
78
 
33
79
  ### Added
@@ -833,7 +879,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
833
879
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
834
880
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
835
881
 
836
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.13...HEAD
882
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.14...HEAD
883
+ [0.2.14]: https://github.com/aletisunil/hx/compare/v0.2.13...v0.2.14
837
884
  [0.2.13]: https://github.com/aletisunil/hx/compare/v0.2.12...v0.2.13
838
885
  [0.2.12]: https://github.com/aletisunil/hx/compare/v0.2.11...v0.2.12
839
886
  [0.2.11]: https://github.com/aletisunil/hx/compare/v0.2.10...v0.2.11
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.13
3
+ Version: 0.2.14
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
@@ -156,7 +156,7 @@ reach it at all.
156
156
 
157
157
  **Which Codex models you get** is decided by your ChatGPT account, not by HX.
158
158
  Signing in fetches the account's own list from the Codex backend, and `/model`
159
- offers exactly that; `/models refresh` asks again. Entitlement is per model —
159
+ offers exactly that; ctrl+r in `/model` asks again. Entitlement is per model -
160
160
  the same `plus` account can run `gpt-5.6-sol` and be refused `gpt-5.3-codex` —
161
161
  so a model the picker does not list is one the subscription does not cover.
162
162
 
@@ -218,7 +218,7 @@ you to sign in again. On a machine with no browser at all, a session token in
218
218
  `HX_DEVIN_API_KEY` or `DEVIN_API_KEY` is used when nothing is saved.
219
219
 
220
220
  Signing in fetches the account's own model list, and `/model` offers exactly
221
- that; `/models refresh` asks again. Devin publishes each reasoning depth as a
221
+ that; ctrl+r in `/model` asks again. Devin publishes each reasoning depth as a
222
222
  model of its own - Claude Opus 5 High, Claude Opus 5 Max - and HX folds each
223
223
  family into one model, so `/effort` picks the depth on Devin exactly as it does
224
224
  on Codex. A fast lane or a 1M-context lane stays a separate model, since it
@@ -264,8 +264,15 @@ rest - are removed from its environment; its TLS settings are kept (see
264
264
  `claude` other than the one on `PATH`.
265
265
 
266
266
  `/model` offers the models Claude Code lists for the account, each with the
267
- efforts it accepts for `/effort`; `/models refresh` asks again. When a usage
268
- limit is reached, HX says which one and when it resets.
267
+ efforts it accepts for `/effort`; ctrl+r in `/model` asks again. A model Claude
268
+ Code runs at its long context keeps `[1m]` in its id
269
+ (`claude-code/claude-fable-5-1[1m]`), exactly as Claude Code's own `/model` runs
270
+ it. Claude Code lists no context sizes, so a model starts at the window it was
271
+ measured serving on every plan - 1M for Opus 5.5, Sonnet 5.5, Sonnet 5 and Opus
272
+ 4.7, otherwise 200k - and from its first turn uses the window Claude Code
273
+ reports for your account. When a usage limit is reached, HX says which one and
274
+ when it resets; when a model or its 1M context needs usage credits your plan
275
+ has off, HX says that instead, and what to pick without them.
269
276
 
270
277
  **Web search.** Optional, and the one credential that is not a model route.
271
278
  It powers two tools:
@@ -421,7 +428,7 @@ be rebound (see [Keybindings](#keybindings)).
421
428
  | `ctrl+d` | exit, when the prompt is empty |
422
429
  | `shift+tab` | cycle permission mode |
423
430
  | `ctrl+p` | command palette |
424
- | `ctrl+l` | model picker |
431
+ | `ctrl+l` | model picker; inside it, `ctrl+r` fetches the catalogue again |
425
432
  | `ctrl+o` | expand tool output (also `ctrl+r`) |
426
433
  | `ctrl+t` | show the current plan |
427
434
  | `ctrl+x` | copy the selected message to the clipboard |
@@ -527,8 +534,7 @@ silently resolved.
527
534
 
528
535
  | Command | Does |
529
536
  |---|---|
530
- | `/model [query]` | pick a model; shows context window, price per Mtok, cache support |
531
- | `/models refresh` | re-fetch the catalogue |
537
+ | `/model [query]` | pick a model; shows context window, price per Mtok, cache support. `ctrl+r` in the picker re-fetches the catalogue |
532
538
  | `/effort [level]` | how hard a reasoning model thinks, from the levels it offers |
533
539
  | `/configure` | session settings and the API key |
534
540
  | `/login [provider]` | sign in to a model route |
@@ -115,7 +115,7 @@ reach it at all.
115
115
 
116
116
  **Which Codex models you get** is decided by your ChatGPT account, not by HX.
117
117
  Signing in fetches the account's own list from the Codex backend, and `/model`
118
- offers exactly that; `/models refresh` asks again. Entitlement is per model —
118
+ offers exactly that; ctrl+r in `/model` asks again. Entitlement is per model -
119
119
  the same `plus` account can run `gpt-5.6-sol` and be refused `gpt-5.3-codex` —
120
120
  so a model the picker does not list is one the subscription does not cover.
121
121
 
@@ -177,7 +177,7 @@ you to sign in again. On a machine with no browser at all, a session token in
177
177
  `HX_DEVIN_API_KEY` or `DEVIN_API_KEY` is used when nothing is saved.
178
178
 
179
179
  Signing in fetches the account's own model list, and `/model` offers exactly
180
- that; `/models refresh` asks again. Devin publishes each reasoning depth as a
180
+ that; ctrl+r in `/model` asks again. Devin publishes each reasoning depth as a
181
181
  model of its own - Claude Opus 5 High, Claude Opus 5 Max - and HX folds each
182
182
  family into one model, so `/effort` picks the depth on Devin exactly as it does
183
183
  on Codex. A fast lane or a 1M-context lane stays a separate model, since it
@@ -223,8 +223,15 @@ rest - are removed from its environment; its TLS settings are kept (see
223
223
  `claude` other than the one on `PATH`.
224
224
 
225
225
  `/model` offers the models Claude Code lists for the account, each with the
226
- efforts it accepts for `/effort`; `/models refresh` asks again. When a usage
227
- limit is reached, HX says which one and when it resets.
226
+ efforts it accepts for `/effort`; ctrl+r in `/model` asks again. A model Claude
227
+ Code runs at its long context keeps `[1m]` in its id
228
+ (`claude-code/claude-fable-5-1[1m]`), exactly as Claude Code's own `/model` runs
229
+ it. Claude Code lists no context sizes, so a model starts at the window it was
230
+ measured serving on every plan - 1M for Opus 5.5, Sonnet 5.5, Sonnet 5 and Opus
231
+ 4.7, otherwise 200k - and from its first turn uses the window Claude Code
232
+ reports for your account. When a usage limit is reached, HX says which one and
233
+ when it resets; when a model or its 1M context needs usage credits your plan
234
+ has off, HX says that instead, and what to pick without them.
228
235
 
229
236
  **Web search.** Optional, and the one credential that is not a model route.
230
237
  It powers two tools:
@@ -380,7 +387,7 @@ be rebound (see [Keybindings](#keybindings)).
380
387
  | `ctrl+d` | exit, when the prompt is empty |
381
388
  | `shift+tab` | cycle permission mode |
382
389
  | `ctrl+p` | command palette |
383
- | `ctrl+l` | model picker |
390
+ | `ctrl+l` | model picker; inside it, `ctrl+r` fetches the catalogue again |
384
391
  | `ctrl+o` | expand tool output (also `ctrl+r`) |
385
392
  | `ctrl+t` | show the current plan |
386
393
  | `ctrl+x` | copy the selected message to the clipboard |
@@ -486,8 +493,7 @@ silently resolved.
486
493
 
487
494
  | Command | Does |
488
495
  |---|---|
489
- | `/model [query]` | pick a model; shows context window, price per Mtok, cache support |
490
- | `/models refresh` | re-fetch the catalogue |
496
+ | `/model [query]` | pick a model; shows context window, price per Mtok, cache support. `ctrl+r` in the picker re-fetches the catalogue |
491
497
  | `/effort [level]` | how hard a reasoning model thinks, from the levels it offers |
492
498
  | `/configure` | session settings and the API key |
493
499
  | `/login [provider]` | sign in to a model route |
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.13"
3
+ __version__ = "0.2.14"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -173,6 +173,7 @@ class SubagentRunner:
173
173
  model_info=self.models.get_or_default(model) if self.models else None,
174
174
  project_context=self.project_context,
175
175
  hooks=self.hooks,
176
+ models=self.models,
176
177
  )
177
178
  loop.origin = f"{definition.name} subagent"
178
179
  return loop
@@ -291,13 +291,10 @@ class Runtime:
291
291
  self.notices.append(f"Model catalogue refresh failed: {describe(exc)}")
292
292
  return
293
293
  finally:
294
- # The loop resolved its model before the refresh, from the cache
295
- # being replaced. Left alone it would gauge against yesterday's
296
- # context window and decide image support from a cache that did
297
- # not record it - until the user happened to switch models.
298
- self.loop.model_info = self.models.get_or_default(self.loop.model)
294
+ self.loop.catalogue_refreshed()
299
295
  for label, error in self.models.subscription_errors():
300
296
  self.notices.append(f"Could not list this account's {label} models: {error}")
297
+ self.notices.extend(self.models.subscription_advice())
301
298
 
302
299
  async def aclose(self) -> None:
303
300
  if self.mcp is not None:
@@ -489,6 +486,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
489
486
  project_context=project_context,
490
487
  instructions=instructions,
491
488
  hooks=hooks,
489
+ models=models,
492
490
  )
493
491
 
494
492
  return Runtime(
@@ -1303,6 +1301,8 @@ def _report_models_after_login(provider_id: str) -> None:
1303
1301
  print(f"Could not refresh the model list: {describe(exc)}", file=sys.stderr)
1304
1302
  for label, error in models.subscription_errors():
1305
1303
  print(f"Could not list this account's {label} models: {error}", file=sys.stderr)
1304
+ for advice in models.subscription_advice():
1305
+ print(advice, file=sys.stderr)
1306
1306
 
1307
1307
  available = [m.id for m in models.all() if m.provider_id == provider_id]
1308
1308
  if not available:
@@ -66,7 +66,7 @@ if TYPE_CHECKING:
66
66
  from hx.hooks.engine import HookEngine
67
67
  from hx.permissions.engine import PermissionEngine
68
68
  from hx.providers.base import Provider
69
- from hx.providers.models import ModelInfo
69
+ from hx.providers.models import ModelInfo, ModelRegistry
70
70
  from hx.skills.runtime import ActiveSkills
71
71
  from hx.tools.registry import ToolRegistry
72
72
 
@@ -107,6 +107,7 @@ class AgentLoop:
107
107
  project_context: str | None = None,
108
108
  instructions: list[Instructions] | None = None,
109
109
  hooks: HookEngine | None = None,
110
+ models: ModelRegistry | None = None,
110
111
  ) -> None:
111
112
  self.provider = provider
112
113
  self.session = session
@@ -124,6 +125,9 @@ class AgentLoop:
124
125
  self.instructions = instructions or []
125
126
  """The AGENTS.md files folded into ``project_context``, least specific first."""
126
127
  self.hooks = hooks
128
+ self.models = models
129
+ """Where a window the backend reports is kept, so the picker and the
130
+ next session start from it rather than from the catalogue's guess."""
127
131
  self._cancelled = False
128
132
  self._turn_index = 0
129
133
  self._steer: list[UserTurn] = []
@@ -155,6 +159,19 @@ class AgentLoop:
155
159
  self.compactor.provider = provider
156
160
  return previous
157
161
 
162
+ def catalogue_refreshed(self) -> None:
163
+ """Re-read the model in force from a catalogue that has just changed.
164
+
165
+ It was resolved from the one being replaced. Left alone, the window the
166
+ gauge and the compaction threshold measure against would stay the old
167
+ one, and image support would be decided from it, until the user
168
+ happened to switch models.
169
+ """
170
+ if self.models is None:
171
+ return
172
+ self.model_info = self.models.get_or_default(self.model)
173
+ self.session.usage.context_window = self.model_info.context_window
174
+
158
175
  def set_model(self, model_id: str, model_info: ModelInfo | None = None) -> None:
159
176
  self.session.meta.model = model_id
160
177
  self.model_info = model_info
@@ -450,6 +467,7 @@ class AgentLoop:
450
467
  if isinstance(item, StreamEnd):
451
468
  stop_reason = item.stop_reason
452
469
  usage = item.usage
470
+ self._adopt_limits(item)
453
471
  continue
454
472
  if not isinstance(item, StreamDelta): # pragma: no cover - defensive
455
473
  continue
@@ -581,6 +599,26 @@ class AgentLoop:
581
599
  blocks.extend(tool_calls)
582
600
  return assistant_message(blocks, model=self.model)
583
601
 
602
+ def _adopt_limits(self, end: StreamEnd) -> None:
603
+ """Take the window the backend reported over the catalogue's figure.
604
+
605
+ Before the usage update that follows, so the gauge it drives - and the
606
+ compaction threshold measured against the same window - move together.
607
+ Kept in the catalogue too, by every mode and every subagent alike: a
608
+ figure only the live session knew would be the guess again next time.
609
+ """
610
+ info = self.model_info
611
+ if info is None or info.id != self.model:
612
+ return
613
+ window = end.context_window or info.context_window
614
+ output = end.max_output_tokens or info.max_output_tokens
615
+ if (window, output) == (info.context_window, info.max_output_tokens):
616
+ return
617
+ self.model_info = replace(info, context_window=window, max_output_tokens=output)
618
+ self.session.usage.context_window = window
619
+ if self.models is not None:
620
+ self.models.adopt_limits(info.id, window, output)
621
+
584
622
  def _record_usage(
585
623
  self,
586
624
  usage: TurnUsage,
@@ -38,6 +38,11 @@ class StreamDelta:
38
38
  class StreamEnd:
39
39
  stop_reason: StopReason
40
40
  usage: TurnUsage = field(default_factory=TurnUsage)
41
+ context_window: int | None = None
42
+ """The model's window as the backend reported it running this turn, when
43
+ it does. Truer than the catalogue's figure, which for some routes is a
44
+ guess: Claude Code lists its models without their sizes."""
45
+ max_output_tokens: int | None = None
41
46
 
42
47
 
43
48
  StreamItem = StreamDelta | StreamEnd
@@ -51,6 +51,7 @@ from hx.core.usage import TurnUsage
51
51
  from hx.net import with_tls_hint
52
52
  from hx.providers import claude_code_transcript as transcript
53
53
  from hx.providers.base import ProviderError, ProviderRequest, StreamDelta, StreamEnd, StreamItem
54
+ from hx.providers.claude_code_catalogue import DATED
54
55
  from hx.providers.claude_code_cli import CliProcess, Exit, ServedTool, ToolServer
55
56
 
56
57
  PROVIDER_ID = cc_auth.PROVIDER_ID
@@ -435,7 +436,7 @@ class ClaudeCodeProvider:
435
436
  frame = await frames.get()
436
437
  if isinstance(frame, Exit):
437
438
  conversation.state = State.DEAD
438
- raise ProviderError(_exit_message(frame, turn.rate_limit))
439
+ raise ProviderError(_exit_message(frame, turn.rate_limit, request.model))
439
440
  kind = frame.get("type")
440
441
  if kind == "stream_event" and frame.get("parent_tool_use_id") is None:
441
442
  for item in turn.consume(frame.get("event") or {}):
@@ -465,7 +466,13 @@ class ClaudeCodeProvider:
465
466
  payload = from_payload(turn)
466
467
  if payload is not None:
467
468
  yield payload
468
- yield StreamEnd(turn.stop_reason(), turn.finish_usage(started))
469
+ window, output = _reported_limits(frame, request.model)
470
+ yield StreamEnd(
471
+ turn.stop_reason(),
472
+ turn.finish_usage(started),
473
+ context_window=window,
474
+ max_output_tokens=output,
475
+ )
469
476
  return
470
477
 
471
478
  def _remember(self, conversation: Conversation, turn: _Turn) -> None:
@@ -800,6 +807,30 @@ def _wire_model(model_id: str) -> str:
800
807
  return model_id[len(MODEL_PREFIX) :] if model_id.startswith(MODEL_PREFIX) else model_id
801
808
 
802
809
 
810
+ def _reported_limits(frame: dict[str, Any], model_id: str) -> tuple[int | None, int | None]:
811
+ """``(contextWindow, maxOutputTokens)`` Claude Code reports for the model run.
812
+
813
+ ``modelUsage`` is keyed by the resolved name, and lists every model the
814
+ turn touched - one Claude Code chose for itself among them - so only the
815
+ entry for the model asked for is taken. Started by alias (``opus``), that
816
+ entry cannot be told apart and nothing is taken.
817
+ """
818
+ wanted = _wire_model(model_id)
819
+ usage = frame.get("modelUsage")
820
+ for name, entry in usage.items() if isinstance(usage, dict) else ():
821
+ if not isinstance(entry, dict):
822
+ continue
823
+ names = {DATED.sub("", str(n)) for n in (name, entry.get("canonicalModel")) if n}
824
+ if wanted not in names:
825
+ continue
826
+ window, output = entry.get("contextWindow"), entry.get("maxOutputTokens")
827
+ return (
828
+ window if isinstance(window, int) and window > 0 else None,
829
+ output if isinstance(output, int) and output > 0 else None,
830
+ )
831
+ return None, None
832
+
833
+
803
834
  def _private_file(text: str) -> Path:
804
835
  fd, name = tempfile.mkstemp(prefix="hx-claude-", suffix=".md")
805
836
  with os.fdopen(fd, "w", encoding="utf-8") as handle:
@@ -833,7 +864,9 @@ def _startup_failure(message: str, cli: CliProcess) -> str:
833
864
  return with_tls_hint(f"Claude Code failed to start: {message}")
834
865
 
835
866
 
836
- def _exit_message(frame: Exit, rate_limit: dict[str, Any] | None) -> str:
867
+ def _exit_message(frame: Exit, rate_limit: dict[str, Any] | None, model_id: str) -> str:
868
+ if _needs_credits(rate_limit, frame.stderr):
869
+ return _credits_message(model_id, frame.stderr.strip())
837
870
  if rate_limit:
838
871
  return _limit_message(rate_limit)
839
872
  detail = frame.stderr.strip()
@@ -849,6 +882,8 @@ def _result_error(
849
882
  errors = frame.get("errors")
850
883
  text = "; ".join(str(e) for e in errors) if isinstance(errors, list) and errors else ""
851
884
  text = text or str(frame.get("result") or frame.get("subtype") or "unknown error")
885
+ if _needs_credits(rate_limit, text):
886
+ return _credits_message(request.model, text)
852
887
  if _billed_as_extra_usage(frame, text, rate_limit):
853
888
  return _extra_usage_message(request, text)
854
889
  if rate_limit or "usage limit" in text.lower() or "out of extra usage" in text.lower():
@@ -858,6 +893,40 @@ def _result_error(
858
893
  return with_tls_hint(f"Claude Code: {text.strip()}")
859
894
 
860
895
 
896
+ def _needs_credits(rate_limit: dict[str, Any] | None, text: str) -> bool:
897
+ """A turn refused because the model, or its long context, needs usage credits.
898
+
899
+ Claude Code reports it as a rejected rate limit, with the billing period's
900
+ reset beside it, but no usage window is reached: the plan does not include
901
+ what was asked for, and it fails the same way until credits are turned on.
902
+ """
903
+ if (rate_limit or {}).get("errorCode") == "credits_required":
904
+ return True
905
+ lowered = text.lower()
906
+ return "requires usage credits" in lowered or "usage credits required" in lowered
907
+
908
+
909
+ def _credits_message(model_id: str, detail: str) -> str:
910
+ """Said in HX's words alone: Claude Code's own adds a link meant for its
911
+ CLI and advice for flags HX sets itself."""
912
+ model = _wire_model(model_id)
913
+ base = model.removesuffix("[1m]")
914
+ lowered = detail.lower()
915
+ # Asked for at 1M, it is the long context that needs them - unless what was
916
+ # said names the model itself: "Fable 5.1 requires usage credits".
917
+ names_model = "requires usage credits" in lowered
918
+ if "1m context" in lowered or "long context" in lowered or (model != base and not names_model):
919
+ what = f"The 1M context of {base} needs"
920
+ instead = f"pick {MODEL_PREFIX}{base} for its standard context"
921
+ else:
922
+ what = f"{base} needs"
923
+ instead = "pick another model"
924
+ return (
925
+ f"{what} usage credits, which your Claude plan has off - its usage limits are not "
926
+ f"reached. Turn them on at claude.ai/settings/usage and send again, or {instead}."
927
+ )
928
+
929
+
861
930
  def _billed_as_extra_usage(
862
931
  frame: dict[str, Any], text: str, rate_limit: dict[str, Any] | None
863
932
  ) -> bool: