hx-cli 0.2.6__tar.gz → 0.2.7__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 (245) hide show
  1. {hx_cli-0.2.6 → hx_cli-0.2.7}/.github/workflows/ci.yml +13 -0
  2. {hx_cli-0.2.6 → hx_cli-0.2.7}/.gitignore +1 -0
  3. {hx_cli-0.2.6 → hx_cli-0.2.7}/CHANGELOG.md +63 -2
  4. {hx_cli-0.2.6 → hx_cli-0.2.7}/PKG-INFO +31 -20
  5. {hx_cli-0.2.6 → hx_cli-0.2.7}/README.md +30 -19
  6. {hx_cli-0.2.6 → hx_cli-0.2.7}/pyproject.toml +1 -1
  7. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/__init__.py +1 -1
  8. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/config.py +1 -1
  9. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/compaction.py +6 -0
  10. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/loop.py +15 -11
  11. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/permissions/engine.py +1 -1
  12. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/permissions/sandbox.py +84 -22
  13. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/openrouter.py +16 -6
  14. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/keydecode.py +173 -15
  15. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/loop.py +5 -1
  16. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/markdown.py +19 -7
  17. hx_cli-0.2.7/src/hx/term/modifiers.py +116 -0
  18. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/primitives.py +54 -3
  19. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/terminal.py +114 -66
  20. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/commands.py +34 -16
  21. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/limits.py +4 -0
  22. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/runtime.py +51 -20
  23. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/views/blocks.py +49 -6
  24. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/views/permission.py +14 -5
  25. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/views/prompt.py +149 -15
  26. hx_cli-0.2.7/tests/conftest.py +28 -0
  27. hx_cli-0.2.7/tests/e2e/conftest.py +302 -0
  28. hx_cli-0.2.7/tests/e2e/report.py +205 -0
  29. hx_cli-0.2.7/tests/e2e/stub.py +363 -0
  30. hx_cli-0.2.7/tests/e2e/terminal.py +473 -0
  31. hx_cli-0.2.7/tests/e2e/test_chat.py +213 -0
  32. hx_cli-0.2.7/tests/e2e/test_cli.py +143 -0
  33. hx_cli-0.2.7/tests/e2e/test_commands.py +255 -0
  34. hx_cli-0.2.7/tests/e2e/test_extend.py +276 -0
  35. hx_cli-0.2.7/tests/e2e/test_input.py +457 -0
  36. hx_cli-0.2.7/tests/e2e/test_print.py +275 -0
  37. hx_cli-0.2.7/tests/e2e/test_tools.py +209 -0
  38. hx_cli-0.2.6/src/hx/providers/fake.py +0 -124
  39. hx_cli-0.2.6/tests/agents/test_subagent.py +0 -310
  40. hx_cli-0.2.6/tests/auth/__init__.py +0 -0
  41. hx_cli-0.2.6/tests/auth/test_codex_oauth.py +0 -226
  42. hx_cli-0.2.6/tests/auth/test_devin_oauth.py +0 -119
  43. hx_cli-0.2.6/tests/auth/test_pkce.py +0 -27
  44. hx_cli-0.2.6/tests/auth/test_resolve.py +0 -166
  45. hx_cli-0.2.6/tests/auth/test_store.py +0 -218
  46. hx_cli-0.2.6/tests/conftest.py +0 -63
  47. hx_cli-0.2.6/tests/core/__init__.py +0 -0
  48. hx_cli-0.2.6/tests/core/test_checkpoints.py +0 -200
  49. hx_cli-0.2.6/tests/core/test_compaction.py +0 -137
  50. hx_cli-0.2.6/tests/core/test_context.py +0 -158
  51. hx_cli-0.2.6/tests/core/test_images.py +0 -281
  52. hx_cli-0.2.6/tests/core/test_lateinject.py +0 -52
  53. hx_cli-0.2.6/tests/core/test_loop.py +0 -738
  54. hx_cli-0.2.6/tests/core/test_loop_images.py +0 -181
  55. hx_cli-0.2.6/tests/core/test_rewind.py +0 -206
  56. hx_cli-0.2.6/tests/core/test_session.py +0 -315
  57. hx_cli-0.2.6/tests/core/test_settings_layers.py +0 -67
  58. hx_cli-0.2.6/tests/core/test_settings_migration.py +0 -152
  59. hx_cli-0.2.6/tests/core/test_steering.py +0 -203
  60. hx_cli-0.2.6/tests/core/test_title.py +0 -70
  61. hx_cli-0.2.6/tests/core/test_usage.py +0 -25
  62. hx_cli-0.2.6/tests/hooks/__init__.py +0 -0
  63. hx_cli-0.2.6/tests/hooks/test_engine.py +0 -205
  64. hx_cli-0.2.6/tests/hooks/test_example_check.py +0 -98
  65. hx_cli-0.2.6/tests/mcp/__init__.py +0 -0
  66. hx_cli-0.2.6/tests/mcp/fixtures/oauth_server.py +0 -438
  67. hx_cli-0.2.6/tests/mcp/test_manager.py +0 -240
  68. hx_cli-0.2.6/tests/mcp/test_oauth.py +0 -670
  69. hx_cli-0.2.6/tests/permissions/__init__.py +0 -0
  70. hx_cli-0.2.6/tests/permissions/test_engine.py +0 -570
  71. hx_cli-0.2.6/tests/permissions/test_parser.py +0 -202
  72. hx_cli-0.2.6/tests/permissions/test_sandbox.py +0 -189
  73. hx_cli-0.2.6/tests/providers/__init__.py +0 -0
  74. hx_cli-0.2.6/tests/providers/test_catalogue.py +0 -349
  75. hx_cli-0.2.6/tests/providers/test_codex.py +0 -113
  76. hx_cli-0.2.6/tests/providers/test_codex_catalogue.py +0 -205
  77. hx_cli-0.2.6/tests/providers/test_devin.py +0 -489
  78. hx_cli-0.2.6/tests/providers/test_devin_catalogue.py +0 -229
  79. hx_cli-0.2.6/tests/providers/test_openrouter.py +0 -308
  80. hx_cli-0.2.6/tests/providers/test_protowire.py +0 -100
  81. hx_cli-0.2.6/tests/providers/test_registry.py +0 -117
  82. hx_cli-0.2.6/tests/providers/test_responses_codec.py +0 -330
  83. hx_cli-0.2.6/tests/skills/__init__.py +0 -0
  84. hx_cli-0.2.6/tests/skills/test_loader.py +0 -157
  85. hx_cli-0.2.6/tests/term/__init__.py +0 -0
  86. hx_cli-0.2.6/tests/term/conftest.py +0 -59
  87. hx_cli-0.2.6/tests/term/test_ansi.py +0 -190
  88. hx_cli-0.2.6/tests/term/test_keydecode.py +0 -233
  89. hx_cli-0.2.6/tests/term/test_loop.py +0 -221
  90. hx_cli-0.2.6/tests/term/test_markdown.py +0 -260
  91. hx_cli-0.2.6/tests/term/test_primitives.py +0 -206
  92. hx_cli-0.2.6/tests/term/test_sanitize.py +0 -77
  93. hx_cli-0.2.6/tests/term/test_screen.py +0 -699
  94. hx_cli-0.2.6/tests/term/test_terminal.py +0 -321
  95. hx_cli-0.2.6/tests/term/test_width.py +0 -109
  96. hx_cli-0.2.6/tests/test_cli.py +0 -409
  97. hx_cli-0.2.6/tests/test_docs.py +0 -203
  98. hx_cli-0.2.6/tests/test_git.py +0 -355
  99. hx_cli-0.2.6/tests/test_install.py +0 -55
  100. hx_cli-0.2.6/tests/test_integration.py +0 -696
  101. hx_cli-0.2.6/tests/test_net.py +0 -110
  102. hx_cli-0.2.6/tests/test_paths.py +0 -54
  103. hx_cli-0.2.6/tests/test_readme.py +0 -309
  104. hx_cli-0.2.6/tests/test_trace.py +0 -538
  105. hx_cli-0.2.6/tests/tools/__init__.py +0 -0
  106. hx_cli-0.2.6/tests/tools/test_background.py +0 -207
  107. hx_cli-0.2.6/tests/tools/test_bash.py +0 -210
  108. hx_cli-0.2.6/tests/tools/test_files.py +0 -284
  109. hx_cli-0.2.6/tests/tools/test_hashline.py +0 -197
  110. hx_cli-0.2.6/tests/tools/test_output.py +0 -31
  111. hx_cli-0.2.6/tests/tools/test_symbols.py +0 -127
  112. hx_cli-0.2.6/tests/tools/test_todo.py +0 -104
  113. hx_cli-0.2.6/tests/tools/test_websearch.py +0 -296
  114. hx_cli-0.2.6/tests/tui/__init__.py +0 -0
  115. hx_cli-0.2.6/tests/tui/support.py +0 -146
  116. hx_cli-0.2.6/tests/tui/test_autocomplete.py +0 -186
  117. hx_cli-0.2.6/tests/tui/test_clipboard.py +0 -111
  118. hx_cli-0.2.6/tests/tui/test_effort.py +0 -167
  119. hx_cli-0.2.6/tests/tui/test_images.py +0 -319
  120. hx_cli-0.2.6/tests/tui/test_keys.py +0 -136
  121. hx_cli-0.2.6/tests/tui/test_login.py +0 -424
  122. hx_cli-0.2.6/tests/tui/test_mcp_login.py +0 -102
  123. hx_cli-0.2.6/tests/tui/test_renderers.py +0 -278
  124. hx_cli-0.2.6/tests/tui/test_session.py +0 -1134
  125. hx_cli-0.2.6/tests/tui/test_theme_json.py +0 -105
  126. hx_cli-0.2.6/tests/tui/test_trace_command.py +0 -257
  127. hx_cli-0.2.6/tests/tui/views/__init__.py +0 -0
  128. hx_cli-0.2.6/tests/tui/views/test_blocks.py +0 -264
  129. hx_cli-0.2.6/tests/tui/views/test_permission.py +0 -328
  130. hx_cli-0.2.6/tests/tui/views/test_pickers.py +0 -314
  131. hx_cli-0.2.6/tests/tui/views/test_prompt.py +0 -374
  132. hx_cli-0.2.6/tests/tui/views/test_runtime.py +0 -737
  133. hx_cli-0.2.6/tests/tui/views/test_status.py +0 -197
  134. hx_cli-0.2.6/tests/tui/views/test_transcript.py +0 -211
  135. {hx_cli-0.2.6 → hx_cli-0.2.7}/examples/hooks/check.sh +0 -0
  136. {hx_cli-0.2.6 → hx_cli-0.2.7}/install.sh +0 -0
  137. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/agents/__init__.py +0 -0
  138. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/agents/definitions.py +0 -0
  139. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/agents/subagent.py +0 -0
  140. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/auth/__init__.py +0 -0
  141. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/auth/oauth/__init__.py +0 -0
  142. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/auth/oauth/browser.py +0 -0
  143. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/auth/oauth/callback.py +0 -0
  144. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/auth/oauth/codex.py +0 -0
  145. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/auth/oauth/devin.py +0 -0
  146. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/auth/oauth/pkce.py +0 -0
  147. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/auth/resolve.py +0 -0
  148. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/auth/store.py +0 -0
  149. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/cli.py +0 -0
  150. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/__init__.py +0 -0
  151. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/checkpoints.py +0 -0
  152. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/context.py +0 -0
  153. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/events.py +0 -0
  154. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/images.py +0 -0
  155. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/lateinject.py +0 -0
  156. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/messages.py +0 -0
  157. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/session.py +0 -0
  158. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/title.py +0 -0
  159. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/core/usage.py +0 -0
  160. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/docs.py +0 -0
  161. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/frontmatter.py +0 -0
  162. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/git.py +0 -0
  163. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/hooks/__init__.py +0 -0
  164. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/hooks/engine.py +0 -0
  165. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/hooks/spec.py +0 -0
  166. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/keys.py +0 -0
  167. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/mcp/__init__.py +0 -0
  168. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/mcp/client.py +0 -0
  169. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/mcp/manager.py +0 -0
  170. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/mcp/oauth.py +0 -0
  171. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/net.py +0 -0
  172. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/paths.py +0 -0
  173. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/permissions/__init__.py +0 -0
  174. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/permissions/parser.py +0 -0
  175. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/__init__.py +0 -0
  176. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/base.py +0 -0
  177. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/codex.py +0 -0
  178. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/codex_catalogue.py +0 -0
  179. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/devin.py +0 -0
  180. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/devin_catalogue.py +0 -0
  181. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/devin_wire.py +0 -0
  182. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/models.py +0 -0
  183. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/protowire.py +0 -0
  184. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/registry.py +0 -0
  185. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/providers/responses_codec.py +0 -0
  186. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/skills/__init__.py +0 -0
  187. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/skills/loader.py +0 -0
  188. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/skills/runtime.py +0 -0
  189. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/__init__.py +0 -0
  190. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/ansi.py +0 -0
  191. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/buffer.py +0 -0
  192. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/component.py +0 -0
  193. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/demo.py +0 -0
  194. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/editor.py +0 -0
  195. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/sanitize.py +0 -0
  196. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/screen.py +0 -0
  197. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/syntax.py +0 -0
  198. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/undo.py +0 -0
  199. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/width.py +0 -0
  200. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/term/word_nav.py +0 -0
  201. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/__init__.py +0 -0
  202. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/anchors.py +0 -0
  203. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/base.py +0 -0
  204. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/bash.py +0 -0
  205. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/edit.py +0 -0
  206. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/glob.py +0 -0
  207. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/grep.py +0 -0
  208. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/output.py +0 -0
  209. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/read.py +0 -0
  210. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/registry.py +0 -0
  211. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/symbols.py +0 -0
  212. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/task.py +0 -0
  213. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/todo.py +0 -0
  214. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/websearch.py +0 -0
  215. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tools/write.py +0 -0
  216. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/trace.py +0 -0
  217. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/__init__.py +0 -0
  218. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/app.py +0 -0
  219. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/clipboard.py +0 -0
  220. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/format.py +0 -0
  221. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/fuzzy.py +0 -0
  222. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/glyphs.py +0 -0
  223. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/killring.py +0 -0
  224. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/paint.py +0 -0
  225. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/renderers.py +0 -0
  226. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/roles.py +0 -0
  227. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/theme.py +0 -0
  228. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/theme_json.py +0 -0
  229. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/themes/ansi.json +0 -0
  230. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/themes/dark.json +0 -0
  231. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/themes/light.json +0 -0
  232. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/views/__init__.py +0 -0
  233. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/views/dialog.py +0 -0
  234. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/views/login.py +0 -0
  235. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/views/pickers.py +0 -0
  236. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/views/status.py +0 -0
  237. {hx_cli-0.2.6 → hx_cli-0.2.7}/src/hx/tui/views/transcript.py +0 -0
  238. {hx_cli-0.2.6 → hx_cli-0.2.7}/tests/__init__.py +0 -0
  239. {hx_cli-0.2.6/tests/agents → hx_cli-0.2.7/tests/e2e}/__init__.py +0 -0
  240. {hx_cli-0.2.6/tests/mcp → hx_cli-0.2.7/tests/e2e}/fixtures/echo_server.py +0 -0
  241. {hx_cli-0.2.6 → hx_cli-0.2.7}/tests/test_live.py +0 -0
  242. {hx_cli-0.2.6 → hx_cli-0.2.7}/tests/test_live_codex.py +0 -0
  243. {hx_cli-0.2.6 → hx_cli-0.2.7}/tests/test_live_devin.py +0 -0
  244. {hx_cli-0.2.6 → hx_cli-0.2.7}/tests/test_live_tavily.py +0 -0
  245. {hx_cli-0.2.6 → hx_cli-0.2.7}/uv.lock +0 -0
