hx-cli 0.2.10__tar.gz → 0.2.12__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 (148) hide show
  1. {hx_cli-0.2.10 → hx_cli-0.2.12}/CHANGELOG.md +39 -1
  2. {hx_cli-0.2.10 → hx_cli-0.2.12}/PKG-INFO +9 -3
  3. {hx_cli-0.2.10 → hx_cli-0.2.12}/README.md +8 -2
  4. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/cli.py +18 -25
  6. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/events.py +3 -0
  7. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/loop.py +149 -36
  8. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/messages.py +58 -1
  9. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/session.py +18 -5
  10. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/title.py +14 -1
  11. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/bash.py +23 -5
  12. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/app.py +4 -0
  13. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/commands.py +1 -1
  14. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/renderers.py +5 -3
  15. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/runtime.py +39 -12
  16. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/blocks.py +17 -1
  17. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/stub.py +59 -2
  18. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/terminal.py +11 -2
  19. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_commands.py +172 -0
  20. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_tools.py +91 -1
  21. {hx_cli-0.2.10 → hx_cli-0.2.12}/.github/workflows/ci.yml +0 -0
  22. {hx_cli-0.2.10 → hx_cli-0.2.12}/.gitignore +0 -0
  23. {hx_cli-0.2.10 → hx_cli-0.2.12}/examples/hooks/check.sh +0 -0
  24. {hx_cli-0.2.10 → hx_cli-0.2.12}/install.sh +0 -0
  25. {hx_cli-0.2.10 → hx_cli-0.2.12}/pyproject.toml +0 -0
  26. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/agents/__init__.py +0 -0
  27. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/agents/definitions.py +0 -0
  28. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/agents/subagent.py +0 -0
  29. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/__init__.py +0 -0
  30. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/__init__.py +0 -0
  31. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/browser.py +0 -0
  32. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/callback.py +0 -0
  33. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/codex.py +0 -0
  34. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/devin.py +0 -0
  35. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/oauth/pkce.py +0 -0
  36. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/resolve.py +0 -0
  37. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/auth/store.py +0 -0
  38. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/config.py +0 -0
  39. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/__init__.py +0 -0
  40. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/checkpoints.py +0 -0
  41. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/compaction.py +0 -0
  42. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/context.py +0 -0
  43. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/images.py +0 -0
  44. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/lateinject.py +0 -0
  45. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/core/usage.py +0 -0
  46. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/docs.py +0 -0
  47. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/frontmatter.py +0 -0
  48. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/git.py +0 -0
  49. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/hooks/__init__.py +0 -0
  50. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/hooks/engine.py +0 -0
  51. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/hooks/spec.py +0 -0
  52. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/keys.py +0 -0
  53. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/mcp/__init__.py +0 -0
  54. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/mcp/client.py +0 -0
  55. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/mcp/manager.py +0 -0
  56. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/mcp/oauth.py +0 -0
  57. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/net.py +0 -0
  58. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/paths.py +0 -0
  59. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/permissions/__init__.py +0 -0
  60. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/permissions/engine.py +0 -0
  61. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/permissions/parser.py +0 -0
  62. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/permissions/sandbox.py +0 -0
  63. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/__init__.py +0 -0
  64. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/base.py +0 -0
  65. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/codex.py +0 -0
  66. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/codex_catalogue.py +0 -0
  67. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/devin.py +0 -0
  68. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/devin_catalogue.py +0 -0
  69. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/devin_wire.py +0 -0
  70. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/models.py +0 -0
  71. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/openrouter.py +0 -0
  72. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/protowire.py +0 -0
  73. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/registry.py +0 -0
  74. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/providers/responses_codec.py +0 -0
  75. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/skills/__init__.py +0 -0
  76. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/skills/loader.py +0 -0
  77. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/skills/runtime.py +0 -0
  78. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/__init__.py +0 -0
  79. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/ansi.py +0 -0
  80. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/buffer.py +0 -0
  81. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/component.py +0 -0
  82. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/demo.py +0 -0
  83. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/editor.py +0 -0
  84. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/keydecode.py +0 -0
  85. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/loop.py +0 -0
  86. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/markdown.py +0 -0
  87. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/modifiers.py +0 -0
  88. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/primitives.py +0 -0
  89. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/sanitize.py +0 -0
  90. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/screen.py +0 -0
  91. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/syntax.py +0 -0
  92. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/terminal.py +0 -0
  93. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/undo.py +0 -0
  94. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/width.py +0 -0
  95. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/term/word_nav.py +0 -0
  96. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/__init__.py +0 -0
  97. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/anchors.py +0 -0
  98. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/base.py +0 -0
  99. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/edit.py +0 -0
  100. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/glob.py +0 -0
  101. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/grep.py +0 -0
  102. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/output.py +0 -0
  103. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/read.py +0 -0
  104. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/registry.py +0 -0
  105. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/symbols.py +0 -0
  106. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/task.py +0 -0
  107. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/todo.py +0 -0
  108. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/websearch.py +0 -0
  109. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tools/write.py +0 -0
  110. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/trace.py +0 -0
  111. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/__init__.py +0 -0
  112. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/clipboard.py +0 -0
  113. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/format.py +0 -0
  114. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/fuzzy.py +0 -0
  115. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/glyphs.py +0 -0
  116. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/killring.py +0 -0
  117. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/limits.py +0 -0
  118. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/paint.py +0 -0
  119. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/roles.py +0 -0
  120. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/theme.py +0 -0
  121. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/theme_json.py +0 -0
  122. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/themes/ansi.json +0 -0
  123. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/themes/dark.json +0 -0
  124. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/themes/light.json +0 -0
  125. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/__init__.py +0 -0
  126. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/dialog.py +0 -0
  127. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/login.py +0 -0
  128. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/permission.py +0 -0
  129. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/pickers.py +0 -0
  130. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/prompt.py +0 -0
  131. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/status.py +0 -0
  132. {hx_cli-0.2.10 → hx_cli-0.2.12}/src/hx/tui/views/transcript.py +0 -0
  133. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/__init__.py +0 -0
  134. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/conftest.py +0 -0
  135. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/__init__.py +0 -0
  136. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/conftest.py +0 -0
  137. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/fixtures/echo_server.py +0 -0
  138. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/report.py +0 -0
  139. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_chat.py +0 -0
  140. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_cli.py +0 -0
  141. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_extend.py +0 -0
  142. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_input.py +0 -0
  143. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/e2e/test_print.py +0 -0
  144. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/test_live.py +0 -0
  145. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/test_live_codex.py +0 -0
  146. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/test_live_devin.py +0 -0
  147. {hx_cli-0.2.10 → hx_cli-0.2.12}/tests/test_live_tavily.py +0 -0
  148. {hx_cli-0.2.10 → hx_cli-0.2.12}/uv.lock +0 -0
@@ -28,6 +28,42 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.12] - 2026-10-01
32
+
33
+ ### Fixed
34
+
35
+ - `/exit` and `ctrl+d` return the shell at once. They used to wait up to 10
36
+ seconds on a model call renaming the session; sessions are no longer renamed
37
+ on the way out, in the TUI or with `hx -p`.
38
+ - A name set with `/title` is no longer replaced by a generated one, including
39
+ one that was still being generated when you set it.
40
+ - The session name is generated from the opening and the latest stretch of the
41
+ conversation, not only its first 2000 characters.
42
+ - A session left before its first name came back is named from your first
43
+ message, instead of being listed by `/resume` with no name.
44
+ - A session left with `/resume` into another directory is renamed for what it
45
+ became, by the HX that starts there, in the background.
46
+
47
+ ---
48
+
49
+ ## [0.2.11] - 2026-10-01
50
+
51
+ ### Fixed
52
+
53
+ - `esc` while a tool runs no longer breaks the session: the next question is
54
+ answered instead of every later turn failing. A session that was interrupted
55
+ or killed mid-tool before this release also carries on after `hx resume`.
56
+ - `esc` during a `Bash` command stops the command. The next `Bash` call gets its
57
+ own output and exit code, not what was left of the one stopped.
58
+ - An interrupted call keeps what it printed, on screen and for the model, and
59
+ reads "Interrupted after <time>" instead of looking like a failure.
60
+ - A tool that finished keeps its result when `esc` lands while its
61
+ `PostToolUse` hook runs.
62
+ - `Bash` shows a short line such as "Starting..." as soon as it is printed,
63
+ rather than only when more output follows.
64
+
65
+ ---
66
+
31
67
  ## [0.2.10] - 2026-09-29
32
68
 
33
69
  ### Fixed
@@ -758,7 +794,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
758
794
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
759
795
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
760
796
 
761
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.10...HEAD
797
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.12...HEAD
798
+ [0.2.12]: https://github.com/aletisunil/hx/compare/v0.2.11...v0.2.12
799
+ [0.2.11]: https://github.com/aletisunil/hx/compare/v0.2.10...v0.2.11
762
800
  [0.2.10]: https://github.com/aletisunil/hx/compare/v0.2.9...v0.2.10
