hx-cli 0.2.3__tar.gz → 0.2.4__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 (225) hide show
  1. hx_cli-0.2.4/.gitignore +19 -0
  2. {hx_cli-0.2.3 → hx_cli-0.2.4}/CHANGELOG.md +86 -1
  3. hx_cli-0.2.3/README.md → hx_cli-0.2.4/PKG-INFO +117 -0
  4. hx_cli-0.2.3/PKG-INFO → hx_cli-0.2.4/README.md +78 -37
  5. {hx_cli-0.2.3 → hx_cli-0.2.4}/pyproject.toml +3 -1
  6. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/__init__.py +1 -1
  7. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/auth/oauth/callback.py +13 -1
  8. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/auth/store.py +16 -2
  9. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/cli.py +81 -1
  10. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/loop.py +73 -15
  11. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/session.py +69 -5
  12. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/mcp/client.py +28 -1
  13. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/permissions/engine.py +128 -11
  14. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/permissions/sandbox.py +46 -6
  15. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/openrouter.py +16 -2
  16. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/base.py +12 -8
  17. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/bash.py +54 -17
  18. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/websearch.py +8 -0
  19. hx_cli-0.2.4/src/hx/trace.py +887 -0
  20. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/commands.py +102 -1
  21. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/runtime.py +1 -1
  22. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/auth/test_codex_oauth.py +21 -0
  23. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/auth/test_store.py +58 -0
  24. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_loop.py +206 -1
  25. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_session.py +70 -0
  26. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/mcp/fixtures/echo_server.py +22 -1
  27. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/mcp/test_manager.py +34 -0
  28. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/permissions/test_engine.py +111 -0
  29. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/permissions/test_sandbox.py +63 -0
  30. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/providers/test_openrouter.py +65 -0
  31. hx_cli-0.2.4/tests/test_trace.py +515 -0
  32. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tools/test_background.py +64 -0
  33. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tools/test_bash.py +48 -0
  34. hx_cli-0.2.4/tests/tui/test_trace_command.py +257 -0
  35. hx_cli-0.2.3/.gitignore +0 -16
  36. {hx_cli-0.2.3 → hx_cli-0.2.4}/.github/workflows/ci.yml +0 -0
  37. {hx_cli-0.2.3 → hx_cli-0.2.4}/examples/hooks/check.sh +0 -0
  38. {hx_cli-0.2.3 → hx_cli-0.2.4}/install.sh +0 -0
  39. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/agents/__init__.py +0 -0
  40. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/agents/definitions.py +0 -0
  41. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/agents/subagent.py +0 -0
  42. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/auth/__init__.py +0 -0
  43. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/auth/oauth/__init__.py +0 -0
  44. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/auth/oauth/browser.py +0 -0
  45. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/auth/oauth/codex.py +0 -0
  46. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/auth/oauth/devin.py +0 -0
  47. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/auth/oauth/pkce.py +0 -0
  48. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/auth/resolve.py +0 -0
  49. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/config.py +0 -0
  50. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/__init__.py +0 -0
  51. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/checkpoints.py +0 -0
  52. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/compaction.py +0 -0
  53. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/context.py +0 -0
  54. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/events.py +0 -0
  55. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/lateinject.py +0 -0
  56. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/messages.py +0 -0
  57. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/title.py +0 -0
  58. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/core/usage.py +0 -0
  59. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/docs.py +0 -0
  60. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/frontmatter.py +0 -0
  61. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/git.py +0 -0
  62. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/hooks/__init__.py +0 -0
  63. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/hooks/engine.py +0 -0
  64. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/hooks/spec.py +0 -0
  65. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/keys.py +0 -0
  66. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/mcp/__init__.py +0 -0
  67. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/mcp/manager.py +0 -0
  68. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/net.py +0 -0
  69. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/paths.py +0 -0
  70. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/permissions/__init__.py +0 -0
  71. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/permissions/parser.py +0 -0
  72. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/__init__.py +0 -0
  73. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/base.py +0 -0
  74. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/codex.py +0 -0
  75. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/codex_catalogue.py +0 -0
  76. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/devin.py +0 -0
  77. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/devin_catalogue.py +0 -0
  78. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/devin_wire.py +0 -0
  79. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/fake.py +0 -0
  80. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/models.py +0 -0
  81. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/protowire.py +0 -0
  82. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/registry.py +0 -0
  83. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/providers/responses_codec.py +0 -0
  84. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/skills/__init__.py +0 -0
  85. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/skills/loader.py +0 -0
  86. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/skills/runtime.py +0 -0
  87. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/__init__.py +0 -0
  88. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/ansi.py +0 -0
  89. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/buffer.py +0 -0
  90. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/component.py +0 -0
  91. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/demo.py +0 -0
  92. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/editor.py +0 -0
  93. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/keydecode.py +0 -0
  94. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/loop.py +0 -0
  95. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/markdown.py +0 -0
  96. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/primitives.py +0 -0
  97. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/sanitize.py +0 -0
  98. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/screen.py +0 -0
  99. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/syntax.py +0 -0
  100. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/terminal.py +0 -0
  101. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/undo.py +0 -0
  102. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/width.py +0 -0
  103. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/term/word_nav.py +0 -0
  104. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/__init__.py +0 -0
  105. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/anchors.py +0 -0
  106. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/edit.py +0 -0
  107. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/glob.py +0 -0
  108. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/grep.py +0 -0
  109. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/output.py +0 -0
  110. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/read.py +0 -0
  111. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/registry.py +0 -0
  112. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/symbols.py +0 -0
  113. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/task.py +0 -0
  114. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/todo.py +0 -0
  115. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tools/write.py +0 -0
  116. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/__init__.py +0 -0
  117. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/app.py +0 -0
  118. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/clipboard.py +0 -0
  119. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/format.py +0 -0
  120. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/fuzzy.py +0 -0
  121. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/glyphs.py +0 -0
  122. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/killring.py +0 -0
  123. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/limits.py +0 -0
  124. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/paint.py +0 -0
  125. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/renderers.py +0 -0
  126. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/roles.py +0 -0
  127. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/theme.py +0 -0
  128. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/theme_json.py +0 -0
  129. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/themes/ansi.json +0 -0
  130. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/themes/dark.json +0 -0
  131. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/themes/light.json +0 -0
  132. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/views/__init__.py +0 -0
  133. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/views/blocks.py +0 -0
  134. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/views/dialog.py +0 -0
  135. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/views/login.py +0 -0
  136. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/views/permission.py +0 -0
  137. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/views/pickers.py +0 -0
  138. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/views/prompt.py +0 -0
  139. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/views/status.py +0 -0
  140. {hx_cli-0.2.3 → hx_cli-0.2.4}/src/hx/tui/views/transcript.py +0 -0
  141. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/__init__.py +0 -0
  142. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/agents/__init__.py +0 -0
  143. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/agents/test_subagent.py +0 -0
  144. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/auth/__init__.py +0 -0
  145. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/auth/test_devin_oauth.py +0 -0
  146. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/auth/test_pkce.py +0 -0
  147. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/auth/test_resolve.py +0 -0
  148. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/conftest.py +0 -0
  149. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/__init__.py +0 -0
  150. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_checkpoints.py +0 -0
  151. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_compaction.py +0 -0
  152. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_context.py +0 -0
  153. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_lateinject.py +0 -0
  154. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_rewind.py +0 -0
  155. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_settings_layers.py +0 -0
  156. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_settings_migration.py +0 -0
  157. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_steering.py +0 -0
  158. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_title.py +0 -0
  159. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/core/test_usage.py +0 -0
  160. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/hooks/__init__.py +0 -0
  161. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/hooks/test_engine.py +0 -0
  162. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/hooks/test_example_check.py +0 -0
  163. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/mcp/__init__.py +0 -0
  164. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/permissions/__init__.py +0 -0
  165. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/permissions/test_parser.py +0 -0
  166. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/providers/__init__.py +0 -0
  167. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/providers/test_catalogue.py +0 -0
  168. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/providers/test_codex.py +0 -0
  169. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/providers/test_codex_catalogue.py +0 -0
  170. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/providers/test_devin.py +0 -0
  171. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/providers/test_devin_catalogue.py +0 -0
  172. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/providers/test_protowire.py +0 -0
  173. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/providers/test_registry.py +0 -0
  174. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/providers/test_responses_codec.py +0 -0
  175. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/skills/__init__.py +0 -0
  176. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/skills/test_loader.py +0 -0
  177. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/__init__.py +0 -0
  178. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/conftest.py +0 -0
  179. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/test_ansi.py +0 -0
  180. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/test_keydecode.py +0 -0
  181. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/test_loop.py +0 -0
  182. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/test_markdown.py +0 -0
  183. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/test_primitives.py +0 -0
  184. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/test_sanitize.py +0 -0
  185. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/test_screen.py +0 -0
  186. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/test_terminal.py +0 -0
  187. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/term/test_width.py +0 -0
  188. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_cli.py +0 -0
  189. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_docs.py +0 -0
  190. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_git.py +0 -0
  191. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_install.py +0 -0
  192. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_integration.py +0 -0
  193. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_live.py +0 -0
  194. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_live_codex.py +0 -0
  195. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_live_devin.py +0 -0
  196. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_live_tavily.py +0 -0
  197. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_net.py +0 -0
  198. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_paths.py +0 -0
  199. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/test_readme.py +0 -0
  200. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tools/__init__.py +0 -0
  201. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tools/test_files.py +0 -0
  202. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tools/test_hashline.py +0 -0
  203. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tools/test_output.py +0 -0
  204. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tools/test_symbols.py +0 -0
  205. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tools/test_todo.py +0 -0
  206. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tools/test_websearch.py +0 -0
  207. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/__init__.py +0 -0
  208. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/support.py +0 -0
  209. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/test_autocomplete.py +0 -0
  210. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/test_clipboard.py +0 -0
  211. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/test_effort.py +0 -0
  212. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/test_keys.py +0 -0
  213. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/test_login.py +0 -0
  214. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/test_renderers.py +0 -0
  215. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/test_session.py +0 -0
  216. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/test_theme_json.py +0 -0
  217. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/views/__init__.py +0 -0
  218. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/views/test_blocks.py +0 -0
  219. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/views/test_permission.py +0 -0
  220. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/views/test_pickers.py +0 -0
  221. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/views/test_prompt.py +0 -0
  222. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/views/test_runtime.py +0 -0
  223. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/views/test_status.py +0 -0
  224. {hx_cli-0.2.3 → hx_cli-0.2.4}/tests/tui/views/test_transcript.py +0 -0
  225. {hx_cli-0.2.3 → hx_cli-0.2.4}/uv.lock +0 -0
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .DS_Store
14
+ .hx/sessions/
15
+ # Not .hx/settings.json: that file is the project's *shared* configuration and
16
+ # is meant to be checked in. What HX writes on your behalf - permission grants
17
+ # and the rest - lives under $HX_HOME/projects/<slug>, outside the checkout
18
+ # entirely, so there is nothing here left to hide.
19
+ snapshot_report.html
@@ -28,6 +28,90 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.4] - 2026-09-19
32
+
33
+ ### Added
34
+
35
+ - `/trace [path]`: write the whole session to one self-contained HTML page and
36
+ open it. The system prompt, the project context, the skills index, every tool
37
+ schema, every prompt and reply, every thinking block, every tool call beside
38
+ the result it produced, the per-call token ledger and the files HX changed.
39
+ Late-injected reminders and compacted turns are included behind a toggle.
40
+ A session still running traces as it stands, including the turn that just
41
+ landed. The page follows your system's dark or light setting and a button
42
+ overrides it; nothing is fetched and nothing is uploaded.
43
+ - `hx trace [SESSION_ID] [PATH]`: the same page for any transcript on the
44
+ machine, without starting a session. Defaults to the last session in this
45
+ directory and prints only the path, so `open "$(hx trace)"` works. Either
46
+ argument may be given alone - a lone one that looks like a path is one.
47
+ - The system prompt and the tool schemas are now written to the transcript on a
48
+ session's first provider call, so a trace of a finished session can say what
49
+ the model was told and not only what it said.
50
+
51
+ ### Security
52
+
53
+ - Permission rules are matched against the path a specifier resolves to, not
54
+ the text it was written as. `deny: Read(~/.ssh/**)` was got past by asking
55
+ for `../../.ssh/id_rsa`, or through a symlink planted in the project. The
56
+ same resolution stops an allow rule leaking: `allow: Edit(src/**)` used to
57
+ cover `src/../../etc/passwd`.
58
+ - `WebFetch` is matched against every URL it was given rather than the first,
59
+ so `deny: WebFetch(https://host/**)` is no longer a question of list order.
60
+ The approval prompt spells the URLs out too; it said `urls=[2 items]`, which
61
+ answered the only question that mattered with a number.
62
+ - The generated Seatbelt profile no longer grants write access to all of
63
+ `/private/var/folders` - every user's and every process's temp and cache.
64
+ `$TMPDIR` was resolved precisely to avoid that, and the profile handed it
65
+ back on the next line. This user's own container stays writable.
66
+ - `~/.hx/auth.json` is created with mode 0600 rather than narrowed to it after
67
+ the tokens are written, closing a window at a predictable path where the
68
+ credential was readable by any local account.
69
+ - The OAuth callback page escapes what the provider sent. An `error` parameter
70
+ carrying markup ran as HTML on a loopback origin that is otherwise the
71
+ user's own.
72
+
73
+ ### Fixed
74
+
75
+ - Slash commands read the session that is in force rather than the one HX
76
+ opened with. After `/resume`, `/trace` wrote a page of zeros into the empty
77
+ startup session's directory and `/cost` reported nobody's usage: the command
78
+ context had captured the session object at startup, and `/resume` rebinds it.
79
+ - A session whose title was the trace template's own data placeholder had the
80
+ whole payload spliced into the page's `<title>`.
81
+ - A trace labelled an encrypted reasoning block "thinking (signed)", which read
82
+ as though the page had dropped the rest of it. It now says the text is the
83
+ provider's summary and the reasoning came back encrypted, and a block that
84
+ arrived with no summary at all says that rather than rendering an empty box.
85
+ - MCP servers survive a large response. One JSON-RPC message is one line and
86
+ asyncio's stream limit is 64 KiB, so a tool returning a file did not truncate
87
+ - it raised out of the read loop and killed the connection for the rest of
88
+ the session. The limit is now 16 MiB, and overshooting it says so.
89
+ - A tool call is no longer dropped when a stream ends without a `finish_reason`.
90
+ The arguments were buffered until that field arrived, so a complete call was
91
+ discarded and the turn reported `end_turn`: the model appeared to stop for no
92
+ reason, and the tokens were already paid for.
93
+ - The Linux sandbox starts again for anyone with credential *files*. Denied
94
+ paths were all shadowed with `--tmpfs`, which mounts a directory and aborts
95
+ bwrap when aimed at a file - and six of them are files, `~/.hx/auth.json`
96
+ among them. Files are shadowed with `/dev/null` instead. bwrap also starts in
97
+ the policy's directory rather than whatever `$PWD` happened to hold.
98
+ - `PersistentShell.cwd` follows the shell. It claimed to track it and was in
99
+ fact the launch directory forever, which meant a background job always
100
+ started where the session opened rather than where `cd` had left it.
101
+ - A background job no longer leaks its log file descriptor for the life of the
102
+ session.
103
+ - A record appended while the transcript was being written is no longer
104
+ dropped. The queue is taken in one step, and a failed write puts its records
105
+ back rather than losing them.
106
+ - An automatic compaction with nothing to summarise is no longer announced and
107
+ rerun every turn for the rest of the session. `/compact` still answers either
108
+ way.
109
+ - `/trace` takes `--no-open`, and never launches a *terminal* browser: with no
110
+ display, `webbrowser` falls through to lynx or w3m, which opens in the
111
+ terminal HX is drawing on and takes the session display with it.
112
+
113
+ ---
114
+
31
115
  ## [0.2.3] - 2026-09-18