@@ -36,8 +36,21 @@ jobs:
36
36
  - uses: actions/checkout@v4
37
37
  - uses: astral-sh/setup-uv@v3
38
38
  - run: uv sync --extra dev --python ${{ matrix.python }}
39
+ # bubblewrap, so the Linux sandbox tests run instead of skipping. Ubuntu
40
+ # 24.04 confines unprivileged user namespaces with AppArmor, which bwrap
41
+ # needs; the runner is throwaway, so lift that rather than ship a profile.
42
+ - if: runner.os == 'Linux'
43
+ run: |
44
+ sudo apt-get update && sudo apt-get install -y bubblewrap
45
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
39
46
  # `live` needs an API key and real spend, so it never runs in CI.
40
47
  - run: uv run pytest -m "not live"
48
+ # What every end-to-end test saw, screen by screen - pass or fail.
49
+ - uses: actions/upload-artifact@v4
50
+ if: always()
51
+ with:
52
+ name: e2e-report-${{ matrix.os }}-${{ matrix.python }}
53
+ path: e2e-report/
41
54
 
42
55
  install-script:
43
56
  runs-on: ubuntu-latest
@@ -17,3 +17,4 @@ htmlcov/
17
17
  # and the rest - lives under $HX_HOME/projects/<slug>, outside the checkout
18
18
  # entirely, so there is nothing here left to hide.
19
19
  snapshot_report.html
20
+ e2e-report/
@@ -7,7 +7,7 @@ wheel, `hx changelog` prints it, and a session can answer "what's new?" or
7
7
  "which version added web search?" from it rather than from a guess. `hx docs`
8
8
  prints the manual next to it.
9
9
 
10
- The rules, enforced by `tests/test_docs.py` so a release cannot forget them:
10
+ The rules, enforced by `tests/e2e/test_cli.py` so a release cannot forget them:
11
11
 
12
12
  - Every released version has a `## [x.y.z] - YYYY-MM-DD` section here, and the
13
13
  version in `src/hx/__init__.py` always has one. Bumping the version without
@@ -28,6 +28,66 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.7] - 2026-09-27
32
+
33
+ ### Added
34
+
35
+ - `HX_OPENROUTER_BASE_URL` points the OpenRouter route at any OpenAI-compatible
36
+ endpoint - a gateway, a recording proxy.
37
+ - Pressing `esc` on a running turn leaves an "Interrupted" line in the transcript,
38
+ so a reply cut off mid-sentence no longer reads as a finished one.
39
+ - A paste over 10 lines or 1000 characters shows in the prompt as
40
+ `[Pasted text #1 +40 lines]` and is sent in full; the token moves and deletes
41
+ as one character, and survives history recall and `ctrl+k`/`ctrl+y`.
42
+ - `\` then `enter` inserts a newline in any terminal, including Apple's Terminal
43
+ and tmux without extended keys, where `shift+enter` cannot be told from `enter`. `\\` then
44
+ `enter` sends a message that ends in a backslash.
45
+
46
+ ### Fixed
47
+
48
+ - `shift+enter` inserts a newline instead of sending the message in kitty, Ghostty,
49
+ WezTerm, iTerm2 and tmux (with `extended-keys on`): HX now asks the terminal for
50
+ the kitty keyboard protocol, falling back to xterm's modifyOtherKeys, and hands
51
+ it back on exit, `ctrl+z` and `/fullscreen`. `ctrl+shift+z` (redo) works there too.
52
+ - `shift+enter` also inserts a newline on macOS in terminals that send it as a
53
+ plain `enter` (VS Code's, Apple's Terminal) or as `option+enter` (the Claude
54
+ desktop app's terminal panel): HX asks macOS which modifiers are held when the
55
+ key arrives. A real `option+enter` still steers, an `enter` hard on the heels of
56
+ a shifted character (`?` then `enter`) still sends, and `HX_MODIFIER_PROBE=0`
57
+ turns the check off.
58
+ - A multi-line paste keeps its line breaks. Terminals send them as carriage
59
+ returns, which HX was deleting, so a pasted table arrived as one long line.
60
+ - Lines typed with `shift+enter` stay separate lines in the transcript instead of
61
+ being joined into one paragraph.
62
+ - On macOS the sandbox now refuses *writes* to credential paths (`~/.ssh`,
63
+ `~/.aws`, `~/.hx/auth.json`, ...) as well as reads, so a session opened in your
64
+ home directory cannot plant an `authorized_keys` entry.
65
+ - On Linux the same holds for a credential path that does not exist yet: a
66
+ session opened in your home directory with no `~/.ssh` could create one and
67
+ plant an `authorized_keys` on the host. HX now creates such a path, empty and
68
+ private (`0700` for a directory, `0600` for a file), and mounts a read-only
69
+ shadow over it. Existing credential directories are shadowed read-only too, so
70
+ a write fails with "Read-only file system" instead of vanishing.
71
+ - Shell commands the model runs show their output in the transcript again - the
72
+ last lines, with `ctrl+o` for the rest - instead of only "Took 12ms".
73
+ - `/context` no longer fails with "'UsageLedger' object has no attribute
74
+ 'total_tokens'"; it lists what fills the window.
75
+ - `/copy` copies the last reply and says how it was copied, instead of failing.
76
+ - `/theme light` reports "Theme: light" rather than "Theme: None".
77
+ - `/compact` on a short session says only that there is nothing to compact, no
78
+ longer followed by a "Compacting…" that never finishes; a compaction that
79
+ saves nothing still reports that it finished, and the context gauge keeps
80
+ counting the system prompt afterwards.
81
+ - `/help` lines its key descriptions up however long a binding is, and a long
82
+ row in `/agents`, `/cost` or any other command output wraps under its own
83
+ column instead of at the left edge.
84
+ - The "always allow" option on an approval names where the rule is really
85
+ saved (`~/.hx`), and the answered approval shows a file relative to the
86
+ project rather than as an absolute path.
87
+ - `/permissions` no longer says the same thing twice.
88
+
89
+ ---
90
+
31
91
  ## [0.2.6] - 2026-09-23
32
92
 
33
93
  ### Added
@@ -639,7 +699,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
639
699
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
640
700
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
641
701
 
642
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.6...HEAD
702
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.7...HEAD
703
+ [0.2.7]: https://github.com/aletisunil/hx/compare/v0.2.6...v0.2.7
643
704
  [0.2.6]: https://github.com/aletisunil/hx/compare/v0.2.5...v0.2.6
644
705
  [0.2.5]: https://github.com/aletisunil/hx/compare/v0.2.4...v0.2.5
645
706
  [0.2.4]: https://github.com/aletisunil/hx/compare/v0.2.3...v0.2.4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.6
3
+ Version: 0.2.7
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
@@ -350,7 +350,7 @@ allowlist it if you would rather not be asked:
350
350
  ```
