hx-cli 0.2.16__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.
- {hx_cli-0.2.16 → hx_cli-0.2.17}/CHANGELOG.md +45 -1
- {hx_cli-0.2.16 → hx_cli-0.2.17}/PKG-INFO +4 -1
- {hx_cli-0.2.16 → hx_cli-0.2.17}/README.md +3 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/cli.py +5 -2
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/loop.py +22 -13
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/permissions/engine.py +31 -5
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/permissions/parser.py +76 -10
- hx_cli-0.2.17/src/hx/text.py +12 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/anchors.py +3 -4
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/bash.py +65 -7
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/edit.py +91 -41
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/glob.py +2 -1
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/grep.py +2 -1
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/output.py +2 -1
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/read.py +15 -2
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/task.py +2 -1
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/websearch.py +2 -1
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/write.py +38 -6
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/commands.py +4 -3
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/renderers.py +9 -3
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/views/status.py +42 -7
- hx_cli-0.2.17/tests/e2e/test_edits.py +221 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/test_print.py +1 -0
- hx_cli-0.2.17/tests/e2e/test_shell.py +184 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/test_tools.py +68 -1
- {hx_cli-0.2.16 → hx_cli-0.2.17}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/.gitignore +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/install.sh +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/pyproject.toml +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/auth/claude_code.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/config.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/docs.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/git.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/keys.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/net.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/paths.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/claude_code.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/claude_code_catalogue.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/claude_code_cli.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/claude_code_transcript.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/trace.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/runtime.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/views/blocks.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/views/pickers.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/conftest.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/fake_claude.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/terminal.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/test_claude_code.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/test_commands.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/e2e/test_queue.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/test_live.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/test_live_claude_code.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/tests/tls_proxy.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.17}/uv.lock +0 -0
|
@@ -28,6 +28,49 @@ 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
|
+
|
|
31
74
|
## [0.2.16] - 2026-10-13
|
|
32
75
|
|
|
33
76
|
### Fixed
|
|
@@ -915,7 +958,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
915
958
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
916
959
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
917
960
|
|
|
918
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
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
|
|
919
963
|
[0.2.16]: https://github.com/aletisunil/hx/compare/v0.2.15...v0.2.16
|
|
920
964
|
[0.2.15]: https://github.com/aletisunil/hx/compare/v0.2.14...v0.2.15
|
|
921
965
|
[0.2.14]: https://github.com/aletisunil/hx/compare/v0.2.13...v0.2.14
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
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`.
|
|
@@ -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 =
|
|
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}
|
|
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
|
|
@@ -877,7 +879,7 @@ class AgentLoop:
|
|
|
877
879
|
from hx.permissions.engine import PermissionRequest
|
|
878
880
|
|
|
879
881
|
tool = self.tools.get(call.name)
|
|
880
|
-
detail, detail_kind = _permission_detail(call)
|
|
882
|
+
detail, detail_kind = _permission_detail(call, self.settings.cwd)
|
|
881
883
|
request = PermissionRequest(
|
|
882
884
|
tool_name=call.name,
|
|
883
885
|
specifier=tool.permission_specifier(call.input),
|
|
@@ -1032,7 +1034,7 @@ def _parse_tool_input(raw: str | None) -> dict[str, Any]:
|
|
|
1032
1034
|
return parsed if isinstance(parsed, dict) else {}
|
|
1033
1035
|
|
|
1034
1036
|
|
|
1035
|
-
def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
|
|
1037
|
+
def _permission_detail(call: ToolUseBlock, cwd: Path) -> tuple[str, str]:
|
|
1036
1038
|
"""What the approval shows, and what kind of thing it is.
|
|
1037
1039
|
|
|
1038
1040
|
For an edit this previews the change against the file on disk, so the user
|
|
@@ -1042,18 +1044,29 @@ def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
|
|
|
1042
1044
|
the text meant a file whose first line was ``---`` - YAML front matter, a
|
|
1043
1045
|
markdown rule - was treated as a diff and run through a painter that
|
|
1044
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.
|
|
1045
1053
|
"""
|
|
1046
1054
|
if call.name == "Bash":
|
|
1047
1055
|
return str(call.input.get("command", "")), "command"
|
|
1048
1056
|
|
|
1049
1057
|
if call.name in {"Edit", "Write"} and (raw_path := call.input.get("file_path")):
|
|
1050
|
-
from
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
|
1053
1066
|
|
|
1054
|
-
path =
|
|
1067
|
+
path = resolve_path(str(raw_path), cwd)
|
|
1055
1068
|
try:
|
|
1056
|
-
before = path
|
|
1069
|
+
before = read_exact(path) if path.is_file() else ""
|
|
1057
1070
|
if call.name == "Write":
|
|
1058
1071
|
after = str(call.input.get("content", ""))
|
|
1059
1072
|
elif call.input.get("hashline"):
|
|
@@ -1062,11 +1075,7 @@ def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
|
|
|
1062
1075
|
# filename and the user approves an edit they never saw.
|
|
1063
1076
|
after = apply_hashline(before, parse_hashline(call.input))
|
|
1064
1077
|
else:
|
|
1065
|
-
after = before
|
|
1066
|
-
for edit in parse_edits(call.input):
|
|
1067
|
-
after = after.replace(
|
|
1068
|
-
edit.old_string, edit.new_string, -1 if edit.replace_all else 1
|
|
1069
|
-
)
|
|
1078
|
+
after = apply_edits(before, parse_edits(call.input))
|
|
1070
1079
|
diff = unified_diff(before, after, str(path))
|
|
1071
1080
|
return (diff, "diff") if diff else (f"{path} (no change)", "text")
|
|
1072
1081
|
except Exception:
|
|
@@ -1103,7 +1112,7 @@ scroll is a prompt they stop reading."""
|
|
|
1103
1112
|
|
|
1104
1113
|
def _one_value(value: Any, limit: int) -> str:
|
|
1105
1114
|
if isinstance(value, list):
|
|
1106
|
-
return f"[{len(value)}
|
|
1115
|
+
return f"[{count(len(value), 'item')}]"
|
|
1107
1116
|
if isinstance(value, dict):
|
|
1108
1117
|
return "{…}"
|
|
1109
1118
|
if isinstance(value, str) and len(value) > limit:
|
|
@@ -144,6 +144,12 @@ class PermissionEngine:
|
|
|
144
144
|
if request.tool_name == "Bash" and request.specifier:
|
|
145
145
|
parsed = parse(request.specifier)
|
|
146
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)
|
|
147
153
|
return PermissionOutcome(
|
|
148
154
|
Decision.ASK,
|
|
149
155
|
"command could not be decomposed; approving it blind is not safe",
|
|
@@ -219,11 +225,11 @@ class PermissionEngine:
|
|
|
219
225
|
return None
|
|
220
226
|
|
|
221
227
|
# A rule holding the whole command verbatim: exact, so it grants no more
|
|
222
|
-
# than the string it names. This is what
|
|
223
|
-
#
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
227
233
|
|
|
228
234
|
parsed = parse(request.specifier)
|
|
229
235
|
if not parsed.segments or parsed.unparseable:
|
|
@@ -244,6 +250,26 @@ class PermissionEngine:
|
|
|
244
250
|
first = first or covering
|
|
245
251
|
return first
|
|
246
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
|
+
|
|
247
273
|
def _match_allow_each(
|
|
248
274
|
self,
|
|
249
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
|
-
|
|
146
|
-
|
|
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
|
|
719
|
-
|
|
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
|
-
|
|
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``
|
|
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
|
-
|
|
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):
|
|
@@ -9,9 +9,11 @@ policy requires :meth:`PersistentShell.restart`.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
|
+
import codecs
|
|
12
13
|
import contextlib
|
|
13
14
|
import os
|
|
14
15
|
import secrets
|
|
16
|
+
import shlex
|
|
15
17
|
import signal
|
|
16
18
|
import time
|
|
17
19
|
import uuid
|
|
@@ -20,6 +22,8 @@ from dataclasses import dataclass, field
|
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
from typing import Any
|
|
22
24
|
|
|
25
|
+
from hx.auth.resolve import PROVIDER_ENV
|
|
26
|
+
from hx.text import count
|
|
23
27
|
from hx.tools.base import Tool, ToolContext, ToolError, ToolResult
|
|
24
28
|
from hx.tools.output import cap_output, summarize_for_ui
|
|
25
29
|
|
|
@@ -34,6 +38,20 @@ MAX_TIMEOUT = 600.0
|
|
|
34
38
|
READ_CHUNK = 4096
|
|
35
39
|
|
|
36
40
|
|
|
41
|
+
def command_environment(extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
42
|
+
"""The environment a command runs with: HX's own, minus HX's credentials.
|
|
43
|
+
|
|
44
|
+
The keys HX signs in with are HX's, not the project's. Inherited, they were
|
|
45
|
+
one auto-approved ``env`` away from the model's context - and from there one
|
|
46
|
+
request away from anywhere - which made denying ``~/.hx/auth.json`` to the
|
|
47
|
+
sandbox beside the point. Every variable HX reads a credential from is
|
|
48
|
+
withheld, the unprefixed fallbacks included.
|
|
49
|
+
"""
|
|
50
|
+
withheld = {name for names in PROVIDER_ENV.values() for name in names}
|
|
51
|
+
inherited = {key: value for key, value in os.environ.items() if key not in withheld}
|
|
52
|
+
return {**inherited, **(extra or {})}
|
|
53
|
+
|
|
54
|
+
|
|
37
55
|
@dataclass(slots=True)
|
|
38
56
|
class ShellResult:
|
|
39
57
|
stdout: str
|
|
@@ -61,7 +79,7 @@ class PersistentShell:
|
|
|
61
79
|
) -> None:
|
|
62
80
|
self._cwd = cwd
|
|
63
81
|
self.shell = shell or os.environ.get("SHELL") or "/bin/bash"
|
|
64
|
-
self.env = {**
|
|
82
|
+
self.env = command_environment({**(env or {}), "HX": "1", "TERM": "dumb"})
|
|
65
83
|
self.sandbox = sandbox
|
|
66
84
|
self._process: asyncio.subprocess.Process | None = None
|
|
67
85
|
self._sentinel = f"__HX_DONE_{secrets.token_hex(8)}__"
|
|
@@ -121,11 +139,31 @@ class PersistentShell:
|
|
|
121
139
|
|
|
122
140
|
# stderr is merged into stdout so the model sees what a user would.
|
|
123
141
|
#
|
|
142
|
+
# The command reaches the shell as one single-quoted word handed to
|
|
143
|
+
# `eval`, with stdin from /dev/null. Written bare, it shared the
|
|
144
|
+
# shell's stdin with the control channel: a command that read it -
|
|
145
|
+
# `cat`, `read`, `ssh host ls` - ate the trailer below, and `cat`
|
|
146
|
+
# echoed it back, so the call "finished" while cat went on
|
|
147
|
+
# swallowing every later command. Evaluated, it still runs in this
|
|
148
|
+
# shell, so cd and exports persist; and an unclosed quote ends with
|
|
149
|
+
# the word instead of running on into the next command.
|
|
150
|
+
#
|
|
151
|
+
# Inline, not read back from a temp file. Reading it back needed
|
|
152
|
+
# `cat`, found on the session's PATH, so `PATH=/nonexistent` or a
|
|
153
|
+
# function named `cat` broke every later command - the one that
|
|
154
|
+
# would repair it included - while each reported success. And the
|
|
155
|
+
# file sat in $TMPDIR, which the sandbox lets commands write, so a
|
|
156
|
+
# job could change what ran after it was approved. Only builtins
|
|
157
|
+
# run here: `eval`, the redirect and `printf`.
|
|
158
|
+
#
|
|
124
159
|
# The trailer carries the working directory as well as the status,
|
|
125
160
|
# because a `cd` is the main thing that persists between calls and
|
|
126
161
|
# nothing outside the shell could otherwise see it. `$PWD` is last
|
|
127
162
|
# so a directory with spaces in it needs no quoting to parse.
|
|
128
|
-
payload =
|
|
163
|
+
payload = (
|
|
164
|
+
f"eval {shlex.quote(command)} </dev/null\n"
|
|
165
|
+
f'printf \'\\n%s %s %s\\n\' {self._sentinel} "$?" "$PWD"\n'
|
|
166
|
+
)
|
|
129
167
|
self._process.stdin.write(payload.encode())
|
|
130
168
|
await self._process.stdin.drain()
|
|
131
169
|
|
|
@@ -156,6 +194,9 @@ class PersistentShell:
|
|
|
156
194
|
async def _read_until_sentinel(self, timeout_seconds: float) -> AsyncIterator[str]:
|
|
157
195
|
assert self._process is not None and self._process.stdout is not None
|
|
158
196
|
stdout = self._process.stdout
|
|
197
|
+
# Incremental, so a character split across two reads is decoded whole
|
|
198
|
+
# rather than as two replacement characters.
|
|
199
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
159
200
|
buffer = ""
|
|
160
201
|
deadline = time.monotonic() + timeout_seconds
|
|
161
202
|
|
|
@@ -173,11 +214,12 @@ class PersistentShell:
|
|
|
173
214
|
self._exit_code = (
|
|
174
215
|
self._process.returncode if self._process.returncode is not None else 1
|
|
175
216
|
)
|
|
217
|
+
buffer += decoder.decode(b"", final=True)
|
|
176
218
|
if buffer:
|
|
177
219
|
yield buffer
|
|
178
220
|
return
|
|
179
221
|
|
|
180
|
-
buffer +=
|
|
222
|
+
buffer += decoder.decode(data)
|
|
181
223
|
if self._sentinel in buffer:
|
|
182
224
|
head, _, tail = buffer.partition(self._sentinel)
|
|
183
225
|
self._exit_code, self._cwd = _parse_trailer(tail, self._cwd)
|
|
@@ -286,8 +328,16 @@ class BackgroundJobs:
|
|
|
286
328
|
self.log_dir = log_dir
|
|
287
329
|
self._jobs: dict[str, BackgroundJob] = {}
|
|
288
330
|
|
|
289
|
-
async def start(
|
|
290
|
-
|
|
331
|
+
async def start(
|
|
332
|
+
self, command: str, cwd: Path, env: dict[str, str], sandbox: Any | None = None
|
|
333
|
+
) -> str:
|
|
334
|
+
"""Returns a job id the model can poll.
|
|
335
|
+
|
|
336
|
+
``env`` is the persistent shell's, so a command sees the same ``HX`` and
|
|
337
|
+
``TERM`` whichever way it runs: with the terminal's own ``TERM``, a tool
|
|
338
|
+
coloured its output in the background and wrote the escapes into the
|
|
339
|
+
log the model reads back.
|
|
340
|
+
"""
|
|
291
341
|
job_id = f"bg_{uuid.uuid4().hex[:8]}"
|
|
292
342
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
293
343
|
log_path = self.log_dir / f"{job_id}.log"
|
|
@@ -300,12 +350,18 @@ class BackgroundJobs:
|
|
|
300
350
|
# the subprocess, so holding the parent's copy keeps one open per job
|
|
301
351
|
# for the life of the session and buys nothing - the log is read back
|
|
302
352
|
# by path.
|
|
353
|
+
#
|
|
354
|
+
# stdin is /dev/null, not HX's own: inherited, it is the terminal the
|
|
355
|
+
# prompt reads from, and a dev server with keyboard shortcuts would
|
|
356
|
+
# take keystrokes meant for HX - or put the terminal into raw mode.
|
|
303
357
|
with log_path.open("wb") as handle:
|
|
304
358
|
process = await asyncio.create_subprocess_exec(
|
|
305
359
|
*argv,
|
|
360
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
306
361
|
stdout=handle,
|
|
307
362
|
stderr=asyncio.subprocess.STDOUT,
|
|
308
363
|
cwd=cwd,
|
|
364
|
+
env=env,
|
|
309
365
|
start_new_session=True,
|
|
310
366
|
)
|
|
311
367
|
self._jobs[job_id] = BackgroundJob(job_id, command, process, log_path)
|
|
@@ -424,7 +480,9 @@ class BashTool(Tool):
|
|
|
424
480
|
# The shell's directory, not the session's: `cd build && make` in
|
|
425
481
|
# one call then a background `./run` in the next has to mean the
|
|
426
482
|
# same thing it would in a terminal.
|
|
427
|
-
job_id = await self.jobs.start(
|
|
483
|
+
job_id = await self.jobs.start(
|
|
484
|
+
command, self.shell.cwd, self.shell.env, self.shell.sandbox
|
|
485
|
+
)
|
|
428
486
|
return ToolResult(
|
|
429
487
|
content=(
|
|
430
488
|
f"Started background job {job_id}.\n"
|
|
@@ -543,7 +601,7 @@ class BashOutputTool(Tool):
|
|
|
543
601
|
return ToolResult(
|
|
544
602
|
content=body,
|
|
545
603
|
spilled_path=capped.spilled_path,
|
|
546
|
-
summary=f"{next_line}
|
|
604
|
+
summary=f"{count(next_line, 'line')}, {'running' if status['running'] else 'finished'}",
|
|
547
605
|
metadata={"next_line": next_line, **status},
|
|
548
606
|
)
|
|
549
607
|
|