hx-cli 0.2.13__tar.gz → 0.2.15__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.15}/CHANGELOG.md +71 -1
  2. {hx_cli-0.2.13 → hx_cli-0.2.15}/PKG-INFO +21 -9
  3. {hx_cli-0.2.13 → hx_cli-0.2.15}/README.md +20 -8
  4. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/agents/subagent.py +1 -0
  6. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/claude_code.py +18 -2
  7. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/cli.py +8 -6
  8. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/loop.py +39 -1
  9. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/net.py +60 -7
  10. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/base.py +5 -0
  11. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/claude_code.py +72 -3
  12. hx_cli-0.2.15/src/hx/providers/claude_code_catalogue.py +276 -0
  13. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/codex.py +1 -1
  14. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/models.py +256 -31
  15. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/registry.py +15 -14
  16. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/ansi.py +7 -0
  17. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/commands.py +56 -36
  18. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/runtime.py +13 -0
  19. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/pickers.py +101 -14
  20. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/conftest.py +42 -4
  21. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/fake_claude.py +79 -6
  22. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/stub.py +32 -1
  23. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_claude_code.py +538 -11
  24. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_cli.py +3 -4
  25. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_commands.py +163 -2
  26. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/test_live_claude_code.py +74 -0
  27. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/tls_proxy.py +41 -1
  28. hx_cli-0.2.13/src/hx/providers/claude_code_catalogue.py +0 -140
  29. {hx_cli-0.2.13 → hx_cli-0.2.15}/.github/workflows/ci.yml +0 -0
  30. {hx_cli-0.2.13 → hx_cli-0.2.15}/.gitignore +0 -0
  31. {hx_cli-0.2.13 → hx_cli-0.2.15}/examples/hooks/check.sh +0 -0
  32. {hx_cli-0.2.13 → hx_cli-0.2.15}/install.sh +0 -0
  33. {hx_cli-0.2.13 → hx_cli-0.2.15}/pyproject.toml +0 -0
  34. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/agents/__init__.py +0 -0
  35. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/agents/definitions.py +0 -0
  36. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/__init__.py +0 -0
  37. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/__init__.py +0 -0
  38. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/browser.py +0 -0
  39. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/callback.py +0 -0
  40. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/codex.py +0 -0
  41. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/devin.py +0 -0
  42. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/oauth/pkce.py +0 -0
  43. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/resolve.py +0 -0
  44. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/auth/store.py +0 -0
  45. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/config.py +0 -0
  46. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/__init__.py +0 -0
  47. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/checkpoints.py +0 -0
  48. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/compaction.py +0 -0
  49. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/context.py +0 -0
  50. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/events.py +0 -0
  51. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/images.py +0 -0
  52. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/lateinject.py +0 -0
  53. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/messages.py +0 -0
  54. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/session.py +0 -0
  55. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/title.py +0 -0
  56. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/core/usage.py +0 -0
  57. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/docs.py +0 -0
  58. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/frontmatter.py +0 -0
  59. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/git.py +0 -0
  60. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/hooks/__init__.py +0 -0
  61. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/hooks/engine.py +0 -0
  62. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/hooks/spec.py +0 -0
  63. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/keys.py +0 -0
  64. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/mcp/__init__.py +0 -0
  65. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/mcp/client.py +0 -0
  66. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/mcp/manager.py +0 -0
  67. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/mcp/oauth.py +0 -0
  68. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/paths.py +0 -0
  69. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/permissions/__init__.py +0 -0
  70. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/permissions/engine.py +0 -0
  71. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/permissions/parser.py +0 -0
  72. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/permissions/sandbox.py +0 -0
  73. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/__init__.py +0 -0
  74. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/claude_code_cli.py +0 -0
  75. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/claude_code_transcript.py +0 -0
  76. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/codex_catalogue.py +0 -0
  77. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/devin.py +0 -0
  78. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/devin_catalogue.py +0 -0
  79. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/devin_wire.py +0 -0
  80. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/openrouter.py +0 -0
  81. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/protowire.py +0 -0
  82. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/providers/responses_codec.py +0 -0
  83. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/skills/__init__.py +0 -0
  84. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/skills/loader.py +0 -0
  85. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/skills/runtime.py +0 -0
  86. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/__init__.py +0 -0
  87. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/buffer.py +0 -0
  88. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/component.py +0 -0
  89. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/demo.py +0 -0
  90. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/editor.py +0 -0
  91. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/keydecode.py +0 -0
  92. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/loop.py +0 -0
  93. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/markdown.py +0 -0
  94. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/modifiers.py +0 -0
  95. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/primitives.py +0 -0
  96. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/sanitize.py +0 -0
  97. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/screen.py +0 -0
  98. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/syntax.py +0 -0
  99. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/terminal.py +0 -0
  100. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/undo.py +0 -0
  101. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/width.py +0 -0
  102. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/term/word_nav.py +0 -0
  103. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/__init__.py +0 -0
  104. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/anchors.py +0 -0
  105. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/base.py +0 -0
  106. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/bash.py +0 -0
  107. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/edit.py +0 -0
  108. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/glob.py +0 -0
  109. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/grep.py +0 -0
  110. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/output.py +0 -0
  111. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/read.py +0 -0
  112. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/registry.py +0 -0
  113. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/symbols.py +0 -0
  114. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/task.py +0 -0
  115. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/todo.py +0 -0
  116. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/websearch.py +0 -0
  117. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tools/write.py +0 -0
  118. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/trace.py +0 -0
  119. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/__init__.py +0 -0
  120. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/app.py +0 -0
  121. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/clipboard.py +0 -0
  122. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/format.py +0 -0
  123. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/fuzzy.py +0 -0
  124. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/glyphs.py +0 -0
  125. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/killring.py +0 -0
  126. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/limits.py +0 -0
  127. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/paint.py +0 -0
  128. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/renderers.py +0 -0
  129. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/roles.py +0 -0
  130. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/theme.py +0 -0
  131. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/theme_json.py +0 -0
  132. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/themes/ansi.json +0 -0
  133. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/themes/dark.json +0 -0
  134. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/themes/light.json +0 -0
  135. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/__init__.py +0 -0
  136. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/blocks.py +0 -0
  137. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/dialog.py +0 -0
  138. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/login.py +0 -0
  139. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/permission.py +0 -0
  140. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/prompt.py +0 -0
  141. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/status.py +0 -0
  142. {hx_cli-0.2.13 → hx_cli-0.2.15}/src/hx/tui/views/transcript.py +0 -0
  143. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/__init__.py +0 -0
  144. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/conftest.py +0 -0
  145. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/__init__.py +0 -0
  146. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/fixtures/echo_server.py +0 -0
  147. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/report.py +0 -0
  148. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/terminal.py +0 -0
  149. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_chat.py +0 -0
  150. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_extend.py +0 -0
  151. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_input.py +0 -0
  152. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_print.py +0 -0
  153. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_queue.py +0 -0
  154. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/e2e/test_tools.py +0 -0
  155. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/test_live.py +0 -0
  156. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/test_live_codex.py +0 -0
  157. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/test_live_devin.py +0 -0
  158. {hx_cli-0.2.13 → hx_cli-0.2.15}/tests/test_live_tavily.py +0 -0
  159. {hx_cli-0.2.13 → hx_cli-0.2.15}/uv.lock +0 -0