351
351
 
352
352
  `CHANGELOG.md` lists what each version added, and a release cannot skip it:
353
- `tests/test_docs.py` fails when `__version__` has no section (see
353
+ `tests/e2e/test_cli.py` fails when `__version__` has no section (see
354
354
  [Releasing](#releasing)).
355
355
 
356
356
  ### Keys
@@ -362,7 +362,7 @@ be rebound (see [Keybindings](#keybindings)).
362
362
  | Key | Does |
363
363
  |---|---|
364
364
  | `enter` | send |
365
- | `ctrl+j` | newline (also `shift+enter`) |
365
+ | `shift+enter` | newline (also `ctrl+j`; in a terminal that cannot send `shift+enter`, end the line with `\` and press `enter`) |
366
366
  | `esc` | interrupt the current turn |
367
367
  | `ctrl+c` | copy the selected text; with nothing selected, clear the prompt (twice on an empty prompt exits) |
368
368
  | `ctrl+d` | exit, when the prompt is empty |
@@ -664,8 +664,11 @@ merge: it is read per layer, and only from your own settings. See
664
664
  Environment overrides: `HX_MODEL`, `HX_SUBAGENT_MODEL`, `HX_MAX_TOKENS`,
665
665
  `HX_PERMISSION_MODE`, `HX_SANDBOX`, `HX_COMPACT_AT`, `HX_GIT_NOTICES`,
666
666
  `HX_THEME`, `HX_QUIET_STARTUP`. Also `HX_HOME` to relocate user state, `HX_TAVILY_API_KEY`
667
- for web search, and `HX_CA_BUNDLE` / `HX_SSL_NO_VERIFY` for TLS (see
668
- [Credentials](#credentials)).
667
+ for web search, `HX_CA_BUNDLE` / `HX_SSL_NO_VERIFY` for TLS (see
668
+ [Credentials](#credentials)), and `HX_OPENROUTER_BASE_URL` to point the OpenRouter
669
+ route at another OpenAI-compatible endpoint - a gateway, or a recording proxy.
670
+ `HX_MODIFIER_PROBE=0` stops HX asking macOS whether shift is held when an
671
+ `enter` arrives (see [Keys](#keys)).
669
672
 
670
673
  ### The system prompt
671
674
 
@@ -774,7 +777,8 @@ confidence prompts rather than passing.
774
777
  **An OS sandbox** wraps command execution: Seatbelt on macOS, bubblewrap on
775
778
  Linux. The filesystem is readable, writes are confined to the project and the
776
779
  temp dir, credential paths (`~/.ssh`, `~/.aws`, and HX's own `auth.json`) are
777
- unreadable, and outbound network is off. If neither backend is present the
780
+ unreadable and unwritable - even inside the project, and even before they
781
+ exist - and outbound network is off. If neither backend is present the
778
782
  status bar says `no-sandbox` rather than implying protection that is not there.
779
783
 
780
784
  Modes cycle with shift+tab: `plan` (read-only — mutating tools are not even
@@ -1117,6 +1121,23 @@ uv run mypy # strict
1117
1121
  uv run hx # run from the checkout
1118
1122
  ```
1119
1123
 
1124
+ The suite is end-to-end. Each test in `tests/e2e/` starts the installed `hx`
1125
+ binary - on a real pseudo-terminal for the TUI, or as a plain subprocess for
1126
+ print mode and the other commands - in a private `$HOME` and `$HX_HOME`, and
1127
+ points it at a local OpenRouter stand-in with `HX_OPENROUTER_BASE_URL`.
1128
+ Everything between the keyboard and the socket is the shipped code; only the
1129
+ model's replies are scripted. The screen is read back through a VT emulator,
1130
+ so assertions are on the cells a terminal shows, not on the bytes HX meant to
1131
+ write.
1132
+
1133
+ Every run writes `e2e-report/`: `index.html` shows each test's outcome and
1134
+ every screen it checked, rendered in colour, and one text file per screen lets
1135
+ two runs be compared with `diff -r`. CI uploads it from every test job as an
1136
+ `e2e-report-*` artifact. Set `HX_E2E_REPORT` to write it somewhere else.
1137
+
1138
+ Tests that need the OS sandbox are skipped where there is no backend
1139
+ (`sandbox-exec` on macOS, `bwrap` on Linux).
1140
+
1120
1141
  Tests marked `live` hit a real API. The OpenRouter ones cost money:
1121
1142
 
1122
1143
  ```sh
@@ -1133,17 +1154,7 @@ uv run pytest -m live tests/test_live_devin.py
1133
1154
 
1134
1155
  They are the only place the wire formats, streaming, tool use and a genuine
1135
1156
  cache hit are proven against a real service; everything else runs against the
1136
- scripted provider.
1137
-
1138
- Tests marked `sandbox` exercise the real OS sandbox and are skipped where no
1139
- backend exists.
1140
-
1141
- The TUI has SVG layout snapshots. Read the diff before accepting a change to
1142
- them - they exist to catch a frame that quietly lost a row:
1143
-
1144
- ```sh
1145
- uv run pytest tests/tui/test_snapshots.py --snapshot-update
1146
- ```
1157
+ stand-in.
1147
1158
 
1148
1159
  ### Layout
1149
1160
 
@@ -1152,7 +1163,7 @@ src/hx/
1152
1163
  cli.py config.py paths.py frontmatter.py git.py
1153
1164
  core/ loop, context assembly, compaction, late injection, sessions, usage
1154
1165
  auth/ credential store, OAuth flows, per-route resolution
1155
- providers/ OpenRouter, Codex, Devin, the model catalogue, a scripted provider for tests
1166
+ providers/ OpenRouter, Codex, Devin, the model catalogue
1156
1167
  tools/ Bash, Read, Write, Edit, Glob, Grep, Symbols, TodoWrite, Task,
1157
1168
  WebSearch, WebFetch, line anchors, output capping
1158
1169
  permissions/ rule engine, shell decomposition, Seatbelt/bubblewrap
@@ -1189,12 +1200,12 @@ Each release:
1189
1200
  # 1. move CHANGELOG.md's [Unreleased] entries under the new version, dated,
1190
1201
  # and leave a fresh empty [Unreleased] behind
1191
1202
  # 2. bump __version__ in src/hx/__init__.py
1192
- # 3. uv run pytest tests/test_docs.py # the version must have a section
1203
+ # 3. uv run pytest tests/e2e/test_cli.py # the version must have a section
1193
1204
  git commit -am "release: X.Y.Z"
1194
1205
  git tag v$(uv run hx --version | cut -d' ' -f2) && git push origin main --tags
1195
1206
  ```
1196
1207
 
1197
- Step 1 is not optional and is not a convention: `tests/test_docs.py` fails the
1208
+ Step 1 is not optional and is not a convention: `tests/e2e/test_cli.py` fails the
1198
1209
  whole suite when `__version__` has no `CHANGELOG.md` section, so a release that
1199
1210
  skips it cannot get past CI to the `publish` job. The file ships inside the
1200
1211
  wheel and `hx changelog` prints it, so a version with no section there reaches
@@ -310,7 +310,7 @@ allowlist it if you would rather not be asked:
310
310
  ```
311
311
 
312
312
  `CHANGELOG.md` lists what each version added, and a release cannot skip it:
313
- `tests/test_docs.py` fails when `__version__` has no section (see
313
+ `tests/e2e/test_cli.py` fails when `__version__` has no section (see
314
314
  [Releasing](#releasing)).
315
315
 
316
316
  ### Keys
@@ -322,7 +322,7 @@ be rebound (see [Keybindings](#keybindings)).
322
322
  | Key | Does |
323
323
  |---|---|
324
324
  | `enter` | send |
325
- | `ctrl+j` | newline (also `shift+enter`) |
325
+ | `shift+enter` | newline (also `ctrl+j`; in a terminal that cannot send `shift+enter`, end the line with `\` and press `enter`) |
326
326
  | `esc` | interrupt the current turn |
327
327
  | `ctrl+c` | copy the selected text; with nothing selected, clear the prompt (twice on an empty prompt exits) |
328
328
  | `ctrl+d` | exit, when the prompt is empty |
@@ -624,8 +624,11 @@ merge: it is read per layer, and only from your own settings. See
624
624
  Environment overrides: `HX_MODEL`, `HX_SUBAGENT_MODEL`, `HX_MAX_TOKENS`,
625
625
  `HX_PERMISSION_MODE`, `HX_SANDBOX`, `HX_COMPACT_AT`, `HX_GIT_NOTICES`,
626
626
  `HX_THEME`, `HX_QUIET_STARTUP`. Also `HX_HOME` to relocate user state, `HX_TAVILY_API_KEY`
627
- for web search, and `HX_CA_BUNDLE` / `HX_SSL_NO_VERIFY` for TLS (see
628
- [Credentials](#credentials)).
627
+ for web search, `HX_CA_BUNDLE` / `HX_SSL_NO_VERIFY` for TLS (see
628
+ [Credentials](#credentials)), and `HX_OPENROUTER_BASE_URL` to point the OpenRouter
629
+ route at another OpenAI-compatible endpoint - a gateway, or a recording proxy.
630
+ `HX_MODIFIER_PROBE=0` stops HX asking macOS whether shift is held when an
631
+ `enter` arrives (see [Keys](#keys)).
629
632
 
630
633
  ### The system prompt
631
634
 
@@ -734,7 +737,8 @@ confidence prompts rather than passing.
734
737
  **An OS sandbox** wraps command execution: Seatbelt on macOS, bubblewrap on
735
738
  Linux. The filesystem is readable, writes are confined to the project and the
736
739
  temp dir, credential paths (`~/.ssh`, `~/.aws`, and HX's own `auth.json`) are
737
- unreadable, and outbound network is off. If neither backend is present the
740
+ unreadable and unwritable - even inside the project, and even before they
741
+ exist - and outbound network is off. If neither backend is present the
738
742
  status bar says `no-sandbox` rather than implying protection that is not there.
739
743
 
740
744
  Modes cycle with shift+tab: `plan` (read-only — mutating tools are not even
@@ -1077,6 +1081,23 @@ uv run mypy # strict
1077
1081
  uv run hx # run from the checkout
1078
1082
  ```
1079
1083
 
1084
+ The suite is end-to-end. Each test in `tests/e2e/` starts the installed `hx`
1085
+ binary - on a real pseudo-terminal for the TUI, or as a plain subprocess for
1086
+ print mode and the other commands - in a private `$HOME` and `$HX_HOME`, and
1087
+ points it at a local OpenRouter stand-in with `HX_OPENROUTER_BASE_URL`.
1088
+ Everything between the keyboard and the socket is the shipped code; only the
1089
+ model's replies are scripted. The screen is read back through a VT emulator,
1090
+ so assertions are on the cells a terminal shows, not on the bytes HX meant to
1091
+ write.
1092
+
1093
+ Every run writes `e2e-report/`: `index.html` shows each test's outcome and
1094
+ every screen it checked, rendered in colour, and one text file per screen lets
1095
+ two runs be compared with `diff -r`. CI uploads it from every test job as an
1096
+ `e2e-report-*` artifact. Set `HX_E2E_REPORT` to write it somewhere else.
1097
+
1098
+ Tests that need the OS sandbox are skipped where there is no backend
1099
+ (`sandbox-exec` on macOS, `bwrap` on Linux).
1100
+
1080
1101
  Tests marked `live` hit a real API. The OpenRouter ones cost money:
1081
1102
 
1082
1103
  ```sh
@@ -1093,17 +1114,7 @@ uv run pytest -m live tests/test_live_devin.py
1093
1114
 
1094
1115
  They are the only place the wire formats, streaming, tool use and a genuine
1095
1116
  cache hit are proven against a real service; everything else runs against the
1096
- scripted provider.
1097
-
1098
- Tests marked `sandbox` exercise the real OS sandbox and are skipped where no
1099
- backend exists.
1100
-
1101
- The TUI has SVG layout snapshots. Read the diff before accepting a change to
1102
- them - they exist to catch a frame that quietly lost a row:
1103
-
1104
- ```sh
1105
- uv run pytest tests/tui/test_snapshots.py --snapshot-update
1106
- ```
1117
+ stand-in.
1107
1118
 
1108
1119
  ### Layout
1109
1120
 
@@ -1112,7 +1123,7 @@ src/hx/
1112
1123
  cli.py config.py paths.py frontmatter.py git.py
1113
1124
  core/ loop, context assembly, compaction, late injection, sessions, usage
1114
1125
  auth/ credential store, OAuth flows, per-route resolution
1115
- providers/ OpenRouter, Codex, Devin, the model catalogue, a scripted provider for tests
1126
+ providers/ OpenRouter, Codex, Devin, the model catalogue
1116
1127
  tools/ Bash, Read, Write, Edit, Glob, Grep, Symbols, TodoWrite, Task,
1117
1128
  WebSearch, WebFetch, line anchors, output capping
1118
1129
  permissions/ rule engine, shell decomposition, Seatbelt/bubblewrap
@@ -1149,12 +1160,12 @@ Each release:
1149
1160
  # 1. move CHANGELOG.md's [Unreleased] entries under the new version, dated,
1150
1161
  # and leave a fresh empty [Unreleased] behind
1151
1162
  # 2. bump __version__ in src/hx/__init__.py
1152
- # 3. uv run pytest tests/test_docs.py # the version must have a section
1163
+ # 3. uv run pytest tests/e2e/test_cli.py # the version must have a section
1153
1164
  git commit -am "release: X.Y.Z"
1154
1165
  git tag v$(uv run hx --version | cut -d' ' -f2) && git push origin main --tags
1155
1166
  ```
1156
1167
 
1157
- Step 1 is not optional and is not a convention: `tests/test_docs.py` fails the
1168
+ Step 1 is not optional and is not a convention: `tests/e2e/test_cli.py` fails the
1158
1169
  whole suite when `__version__` has no `CHANGELOG.md` section, so a release that
1159
1170
  skips it cannot get past CI to the `publish` job. The file ships inside the
1160
1171
  wheel and `hx changelog` prints it, so a version with no section there reaches
@@ -124,5 +124,5 @@ asyncio_mode = "auto"
124
124
  addopts = "-q --strict-markers -m 'not live'"
125
125
  markers = [
126
126
  "live: hits a real API (OpenRouter, or Tavily for the web search tools)",
127
- "sandbox: requires sandbox-exec (macOS) or bwrap (Linux)",
127
+ "e2e: drives the installed hx binary against a local OpenRouter stand-in",
128
128
  ]
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.2.7"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -5,7 +5,7 @@ Precedence, lowest to highest::
5
5
  defaults
6
6
  < ~/.hx/settings.json
7
7
  < <cwd>/.hx/settings.json (shared, check it in)
8
- < <cwd>/.hx/settings.local.json (this machine only, never checked in)
8
+ < ~/.hx/projects/<project>/settings.local.json (this machine only)
9
9
  < environment
10
10
  < CLI flags
11
11
 
@@ -105,6 +105,12 @@ class Compactor:
105
105
 
106
106
  return list(messages[:boundary]), list(messages[boundary:])
107
107
 
108
+ def can_compact(self, messages: list[Message]) -> bool:
109
+ """Whether :meth:`compact` would summarise anything - checked before a
110
+ compaction is announced, so one is never started only to do nothing."""
111
+ dropped, _ = self.split(messages)
112
+ return len(dropped) >= self.MIN_MESSAGES_TO_COMPACT
113
+
108
114
  async def compact(
109
115
  self,
110
116
  messages: list[Message],
@@ -790,8 +790,7 @@ class AgentLoop:
790
790
  fraction = self.session.usage.context_fraction
791
791
  if not self.compactor.should_compact(fraction, self.settings.context.compact_at):
792
792
  return
793
- dropped, _ = self.compactor.split(self.session.active_messages())
794
- if len(dropped) < self.compactor.MIN_MESSAGES_TO_COMPACT:
793
+ if not self.compactor.can_compact(self.session.active_messages()):
795
794
  return
796
795
  await self.compact(reason=f"context at {fraction:.0%} of the window")
797
796
 
@@ -805,6 +804,11 @@ class AgentLoop:
805
804
  """
806
805
  if self.compactor is None:
807
806
  return False
807
+ # Decided before anything is announced: "Compacting…" followed by
808
+ # nothing, beside the caller's "nothing to compact", reads as a
809
+ # compaction that started and never finished.
810
+ if not self.compactor.can_compact(self.session.active_messages()):
811
+ return False
808
812
 
809
813
  self.bus.publish(CompactionStarted(reason=reason))
810
814
  try:
@@ -813,16 +817,16 @@ class AgentLoop:
813
817
  self.bus.publish(ErrorRaised(message=f"Compaction failed: {exc}", recoverable=True))
814
818
  return False
815
819
 
816
- if not result.dropped:
817
- self.bus.publish(
818
- CompactionFinished(
819
- tokens_before=result.tokens_before, tokens_after=result.tokens_after
820
- )
821
- )
822
- return False
823
-
824
820
  self.session.record_compaction(result.dropped, result.kept, result.summary)
825
- self.session.usage.context_tokens = result.tokens_after
821
+ # The estimates cover the conversation only; the system prompt and tools
822
+ # ahead of it are still sent, so they stay in the gauge - estimated the
823
+ # same way, rather than subtracted out of the provider's real count.
824
+ prefix = sum(
825
+ section.tokens
826
+ for section in (self.last_context.sections if self.last_context else [])
827
+ if section.name != "history"
828
+ )
829
+ self.session.usage.context_tokens = prefix + result.tokens_after
826
830
  self.bus.publish(
827
831
  CompactionFinished(tokens_before=result.tokens_before, tokens_after=result.tokens_after)
828
832
  )
@@ -29,7 +29,7 @@ class GrantScope(StrEnum):
29
29
  ONCE = "once"
30
30
  SESSION = "session"
31
31
  ALWAYS = "always"
32
- """Persisted to ``.hx/settings.local.json``."""
32
+ """Persisted to ``~/.hx/projects/<project>/settings.local.json``."""
33
33
 
34
34
 
35
35
  DEFAULT_MUTATING_TOOLS = frozenset({"Bash", "Write", "Edit", "Task"})
@@ -27,27 +27,40 @@ class SandboxBackend(StrEnum):
27
27
 
28
28
 
29
29
  CREDENTIAL_PATHS = (
30
- "~/.ssh",
31
- "~/.aws",
32
- "~/.gnupg",
33
- "~/.kube",
30
+ "~/.ssh/",
31
+ "~/.aws/",
32
+ "~/.gnupg/",
33
+ "~/.kube/",
34
34
  "~/.docker/config.json",
35
- "~/.config/gh",
35
+ "~/.config/gh/",
36
36
  "~/.netrc",
37
37
  "~/.npmrc",
38
38
  "~/.pypirc",
39
39
  "~/.hx/auth.json",
40
40
  )
41
- """Denied even inside readable roots. A coding agent has no business reading
42
- these, and one prompt-injected `cat ~/.ssh/id_rsa` is all it takes."""
41
+ """Denied even inside readable and writable roots. A coding agent has no
42
+ business reading these, and one prompt-injected `cat ~/.ssh/id_rsa` is all it
43
+ takes - or writing them, where an `authorized_keys` line is a way back in.
44
+
45
+ A trailing slash marks a directory. That matters only for one that does not
46
+ exist yet: see :func:`build_bwrap_argv`."""
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class DeniedPath:
51
+ path: Path
52
+ is_dir: bool
53
+ """What the path is meant to be, which is what it gets created as if it has
54
+ to be created - ``~/.ssh`` made as a file would break ssh as surely as
55
+ ``~/.netrc`` made as a directory would break curl."""
43
56
 
44
57
 
45
58
  @dataclass(slots=True)
46
59
  class SandboxPolicy:
47
60
  writable_paths: tuple[Path, ...] = ()
48
61
  readable_paths: tuple[Path, ...] = ()
49
- deny_paths: tuple[Path, ...] = field(default_factory=tuple)
50
- """Explicitly unreadable even inside readable roots - credential stores,
62
+ deny_paths: tuple[DeniedPath, ...] = field(default_factory=tuple)
63
+ """Unreadable and unwritable even inside allowed roots - credential stores,
51
64
  ``~/.ssh``, ``~/.aws``, and HX's own ``auth.json``."""
52
65
  allow_network: bool = False
53
66
  allow_subprocess: bool = True
@@ -127,7 +140,10 @@ def default_policy(cwd: Path, allow_network: bool = False) -> SandboxPolicy:
127
140
  return SandboxPolicy(
128
141
  writable_paths=tuple(dict.fromkeys(writable)),
129
142
  readable_paths=(Path("/"),),
130
- deny_paths=tuple(_real(Path(p).expanduser()) for p in CREDENTIAL_PATHS),
143
+ deny_paths=tuple(
144
+ DeniedPath(_real(Path(p).expanduser()), is_dir=p.endswith("/"))
145
+ for p in CREDENTIAL_PATHS
146
+ ),
131
147
  allow_network=allow_network,
132
148
  cwd=cwd.resolve(),
133
149
  )
@@ -195,9 +211,13 @@ def build_seatbelt_profile(policy: SandboxPolicy) -> str:
195
211
  lines.append("(allow network*)" if policy.allow_network else "(deny network*)")
196
212
 
197
213
  if policy.deny_paths:
198
- lines += ["", ";; Credentials - last match wins, so these override the read allowance"]
199
- for path in policy.deny_paths:
200
- lines.append(f"(deny file-read* (subpath {_sbpl_string(_real(path))}))")
214
+ # Writes too: a project opened in $HOME makes ~/.ssh writable, and a
215
+ # planted authorized_keys is worse than a leaked key.
216
+ lines += ["", ";; Credentials - last match wins, so these override both allowances"]
217
+ for denied in policy.deny_paths:
218
+ lines.append(
219
+ f"(deny file-read* file-write* (subpath {_sbpl_string(_real(denied.path))}))"
220
+ )
201
221
 
202
222
  return "\n".join(lines) + "\n"
203
223
 
@@ -229,7 +249,13 @@ def _sbpl_string(path: Path | str) -> str:
229
249
 
230
250
  def build_bwrap_argv(policy: SandboxPolicy, argv: list[str]) -> list[str]:
231
251
  """Generate the ``bwrap`` invocation: ``--ro-bind / /``, project bound
232
- read-write, ``--unshare-net`` unless network is allowed."""
252
+ read-write, credential paths shadowed, ``--unshare-net`` unless network is
253
+ allowed.
254
+
255
+ Not quite side-effect free: a credential path that does not exist but sits
256
+ inside a writable root is created on the host first - see
257
+ :func:`_ensure_mountpoint`.
258
+ """
233
259
  command = [
234
260
  "bwrap",
235
261
  "--ro-bind",
@@ -241,20 +267,23 @@ def build_bwrap_argv(policy: SandboxPolicy, argv: list[str]) -> list[str]:
241
267
  "/proc",
242
268
  "--die-with-parent",
243
269
  ]
244
- for path in policy.writable_paths:
245
- command += ["--bind", str(_real(path)), str(_real(path))]
246
- for path in policy.deny_paths:
247
- if not path.exists():
270
+ writable = [_real(path) for path in policy.writable_paths]
271
+ for path in writable:
272
+ command += ["--bind", str(path), str(path)]
273
+ for denied in policy.deny_paths:
274
+ path = _real(denied.path)
275
+ if not path.exists() and not _ensure_mountpoint(denied, writable):
248
276
  continue
249
- # There is no "deny read" in bwrap, so the path is shadowed instead -
250
- # and what it is shadowed *with* has to match what is there. `--tmpfs`
277
+ # There is no "deny" in bwrap, so the path is shadowed instead - and
278
+ # what it is shadowed *with* has to match what is there. `--tmpfs`
251
279
  # mounts a directory, and aiming it at a regular file aborts bwrap
252
280
  # outright: the sandbox never starts, and the command it was wrapping
253
281
  # dies with it. Six of the credential paths are files, `~/.hx/auth.json`
254
282
  # among them - the one HX writes itself - so this was every Linux user
255
- # who had ever logged in.
283
+ # who had ever logged in. Read-only either way, so a write fails the
284
+ # way it does under Seatbelt instead of vanishing into a tmpfs.
256
285
  if path.is_dir():
257
- command += ["--tmpfs", str(path)]
286
+ command += ["--tmpfs", str(path), "--remount-ro", str(path)]
258
287
  else:
259
288
  command += ["--ro-bind", os.devnull, str(path)]
260
289
  if not policy.allow_network:
@@ -265,3 +294,36 @@ def build_bwrap_argv(policy: SandboxPolicy, argv: list[str]) -> list[str]:
265
294
  if policy.cwd is not None:
266
295
  command += ["--chdir", str(_real(policy.cwd))]
267
296
  return [*command, "--", *argv]
297
+
298
+
299
+ def _ensure_mountpoint(denied: DeniedPath, writable: list[Path]) -> bool:
300
+ """Create a missing credential path on the host, so it can be shadowed.
301
+
302
+ bwrap can only deny a path by mounting over it, and a mount needs something
303
+ to mount on. Outside the writable roots, skipping a missing path is safe:
304
+ under ``--ro-bind / /`` the sandbox cannot create it either. Inside one it
305
+ is not - a session opened in ``$HOME`` with no ``~/.ssh`` yet could
306
+ ``mkdir ~/.ssh`` and plant an ``authorized_keys`` on the host.
307
+
308
+ So the path is created, empty and private, as what it is meant to be. It
309
+ stays: removing it after the command would race any other sandbox still
310
+ shadowing it, and an empty ``~/.ssh`` is what ssh itself would have made.
311
+ Left to bwrap, it would appear too - but a file as ``0444``, which the
312
+ user's own tools could not then write.
313
+
314
+ Returns whether the path now exists. A failure means the parent refuses
315
+ this user, and the sandbox runs as the same user, so it cannot create the
316
+ path either.
317
+ """
318
+ path = _real(denied.path)
319
+ if not any(path.is_relative_to(root) for root in writable):
320
+ return False
321
+ try:
322
+ path.parent.mkdir(parents=True, exist_ok=True)
323
+ if denied.is_dir:
324
+ path.mkdir(mode=0o700, exist_ok=True)
325
+ else:
326
+ os.close(os.open(path, os.O_WRONLY | os.O_CREAT, 0o600))
327
+ except OSError:
328
+ return False
329
+ return path.exists()
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import asyncio
18
18
  import json
19
+ import os
19
20
  import random
20
21
  import time
21
22
  from collections.abc import AsyncIterator
@@ -39,8 +40,17 @@ from hx.net import async_client
39
40
  from hx.providers.base import ProviderError, ProviderRequest, StreamDelta, StreamEnd, StreamItem
40
41
 
41
42
  API_BASE = "https://openrouter.ai/api/v1"
42
- CHAT_COMPLETIONS = f"{API_BASE}/chat/completions"
43
- MODELS = f"{API_BASE}/models"
43
+
44
+ BASE_URL_VAR = "HX_OPENROUTER_BASE_URL"
45
+ """Points the OpenRouter route at another OpenAI-compatible endpoint: a
46
+ gateway, a recording proxy, or the local stand-in the end-to-end suite runs."""
47
+
48
+
49
+ def api_base() -> str:
50
+ """The endpoint the OpenRouter route talks to: ``HX_OPENROUTER_BASE_URL``
51
+ when set, otherwise OpenRouter itself."""
52
+ return (os.environ.get(BASE_URL_VAR) or "").strip().rstrip("/") or API_BASE
53
+
44
54
 
45
55
  DEFAULT_HEADERS = {
46
56
  "HTTP-Referer": "https://github.com/aletisunil/hx",
@@ -68,12 +78,12 @@ class OpenRouterProvider:
68
78
  self,
69
79
  api_key: str,
70
80
  *,
71
- base_url: str = API_BASE,
81
+ base_url: str | None = None,
72
82
  timeout: float = 600.0,
73
83
  max_retries: int = 3,
74
84
  ) -> None:
75
85
  self.api_key = api_key
76
- self.base_url = base_url.rstrip("/")
86
+ self.base_url = (base_url or api_base()).rstrip("/")
77
87
  self.max_retries = max_retries
78
88
  self._client = async_client(
79
89
  timeout=httpx.Timeout(timeout, connect=15.0),
@@ -437,11 +447,11 @@ def _error_message(status: int, body: str) -> str:
437
447
  return f"OpenRouter {status}: {message or body[:400]}"
438
448
 
439
449
 
440
- async def fetch_models(api_key: str, base_url: str = API_BASE) -> list[dict[str, Any]]:
450
+ async def fetch_models(api_key: str, base_url: str | None = None) -> list[dict[str, Any]]:
441
451
  """GET ``/models``. Used by :class:`~hx.providers.models.ModelRegistry`."""
442
452
  async with async_client(timeout=30.0) as client:
443
453
  response = await client.get(
444
- f"{base_url.rstrip('/')}/models",
454
+ f"{(base_url or api_base()).rstrip('/')}/models",
445
455
  headers={"Authorization": f"Bearer {api_key}", **DEFAULT_HEADERS},
446
456
  )
447
457
  if response.status_code >= 400: