hx-cli 0.2.15__tar.gz → 0.2.17__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 (161) hide show
  1. {hx_cli-0.2.15 → hx_cli-0.2.17}/CHANGELOG.md +60 -1
  2. {hx_cli-0.2.15 → hx_cli-0.2.17}/PKG-INFO +4 -1
  3. {hx_cli-0.2.15 → hx_cli-0.2.17}/README.md +3 -0
  4. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/cli.py +5 -2
  6. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/loop.py +58 -23
  7. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/permissions/engine.py +34 -5
  8. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/permissions/parser.py +76 -10
  9. hx_cli-0.2.17/src/hx/text.py +12 -0
  10. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/anchors.py +3 -4
  11. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/bash.py +65 -7
  12. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/edit.py +91 -41
  13. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/glob.py +2 -1
  14. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/grep.py +2 -1
  15. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/output.py +2 -1
  16. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/read.py +15 -2
  17. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/task.py +2 -1
  18. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/websearch.py +2 -1
  19. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/write.py +38 -6
  20. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/commands.py +4 -3
  21. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/glyphs.py +4 -0
  22. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/renderers.py +9 -3
  23. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/runtime.py +41 -14
  24. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/blocks.py +18 -7
  25. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/status.py +42 -7
  26. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/transcript.py +8 -4
  27. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/terminal.py +5 -1
  28. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_chat.py +3 -2
  29. hx_cli-0.2.17/tests/e2e/test_edits.py +221 -0
  30. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_print.py +1 -0
  31. hx_cli-0.2.17/tests/e2e/test_shell.py +184 -0
  32. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_tools.py +183 -1
  33. {hx_cli-0.2.15 → hx_cli-0.2.17}/.github/workflows/ci.yml +0 -0
  34. {hx_cli-0.2.15 → hx_cli-0.2.17}/.gitignore +0 -0
  35. {hx_cli-0.2.15 → hx_cli-0.2.17}/examples/hooks/check.sh +0 -0
  36. {hx_cli-0.2.15 → hx_cli-0.2.17}/install.sh +0 -0
  37. {hx_cli-0.2.15 → hx_cli-0.2.17}/pyproject.toml +0 -0
  38. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/agents/__init__.py +0 -0
  39. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/agents/definitions.py +0 -0
  40. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/agents/subagent.py +0 -0
  41. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/__init__.py +0 -0
  42. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/claude_code.py +0 -0
  43. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/__init__.py +0 -0
  44. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/browser.py +0 -0
  45. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/callback.py +0 -0
  46. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/codex.py +0 -0
  47. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/devin.py +0 -0
  48. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/pkce.py +0 -0
  49. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/resolve.py +0 -0
  50. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/store.py +0 -0
  51. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/config.py +0 -0
  52. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/__init__.py +0 -0
  53. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/checkpoints.py +0 -0
  54. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/compaction.py +0 -0
  55. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/context.py +0 -0
  56. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/events.py +0 -0
  57. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/images.py +0 -0
  58. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/lateinject.py +0 -0
  59. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/messages.py +0 -0
  60. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/session.py +0 -0
  61. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/title.py +0 -0
  62. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/usage.py +0 -0
  63. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/docs.py +0 -0
  64. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/frontmatter.py +0 -0
  65. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/git.py +0 -0
  66. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/hooks/__init__.py +0 -0
  67. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/hooks/engine.py +0 -0
  68. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/hooks/spec.py +0 -0
  69. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/keys.py +0 -0
  70. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/mcp/__init__.py +0 -0
  71. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/mcp/client.py +0 -0
  72. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/mcp/manager.py +0 -0
  73. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/mcp/oauth.py +0 -0
  74. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/net.py +0 -0
  75. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/paths.py +0 -0
  76. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/permissions/__init__.py +0 -0
  77. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/permissions/sandbox.py +0 -0
  78. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/__init__.py +0 -0
  79. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/base.py +0 -0
  80. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/claude_code.py +0 -0
  81. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/claude_code_catalogue.py +0 -0
  82. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/claude_code_cli.py +0 -0
  83. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/claude_code_transcript.py +0 -0
  84. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/codex.py +0 -0
  85. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/codex_catalogue.py +0 -0
  86. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/devin.py +0 -0
  87. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/devin_catalogue.py +0 -0
  88. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/devin_wire.py +0 -0
  89. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/models.py +0 -0
  90. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/openrouter.py +0 -0
  91. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/protowire.py +0 -0
  92. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/registry.py +0 -0
  93. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/responses_codec.py +0 -0
  94. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/skills/__init__.py +0 -0
  95. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/skills/loader.py +0 -0
  96. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/skills/runtime.py +0 -0
  97. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/__init__.py +0 -0
  98. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/ansi.py +0 -0
  99. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/buffer.py +0 -0
  100. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/component.py +0 -0
  101. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/demo.py +0 -0
  102. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/editor.py +0 -0
  103. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/keydecode.py +0 -0
  104. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/loop.py +0 -0
  105. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/markdown.py +0 -0
  106. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/modifiers.py +0 -0
  107. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/primitives.py +0 -0
  108. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/sanitize.py +0 -0
  109. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/screen.py +0 -0
  110. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/syntax.py +0 -0
  111. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/terminal.py +0 -0
  112. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/undo.py +0 -0
  113. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/width.py +0 -0
  114. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/word_nav.py +0 -0
  115. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/__init__.py +0 -0
  116. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/base.py +0 -0
  117. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/registry.py +0 -0
  118. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/symbols.py +0 -0
  119. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/todo.py +0 -0
  120. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/trace.py +0 -0
  121. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/__init__.py +0 -0
  122. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/app.py +0 -0
  123. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/clipboard.py +0 -0
  124. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/format.py +0 -0
  125. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/fuzzy.py +0 -0
  126. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/killring.py +0 -0
  127. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/limits.py +0 -0
  128. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/paint.py +0 -0
  129. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/roles.py +0 -0
  130. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/theme.py +0 -0
  131. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/theme_json.py +0 -0
  132. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/themes/ansi.json +0 -0
  133. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/themes/dark.json +0 -0
  134. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/themes/light.json +0 -0
  135. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/__init__.py +0 -0
  136. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/dialog.py +0 -0
  137. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/login.py +0 -0
  138. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/permission.py +0 -0
  139. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/pickers.py +0 -0
  140. {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/prompt.py +0 -0
  141. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/__init__.py +0 -0
  142. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/conftest.py +0 -0
  143. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/__init__.py +0 -0
  144. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/conftest.py +0 -0
  145. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/fake_claude.py +0 -0
  146. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/fixtures/echo_server.py +0 -0
  147. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/report.py +0 -0
  148. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/stub.py +0 -0
  149. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_claude_code.py +0 -0
  150. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_cli.py +0 -0
  151. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_commands.py +0 -0
  152. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_extend.py +0 -0
  153. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_input.py +0 -0
  154. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_queue.py +0 -0
  155. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/test_live.py +0 -0
  156. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/test_live_claude_code.py +0 -0
  157. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/test_live_codex.py +0 -0
  158. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/test_live_devin.py +0 -0
  159. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/test_live_tavily.py +0 -0
  160. {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/tls_proxy.py +0 -0
  161. {hx_cli-0.2.15 → hx_cli-0.2.17}/uv.lock +0 -0
@@ -28,6 +28,63 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.17] - 2026-10-18
32
+
33
+ ### Security
34
+
35
+ - The API keys HX itself signs in with (`HX_OPENROUTER_API_KEY`, `OPENROUTER_API_KEY`, and the
36
+ Tavily and Devin keys) are no longer passed to the commands it runs. An auto-approved `env`
37
+ used to put them straight into the model's context.
38
+ - `xxd -r`, `xxd in out`, `uniq in out` and `sort --compress-program` now ask before they run.
39
+ They write files or run a program, but they were auto-approved as read-only commands.
40
+ - A long option cut short now counts as the full option, as getopt_long and git read it.
41
+ `sort --compress=sh`, `sort --out=f`, `git grep --open-files-in=sh` and `git branch --del`
42
+ were auto-approved as read-only. So were `xxd in -p` and macOS `uniq in -c`, which write a
43
+ file named after the flag.
44
+
45
+ ### Fixed
46
+
47
+ - A command that reads stdin (`cat`, `read`, `ssh host ls`) no longer breaks the shell. It used
48
+ to swallow every later command, which then reported success without running. Commands now
49
+ get end-of-file on stdin, and a command with an unclosed quote fails on its own.
50
+ - Background jobs no longer read from the terminal, so a dev server cannot take keystrokes
51
+ from the prompt.
52
+ - Background jobs get `HX=1` and `TERM=dumb`, the same as other commands. A tool that colours
53
+ its output for a real terminal used to write escape codes into the job's log.
54
+ - Non-ASCII command output no longer shows `�` where a read split a character.
55
+ - An edit to a CRLF file keeps CRLF on every line. Each edit used to rewrite the whole file
56
+ with LF, and the diff did not show it. `old_string` with plain newlines now matches there.
57
+ - A hashline replacement that ends with a newline no longer adds a blank line after it.
58
+ - Edit and Write keep a file's mode, so a script stays executable, and write through a
59
+ symlink instead of replacing it with a regular file.
60
+ - With `--cwd`, or `hx resume` from another directory, the approval shows the diff of the
61
+ file the edit will change. It used to read a relative path from the shell's directory.
62
+ - An edit's `+N -M` and its diff now include changed lines that start with `--` or `++`,
63
+ such as SQL comments.
64
+ - "Always allow" on a command HX cannot split into parts, such as a heredoc with an
65
+ apostrophe, is now honoured in later sessions instead of asking again.
66
+ - Counts of one read in the singular: "read 1 line", "1 result", "1 match", "1 tool".
67
+ - Write reports the number of lines the way Read numbers them. A file ending in a newline
68
+ was reported one line longer.
69
+ - The status bar shortens a long project path from its start, so the project's name and
70
+ branch stay visible. Before the first request, the cache field reads "no cache".
71
+
72
+ ---
73
+
74
+ ## [0.2.16] - 2026-10-13
75
+
76
+ ### Fixed
77
+
78
+ - The "Working…" status and the tool spinners now keep moving while a tool runs. They
79
+ used to vanish or freeze from the moment the model asked for a tool until its next reply,
80
+ so a long command looked like an idle session. The clock now counts the whole turn, not
81
+ only the time since the last model reply.
82
+ - A tool call waiting on an approval shows `?` and does not spin, and its "Took" counts
83
+ only the run, not the time you spent deciding. A denied call never ran, so it shows no
84
+ "Took" at all.
85
+
86
+ ---
87
+
31
88
  ## [0.2.15] - 2026-10-04
32
89
 
33
90
  ### Changed
@@ -901,7 +958,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
901
958
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
902
959
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
903
960
 
904
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.15...HEAD
961
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.17...HEAD
962
+ [0.2.17]: https://github.com/aletisunil/hx/compare/v0.2.16...v0.2.17
963
+ [0.2.16]: https://github.com/aletisunil/hx/compare/v0.2.15...v0.2.16
905
964
  [0.2.15]: https://github.com/aletisunil/hx/compare/v0.2.14...v0.2.15
906
965
  [0.2.14]: https://github.com/aletisunil/hx/compare/v0.2.13...v0.2.14
907
966
  [0.2.13]: https://github.com/aletisunil/hx/compare/v0.2.12...v0.2.13
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.15
3
+ Version: 0.2.17
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
@@ -871,6 +871,9 @@ temp dir, credential paths (`~/.ssh`, `~/.aws`, and HX's own `auth.json`) are
871
871
  unreadable and unwritable - even inside the project, and even before they
872
872
  exist - and outbound network is off. If neither backend is present the
873
873
  status bar says `no-sandbox` rather than implying protection that is not there.
874
+ The API keys HX signs in with (`HX_OPENROUTER_API_KEY`, `OPENROUTER_API_KEY`,
875
+ and the Tavily and Devin keys) are removed from the environment of every
876
+ command it runs.
874
877
 
875
878
  Modes cycle with shift+tab: `plan` (read-only — mutating tools are not even
876
879
  offered to the model), `default`, `acceptEdits`, `bypass`.
@@ -830,6 +830,9 @@ temp dir, credential paths (`~/.ssh`, `~/.aws`, and HX's own `auth.json`) are
830
830
  unreadable and unwritable - even inside the project, and even before they
831
831
  exist - and outbound network is off. If neither backend is present the
832
832
  status bar says `no-sandbox` rather than implying protection that is not there.
833
+ The API keys HX signs in with (`HX_OPENROUTER_API_KEY`, `OPENROUTER_API_KEY`,
834
+ and the Tavily and Devin keys) are removed from the environment of every
835
+ command it runs.
833
836
 
834
837
  Modes cycle with shift+tab: `plan` (read-only — mutating tools are not even
835
838
  offered to the model), `default`, `acceptEdits`, `bypass`.
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.15"
3
+ __version__ = "0.2.17"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -27,6 +27,7 @@ from typing import Any
27
27
 
28
28
  from hx import __version__
29
29
  from hx.paths import auth_file
30
+ from hx.text import count
30
31
 
31
32
  USAGE = """\
32
33
  hx - an agent harness for the terminal
@@ -1033,7 +1034,9 @@ def run_mcp_command(args: list[str]) -> int:
1033
1034
  print(f"{status.name:<20} {status.error} - run `hx mcp login {status.name}`")
1034
1035
  continue
1035
1036
  mark = "ok" if status.connected else "unavailable"
1036
- detail = f" - {status.error}" if status.error else f" ({status.tool_count} tools)"
1037
+ detail = (
1038
+ f" - {status.error}" if status.error else f" ({count(status.tool_count, 'tool')})"
1039
+ )
1037
1040
  print(f"{status.name:<20} {mark}{detail}")
1038
1041
  return 0
1039
1042
 
@@ -1118,7 +1121,7 @@ def _mcp_login(config: Any) -> int:
1118
1121
 
1119
1122
  status = asyncio.run(_probe_servers([config]))[0]
1120
1123
  if status.connected:
1121
- print(f"Signed in to {config.name}: {status.tool_count} tools available.")
1124
+ print(f"Signed in to {config.name}: {count(status.tool_count, 'tool')} available.")
1122
1125
  return 0
1123
1126
  print(f"Signed in, but {config.name} is still unavailable: {status.error}", file=sys.stderr)
1124
1127
  return 1
@@ -52,10 +52,12 @@ from hx.core.session import Environment
52
52
  from hx.core.usage import TurnUsage, compute_cost
53
53
  from hx.hooks.spec import HookOutcome
54
54
  from hx.providers.base import ProviderError, ProviderRequest, StreamDelta, StreamEnd
55
+ from hx.text import count
55
56
  from hx.tools.base import ToolContext, ToolResult
56
57
 
57
58
  if TYPE_CHECKING:
58
59
  from collections.abc import Awaitable, Callable, Sequence
60
+ from pathlib import Path
59
61
 
60
62
  from hx.config import Settings
61
63
  from hx.core.compaction import Compactor
@@ -75,6 +77,30 @@ class _Steered(Exception):
75
77
  """Internal: this stream was cancelled to deliver a steer, not to stop."""
76
78
 
77
79
 
80
+ class _Stopwatch:
81
+ """How long one tool call has run, for its "Took".
82
+
83
+ Started once the call is cleared to run, and zero until then. The time a
84
+ person spent on an approval is not the tool's: a three-second command
85
+ answered after a minute's thought said it took a minute, and a denied one
86
+ that never ran said it took as long as the user took to say no.
87
+ """
88
+
89
+ __slots__ = ("_started",)
90
+
91
+ def __init__(self) -> None:
92
+ self._started: float | None = None
93
+
94
+ def start(self) -> None:
95
+ self._started = time.monotonic()
96
+
97
+ @property
98
+ def ms(self) -> float:
99
+ if self._started is None:
100
+ return 0.0
101
+ return (time.monotonic() - self._started) * 1000
102
+
103
+
78
104
  @dataclass(slots=True)
79
105
  class TurnResult:
80
106
  stop_reason: StopReason
@@ -712,7 +738,7 @@ class AgentLoop:
712
738
  self, call: ToolUseBlock, results: dict[str, ToolResultBlock]
713
739
  ) -> ToolResultBlock:
714
740
  """Run one call. Interrupted, it leaves its answer in ``results``."""
715
- started = time.monotonic()
741
+ clock = _Stopwatch()
716
742
 
717
743
  # PreToolUse runs before the call is announced. A hook that rewrites the
718
744
  # input must not leave the transcript showing a command that never ran,
@@ -730,7 +756,7 @@ class AgentLoop:
730
756
  )
731
757
  streamed: list[str] = []
732
758
  try:
733
- return await self._run_announced(call, pre, started, streamed, results)
759
+ return await self._run_announced(call, pre, clock, streamed, results)
734
760
  except asyncio.CancelledError:
735
761
  if call.id in results:
736
762
  # The tool had finished and only its hooks were cut off.
@@ -743,7 +769,7 @@ class AgentLoop:
743
769
  ToolCallFinished(
744
770
  tool_use_id=call.id,
745
771
  is_error=True,
746
- duration_ms=(time.monotonic() - started) * 1000,
772
+ duration_ms=clock.ms,
747
773
  summary="interrupted",
748
774
  interrupted=True,
749
775
  )
@@ -754,7 +780,7 @@ class AgentLoop:
754
780
  self,
755
781
  call: ToolUseBlock,
756
782
  pre: HookOutcome,
757
- started: float,
783
+ clock: _Stopwatch,
758
784
  streamed: list[str],
759
785
  results: dict[str, ToolResultBlock],
760
786
  ) -> ToolResultBlock:
@@ -772,7 +798,7 @@ class AgentLoop:
772
798
  ToolCallFinished(
773
799
  tool_use_id=call.id,
774
800
  is_error=True,
775
- duration_ms=(time.monotonic() - started) * 1000,
801
+ duration_ms=clock.ms,
776
802
  summary="blocked by hook",
777
803
  detail=refusal.content,
778
804
  )
@@ -785,12 +811,13 @@ class AgentLoop:
785
811
  ToolCallFinished(
786
812
  tool_use_id=call.id,
787
813
  is_error=True,
788
- duration_ms=(time.monotonic() - started) * 1000,
814
+ duration_ms=clock.ms,
789
815
  summary="denied",
790
816
  detail=denied.content,
791
817
  )
792
818
  )
793
819
  return denied
820
+ clock.start()
794
821
 
795
822
  def progress(chunk: str) -> None:
796
823
  streamed.append(chunk)
@@ -812,12 +839,12 @@ class AgentLoop:
812
839
  except asyncio.CancelledError:
813
840
  # The tool finished and its effects are real: its own result stands,
814
841
  # rather than telling the model a write that happened never did.
815
- results[call.id] = self._finish(call, result, HookOutcome(), started)
842
+ results[call.id] = self._finish(call, result, HookOutcome(), clock)
816
843
  raise
817
- return self._finish(call, result, post, started)
844
+ return self._finish(call, result, post, clock)
818
845
 
819
846
  def _finish(
820
- self, call: ToolUseBlock, result: ToolResult, post: HookOutcome, started: float
847
+ self, call: ToolUseBlock, result: ToolResult, post: HookOutcome, clock: _Stopwatch
821
848
  ) -> ToolResultBlock:
822
849
  """Announce a call the tool finished, and its result for the model."""
823
850
  content = result.content
@@ -830,7 +857,7 @@ class AgentLoop:
830
857
  ToolCallFinished(
831
858
  tool_use_id=call.id,
832
859
  is_error=result.is_error or post.blocked,
833
- duration_ms=(time.monotonic() - started) * 1000,
860
+ duration_ms=clock.ms,
834
861
  summary=result.summary or ("error" if result.is_error else "done"),
835
862
  detail=content if result.is_error or post.blocked else "",
836
863
  metadata=dict(result.metadata),
@@ -852,7 +879,7 @@ class AgentLoop:
852
879
  from hx.permissions.engine import PermissionRequest
853
880
 
854
881
  tool = self.tools.get(call.name)
855
- detail, detail_kind = _permission_detail(call)
882
+ detail, detail_kind = _permission_detail(call, self.settings.cwd)
856
883
  request = PermissionRequest(
857
884
  tool_name=call.name,
858
885
  specifier=tool.permission_specifier(call.input),
@@ -863,6 +890,7 @@ class AgentLoop:
863
890
  detail=detail,
864
891
  detail_kind=detail_kind,
865
892
  origin=self.origin,
893
+ tool_use_id=call.id,
866
894
  )
867
895
  allowed, reason = await self.permissions.request(
868
896
  request, on_ask=lambda: self._announce_ask(call.id, request)
@@ -1006,7 +1034,7 @@ def _parse_tool_input(raw: str | None) -> dict[str, Any]:
1006
1034
  return parsed if isinstance(parsed, dict) else {}
1007
1035
 
1008
1036
 
1009
- def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
1037
+ def _permission_detail(call: ToolUseBlock, cwd: Path) -> tuple[str, str]:
1010
1038
  """What the approval shows, and what kind of thing it is.
1011
1039
 
1012
1040
  For an edit this previews the change against the file on disk, so the user
@@ -1016,18 +1044,29 @@ def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
1016
1044
  the text meant a file whose first line was ``---`` - YAML front matter, a
1017
1045
  markdown rule - was treated as a diff and run through a painter that
1018
1046
  strips exactly those lines, leaving the user approving a blank space.
1047
+
1048
+ The path is resolved against the session's directory, the way the tool
1049
+ resolves it. HX never changes its own working directory, so under
1050
+ ``--cwd`` - or a session resumed from somewhere else - a relative path
1051
+ read from the process directory previewed a different file, or none, and
1052
+ the user approved "no change" for an edit that changed something.
1019
1053
  """
1020
1054
  if call.name == "Bash":
1021
1055
  return str(call.input.get("command", "")), "command"
1022
1056
 
1023
1057
  if call.name in {"Edit", "Write"} and (raw_path := call.input.get("file_path")):
1024
- from pathlib import Path
1025
-
1026
- from hx.tools.edit import apply_hashline, parse_edits, parse_hashline, unified_diff
1058
+ from hx.tools.edit import (
1059
+ apply_edits,
1060
+ apply_hashline,
1061
+ parse_edits,
1062
+ parse_hashline,
1063
+ unified_diff,
1064
+ )
1065
+ from hx.tools.read import read_exact, resolve_path
1027
1066
 
1028
- path = Path(str(raw_path))
1067
+ path = resolve_path(str(raw_path), cwd)
1029
1068
  try:
1030
- before = path.read_text(encoding="utf-8") if path.is_file() else ""
1069
+ before = read_exact(path) if path.is_file() else ""
1031
1070
  if call.name == "Write":
1032
1071
  after = str(call.input.get("content", ""))
1033
1072
  elif call.input.get("hashline"):
@@ -1036,11 +1075,7 @@ def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
1036
1075
  # filename and the user approves an edit they never saw.
1037
1076
  after = apply_hashline(before, parse_hashline(call.input))
1038
1077
  else:
1039
- after = before
1040
- for edit in parse_edits(call.input):
1041
- after = after.replace(
1042
- edit.old_string, edit.new_string, -1 if edit.replace_all else 1
1043
- )
1078
+ after = apply_edits(before, parse_edits(call.input))
1044
1079
  diff = unified_diff(before, after, str(path))
1045
1080
  return (diff, "diff") if diff else (f"{path} (no change)", "text")
1046
1081
  except Exception:
@@ -1077,7 +1112,7 @@ scroll is a prompt they stop reading."""
1077
1112
 
1078
1113
  def _one_value(value: Any, limit: int) -> str:
1079
1114
  if isinstance(value, list):
1080
- return f"[{len(value)} items]"
1115
+ return f"[{count(len(value), 'item')}]"
1081
1116
  if isinstance(value, dict):
1082
1117
  return "{…}"
1083
1118
  if isinstance(value, str) and len(value) > limit:
@@ -78,6 +78,9 @@ class PermissionRequest:
78
78
  """
79
79
  origin: str | None = None
80
80
  """Which subagent asked, when it was not the main conversation."""
81
+ tool_use_id: str | None = None
82
+ """The call waiting on this, so the UI can show that call as waiting
83
+ rather than running. None for a request no tool call made - a `!` line."""
81
84
 
82
85
  def all_specifiers(self) -> tuple[str, ...]:
83
86
  """Everything a rule has to be matched against for this one call."""
@@ -141,6 +144,12 @@ class PermissionEngine:
141
144
  if request.tool_name == "Bash" and request.specifier:
142
145
  parsed = parse(request.specifier)
143
146
  if parsed.unparseable:
147
+ # A rule holding the whole command verbatim grants exactly what it
148
+ # names, so it covers what cannot be decomposed - it is what "always
149
+ # allow" saves for such a command. Returning before looking made
150
+ # that saved rule ask again in every later session. Ask still wins.
151
+ if not self._match(request, Decision.ASK) and (exact := self._exact_allow(request)):
152
+ return PermissionOutcome(Decision.ALLOW, "matched an allow rule", exact)
144
153
  return PermissionOutcome(
145
154
  Decision.ASK,
146
155
  "command could not be decomposed; approving it blind is not safe",
@@ -216,11 +225,11 @@ class PermissionEngine:
216
225
  return None
217
226
 
218
227
  # A rule holding the whole command verbatim: exact, so it grants no more
219
- # than the string it names. This is what covers a command that cannot be
220
- # decomposed, and what keeps hand-written full-command rules working.
221
- for rule in candidates:
222
- if rule.specifier == request.specifier:
223
- return rule
228
+ # than the string it names. This is what keeps hand-written full-command
229
+ # rules working; :meth:`evaluate` also consults it for a command that
230
+ # cannot be decomposed.
231
+ if exact := self._exact_allow(request):
232
+ return exact
224
233
 
225
234
  parsed = parse(request.specifier)
226
235
  if not parsed.segments or parsed.unparseable:
@@ -241,6 +250,26 @@ class PermissionEngine:
241
250
  first = first or covering
242
251
  return first
243
252
 
253
+ def _exact_allow(self, request: PermissionRequest) -> Rule | None:
254
+ """An allow rule whose specifier is this request's, character for character.
255
+
256
+ Compared stripped, as both sides are by the time they mean anything: a
257
+ rule's specifier is stripped as it is parsed, and Bash strips a command
258
+ before running it.
259
+ """
260
+ specifier = (request.specifier or "").strip()
261
+ return next(
262
+ (
263
+ rule
264
+ for rule in self.rules
265
+ if rule.decision is Decision.ALLOW
266
+ and rule.tool in {request.tool_name, "*"}
267
+ and rule.specifier is not None
268
+ and rule.specifier == specifier
269
+ ),
270
+ None,
271
+ )
272
+
244
273
  def _match_allow_each(
245
274
  self,
246
275
  request: PermissionRequest,
@@ -132,18 +132,37 @@ MUTATING_FLAGS: dict[str, frozenset[str]] = {
132
132
  {"-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf", "-fls"}
133
133
  ),
134
134
  "fd": frozenset({"-x", "--exec", "-X", "--exec-batch"}),
135
- "sort": frozenset({"-o", "--output"}),
135
+ "sort": frozenset({"-o", "--output", "--compress-program"}),
136
136
  "tree": frozenset({"-o"}),
137
137
  "date": frozenset({"-s", "--set"}),
138
138
  "rg": frozenset({"--pre"}),
139
139
  "yq": frozenset({"-i", "--inplace"}),
140
+ # Any word starting -r reverts: xxd reads `-r`, `-rp` and `-revert` alike.
141
+ "xxd": frozenset({"-r"}),
140
142
  }
141
143
  """Flags that turn a read-only command into a writing or executing one.
142
144
 
143
145
  ``sed`` edits in place under ``-i``, ``find`` runs arbitrary programs under
144
- ``-exec`` and unlinks under ``-delete``, ``sort -o`` overwrites its own input.
145
- Treating the executable name alone as read-only allowed all three through
146
- unprompted.
146
+ ``-exec`` and unlinks under ``-delete``, ``sort -o`` overwrites its own input,
147
+ ``sort --compress-program`` runs whatever it names and ``xxd -r`` turns a hex
148
+ dump back into bytes - into a file, given one. Treating the executable name
149
+ alone as read-only allowed them all through unprompted.
150
+ """
151
+
152
+ OUTPUT_OPERAND_COMMANDS: dict[str, frozenset[str]] = {
153
+ # uniq [OPTION]... [INPUT [OUTPUT]]
154
+ "uniq": frozenset({"-f", "--skip-fields", "-s", "--skip-chars", "-w", "--check-chars"}),
155
+ # xxd [options] [infile [outfile]]
156
+ "xxd": frozenset(
157
+ {"-c", "-cols", "-g", "-groupsize", "-l", "-len", "-n", "-name", "-o", "-offset"}
158
+ | {"-s", "-seek", "-R"}
159
+ ),
160
+ }
161
+ """Commands that write a file named by their second operand, with no flag at all.
162
+
163
+ ``uniq in out`` and ``xxd in out`` overwrite ``out``. The value is the options
164
+ that take a separate argument, so ``uniq -f 1 in`` is read as one operand, not
165
+ two.
147
166
  """
148
167
 
149
168
  GIT_READ_ONLY_SUBCOMMANDS = frozenset(
@@ -602,6 +621,8 @@ def is_read_only(segment: CommandSegment) -> bool:
602
621
  return not _env_runs_a_command(segment.args)
603
622
  if segment.executable in PROGRAM_TEXT_COMMANDS and _program_text_writes(segment):
604
623
  return False
624
+ if segment.executable in OUTPUT_OPERAND_COMMANDS and _names_an_output(segment):
625
+ return False
605
626
  return not _has_flag(
606
627
  segment.args,
607
628
  MUTATING_FLAGS.get(segment.executable, frozenset()),
@@ -640,6 +661,31 @@ grounds to call the command read-only: ``awk -f prog.awk`` runs whatever
640
661
  """
641
662
 
642
663
 
664
+ def _names_an_output(segment: CommandSegment) -> bool:
665
+ """True when the command was given an output file as its second operand.
666
+
667
+ Counting errs towards two: an option this does not know to take a value
668
+ makes its value look like an operand, which prompts - never the reverse.
669
+ Options end at the first operand, as for xxd and BSD uniq: there ``xxd in
670
+ -p`` and ``uniq in -c`` write files named ``-p`` and ``-c``. GNU uniq reads
671
+ that ``-c`` as an option, and the command only prompts where it need not.
672
+ """
673
+ takes_value = OUTPUT_OPERAND_COMMANDS[segment.executable]
674
+ operands = 0
675
+ options_done = False
676
+ args = iter(segment.args)
677
+ for arg in args:
678
+ if not options_done and arg == "--":
679
+ options_done = True
680
+ elif not options_done and arg.startswith("-") and arg != "-":
681
+ if arg in takes_value:
682
+ next(args, None)
683
+ else:
684
+ operands += 1
685
+ options_done = True
686
+ return operands >= 2
687
+
688
+
643
689
  def _program_text_writes(segment: CommandSegment) -> bool:
644
690
  """True when the program handed to ``awk``/``sed`` can write or execute."""
645
691
  if _has_option(segment.args, PROGRAM_FILE_OPTIONS[segment.executable]):
@@ -710,13 +756,15 @@ def _split_option(arg: str, options: tuple[str, ...]) -> tuple[str, str | None]
710
756
  Three spellings reach the same option and a shell accepts all three:
711
757
  separate (``-e`` then the script), ``=``-attached (``--expression=…``) and,
712
758
  for short options, packed into the same word (``-e'w out'``). Missing the
713
- last one let a writing script through unread.
759
+ last one let a writing script through unread. A long option may also be cut
760
+ short (``--expr=…``) - see :func:`_abbreviates`.
714
761
  """
715
762
  if arg in options:
716
763
  return arg, None
717
764
  name, sep, value = arg.partition("=")
718
- if sep and name in options:
719
- return name, value
765
+ option = next((o for o in options if name == o or _abbreviates(name, o)), None)
766
+ if option is not None:
767
+ return option, value if sep else None
720
768
  if len(arg) > 2 and arg.startswith("-") and not arg.startswith("--") and arg[:2] in options:
721
769
  return arg[:2], arg[2:]
722
770
  return None
@@ -745,12 +793,14 @@ def _option_values(args: tuple[str, ...], options: tuple[str, ...]) -> list[str]
745
793
  return values
746
794
 
747
795
 
748
- ATTACHED_VALUE_COMMANDS = frozenset({"date"})
796
+ ATTACHED_VALUE_COMMANDS = frozenset({"date", "xxd"})
749
797
  """Commands whose short options swallow the rest of their word as a value.
750
798
 
751
799
  ``date -Iseconds`` is one option and its argument, not a cluster of five, so
752
800
  only its first letter may be read as a flag - otherwise the ``s`` of
753
801
  ``seconds`` matches ``date -s`` and a pure formatting call prompts every time.
802
+ ``xxd`` reads only the start of each word as well: ``-groupsize`` is not a
803
+ ``-r`` for the r inside it.
754
804
  """
755
805
 
756
806
 
@@ -764,7 +814,8 @@ def _has_flag(
764
814
  clustered (``sed -ni``). Only two-character flags take part in the attached
765
815
  and clustered readings - ``find``'s options are single-dash words, and
766
816
  scanning ``-depth`` for the letters of ``-delete`` would refuse commands that
767
- write nothing.
817
+ write nothing. A long flag also matches when cut short, as
818
+ :func:`_abbreviates` describes.
768
819
 
769
820
  ``cluster_first_only`` narrows the clustered reading to the first letter, for
770
821
  commands where the rest of the word is that option's value rather than more
@@ -775,7 +826,8 @@ def _has_flag(
775
826
  for arg in args:
776
827
  if arg == "--":
777
828
  break # everything after this is an operand, not an option
778
- if arg.split("=", 1)[0] in flags:
829
+ name = arg.split("=", 1)[0]
830
+ if name in flags or any(_abbreviates(name, flag) for flag in flags):
779
831
  return True
780
832
  if not arg.startswith("-") or arg.startswith("--"):
781
833
  continue
@@ -787,6 +839,20 @@ def _has_flag(
787
839
  return False
788
840
 
789
841
 
842
+ def _abbreviates(name: str, option: str) -> bool:
843
+ """True when ``name`` is the long ``option`` cut short: ``--compress`` for
844
+ ``--compress-program``.
845
+
846
+ getopt_long and git's option parser take any unambiguous prefix of a long
847
+ option as the option itself, so matching only the full spelling let ``sort
848
+ --compress=sh`` and ``git grep --open-files-in=sh`` run a program unprompted.
849
+ Every prefix counts, ambiguous or not: an ambiguous one is an error, and one
850
+ that is itself another whole option only prompts where it need not - never
851
+ the reverse.
852
+ """
853
+ return len(name) > 2 and name.startswith("--") and option.startswith(name) and name != option
854
+
855
+
790
856
  def _leading_letters(text: str) -> str:
791
857
  """The run of option letters at the head of a cluster - ``i`` of ``-i.bak``."""
792
858
  end = 0
@@ -0,0 +1,12 @@
1
+ """Wording shared by the tools, the TUI and the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def count(number: int, singular: str, plural: str | None = None) -> str:
7
+ """``1 line``, ``2 lines``: a number with its noun agreeing.
8
+
9
+ ``plural`` is for the nouns an ``s`` does not make plural - ``match``.
10
+ """
11
+ noun = singular if number == 1 else (plural or f"{singular}s")
12
+ return f"{number} {noun}"
@@ -112,13 +112,12 @@ def resolve(lines: list[str], anchor: str) -> int:
112
112
  return matches[0]
113
113
 
114
114
 
115
- def replace_span(lines: list[str], start: int, end: int, replacement: str) -> list[str]:
116
- """Replace ``lines[start:end + 1]`` with ``replacement`` split into lines.
115
+ def replace_span(lines: list[str], start: int, end: int, replacement: list[str]) -> list[str]:
116
+ """Replace ``lines[start:end + 1]`` with the ``replacement`` lines.
117
117
 
118
118
  An empty replacement deletes the span rather than inserting a blank line.
119
119
  """
120
- new_lines = replacement.split("\n") if replacement else []
121
- return lines[:start] + new_lines + lines[end + 1 :]
120
+ return lines[:start] + replacement + lines[end + 1 :]
122
121
 
123
122
 
124
123
  class AnchorError(Exception):