@@ -28,6 +28,74 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.15] - 2026-10-04
32
+
33
+ ### Changed
34
+
35
+ - `/login` and `hx auth login` list the accounts as OpenAI, Claude, OpenRouter, Devin. At the
36
+ `hx auth login` menu, Enter still picks OpenRouter.
37
+
38
+ ### Fixed
39
+
40
+ - `/login claude-code` behind a network whose TLS inspection signs with a certificate not
41
+ valid for servers ("unsupported certificate purpose") now gives Claude Code's reason and
42
+ the fix: have the network fixed or exempted, or set `HX_SSL_NO_VERIFY=1` meanwhile. It
43
+ used to give the whole sign-in URL and no fix. Turns and HX's own requests that fail the
44
+ same way give the same advice, not "add the CA", which is trusted already.
45
+ - A `HX_CA_BUNDLE`, `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` that names a file holding no
46
+ certificates no longer crashes HX at startup. HX says so and verifies against the system
47
+ trust store, and does not hand the file to Claude Code.
48
+ - A wrapped error notice no longer shows a bright green background under every line after
49
+ the first.
50
+
51
+ ---
52
+
53
+ ## [0.2.14] - 2026-10-04
54
+
55
+ ### Added
56
+
57
+ - When the installed Claude Code is behind its current release - and so lists older Claude
58
+ models than your subscription can run - `/model` says so under the list, with the command
59
+ that updates it (`brew upgrade --cask claude-code` for a Homebrew install, otherwise
60
+ `claude update`). After updating, the next `/model` lists the new models; no
61
+ refresh needed.
62
+
63
+ ### Changed
64
+
65
+ - The model catalogue is refreshed from inside the `/model` picker: press `ctrl+r`. The
66
+ list updates in place and keeps your filter and selection; the outcome, or why the refresh
67
+ failed, shows under the list. With no models at all, `/model` now opens anyway and says
68
+ so, so `ctrl+r` is there to fill it.
69
+
70
+ ### Removed
71
+
72
+ - `/models refresh`. It sat one letter away from `/model` and did nothing without its one
73
+ argument; use `ctrl+r` in the `/model` picker.
74
+
75
+ ### Fixed
76
+
77
+ - On the `claude-code` route, the context window is the one Claude Code reports, not a
78
+ 200k guess: Opus 5.5 runs with 1M. Claude Code lists its models without their sizes, and
79
+ what it serves depends on the plan, so a model starts at the window it was measured
80
+ serving on every plan - 1M for Opus 5.5, Sonnet 5.5, Sonnet 5 and Opus 4.7, 200k
81
+ otherwise - until its first turn reports the real window and maximum output for your
82
+ account. From then on the context gauge, the compaction threshold, the `/model` picker
83
+ and later sessions all use it, and a catalogue refresh keeps it.
84
+ - A model Claude Code lists at its long context - Fable 5.1 as `claude-fable-5-1[1m]` -
85
+ runs at it: `/model` offers `claude-code/claude-fable-5-1[1m]` at 1M, where HX used to
86
+ drop the `[1m]` and run the model at its standard context.
87
+ - A turn refused because the model, or its 1M context, needs usage credits your Claude plan
88
+ has off no longer reads "usage limit is reached. It resets at ...": HX says what needs the
89
+ credits, where to turn them on, and which model runs without them. Once they are on, the
90
+ next turn runs.
91
+ - After a catalogue refresh (`ctrl+r` in `/model`, or signing in), the session in progress
92
+ measures its context against the refreshed window, not the one it started with.
93
+ - `/model anthropic` (or typing `anthropic` in the picker) lists the Claude models your Claude
94
+ Code subscription runs, first, ahead of the per-token OpenRouter ones; `openai` does the same
95
+ for Codex. Several words match in any order: `/model anthropic sonnet`.
96
+
97
+ ---
98
+
31
99
  ## [0.2.13] - 2026-10-04