32
116
 
33
117
  ### Added
@@ -481,7 +565,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
481
565
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
482
566
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
483
567
 
484
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.3...HEAD
568
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.4...HEAD
569
+ [0.2.4]: https://github.com/aletisunil/hx/compare/v0.2.3...v0.2.4
485
570
  [0.2.3]: https://github.com/aletisunil/hx/compare/v0.2.2...v0.2.3
486
571
  [0.2.2]: https://github.com/aletisunil/hx/compare/v0.2.1...v0.2.2
487
572
  [0.2.1]: https://github.com/aletisunil/hx/compare/v0.2.0...v0.2.1
@@ -1,3 +1,42 @@
1
+ Metadata-Version: 2.5
2
+ Name: hx-cli
3
+ Version: 0.2.4
4
+ Summary: HX - an agent harness for the terminal
5
+ Project-URL: Homepage, https://hx.sunilaleti.dev
6
+ Project-URL: Documentation, https://hx.sunilaleti.dev
7
+ Project-URL: Source, https://github.com/aletisunil/hx
8
+ Project-URL: Issues, https://github.com/aletisunil/hx/issues
9
+ Author: Sunil Aleti
10
+ License: MIT
11
+ Keywords: agent,cli,coding-agent,llm,openrouter,tui
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: markdown-it-py>=3.0
21
+ Requires-Dist: pydantic>=2.7
22
+ Requires-Dist: pygments>=2.17
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: truststore>=0.10
25
+ Requires-Dist: wcwidth>=0.2.13
26
+ Provides-Extra: dev
27
+ Requires-Dist: mypy>=1.11; extra == 'dev'
28
+ Requires-Dist: pyte>=0.8; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
30
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
31
+ Requires-Dist: pytest>=8.2; extra == 'dev'
32
+ Requires-Dist: ruff>=0.6; extra == 'dev'
33
+ Requires-Dist: types-pygments; extra == 'dev'
34
+ Requires-Dist: types-pyyaml; extra == 'dev'
35
+ Provides-Extra: symbols
36
+ Requires-Dist: tree-sitter-language-pack>=0.7; extra == 'symbols'
37
+ Requires-Dist: tree-sitter>=0.23; extra == 'symbols'
38
+ Description-Content-Type: text/markdown
39
+
1
40
  # HX
2
41
 
3
42
  An agent harness for the terminal. Python core, its own terminal renderer,
@@ -260,6 +299,9 @@ hx -p "explain this repo" # headless: streams to stdout, tool activity to stde
260
299
  hx resume # resume the last session here
261
300
  hx resume <session-id> # resume a specific one
262
301
  hx prompt # print the system prompt this directory would use
302
+ hx trace # write the last session here to an HTML page
303
+ hx trace out.html # the same, to a path you choose
304
+ hx trace <id> out.html # a specific session, to a path you choose
263
305
  hx --model openai/gpt-5 # override the model for one run
264
306
  hx --mode plan # start read-only
265
307
  hx --cwd ../other-project # run against a different directory
@@ -407,6 +449,7 @@ than being silently resolved.
407
449
  | `/theme [name]` | `dark`, `light`, `ansi`, or any theme in `~/.hx/themes` |
408
450
  | `/queue [steer <n>\|clear]` | messages waiting for the turn to end, and what to do with them |
409
451
  | `/copy` | copy the last reply to the clipboard |
452
+ | `/trace [path]` | write the whole session to a self-contained HTML page |
410
453
  | `/fullscreen [on\|off]` | take the whole window, with the prompt pinned to the bottom |
411
454
  | `/mouse [on\|off]` | mouse reporting, and with it your terminal's own text selection |
