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