763
801
  [0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
764
802
  [0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.10
3
+ Version: 0.2.12
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
@@ -709,8 +709,14 @@ After the first exchange, HX asks the model for a short name for the session and
709
709
  writes it to `~/.hx/sessions/<id>/meta.json`, so `/resume` lists work rather
710
710
  than timestamps. It is one small call — cap 32 output tokens, `models.title_model`
711
711
  if you want a cheaper model for it — and it is counted in `/cost` like any other.
712
- If the call fails the session is still named, from your first message. `/title
713
- <text>` renames it.
712
+ If the call fails, or you leave before it is back, the session is still named,
713
+ from your first message.
714
+
715
+ The live session is never renamed while it runs, and nothing is renamed on
716
+ `/exit`, which returns your shell at once. A session you leave with `/clear` or
717
+ `/resume` - including a `/resume` into another directory - is renamed in the
718
+ background for what it became. `/title <text>` renames it yourself, and a name
719
+ you chose is never replaced.
714
720
 
715
721
  `/resume` lists every session except the one you are in: this directory's
716
722
  first, then other directories', each row naming where it lives. A session from
@@ -669,8 +669,14 @@ After the first exchange, HX asks the model for a short name for the session and
669
669
  writes it to `~/.hx/sessions/<id>/meta.json`, so `/resume` lists work rather
670
670
  than timestamps. It is one small call — cap 32 output tokens, `models.title_model`
671
671
  if you want a cheaper model for it — and it is counted in `/cost` like any other.
672
- If the call fails the session is still named, from your first message. `/title
673
- <text>` renames it.
672
+ If the call fails, or you leave before it is back, the session is still named,
673
+ from your first message.
674
+
675
+ The live session is never renamed while it runs, and nothing is renamed on
676
+ `/exit`, which returns your shell at once. A session you leave with `/clear` or
677
+ `/resume` - including a `/resume` into another directory - is renamed in the
678
+ background for what it became. `/title <text>` renames it yourself, and a name
679
+ you chose is never replaced.
674
680
 
675
681
  `/resume` lists every session except the one you are in: this directory's
676
682
  first, then other directories', each row naming where it lives. A session from
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.10"
3
+ __version__ = "0.2.12"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -609,6 +609,8 @@ def run_tui_command(parsed: ParsedArgs) -> int:
609
609
  from hx.auth.resolve import MissingCredential
610
610
  from hx.tui.app import run_tui
611
611
 
612
+ # Taken before anything starts, so no tool, MCP server or nested hx inherits it.
613
+ rename_left = os.environ.pop(RENAME_LEFT_ENV, None)
612
614
  try:
613
615
  resume = _resume_target(parsed)
614
616
  moved = _adopt_session_directory(parsed, resume)
@@ -648,10 +650,8 @@ def run_tui_command(parsed: ParsedArgs) -> int:
648
650
  notices=runtime.notices,
649
651
  checkpoints=runtime.checkpoints,
650
652
  tracker=runtime.tracker,
653
+ rename_left=rename_left,
651
654
  )
652
- # The TUI is down but the provider is not, which is the one moment
653
- # the whole session exists and nothing is competing for the screen.
654
- await _rename_closed_session(runtime.loop)
655
655
  finally:
656
656
  runtime.bus.close()
657
657
  # Everything, not only the provider: a relaunch replaces this
@@ -661,7 +661,8 @@ def run_tui_command(parsed: ParsedArgs) -> int:
661
661
 
662
662
  asyncio.run(main_async())
663
663
  if relaunch is not None:
664
- _relaunch(relaunch)
664
+ left = runtime.loop.session
665
+ _relaunch(relaunch, left=left.meta.session_id if left.messages else None)
665
666
  return 0
666
667
 
667
668
 
@@ -724,9 +725,19 @@ def relaunch_argv(argv: list[str], session_id: str) -> list[str]:
724
725
  return [*kept, "resume", session_id]
725
726
 
726
727
 
727
- def _relaunch(session_id: str) -> None:
728
+ RENAME_LEFT_ENV = "HX_RENAME_LEFT_SESSION"
729
+ """The session a relaunch left behind, handed to the HX that replaces it.
730
+
731
+ ``/clear`` and ``/resume`` rename the session the user leaves, in the
732
+ background. A relaunch cannot: ``exec`` ends this process before the call is
733
+ back. So the new process does it, as detached as it is everywhere else."""
734
+
735
+
736
+ def _relaunch(session_id: str, *, left: str | None = None) -> None:
728
737
  """Replace this process with HX resumed on ``session_id``.
729
738
 
739
+ ``left`` is the session being left, for the new process to rename.
740
+
730
741
  ``exec`` rather than a child: the terminal, the pid and the shell's job
731
742
  control stay exactly as they were, and there is no parent left waiting.
732
743
 
@@ -738,30 +749,13 @@ def _relaunch(session_id: str) -> None:
738
749
  """
739
750
  prefix = sys.orig_argv[: len(sys.orig_argv) - len(sys.argv) + 1]
740
751
  command = [sys.executable, *prefix[1:], *relaunch_argv(sys.argv[1:], session_id)]
752
+ if left is not None:
753
+ os.environ[RENAME_LEFT_ENV] = left
741
754
  sys.stdout.flush()
742
755
  sys.stderr.flush()
743
756
  os.execv(sys.executable, command)
744
757
 
745
758
 
746
- RENAME_TIMEOUT_SECONDS = 10.0
747
- """A session name is not worth making the user wait for. Exit wins the tie."""
748
-
749
-
750
- async def _rename_closed_session(loop: Any) -> None:
751
- """Re-name the session now that it is over, if the work moved on.
752
-
753
- The first name is written after one exchange and never revisited, so
754
- ``/resume`` ends up listing opening questions. Bounded and swallowed: this
755
- runs while the user is waiting for their shell prompt back.
756
- """
757
- try:
758
- await asyncio.wait_for(loop.retitle_session(), timeout=RENAME_TIMEOUT_SECONDS)
759
- except Exception:
760
- # Including the timeout. A rename that cannot happen is not an error the
761
- # user needs at the moment they are leaving; the old name still stands.
762
- return
763
-
764
-
765
759
  def run_print_command(parsed: ParsedArgs) -> int:
766
760
  """Headless single-prompt run. Streams assistant text to stdout and tool
767
761
  activity to stderr, so stdout stays pipeable."""
@@ -828,7 +822,6 @@ def run_print_command(parsed: ParsedArgs) -> int:
828
822
  print(f"[mcp] {status.name} unavailable: {status.error}", file=sys.stderr)
829
823
  try:
830
824
  result = await runtime.loop.run(parsed.prompt, images)
831
- await _rename_closed_session(runtime.loop)
832
825
  finally:
833
826
  await asyncio.sleep(0.05)
834
827
  runtime.bus.close()
@@ -67,6 +67,9 @@ class ToolCallFinished(Event):
67
67
  metadata: dict[str, Any] = field(default_factory=dict)
68
68
  """Structured detail the renderer draws with - an edit's diff, a command's
69
69
  exit code. Presentation only: the model never sees it."""
70
+ interrupted: bool = False
71
+ """The user stopped the turn while this call ran. Whatever it streamed is
72
+ kept: how far a command got is what the user wants to see."""
70
73
 
71
74
 
72
75
  @dataclass(slots=True)
@@ -42,7 +42,9 @@ from hx.core.messages import (
42
42
  ToolResultBlock,
43
43
  ToolUseBlock,
44
44
  UserTurn,
45
+ answer_unanswered_calls,
45
46
  assistant_message,
47
+ interrupted_result,
46
48
  tool_result_message,
47
49
  user_message,
48
50
  )
@@ -50,7 +52,7 @@ from hx.core.session import Environment
50
52
  from hx.core.usage import TurnUsage, compute_cost
51
53
  from hx.hooks.spec import HookOutcome
52
54
  from hx.providers.base import ProviderError, ProviderRequest, StreamDelta, StreamEnd
53
- from hx.tools.base import ToolContext
55
+ from hx.tools.base import ToolContext, ToolResult
54
56
 
55
57
  if TYPE_CHECKING:
56
58
  from collections.abc import Awaitable, Callable, Sequence
@@ -225,6 +227,27 @@ class AgentLoop:
225
227
  self.bus.publish(TurnFinished(self._turn_index, stop_reason))
226
228
 
227
229
  calls = message.tool_uses()
230
+ if calls and self._cancelled:
231
+ # Stopped between the answer and its tools: none of them ran,
232
+ # and the transcript has to say so before the turn ends - as
233
+ # does the screen, which a resumed session draws them on too.
234
+ for call in calls:
235
+ self.bus.publish(
236
+ ToolCallStarted(
237
+ tool_use_id=call.id,
238
+ name=self._display_name(call.name),
239
+ input=call.input,
240
+ )
241
+ )
242
+ self.bus.publish(
243
+ ToolCallFinished(
244
+ tool_use_id=call.id,
245
+ is_error=True,
246
+ summary="interrupted",
247
+ interrupted=True,
248
+ )
249
+ )
250
+ produced.append(self._answer_calls(calls, {}))
228
251
  if not calls or self._cancelled:
229
252
  # A steer that arrived during the last stretch is the next thing
230
253
  # the user said, so the conversation continues rather than
@@ -234,10 +257,15 @@ class AgentLoop:
234
257
  await self._name_session()
235
258
  return TurnResult(stop_reason, produced)
236
259
 
237
- results = await self._execute_tools(calls)
238
- result_message = tool_result_message(results)
239
- self.session.append(result_message)
240
- produced.append(result_message)
260
+ results: dict[str, ToolResultBlock] = {}
261
+ try:
262
+ await self._execute_tools(calls, results)
263
+ finally:
264
+ # Also on interrupt. The calls are already in the transcript,
265
+ # and a call left without a result is rejected by every route on
266
+ # the next request - so every later turn in the session would
267
+ # fail the same way. What finished keeps its real result.
268
+ produced.append(self._answer_calls(calls, results))
241
269
 
242
270
  self.bus.publish(
243
271
  ErrorRaised(message=f"Stopped after {self.MAX_TURNS} turns", recoverable=True)
@@ -324,7 +352,8 @@ class AgentLoop:
324
352
  moment ``run`` returns - a background task would simply be killed.
325
353
 
326
354
  Naming must never cost the user a turn, so every failure falls back to
327
- the first thing they said.
355
+ the first thing they said - including leaving before the call is back,
356
+ which would otherwise list the session in ``/resume`` with no name.
328
357
  """
329
358
  if self.origin is not None or self.session.meta.title or self._cancelled:
330
359
  return
@@ -333,37 +362,50 @@ class AgentLoop:
333
362
  if messages is None:
334
363
  return
335
364
 
336
- title = await self._ask_for_title(messages, self.session)
337
365
  from hx.core.title import fallback_title
338
366
 
367
+ try:
368
+ title = await self._ask_for_title(messages, self.session)
369
+ except asyncio.CancelledError:
370
+ if not self.session.meta.title:
371
+ self.session.set_title(fallback_title(messages))
372
+ raise
373
+ # The user may have named it with /title while the call was out; the
374
+ # turn is over by then, and their name stands.
375
+ if self.session.meta.title:
376
+ return
377
+
339
378
  self.session.set_title(title or fallback_title(messages))
340
379
 
341
- async def retitle_session(self, session: Session | None = None) -> None:
342
- """Rename a session for what it turned into, as it closes.
380
+ async def retitle_session(self, target: Session) -> None:
381
+ """Rename a session for what it turned into, as the user leaves it.
343
382
 
344
383
  The first name is written after one exchange, so it describes an opening
345
- question. Two hours later it is the wrong label on the row the user has
346
- to recognise in ``/resume``, and the list gives them no way to know that.
347
-
348
- ``session`` names the one to rename, for the cases where it is no longer
349
- the live one - ``/clear`` and ``/resume`` both leave a session behind.
350
-
351
- Only when the transcript grew since the name was written, and never at
352
- the cost of the name already there: a rename that fails leaves the old
353
- title alone rather than replacing something specific with a guess.
384
+ question. ``/clear`` and ``/resume`` both leave a session behind, and
385
+ that is the moment to name it for what it became - detached, so nobody
386
+ waits on it. The live session is never renamed while it runs.
387
+
388
+ Only when the conversation changed since the name was written, never
389
+ over a name the user chose, and never at the cost of the name already
390
+ there: a rename that fails leaves the old title alone rather than
391
+ replacing something specific with a guess.
354
392
  """
355
393
  if self.origin is not None:
356
394
  return
357
395
 
358
- target = session if session is not None else self.session
396
+ if target.meta.title_pinned:
397
+ return
359
398
  messages = self._nameable_messages(target)
360
399
  if messages is None:
361
400
  return
362
- if len(target.messages) <= target.meta.title_message_count:
401
+ prompt_count = target.meta.prompt_count
402
+ if prompt_count == target.meta.title_prompt_count:
363
403
  return
364
404
 
365
- if title := await self._ask_for_title(messages, target):
366
- target.set_title(title)
405
+ title = await self._ask_for_title(messages, target)
406
+ # The user may have named it while the call was out; their name stands.
407
+ if title and not target.meta.title_pinned:
408
+ target.set_title(title, prompt_count=prompt_count)
367
409
 
368
410
  def _nameable_messages(self, session: Session) -> list[Message] | None:
369
411
  """The transcript a name is derived from, or ``None`` if there is none."""
@@ -459,7 +501,9 @@ class AgentLoop:
459
501
  )
460
502
 
461
503
  async def _build_request(self) -> ProviderRequest:
462
- messages = await self.injections.apply(self.session.active_messages())
504
+ messages = answer_unanswered_calls(
505
+ await self.injections.apply(self.session.active_messages())
506
+ )
463
507
  if self.model_info is None or not self.model_info.supports_images:
464
508
  from hx.core.images import without_images
465
509
 
@@ -570,31 +614,48 @@ class AgentLoop:
570
614
  )
571
615
  )
572
616
 
573
- async def _execute_tools(self, calls: list[ToolUseBlock]) -> list[ToolResultBlock]:
617
+ async def _execute_tools(
618
+ self, calls: list[ToolUseBlock], results: dict[str, ToolResultBlock]
619
+ ) -> None:
574
620
  """Permission-check, then run. Read-only calls are gathered concurrently;
575
621
  mutating calls run in emission order.
576
622
 
623
+ Each result lands in ``results`` the moment its call finishes, so an
624
+ interrupt keeps what had already run.
625
+
577
626
  A denied call becomes an ``is_error`` result rather than an exception, so
578
627
  the model can react instead of the turn dying.
579
628
  """
580
- results: dict[str, ToolResultBlock] = {}
581
629
  concurrent: list[ToolUseBlock] = []
582
630
 
631
+ async def run(call: ToolUseBlock) -> None:
632
+ results[call.id] = await self._run_one(call, results)
633
+
583
634
  for call in calls:
584
635
  if self._runs_serially(call):
585
636
  for pending in concurrent:
586
- results[pending.id] = await self._run_one(pending)
637
+ await run(pending)
587
638
  concurrent.clear()
588
- results[call.id] = await self._run_one(call)
639
+ await run(call)
589
640
  else:
590
641
  concurrent.append(call)
591
642
 
592
643
  if concurrent:
593
- gathered = await asyncio.gather(*(self._run_one(c) for c in concurrent))
594
- for call, result in zip(concurrent, gathered, strict=True):
595
- results[call.id] = result
644
+ await asyncio.gather(*(run(c) for c in concurrent))
596
645
 
597
- return [results[c.id] for c in calls]
646
+ def _answer_calls(
647
+ self, calls: list[ToolUseBlock], results: dict[str, ToolResultBlock]
648
+ ) -> Message:
649
+ """Append the results for ``calls``, in the order the model asked for them.
650
+
651
+ A call with no result was cut off by the user, before it started or
652
+ while it ran, and is answered as exactly that.
653
+ """
654
+ message = tool_result_message(
655
+ [results.get(call.id) or interrupted_result(call.id) for call in calls]
656
+ )
657
+ self.session.append(message)
658
+ return message
598
659
 
599
660
  def _runs_serially(self, call: ToolUseBlock) -> bool:
600
661
  try:
@@ -609,7 +670,10 @@ class AgentLoop:
609
670
  """Attribute a subagent's tool calls so they do not read as the parent's."""
610
671
  return name if self.origin is None else f"{self.origin} > {name}"
611
672
 
612
- async def _run_one(self, call: ToolUseBlock) -> ToolResultBlock:
673
+ async def _run_one(
674
+ self, call: ToolUseBlock, results: dict[str, ToolResultBlock]
675
+ ) -> ToolResultBlock:
676
+ """Run one call. Interrupted, it leaves its answer in ``results``."""
613
677
  started = time.monotonic()
614
678
 
615
679
  # PreToolUse runs before the call is announced. A hook that rewrites the
@@ -626,7 +690,40 @@ class AgentLoop:
626
690
  tool_use_id=call.id, name=self._display_name(call.name), input=call.input
627
691
  )
628
692
  )
693
+ streamed: list[str] = []
694
+ try:
695
+ return await self._run_announced(call, pre, started, streamed, results)
696
+ except asyncio.CancelledError:
697
+ if call.id in results:
698
+ # The tool had finished and only its hooks were cut off.
699
+ raise
700
+ cap = self.settings.context.tool_output_char_cap
701
+ results[call.id] = interrupted_result(call.id, "".join(streamed)[-cap:])
702
+ # The call is on screen as running; left alone it would spin on
703
+ # under the "Interrupted" notice for the rest of the session.
704
+ self.bus.publish(
705
+ ToolCallFinished(
706
+ tool_use_id=call.id,
707
+ is_error=True,
708
+ duration_ms=(time.monotonic() - started) * 1000,
709
+ summary="interrupted",
710
+ interrupted=True,
711
+ )
712
+ )
713
+ raise
629
714
 
715
+ async def _run_announced(
716
+ self,
717
+ call: ToolUseBlock,
718
+ pre: HookOutcome,
719
+ started: float,
720
+ streamed: list[str],
721
+ results: dict[str, ToolResultBlock],
722
+ ) -> ToolResultBlock:
723
+ """The part of :meth:`_run_one` after the call is on screen.
724
+
725
+ What the tool prints is kept in ``streamed`` for an interrupt to report.
726
+ """
630
727
  if pre.blocked:
631
728
  refusal = ToolResultBlock(
632
729
  tool_use_id=call.id,
@@ -657,18 +754,34 @@ class AgentLoop:
657
754
  )
658
755
  return denied
659
756
 
757
+ def progress(chunk: str) -> None:
758
+ streamed.append(chunk)
759
+ self._emit_progress(call.id, chunk)
760
+
660
761
  ctx = ToolContext(
661
762
  cwd=self.settings.cwd,
662
763
  session_id=self.session.meta.session_id,
663
764
  tool_use_id=call.id,
664
765
  settings=self.settings,
665
- emit_progress=lambda chunk: self._emit_progress(call.id, chunk),
766
+ emit_progress=progress,
666
767
  )
667
768
  result = await self.tools.call(call.name, call.input, ctx)
668
769
 
669
- post = await self._fire_hooks(
670
- lambda h: h.post_tool_use(call.name, call.input, result.content, result.is_error)
671
- )
770
+ try:
771
+ post = await self._fire_hooks(
772
+ lambda h: h.post_tool_use(call.name, call.input, result.content, result.is_error)
773
+ )
774
+ except asyncio.CancelledError:
775
+ # The tool finished and its effects are real: its own result stands,
776
+ # rather than telling the model a write that happened never did.
777
+ results[call.id] = self._finish(call, result, HookOutcome(), started)
778
+ raise
779
+ return self._finish(call, result, post, started)
780
+
781
+ def _finish(
782
+ self, call: ToolUseBlock, result: ToolResult, post: HookOutcome, started: float
783
+ ) -> ToolResultBlock:
784
+ """Announce a call the tool finished, and its result for the model."""
672
785
  content = result.content
673
786
  if post.blocked:
674
787
  content = f"{content}\n\nA hook rejected this result: {post.reason}"
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import time
11
11
  from collections.abc import Sequence
12
- from dataclasses import dataclass, field
12
+ from dataclasses import dataclass, field, replace
13
13
  from enum import StrEnum
14
14
  from typing import Any, Literal
15
15
 
@@ -238,3 +238,60 @@ def assistant_message(blocks: list[ContentBlock], model: str | None = None) -> M
238
238
  def tool_result_message(results: list[ToolResultBlock]) -> Message:
239
239
  """Tool results are carried on a ``user``-role message, matching the provider wire format."""
240
240
  return Message(role="user", content=list(results))
241
+
242
+
243
+ INTERRUPTED = "Interrupted by the user before this call finished."
244
+ _OUTPUT_SO_FAR = "\n\nOutput before the interrupt:\n"
245
+
246
+
247
+ def interrupted_result(tool_use_id: str, output: str = "") -> ToolResultBlock:
248
+ """The answer for a call the user cut off, before it started or while it ran.
249
+
250
+ What it printed before then goes with it: how far a command got is what
251
+ the model needs to decide whether to run it again.
252
+ """
253
+ content = f"{INTERRUPTED}{_OUTPUT_SO_FAR}{output}" if output.strip() else INTERRUPTED
254
+ return ToolResultBlock(tool_use_id=tool_use_id, content=content, is_error=True)
255
+
256
+
257
+ def interrupted_output(result: ToolResultBlock) -> str | None:
258
+ """What an interrupted call printed, or ``None`` when ``result`` is not one."""
259
+ if not (result.is_error and result.content.startswith(INTERRUPTED)):
260
+ return None
261
+ return result.content.removeprefix(INTERRUPTED).removeprefix(_OUTPUT_SO_FAR)
262
+
263
+
264
+ def answer_unanswered_calls(messages: Sequence[Message]) -> list[Message]:
265
+ """``messages`` with every tool call answered by the message after it.
266
+
267
+ The loop answers every call it starts, interrupted or not, so a request is
268
+ never built while a call waits. A transcript can still hold a call with no
269
+ result: written before the loop did that, or by a process killed mid-tool.
270
+ Every route rejects such a call - on every request after it, since it never
271
+ leaves the history - so the missing results are filled in as interrupted.
272
+ Only the request is repaired; the transcript on disk keeps what happened.
273
+ """
274
+ repaired: list[Message] = []
275
+ waiting: list[ToolUseBlock] = []
276
+ for message in [*messages, None]:
277
+ if waiting:
278
+ results = {r.tool_use_id: r for r in message.tool_results()} if message else {}
279
+ if any(call.id not in results for call in waiting):
280
+ answers = [results.get(c.id) or interrupted_result(c.id) for c in waiting]
281
+ ids = {call.id for call in waiting}
282
+ if message is not None and results:
283
+ # Into the results already there: the calls stay answered
284
+ # by one message, in the order they were made.
285
+ rest = [
286
+ b
287
+ for b in message.content
288
+ if not (isinstance(b, ToolResultBlock) and b.tool_use_id in ids)
289
+ ]
290
+ message = replace(message, content=[*answers, *rest])
291
+ else:
292
+ repaired.append(tool_result_message(answers))
293
+ if message is None:
294
+ break
295
+ repaired.append(message)
296
+ waiting = message.tool_uses() if message.role == "assistant" else []
297
+ return repaired
@@ -39,9 +39,12 @@ class SessionMeta:
39
39
  updated_at: float
40
40
  model: str
41
41
  title: str | None = None
42
- title_message_count: int = 0
43
- """How long the transcript was when the title was written, so closing the
44
- session can tell whether the name still describes it."""
42
+ title_prompt_count: int = 0
43
+ """How many prompts the session held when the title was written, so the
44
+ session can tell when the conversation has moved on from its name."""
45
+ title_pinned: bool = False
46
+ """The user named the session with ``/title``. Their name is never replaced
47
+ by a generated one."""
45
48
  message_count: int = 0
46
49
  """Every message record: the user's prompts, the assistant's replies, and
47
50
  the tool-result messages that ride the user role to match the provider wire
@@ -166,14 +169,24 @@ class Session:
166
169
  self._pending.append({"kind": "environment", "data": asdict(environment)})
167
170
  self.flush()
168
171
 
169
- def set_title(self, title: str) -> None:
172
+ def set_title(
173
+ self, title: str, *, pinned: bool = False, prompt_count: int | None = None
174
+ ) -> None:
170
175
  """Name the session for ``/resume``.
171
176
 
177
+ ``pinned`` marks a name the user chose, which generated names never
178
+ replace. ``prompt_count`` is how much of the conversation the name
179
+ describes, when that is less than the whole of it - a name generated in
180
+ the background describes the transcript as it was when it was asked for.
181
+
172
182
  ``updated_at`` is deliberately left alone: it orders the picker and must
173
183
  keep reflecting real activity, not the moment a name was written.
174
184
  """
175
185
  self.meta.title = title.strip() or None
176
- self.meta.title_message_count = len(self.messages)
186
+ self.meta.title_pinned = pinned and self.meta.title is not None
187
+ self.meta.title_prompt_count = (
188
+ self.meta.prompt_count if prompt_count is None else prompt_count
189
+ )
177
190
  self._write_meta()
178
191
 
179
192
  def record_usage(self, usage: TurnUsage) -> None:
@@ -27,6 +27,10 @@ TITLE_MAX_TOKENS = 32
27
27
  MAX_TITLE_CHARS = 50
28
28
  """A session row is read at a glance, in a list. Longer names stop being names."""
29
29
  TRANSCRIPT_CHARS = 2_000
30
+ """How much of the conversation the naming call sees."""
31
+ OPENING_CHARS = 500
32
+ """Of that, how much is the opening. The rest is the latest stretch: a session
33
+ renamed for what it turned into has to be shown what it turned into."""
30
34
 
31
35
  TITLE_PROMPT = """\
32
36
  Name this coding session so it can be recognised in a list of sessions.
@@ -49,7 +53,7 @@ def build_title_request(messages: list[Message], model: str) -> ProviderRequest:
49
53
  from hx.core.context import AssembledContext, PromptSection
50
54
  from hx.providers.base import ProviderRequest
51
55
 
52
- transcript = render_transcript(messages)[:TRANSCRIPT_CHARS]
56
+ transcript = _excerpt(render_transcript(messages))
53
57
  context = AssembledContext(
54
58
  system=[PromptSection("system", "You name things briefly and precisely.")],
55
59
  messages=[
@@ -83,6 +87,15 @@ async def generate_title(
83
87
  return clean_title("".join(parts)) or None, usage
84
88
 
85
89
 
90
+ def _excerpt(transcript: str) -> str:
91
+ """The opening and the latest stretch of a transcript too long to send whole."""
92
+ if len(transcript) <= TRANSCRIPT_CHARS:
93
+ return transcript
94
+ opening = transcript[:OPENING_CHARS]
95
+ latest = transcript[-(TRANSCRIPT_CHARS - OPENING_CHARS) :]
96
+ return f"{opening}\n[... earlier conversation omitted ...]\n{latest}"
97
+
98
+
86
99
  def clean_title(raw: str) -> str:
87
100
  """Strip the decoration models add to a name, and cap its length.
88
101