412
455
  | `/init` | generate an `AGENTS.md` for the project |
@@ -652,6 +695,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
652
695
  | `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
653
696
  | `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
654
697
  | `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
698
+ | `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
655
699
  | `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
656
700
  | `~/.hx/projects/<project>/settings.local.json` | this machine's settings for one project - where "always allow" lands |
657
701
  | `./.hx/settings.json` | project settings, checked in if you like — read by HX, never written |
@@ -735,6 +779,79 @@ rest to the session directory, and hands the model that path to grep.
735
779
 
736
780
  ---
737
781
 
782
+ ## Tracing a session
783
+
784
+ `/trace` writes everything that happened to one self-contained HTML file and
785
+ opens it in a browser:
786
+
787
+ ```
788
+ /trace # ~/.hx/sessions/<id>/trace.html
789
+ /trace ~/Desktop/bug.html # somewhere you choose
790
+ ```
791
+
792
+ Everything means everything. The system prompt, the project context, the skills
793
+ index, every tool schema as the provider received it, every prompt, every
794
+ reply, every thinking block, every tool call next to the result it produced,
795
+ the per-call token ledger, and the files HX changed. Late-injected reminders
796
+ and compacted turns are in there too, behind a toggle that says how many there
797
+ are — a trace that hides the reminders and the superseded turns is not a trace
798
+ of the session that ran.
799
+
800
+ Nothing is summarised and nothing is prettified. Assistant text is shown as it
801
+ arrived, backticks and all, because a trace exists to answer "what actually
802
+ went through" and a rendered version of that is a different document. Long tool
803
+ results are folded rather than truncated, with the character count on the fold.
804
+
805
+ **Thinking, where the provider allows it.** How much of a model's reasoning HX
806
+ ever sees is the provider's decision, not HX's. Anthropic's models return the
807
+ thinking itself. The Responses API - the OpenAI and Codex routes - returns a
808
+ one-line summary in plaintext and the reasoning encrypted, to be replayed on
809
+ the next call and read by nobody, HX included. A trace labels which of the two
810
+ it is holding, because a one-line thinking block is otherwise indistinguishable
811
+ from a trace that dropped the rest.
812
+
813
+ A running session traces as it stands right now, including the turn that just
814
+ landed. The page says so, because a trace of a live session is a snapshot.
815
+
816
+ **Reading it.** Long tool results are folded and the reference panels - the
817
+ prompt, the schemas, the ledger - start shut, so the transcript is what you
818
+ land on. "Expand all" opens the whole page, panels included, and turns into
819
+ "Collapse all". A session with no turns yet opens its panels on its own, since
820
+ there is nothing else to read.
821
+
822
+ **Dark or light.** The page follows your system by default and a button in the
823
+ corner overrides it, remembered per browser. No stylesheet is fetched to do it.
824
+
825
+ **Nothing is uploaded and nothing is fetched.** The payload, the stylesheet and
826
+ the script all live in the file, so it opens offline, renders the same a year
827
+ later, and a trace of a private repository stays as private as the directory
828
+ you wrote it into. The embedded payload is also the machine-readable trace: it
829
+ is JSON, in a `<script id="trace-data">` element, and `/trace` is the only way
830
+ HX will ever put your transcript anywhere but `~/.hx`.
831
+
832
+ From outside a session, on any transcript on the machine:
833
+
834
+ ```sh
835
+ hx trace # the last session in this directory
836
+ hx trace 20260918-214743-b5b8f9 # one by id
837
+ hx trace ~/somewhere.html # the last session, to a path you choose
838
+ hx trace <id> ~/somewhere.html # both, to a path or a directory
839
+ open "$(hx trace)" # the path is all that goes to stdout
840
+ ```
841
+
842
+ `hx trace` reads the transcript and runs nothing, so it works on a session that
843
+ ended months ago and on one another terminal has open right now.
844
+
845
+ **What a trace can be missing.** The prompt and the tool schemas are written to
846
+ the transcript on the session's first provider call — the first moment the tool
847
+ registry has settled, because MCP servers connect after the loop is built. A
848
+ session traced before that call, or one recorded by a version of HX older than
849
+ this feature, has the transcript but not the prompt, and the page leaves those
850
+ panels out rather than guessing. `/trace` inside a live session fills them in
851
+ from the loop either way.
852
+
853
+ ---
854
+
738
855
  ## Reading and editing code
739
856
 
740
857
  **Line anchors.** `Read` labels every line with a short content hash:
@@ -1,40 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: hx-cli
3
- Version: 0.2.3
4
- Summary: HX - an agent harness for the terminal
5
- Project-URL: Homepage, https://github.com/aletisunil/hx
6
- Project-URL: Issues, https://github.com/aletisunil/hx/issues
7
- Author: Sunil Aleti
8
- License: MIT
9
- Keywords: agent,cli,coding-agent,llm,openrouter,tui
10
- Classifier: Development Status :: 3 - Alpha
11
- Classifier: Environment :: Console
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
- Classifier: Programming Language :: Python :: 3.13
15
- Classifier: Programming Language :: Python :: 3.14
16
- Requires-Python: >=3.11
17
- Requires-Dist: httpx>=0.27
18
- Requires-Dist: markdown-it-py>=3.0
19
- Requires-Dist: pydantic>=2.7
20
- Requires-Dist: pygments>=2.17
21
- Requires-Dist: pyyaml>=6.0
22
- Requires-Dist: truststore>=0.10
23
- Requires-Dist: wcwidth>=0.2.13
24
- Provides-Extra: dev
25
- Requires-Dist: mypy>=1.11; extra == 'dev'
26
- Requires-Dist: pyte>=0.8; extra == 'dev'
27
- Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
28
- Requires-Dist: pytest-cov>=5.0; extra == 'dev'
29
- Requires-Dist: pytest>=8.2; extra == 'dev'
30
- Requires-Dist: ruff>=0.6; extra == 'dev'
31
- Requires-Dist: types-pygments; extra == 'dev'
32
- Requires-Dist: types-pyyaml; extra == 'dev'
33
- Provides-Extra: symbols
34
- Requires-Dist: tree-sitter-language-pack>=0.7; extra == 'symbols'
35
- Requires-Dist: tree-sitter>=0.23; extra == 'symbols'
36
- Description-Content-Type: text/markdown
37
-
38
1
  # HX
39
2
 
40
3
  An agent harness for the terminal. Python core, its own terminal renderer,
@@ -297,6 +260,9 @@ hx -p "explain this repo" # headless: streams to stdout, tool activity to stde
297
260
  hx resume # resume the last session here
298
261
  hx resume <session-id> # resume a specific one
299
262
  hx prompt # print the system prompt this directory would use
263
+ hx trace # write the last session here to an HTML page
264
+ hx trace out.html # the same, to a path you choose
265
+ hx trace <id> out.html # a specific session, to a path you choose
300
266
  hx --model openai/gpt-5 # override the model for one run
301
267
  hx --mode plan # start read-only
302
268
  hx --cwd ../other-project # run against a different directory
@@ -444,6 +410,7 @@ than being silently resolved.
444
410
  | `/theme [name]` | `dark`, `light`, `ansi`, or any theme in `~/.hx/themes` |
445
411
  | `/queue [steer <n>\|clear]` | messages waiting for the turn to end, and what to do with them |
446
412
  | `/copy` | copy the last reply to the clipboard |
413
+ | `/trace [path]` | write the whole session to a self-contained HTML page |
447
414
  | `/fullscreen [on\|off]` | take the whole window, with the prompt pinned to the bottom |
448
415
  | `/mouse [on\|off]` | mouse reporting, and with it your terminal's own text selection |
449
416
  | `/init` | generate an `AGENTS.md` for the project |
@@ -689,6 +656,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
689
656
  | `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
690
657
  | `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
691
658
  | `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
659
+ | `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
692
660
  | `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
693
661
  | `~/.hx/projects/<project>/settings.local.json` | this machine's settings for one project - where "always allow" lands |
694
662
  | `./.hx/settings.json` | project settings, checked in if you like — read by HX, never written |
@@ -772,6 +740,79 @@ rest to the session directory, and hands the model that path to grep.
772
740
 
773
741
  ---
774
742
 
743
+ ## Tracing a session
744
+
745
+ `/trace` writes everything that happened to one self-contained HTML file and
746
+ opens it in a browser:
747
+
748
+ ```
749
+ /trace # ~/.hx/sessions/<id>/trace.html
750
+ /trace ~/Desktop/bug.html # somewhere you choose
751
+ ```
752
+
753
+ Everything means everything. The system prompt, the project context, the skills
754
+ index, every tool schema as the provider received it, every prompt, every
755
+ reply, every thinking block, every tool call next to the result it produced,
756
+ the per-call token ledger, and the files HX changed. Late-injected reminders
757
+ and compacted turns are in there too, behind a toggle that says how many there
758
+ are — a trace that hides the reminders and the superseded turns is not a trace
759
+ of the session that ran.
760
+
761
+ Nothing is summarised and nothing is prettified. Assistant text is shown as it
762
+ arrived, backticks and all, because a trace exists to answer "what actually
763
+ went through" and a rendered version of that is a different document. Long tool
764
+ results are folded rather than truncated, with the character count on the fold.
765
+
766
+ **Thinking, where the provider allows it.** How much of a model's reasoning HX
767
+ ever sees is the provider's decision, not HX's. Anthropic's models return the
768
+ thinking itself. The Responses API - the OpenAI and Codex routes - returns a
769
+ one-line summary in plaintext and the reasoning encrypted, to be replayed on
770
+ the next call and read by nobody, HX included. A trace labels which of the two
771
+ it is holding, because a one-line thinking block is otherwise indistinguishable
772
+ from a trace that dropped the rest.
773
+
774
+ A running session traces as it stands right now, including the turn that just
775
+ landed. The page says so, because a trace of a live session is a snapshot.
776
+
777
+ **Reading it.** Long tool results are folded and the reference panels - the
778
+ prompt, the schemas, the ledger - start shut, so the transcript is what you
779
+ land on. "Expand all" opens the whole page, panels included, and turns into
780
+ "Collapse all". A session with no turns yet opens its panels on its own, since
781
+ there is nothing else to read.
782
+
783
+ **Dark or light.** The page follows your system by default and a button in the
784
+ corner overrides it, remembered per browser. No stylesheet is fetched to do it.
785
+
786
+ **Nothing is uploaded and nothing is fetched.** The payload, the stylesheet and
787
+ the script all live in the file, so it opens offline, renders the same a year
788
+ later, and a trace of a private repository stays as private as the directory
789
+ you wrote it into. The embedded payload is also the machine-readable trace: it
790
+ is JSON, in a `<script id="trace-data">` element, and `/trace` is the only way
791
+ HX will ever put your transcript anywhere but `~/.hx`.
792
+
793
+ From outside a session, on any transcript on the machine:
794
+
795
+ ```sh
796
+ hx trace # the last session in this directory
797
+ hx trace 20260918-214743-b5b8f9 # one by id
798
+ hx trace ~/somewhere.html # the last session, to a path you choose
799
+ hx trace <id> ~/somewhere.html # both, to a path or a directory
800
+ open "$(hx trace)" # the path is all that goes to stdout
801
+ ```
802
+
803
+ `hx trace` reads the transcript and runs nothing, so it works on a session that
804
+ ended months ago and on one another terminal has open right now.
805
+
806
+ **What a trace can be missing.** The prompt and the tool schemas are written to
807
+ the transcript on the session's first provider call — the first moment the tool
808
+ registry has settled, because MCP servers connect after the loop is built. A
809
+ session traced before that call, or one recorded by a version of HX older than
810
+ this feature, has the transcript but not the prompt, and the page leaves those
811
+ panels out rather than guessing. `/trace` inside a live session fills them in
812
+ from the loop either way.
813
+
814
+ ---
815
+
775
816
  ## Reading and editing code
776
817
 
777
818
  **Line anchors.** `Read` labels every line with a short content hash:
@@ -60,7 +60,9 @@ dev = [
60
60
  hx = "hx.cli:main"
61
61
 
62
62
  [project.urls]
63
- Homepage = "https://github.com/aletisunil/hx"
63
+ Homepage = "https://hx.sunilaleti.dev"
64
+ Documentation = "https://hx.sunilaleti.dev"
65
+ Source = "https://github.com/aletisunil/hx"
64
66
  Issues = "https://github.com/aletisunil/hx/issues"
65
67
 
66
68
  # The version lives in the package, not here - three hand-maintained copies
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -12,6 +12,7 @@ where the browser reaches the host by another address.
12
12
  from __future__ import annotations
13
13
 
14
14
  import asyncio
15
+ import html
15
16
  import os
16
17
  import threading
17
18
  from dataclasses import dataclass
@@ -49,7 +50,18 @@ p{{margin:0;color:#9aa4b2}}
49
50
 
50
51
 
51
52
  def _page(title: str, body: str, *, ok: bool) -> bytes:
52
- return _PAGE.format(title=title, body=body, colour="#7ee787" if ok else "#ff7b72").encode()
53
+ """Render the one page this server serves.
54
+
55
+ Both fields are escaped: ``body`` carries the provider's ``error``
56
+ parameter, which arrives from the network and is under nobody's control
57
+ here. Interpolated raw, ``?error=<img src=x onerror=...>`` executed on an
58
+ origin that is otherwise this machine's own.
59
+ """
60
+ return _PAGE.format(
61
+ title=html.escape(title),
62
+ body=html.escape(body),
63
+ colour="#7ee787" if ok else "#ff7b72",
64
+ ).encode()
53
65
 
54
66
 
55
67
  class _Handler(BaseHTTPRequestHandler):
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import asyncio
23
23
  import json
24
+ import os
24
25
  import time
25
26
  from collections.abc import Awaitable, Callable
26
27
  from dataclasses import dataclass, field, replace
@@ -172,9 +173,22 @@ class AuthStore:
172
173
  path.parent.mkdir(parents=True, exist_ok=True)
173
174
  payload = {pid: cred.to_json() for pid, cred in sorted(data.items())}
174
175
 
176
+ # Created 0600, not chmod'd to it afterwards. The file is opened at
177
+ # whatever the umask allows and only narrowed once the tokens are
178
+ # already in it, which leaves a window - at a path anyone can predict -
179
+ # where the credential is world-readable. Passing the mode to `open`
180
+ # closes it: there is no instant at which the descriptor exists and the
181
+ # permissions are wrong.
175
182
  tmp = path.with_suffix(".json.tmp")
176
- tmp.write_text(json.dumps(payload, indent=2))
177
- # Restrict before the rename so the token is never briefly world-readable.
183
+ descriptor = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
184
+ try:
185
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
186
+ handle.write(json.dumps(payload, indent=2))
187
+ except BaseException:
188
+ tmp.unlink(missing_ok=True)
189
+ raise
190
+ # O_CREAT leaves an existing file's mode alone, so a store written by
191
+ # an older HX is narrowed here rather than keeping its old permissions.
178
192
  tmp.chmod(0o600)
179
193
  tmp.replace(path)
180
194
 
@@ -4,6 +4,7 @@
4
4
  ``hx -p "..."`` print mode: run one prompt headless, stream to stdout
5
5
  ``hx resume [id]`` resume a session
6
6
  ``hx prompt`` print the resolved system prompt
7
+ ``hx trace [id]`` write a session to a self-contained HTML page
7
8
  ``hx mcp ...`` manage MCP servers
8
9
  ``hx docs [section]`` print the shipped manual
9
10
  ``hx changelog [ver]`` print what shipped in each version
@@ -35,6 +36,8 @@ Usage:
35
36
  hx -p, --print PROMPT Run one prompt headlessly and print the result
36
37
  hx resume [SESSION_ID] Resume a previous session
37
38
  hx prompt Print the system prompt this directory would use
39
+ hx trace [SESSION_ID] [PATH]
40
+ Write a session to a self-contained HTML trace
38
41
  hx mcp list|add|remove Manage MCP servers
39
42
  hx auth [set|clear] Show or change the OpenRouter API key
40
43
  hx docs [SECTION|--all] Print the manual, or one section of it
@@ -97,6 +100,8 @@ def _dispatch(args: list[str]) -> int:
97
100
  return run_auth_command(list(parsed.rest))
98
101
  if parsed.command == "prompt":
99
102
  return run_prompt_command(parsed)
103
+ if parsed.command == "trace":
104
+ return run_trace_command(parsed)
100
105
  if parsed.command == "docs":
101
106
  return run_docs_command(list(parsed.rest))
102
107
  if parsed.command == "changelog":
@@ -162,7 +167,7 @@ def parse_args(args: list[str]) -> ParsedArgs:
162
167
 
163
168
  if positional:
164
169
  head, *tail = positional
165
- if head in {"resume", "prompt", "mcp", "upgrade", "auth"}:
170
+ if head in {"resume", "prompt", "trace", "mcp", "upgrade", "auth"}:
166
171
  parsed.command = head
167
172
  parsed.rest = tuple(tail)
168
173
  if head == "resume" and tail:
@@ -704,6 +709,81 @@ def run_prompt_command(parsed: ParsedArgs) -> int:
704
709
  return 0
705
710
 
706
711
 
712
+ def _looks_like_a_path(arg: str) -> bool:
713
+ """Whether a lone ``hx trace`` argument is a destination, not a session id.
714
+
715
+ Session ids are ``20260918-215601-7608a6b0``: no separator, no extension,
716
+ no leading ``~`` or ``.``. Anything wearing one of those is somewhere the
717
+ user wants the file, and reading it as an id produces ``no session
718
+ '/Users/you/bug.html'`` - an error about the wrong thing entirely.
719
+ """
720
+ return (
721
+ "/" in arg or os.sep in arg or arg.startswith(("~", ".")) or arg.lower().endswith(".html")
722
+ )
723
+
724
+
725
+ def run_trace_command(parsed: ParsedArgs) -> int:
726
+ """``hx trace [SESSION_ID] [PATH]`` - write a session out as one HTML page.
727
+
728
+ Reads the transcript rather than running anything, so it works on a session
729
+ that ended months ago and on one another terminal has open right now. A
730
+ live session traced this way is current as of its last flush, which is
731
+ every message; ``/trace`` inside that session is the one that also catches
732
+ the usage of a turn still in flight.
733
+
734
+ Either argument may be given alone. A lone one that looks like a path is
735
+ one, so ``hx trace ~/bug.html`` means what ``/trace ~/bug.html`` means
736
+ inside a session rather than hunting for a session by that name.
737
+ """
738
+ from hx.core.session import SessionNotFound, latest_session, load_session
739
+ from hx.trace import TRACE_FILENAME, build_trace, default_trace_path, write_trace
740
+
741
+ rest = list(parsed.rest)
742
+ cwd = (parsed.cwd or Path.cwd()).resolve()
743
+
744
+ target: str | None = None
745
+ session_id: str | None = None
746
+ if len(rest) > 1:
747
+ session_id, target = rest[0], rest[1]
748
+ elif rest:
749
+ if _looks_like_a_path(rest[0]):
750
+ target = rest[0]
751
+ else:
752
+ session_id = rest[0]
753
+
754
+ if session_id is None:
755
+ latest = latest_session(cwd)
756
+ if latest is None:
757
+ print(f"error: no sessions recorded for {cwd}", file=sys.stderr)
758
+ return 1
759
+ session_id = latest.session_id
760
+
761
+ try:
762
+ session = load_session(session_id)
763
+ except SessionNotFound:
764
+ print(f"error: no session {session_id!r}", file=sys.stderr)
765
+ return 1
766
+
767
+ destination = default_trace_path(session)
768
+ if target is not None:
769
+ # Relative paths stay relative to the shell, not to ``--cwd``: that flag
770
+ # picks the project to trace, and a shell user typing ``out.html`` means
771
+ # the directory they are standing in.
772
+ destination = Path(target).expanduser()
773
+ if destination.is_dir():
774
+ destination = destination / TRACE_FILENAME
775
+
776
+ try:
777
+ written = write_trace(build_trace(session), destination)
778
+ except OSError as exc:
779
+ print(f"error: could not write the trace: {exc}", file=sys.stderr)
780
+ return 1
781
+
782
+ # The path on stdout alone, so `open "$(hx trace)"` works.
783
+ print(written)
784
+ return 0
785
+
786
+
707
787
  def run_docs_command(args: list[str]) -> int:
708
788
  """``hx docs [SECTION|--all]`` - the manual that shipped with this version.
709
789