32
100
 
33
101
  ### Added
@@ -833,7 +901,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
833
901
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
834
902
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
835
903
 
836
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.13...HEAD
904
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.15...HEAD
905
+ [0.2.15]: https://github.com/aletisunil/hx/compare/v0.2.14...v0.2.15
906
+ [0.2.14]: https://github.com/aletisunil/hx/compare/v0.2.13...v0.2.14
837
907
  [0.2.13]: https://github.com/aletisunil/hx/compare/v0.2.12...v0.2.13
838
908
  [0.2.12]: https://github.com/aletisunil/hx/compare/v0.2.11...v0.2.12
839
909
  [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.15
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:
@@ -328,7 +335,13 @@ export HX_CA_BUNDLE=/path/to/corp-ca.pem # SSL_CERT_FILE and REQUESTS_CA_BUNDL
328
335
  `HX_SSL_NO_VERIFY=1` turns verification off entirely; it prints a warning at
329
336
  startup every session, because it is not a fix. A TLS failure is reported in
330
337
  one line - `/model` and the startup notice both name the certificate problem
331
- rather than reporting an empty catalogue with no cause.
338
+ rather than reporting an empty catalogue with no cause. A file that holds no
339
+ certificates is ignored, with a startup notice that says so.
340
+
341
+ "Unsupported certificate purpose" (or "unsuitable") is different: the CA is
342
+ trusted, but the proxy signs with a certificate that is not valid for servers,
343
+ so no CA setting fixes it. The network has to fix that certificate or exempt
344
+ the service from inspection; until then only `HX_SSL_NO_VERIFY=1` gets through.
332
345
 
333
346
  The same settings cover the `claude-code` route, whose requests Claude Code
334
347
  makes with its own TLS stack: HX passes `HX_CA_BUNDLE` on to it as
@@ -421,7 +434,7 @@ be rebound (see [Keybindings](#keybindings)).
421
434
  | `ctrl+d` | exit, when the prompt is empty |
422
435
  | `shift+tab` | cycle permission mode |
423
436
  | `ctrl+p` | command palette |
424
- | `ctrl+l` | model picker |
437
+ | `ctrl+l` | model picker; inside it, `ctrl+r` fetches the catalogue again |
425
438
  | `ctrl+o` | expand tool output (also `ctrl+r`) |
426
439
  | `ctrl+t` | show the current plan |
427
440
  | `ctrl+x` | copy the selected message to the clipboard |
@@ -527,8 +540,7 @@ silently resolved.
527
540
 
528
541
  | Command | Does |
529
542
  |---|---|
530
- | `/model [query]` | pick a model; shows context window, price per Mtok, cache support |
531
- | `/models refresh` | re-fetch the catalogue |
543
+ | `/model [query]` | pick a model; shows context window, price per Mtok, cache support. `ctrl+r` in the picker re-fetches the catalogue |
532
544
  | `/effort [level]` | how hard a reasoning model thinks, from the levels it offers |
533
545
  | `/configure` | session settings and the API key |
534
546
  | `/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:
@@ -287,7 +294,13 @@ export HX_CA_BUNDLE=/path/to/corp-ca.pem # SSL_CERT_FILE and REQUESTS_CA_BUNDL
287
294
  `HX_SSL_NO_VERIFY=1` turns verification off entirely; it prints a warning at
288
295
  startup every session, because it is not a fix. A TLS failure is reported in
289
296
  one line - `/model` and the startup notice both name the certificate problem
290
- rather than reporting an empty catalogue with no cause.
297
+ rather than reporting an empty catalogue with no cause. A file that holds no
298
+ certificates is ignored, with a startup notice that says so.
299
+
300
+ "Unsupported certificate purpose" (or "unsuitable") is different: the CA is
301
+ trusted, but the proxy signs with a certificate that is not valid for servers,
302
+ so no CA setting fixes it. The network has to fix that certificate or exempt
303
+ the service from inspection; until then only `HX_SSL_NO_VERIFY=1` gets through.
291
304
 
292
305
  The same settings cover the `claude-code` route, whose requests Claude Code
293
306
  makes with its own TLS stack: HX passes `HX_CA_BUNDLE` on to it as
@@ -380,7 +393,7 @@ be rebound (see [Keybindings](#keybindings)).
380
393
  | `ctrl+d` | exit, when the prompt is empty |
381
394
  | `shift+tab` | cycle permission mode |
382
395
  | `ctrl+p` | command palette |
383
- | `ctrl+l` | model picker |
396
+ | `ctrl+l` | model picker; inside it, `ctrl+r` fetches the catalogue again |
384
397
  | `ctrl+o` | expand tool output (also `ctrl+r`) |
385
398
  | `ctrl+t` | show the current plan |
386
399
  | `ctrl+x` | copy the selected message to the clipboard |
@@ -486,8 +499,7 @@ silently resolved.
486
499
 
487
500
  | Command | Does |
488
501
  |---|---|
489
- | `/model [query]` | pick a model; shows context window, price per Mtok, cache support |
490
- | `/models refresh` | re-fetch the catalogue |
502
+ | `/model [query]` | pick a model; shows context window, price per Mtok, cache support. `ctrl+r` in the picker re-fetches the catalogue |
491
503
  | `/effort [level]` | how hard a reasoning model thinks, from the levels it offers |
492
504
  | `/configure` | session settings and the API key |
493
505
  | `/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.15"
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
@@ -290,6 +290,22 @@ def login_terminal() -> int:
290
290
 
291
291
 
292
292
  _URL = re.compile(r"https://\S+")
293
+ _PASTE_PROMPT = re.compile(r"^.*Paste code[^>]*>\s*")
294
+ _FAILED = re.compile(r"^Login failed:\s*", re.IGNORECASE)
295
+
296
+
297
+ def _failure(output: list[str]) -> str | None:
298
+ """Why ``claude auth login`` failed: its last line of its own, ``unsupported
299
+ certificate purpose`` from ``Login failed: unsupported certificate purpose``.
300
+
301
+ Not the URL it printed before - the screen already showed that, and it is
302
+ wider than the notice - nor the paste prompt the reason is printed after.
303
+ """
304
+ for line in reversed(output):
305
+ line = _FAILED.sub("", _PASTE_PROMPT.sub("", line)).strip()
306
+ if line and not _URL.search(line):
307
+ return line
308
+ return None
293
309
 
294
310
 
295
311
  class LoginFailed(Exception):
@@ -361,8 +377,8 @@ async def login_interactive(interaction: LoginInteraction) -> LoginStatus:
361
377
  forget_status()
362
378
 
363
379
  if code != 0:
364
- tail = " ".join(line.strip() for line in output[-3:] if line.strip())
365
- raise LoginFailed(f"`claude auth login` failed: {tail or f'exit {code}'}")
380
+ reason = _failure(output) or f"exit {code}"
381
+ raise LoginFailed(net.with_tls_hint(f"`claude auth login` failed: {reason}"))
366
382
  current = await status_async(fresh=True)
367
383
  if current is None or not current.logged_in:
368
384
  raise LoginFailed("Claude Code still reports no login.")
@@ -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:
@@ -1432,8 +1432,10 @@ def _choose_provider() -> str:
1432
1432
  print("Sign in to:")
1433
1433
  for index, spec in enumerate(registry.SPECS, start=1):
1434
1434
  print(f" {index} {spec.label}")
1435
+ # Enter still means OpenRouter, wherever it sits in the list.
1436
+ default = str(next(i for i, s in enumerate(registry.SPECS, start=1) if s.id == OPENROUTER))
1435
1437
  try:
1436
- choice = input("Choice [1]: ").strip() or "1"
1438
+ choice = input(f"Choice [{default}]: ").strip() or default
1437
1439
  except (EOFError, KeyboardInterrupt):
1438
1440
  print()
1439
1441
  return OPENROUTER
@@ -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,
@@ -41,11 +41,38 @@ _TRUTHY = frozenset({"1", "true", "yes", "on"})
41
41
 
42
42
 
43
43
  def ca_bundle() -> Path | None:
44
- """The CA bundle the environment names, if it points at a real file."""
44
+ """The CA bundle the environment names, if it points at a file of certificates."""
45
+ named = _named_bundle()
46
+ if named is None or _load_error(named[1]) is not None:
47
+ return None
48
+ return named[1]
49
+
50
+
51
+ def _named_bundle() -> tuple[str, Path] | None:
52
+ """The first of :data:`CA_BUNDLE_VARS` that names a real file, and that file."""
45
53
  for name in CA_BUNDLE_VARS:
46
54
  raw = (os.environ.get(name) or "").strip()
47
55
  if raw and Path(raw).expanduser().is_file():
48
- return Path(raw).expanduser()
56
+ return name, Path(raw).expanduser()
57
+ return None
58
+
59
+
60
+ def _load_error(path: Path) -> str | None:
61
+ """Why ``path`` cannot serve as a CA bundle - a DER file, a key, an empty
62
+ file - or ``None`` when it can. Asked again only when the file changes."""
63
+ try:
64
+ stat = path.stat()
65
+ except OSError as exc:
66
+ return exc.strerror or str(exc)
67
+ return _load_error_of(path, stat.st_mtime_ns, stat.st_size)
68
+
69
+
70
+ @lru_cache(maxsize=4)
71
+ def _load_error_of(path: Path, mtime_ns: int, size: int) -> str | None:
72
+ try:
73
+ ssl.create_default_context(cafile=str(path))
74
+ except (ssl.SSLError, OSError, ValueError) as exc:
75
+ return _flatten(getattr(exc, "reason", None) or str(exc))
49
76
  return None
50
77
 
51
78
 
@@ -80,6 +107,15 @@ def tls_notice() -> str | None:
80
107
  """One line for the startup transcript when TLS is not stock."""
81
108
  if verification_disabled():
82
109
  return f"{NO_VERIFY_VAR} is set: TLS certificates are not being verified."
110
+ named = _named_bundle()
111
+ if named is not None and (error := _load_error(named[1])) is not None:
112
+ # Ignored, as OpenSSL and Node ignore a CA file they cannot read: failing
113
+ # to start would cost every route, the ones this file is not for too.
114
+ name, path = named
115
+ return (
116
+ f"{name} names {path}, which holds no usable CA certificates ({error}). "
117
+ f"Verifying TLS against the system trust store instead."
118
+ )
83
119
  bundle = ca_bundle()
84
120
  return f"Verifying TLS against {bundle}." if bundle is not None else None
85
121
 
@@ -108,13 +144,29 @@ _CERT_FAILURE = re.compile(
108
144
  r"certificate verif"
109
145
  r"|self[- ]signed certificate"
110
146
  r"|unable to (?:get local issuer|verify the first) certificate"
111
- r"|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT|SELF_SIGNED_CERT_IN_CHAIN",
147
+ r"|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT|SELF_SIGNED_CERT_IN_CHAIN"
148
+ r"|certificate purpose|INVALID_PURPOSE",
112
149
  re.IGNORECASE,
113
150
  )
114
151
 
152
+ _WRONG_PURPOSE = re.compile(r"(?:unsupported|unsuitable) certificate purpose|INVALID_PURPOSE")
153
+ """A chain the client does trust, but with a certificate in it - typically the
154
+ issuing CA of a network that intercepts TLS - that may not serve as a TLS
155
+ server's: OpenSSL says "unsuitable", the BoringSSL in Claude Code "unsupported"."""
156
+
115
157
 
116
- def tls_hint() -> str:
117
- """What to do about a certificate the client would not trust."""
158
+ def tls_hint(reason: str = "") -> str:
159
+ """What to do about a certificate the client would not accept.
160
+
161
+ ``reason`` is the client's own words, when there are any: a certificate of
162
+ the wrong purpose is already trusted, so adding its CA again changes nothing.
163
+ """
164
+ if _WRONG_PURPOSE.search(reason):
165
+ return (
166
+ "Your network intercepts TLS with a certificate that is not valid for servers. "
167
+ "Ask its administrator to fix that certificate or to exclude this service from "
168
+ f"inspection; until then, {NO_VERIFY_VAR}=1 turns verification off."
169
+ )
118
170
  bundle = ca_bundle()
119
171
  if bundle is not None:
120
172
  return (
@@ -130,7 +182,7 @@ def tls_hint() -> str:
130
182
  def with_tls_hint(message: str) -> str:
131
183
  """``message``, followed by :func:`tls_hint` when it reports a certificate
132
184
  failure - in any client's words: OpenSSL's, Node's, Claude Code's."""
133
- hint = tls_hint()
185
+ hint = tls_hint(message)
134
186
  if not _CERT_FAILURE.search(message) or hint in message:
135
187
  return message
136
188
  return f"{message.rstrip().rstrip('.')}. {hint}"
@@ -156,7 +208,8 @@ def describe(exc: BaseException) -> str:
156
208
  or getattr(err, "reason", None)
157
209
  or "certificate verify failed"
158
210
  )
159
- return f"TLS certificate verification failed ({_flatten(reason)}). {tls_hint()}"
211
+ reason = _flatten(reason)
212
+ return f"TLS certificate verification failed ({reason}). {tls_hint(reason)}"
160
213
  if isinstance(err, ssl.SSLError):
161
214
  return f"TLS handshake failed: {_flatten(err.reason or str(err))}."
162
215
  if isinstance(exc, httpx.TimeoutException):
@@ -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: