hx-cli 0.2.10__tar.gz → 0.2.11__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.11}/CHANGELOG.md +20 -1
  2. {hx_cli-0.2.10 → hx_cli-0.2.11}/PKG-INFO +1 -1
  3. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/__init__.py +1 -1
  4. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/events.py +3 -0
  5. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/loop.py +118 -19
  6. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/messages.py +58 -1
  7. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/bash.py +23 -5
  8. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/renderers.py +5 -3
  9. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/runtime.py +10 -5
  10. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/blocks.py +17 -1
  11. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/stub.py +33 -0
  12. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/terminal.py +11 -2
  13. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_tools.py +91 -1
  14. {hx_cli-0.2.10 → hx_cli-0.2.11}/.github/workflows/ci.yml +0 -0
  15. {hx_cli-0.2.10 → hx_cli-0.2.11}/.gitignore +0 -0
  16. {hx_cli-0.2.10 → hx_cli-0.2.11}/README.md +0 -0
  17. {hx_cli-0.2.10 → hx_cli-0.2.11}/examples/hooks/check.sh +0 -0
  18. {hx_cli-0.2.10 → hx_cli-0.2.11}/install.sh +0 -0
  19. {hx_cli-0.2.10 → hx_cli-0.2.11}/pyproject.toml +0 -0
  20. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/agents/__init__.py +0 -0
  21. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/agents/definitions.py +0 -0
  22. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/agents/subagent.py +0 -0
  23. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/__init__.py +0 -0
  24. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/__init__.py +0 -0
  25. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/browser.py +0 -0
  26. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/callback.py +0 -0
  27. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/codex.py +0 -0
  28. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/devin.py +0 -0
  29. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/pkce.py +0 -0
  30. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/resolve.py +0 -0
  31. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/store.py +0 -0
  32. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/cli.py +0 -0
  33. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/config.py +0 -0
  34. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/__init__.py +0 -0
  35. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/checkpoints.py +0 -0
  36. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/compaction.py +0 -0
  37. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/context.py +0 -0
  38. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/images.py +0 -0
  39. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/lateinject.py +0 -0
  40. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/session.py +0 -0
  41. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/title.py +0 -0
  42. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/usage.py +0 -0
  43. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/docs.py +0 -0
  44. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/frontmatter.py +0 -0
  45. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/git.py +0 -0
  46. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/hooks/__init__.py +0 -0
  47. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/hooks/engine.py +0 -0
  48. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/hooks/spec.py +0 -0
  49. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/keys.py +0 -0
  50. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/mcp/__init__.py +0 -0
  51. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/mcp/client.py +0 -0
  52. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/mcp/manager.py +0 -0
  53. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/mcp/oauth.py +0 -0
  54. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/net.py +0 -0
  55. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/paths.py +0 -0
  56. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/permissions/__init__.py +0 -0
  57. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/permissions/engine.py +0 -0
  58. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/permissions/parser.py +0 -0
  59. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/permissions/sandbox.py +0 -0
  60. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/__init__.py +0 -0
  61. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/base.py +0 -0
  62. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/codex.py +0 -0
  63. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/codex_catalogue.py +0 -0
  64. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/devin.py +0 -0
  65. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/devin_catalogue.py +0 -0
  66. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/devin_wire.py +0 -0
  67. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/models.py +0 -0
  68. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/openrouter.py +0 -0
  69. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/protowire.py +0 -0
  70. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/registry.py +0 -0
  71. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/responses_codec.py +0 -0
  72. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/skills/__init__.py +0 -0
  73. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/skills/loader.py +0 -0
  74. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/skills/runtime.py +0 -0
  75. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/__init__.py +0 -0
  76. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/ansi.py +0 -0
  77. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/buffer.py +0 -0
  78. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/component.py +0 -0
  79. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/demo.py +0 -0
  80. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/editor.py +0 -0
  81. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/keydecode.py +0 -0
  82. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/loop.py +0 -0
  83. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/markdown.py +0 -0
  84. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/modifiers.py +0 -0
  85. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/primitives.py +0 -0
  86. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/sanitize.py +0 -0
  87. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/screen.py +0 -0
  88. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/syntax.py +0 -0
  89. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/terminal.py +0 -0
  90. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/undo.py +0 -0
  91. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/width.py +0 -0
  92. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/word_nav.py +0 -0
  93. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/__init__.py +0 -0
  94. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/anchors.py +0 -0
  95. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/base.py +0 -0
  96. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/edit.py +0 -0
  97. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/glob.py +0 -0
  98. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/grep.py +0 -0
  99. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/output.py +0 -0
  100. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/read.py +0 -0
  101. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/registry.py +0 -0
  102. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/symbols.py +0 -0
  103. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/task.py +0 -0
  104. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/todo.py +0 -0
  105. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/websearch.py +0 -0
  106. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/write.py +0 -0
  107. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/trace.py +0 -0
  108. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/__init__.py +0 -0
  109. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/app.py +0 -0
  110. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/clipboard.py +0 -0
  111. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/commands.py +0 -0
  112. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/format.py +0 -0
  113. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/fuzzy.py +0 -0
  114. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/glyphs.py +0 -0
  115. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/killring.py +0 -0
  116. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/limits.py +0 -0
  117. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/paint.py +0 -0
  118. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/roles.py +0 -0
  119. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/theme.py +0 -0
  120. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/theme_json.py +0 -0
  121. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/themes/ansi.json +0 -0
  122. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/themes/dark.json +0 -0
  123. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/themes/light.json +0 -0
  124. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/__init__.py +0 -0
  125. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/dialog.py +0 -0
  126. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/login.py +0 -0
  127. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/permission.py +0 -0
  128. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/pickers.py +0 -0
  129. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/prompt.py +0 -0
  130. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/status.py +0 -0
  131. {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/transcript.py +0 -0
  132. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/__init__.py +0 -0
  133. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/conftest.py +0 -0
  134. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/__init__.py +0 -0
  135. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/conftest.py +0 -0
  136. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/fixtures/echo_server.py +0 -0
  137. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/report.py +0 -0
  138. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_chat.py +0 -0
  139. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_cli.py +0 -0
  140. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_commands.py +0 -0
  141. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_extend.py +0 -0
  142. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_input.py +0 -0
  143. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_print.py +0 -0
  144. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/test_live.py +0 -0
  145. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/test_live_codex.py +0 -0
  146. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/test_live_devin.py +0 -0
  147. {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/test_live_tavily.py +0 -0
  148. {hx_cli-0.2.10 → hx_cli-0.2.11}/uv.lock +0 -0
@@ -28,6 +28,24 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.11] - 2026-10-01
32
+
33
+ ### Fixed
34
+
35
+ - `esc` while a tool runs no longer breaks the session: the next question is
36
+ answered instead of every later turn failing. A session that was interrupted
37
+ or killed mid-tool before this release also carries on after `hx resume`.
38
+ - `esc` during a `Bash` command stops the command. The next `Bash` call gets its
39
+ own output and exit code, not what was left of the one stopped.
40
+ - An interrupted call keeps what it printed, on screen and for the model, and
41
+ reads "Interrupted after <time>" instead of looking like a failure.
42
+ - A tool that finished keeps its result when `esc` lands while its
43
+ `PostToolUse` hook runs.
44
+ - `Bash` shows a short line such as "Starting..." as soon as it is printed,
45
+ rather than only when more output follows.
46
+
47
+ ---
48
+
31
49
  ## [0.2.10] - 2026-09-29
32
50
 
33
51
  ### Fixed
@@ -758,7 +776,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
758
776
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
759
777
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
760
778
 
761
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.10...HEAD
779
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.11...HEAD
780
+ [0.2.11]: https://github.com/aletisunil/hx/compare/v0.2.10...v0.2.11
762
781
  [0.2.10]: https://github.com/aletisunil/hx/compare/v0.2.9...v0.2.10
763
782
  [0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
764
783
  [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.11
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
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.10"
3
+ __version__ = "0.2.11"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -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)
@@ -459,7 +487,9 @@ class AgentLoop:
459
487
  )
460
488
 
461
489
  async def _build_request(self) -> ProviderRequest:
462
- messages = await self.injections.apply(self.session.active_messages())
490
+ messages = answer_unanswered_calls(
491
+ await self.injections.apply(self.session.active_messages())
492
+ )
463
493
  if self.model_info is None or not self.model_info.supports_images:
464
494
  from hx.core.images import without_images
465
495
 
@@ -570,31 +600,48 @@ class AgentLoop:
570
600
  )
571
601
  )
572
602
 
573
- async def _execute_tools(self, calls: list[ToolUseBlock]) -> list[ToolResultBlock]:
603
+ async def _execute_tools(
604
+ self, calls: list[ToolUseBlock], results: dict[str, ToolResultBlock]
605
+ ) -> None:
574
606
  """Permission-check, then run. Read-only calls are gathered concurrently;
575
607
  mutating calls run in emission order.
576
608
 
609
+ Each result lands in ``results`` the moment its call finishes, so an
610
+ interrupt keeps what had already run.
611
+
577
612
  A denied call becomes an ``is_error`` result rather than an exception, so
578
613
  the model can react instead of the turn dying.
579
614
  """
580
- results: dict[str, ToolResultBlock] = {}
581
615
  concurrent: list[ToolUseBlock] = []
582
616
 
617
+ async def run(call: ToolUseBlock) -> None:
618
+ results[call.id] = await self._run_one(call, results)
619
+
583
620
  for call in calls:
584
621
  if self._runs_serially(call):
585
622
  for pending in concurrent:
586
- results[pending.id] = await self._run_one(pending)
623
+ await run(pending)
587
624
  concurrent.clear()
588
- results[call.id] = await self._run_one(call)
625
+ await run(call)
589
626
  else:
590
627
  concurrent.append(call)
591
628
 
592
629
  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
630
+ await asyncio.gather(*(run(c) for c in concurrent))
596
631
 
597
- return [results[c.id] for c in calls]
632
+ def _answer_calls(
633
+ self, calls: list[ToolUseBlock], results: dict[str, ToolResultBlock]
634
+ ) -> Message:
635
+ """Append the results for ``calls``, in the order the model asked for them.
636
+
637
+ A call with no result was cut off by the user, before it started or
638
+ while it ran, and is answered as exactly that.
639
+ """
640
+ message = tool_result_message(
641
+ [results.get(call.id) or interrupted_result(call.id) for call in calls]
642
+ )
643
+ self.session.append(message)
644
+ return message
598
645
 
599
646
  def _runs_serially(self, call: ToolUseBlock) -> bool:
600
647
  try:
@@ -609,7 +656,10 @@ class AgentLoop:
609
656
  """Attribute a subagent's tool calls so they do not read as the parent's."""
610
657
  return name if self.origin is None else f"{self.origin} > {name}"
611
658
 
612
- async def _run_one(self, call: ToolUseBlock) -> ToolResultBlock:
659
+ async def _run_one(
660
+ self, call: ToolUseBlock, results: dict[str, ToolResultBlock]
661
+ ) -> ToolResultBlock:
662
+ """Run one call. Interrupted, it leaves its answer in ``results``."""
613
663
  started = time.monotonic()
614
664
 
615
665
  # PreToolUse runs before the call is announced. A hook that rewrites the
@@ -626,7 +676,40 @@ class AgentLoop:
626
676
  tool_use_id=call.id, name=self._display_name(call.name), input=call.input
627
677
  )
628
678
  )
679
+ streamed: list[str] = []
680
+ try:
681
+ return await self._run_announced(call, pre, started, streamed, results)
682
+ except asyncio.CancelledError:
683
+ if call.id in results:
684
+ # The tool had finished and only its hooks were cut off.
685
+ raise
686
+ cap = self.settings.context.tool_output_char_cap
687
+ results[call.id] = interrupted_result(call.id, "".join(streamed)[-cap:])
688
+ # The call is on screen as running; left alone it would spin on
689
+ # under the "Interrupted" notice for the rest of the session.
690
+ self.bus.publish(
691
+ ToolCallFinished(
692
+ tool_use_id=call.id,
693
+ is_error=True,
694
+ duration_ms=(time.monotonic() - started) * 1000,
695
+ summary="interrupted",
696
+ interrupted=True,
697
+ )
698
+ )
699
+ raise
629
700
 
701
+ async def _run_announced(
702
+ self,
703
+ call: ToolUseBlock,
704
+ pre: HookOutcome,
705
+ started: float,
706
+ streamed: list[str],
707
+ results: dict[str, ToolResultBlock],
708
+ ) -> ToolResultBlock:
709
+ """The part of :meth:`_run_one` after the call is on screen.
710
+
711
+ What the tool prints is kept in ``streamed`` for an interrupt to report.
712
+ """
630
713
  if pre.blocked:
631
714
  refusal = ToolResultBlock(
632
715
  tool_use_id=call.id,
@@ -657,18 +740,34 @@ class AgentLoop:
657
740
  )
658
741
  return denied
659
742
 
743
+ def progress(chunk: str) -> None:
744
+ streamed.append(chunk)
745
+ self._emit_progress(call.id, chunk)
746
+
660
747
  ctx = ToolContext(
661
748
  cwd=self.settings.cwd,
662
749
  session_id=self.session.meta.session_id,
663
750
  tool_use_id=call.id,
664
751
  settings=self.settings,
665
- emit_progress=lambda chunk: self._emit_progress(call.id, chunk),
752
+ emit_progress=progress,
666
753
  )
667
754
  result = await self.tools.call(call.name, call.input, ctx)
668
755
 
669
- post = await self._fire_hooks(
670
- lambda h: h.post_tool_use(call.name, call.input, result.content, result.is_error)
671
- )
756
+ try:
757
+ post = await self._fire_hooks(
758
+ lambda h: h.post_tool_use(call.name, call.input, result.content, result.is_error)
759
+ )
760
+ except asyncio.CancelledError:
761
+ # The tool finished and its effects are real: its own result stands,
762
+ # rather than telling the model a write that happened never did.
763
+ results[call.id] = self._finish(call, result, HookOutcome(), started)
764
+ raise
765
+ return self._finish(call, result, post, started)
766
+
767
+ def _finish(
768
+ self, call: ToolUseBlock, result: ToolResult, post: HookOutcome, started: float
769
+ ) -> ToolResultBlock:
770
+ """Announce a call the tool finished, and its result for the model."""
672
771
  content = result.content
673
772
  if post.blocked:
674
773
  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
@@ -143,6 +143,13 @@ class PersistentShell:
143
143
  if not interrupted or not await self._drain_stale_sentinel():
144
144
  await self.restart()
145
145
  yield "[hx] shell did not recover from the interrupt; restarted\n"
146
+ except asyncio.CancelledError:
147
+ # The user stopped the turn. The command must stop with it, and
148
+ # its sentinel must not be left for the next command to read -
149
+ # the same recovery as a timeout, without the output to report.
150
+ if not await self.interrupt() or not await self._drain_stale_sentinel():
151
+ await self.restart()
152
+ raise
146
153
 
147
154
  self._duration_ms = (time.monotonic() - started) * 1000
148
155
 
@@ -178,11 +185,14 @@ class PersistentShell:
178
185
  yield head
179
186
  return
180
187
 
181
- # Hold back a partial sentinel so it is never emitted as output.
182
- safe = len(buffer) - len(self._sentinel)
183
- if safe > 0:
184
- yield buffer[:safe]
185
- buffer = buffer[safe:]
188
+ # Hold back only what could be the start of the sentinel, so it is
189
+ # never emitted as output. Holding back a sentinel's length of
190
+ # anything hid a short line - "Starting..." before a long build -
191
+ # until the command ended.
192
+ held = _sentinel_prefix_at_end(buffer, self._sentinel)
193
+ if len(buffer) > held:
194
+ yield buffer[: len(buffer) - held]
195
+ buffer = buffer[len(buffer) - held :]
186
196
 
187
197
  async def _drain_stale_sentinel(self, timeout_seconds: float = 5.0) -> bool:
188
198
  """Consume output up to the sentinel owed by an interrupted command."""
@@ -564,6 +574,14 @@ class KillShellTool(Tool):
564
574
  return ToolResult(content=f"Killed {job_id}.", summary=f"killed {job_id}")
565
575
 
566
576
 
577
+ def _sentinel_prefix_at_end(buffer: str, sentinel: str) -> int:
578
+ """Length of the longest end of ``buffer`` that ``sentinel`` starts with."""
579
+ for size in range(min(len(buffer), len(sentinel) - 1), 0, -1):
580
+ if sentinel.startswith(buffer[-size:]):
581
+ return size
582
+ return 0
583
+
584
+
567
585
  def _parse_trailer(tail: str, previous: Path) -> tuple[int, Path]:
568
586
  """Read the ``<status> <pwd>`` the sentinel is followed by.
569
587
 
@@ -69,6 +69,7 @@ class ToolCall:
69
69
  finished: bool = False
70
70
  expanded: bool = False
71
71
  duration_ms: float = 0.0
72
+ interrupted: bool = False
72
73
 
73
74
 
74
75
  #: Fields of a :class:`ToolCall` carrying text somebody else wrote.
@@ -525,9 +526,10 @@ class BashRenderer(ToolRenderer):
525
526
  lines, hidden = _tail(output, self.preview_lines, call.expanded)
526
527
  if hidden:
527
528
  out.append(expand_note(hidden))
528
- role = "error" if call.is_error else "tool_output"
529
+ # Cut off, it printed what it printed: that is output, not an error.
530
+ role = "error" if call.is_error and not call.interrupted else "tool_output"
529
531
  out.extend(fg(role, line) for line in lines)
530
- if call.finished and call.duration_ms:
532
+ if call.finished and call.duration_ms and not call.interrupted:
531
533
  out.append(fg("dim", f"Took {format_duration(call.duration_ms)}"))
532
534
  return out
533
535
 
@@ -703,7 +705,7 @@ def _plain_output(call: ToolCall, limit: int) -> list[str]:
703
705
  if not call.output.strip():
704
706
  return []
705
707
  lines, hidden = _head(call.output, limit, call.expanded)
706
- role = "error" if call.is_error else "tool_output"
708
+ role = "error" if call.is_error and not call.interrupted else "tool_output"
707
709
  out = [fg(role, line) for line in lines]
708
710
  return [*out, expand_note(hidden)] if hidden else out
709
711
 
@@ -23,6 +23,7 @@ from hx.core.messages import (
23
23
  ToolResultBlock,
24
24
  ToolUseBlock,
25
25
  UserTurn,
26
+ interrupted_output,
26
27
  )
27
28
  from hx.core.usage import format_tokens
28
29
  from hx.git import BranchWatcher
@@ -628,7 +629,11 @@ class HXSession:
628
629
  is the part the reader came back for.
629
630
  """
630
631
  params = dict(call.input or {})
631
- failed = result is not None and result.is_error
632
+ # No result at all is a call cut off by a version of HX that did not
633
+ # answer interrupted calls - the same thing, said the old way.
634
+ printed = "" if result is None else interrupted_output(result)
635
+ interrupted = printed is not None
636
+ failed = interrupted or (result is not None and result.is_error)
632
637
  if call.name.lower() in SILENT_TOOLS and not failed:
633
638
  return TodoBlock(params.get("todos") or [])
634
639
  return ToolBlock(
@@ -636,11 +641,10 @@ class HXSession:
636
641
  name=call.name,
637
642
  params=params,
638
643
  cwd=Path(self.settings.cwd),
639
- output=result.content if result is not None else "",
644
+ output=result.content if result is not None and printed is None else printed or "",
640
645
  is_error=failed,
641
- # No result means the turn was interrupted mid-call, and the
642
- # block says so rather than claiming a finish that never came.
643
- finished=result is not None,
646
+ finished=True,
647
+ interrupted=interrupted,
644
648
  )
645
649
  )
646
650
 
@@ -1276,6 +1280,7 @@ class HXSession:
1276
1280
  output=event.detail or finished.call.output,
1277
1281
  metadata=event.metadata,
1278
1282
  duration_ms=event.duration_ms or 0.0,
1283
+ interrupted=event.interrupted,
1279
1284
  )
1280
1285
  if held and event.is_error:
1281
1286
  transcript.append(finished)
@@ -23,7 +23,13 @@ from hx.term.sanitize import plain_text
23
23
  from hx.tui.glyphs import IMAGE, NOTICE, SPINNER, TOOL_DONE, TOOL_FAILED
24
24
  from hx.tui.limits import PREVIEW_LINES, STREAM_TAIL
25
25
  from hx.tui.paint import ThemePainter, fg, tint
26
- from hx.tui.renderers import ToolCall, renderer_for, sanitized_call, sanitized_fields
26
+ from hx.tui.renderers import (
27
+ ToolCall,
28
+ format_duration,
29
+ renderer_for,
30
+ sanitized_call,
31
+ sanitized_fields,
32
+ )
27
33
 
28
34
  if TYPE_CHECKING:
29
35
  from hx.core.messages import ImageBlock
@@ -237,6 +243,8 @@ class ToolBlock(Widget):
237
243
  def _state(self) -> str:
238
244
  if not self._call.finished:
239
245
  return "running"
246
+ if self._call.interrupted:
247
+ return "interrupted"
240
248
  return "error" if self._call.is_error else "done"
241
249
 
242
250
  def draw(self, width: int) -> list[str]:
@@ -246,6 +254,8 @@ class ToolBlock(Widget):
246
254
  "running": (SPINNER[self._frame], "accent", "tool_pending_bg"),
247
255
  "done": (TOOL_DONE, "success", "tool_success_bg"),
248
256
  "error": (TOOL_FAILED, "error", "tool_error_bg"),
257
+ # Stopped, not failed: the warning the "Interrupted" line is drawn in.
258
+ "interrupted": (TOOL_FAILED, "warning", "tool_pending_bg"),
249
259
  }[state]
250
260
 
251
261
  renderer = renderer_for(self._call.name)
@@ -253,6 +263,12 @@ class ToolBlock(Widget):
253
263
  # The body is indented to sit under the header's text rather than under
254
264
  # its marker, so the block has one left edge instead of two.
255
265
  lines += [f" {line}" for line in renderer.body(self._call)]
266
+ if self._call.interrupted:
267
+ took = self._call.duration_ms
268
+ lines.append(
269
+ " "
270
+ + fg("warning", f"Interrupted{f' after {format_duration(took)}' if took else ''}")
271
+ )
256
272
 
257
273
  return Box(1, 1, tint(background), Lines(lines, padding_x=0)).render(width)
258
274
 
@@ -160,6 +160,22 @@ def _text(content: Any) -> str:
160
160
  return ""
161
161
 
162
162
 
163
+ def _unanswered_tool_calls(messages: list[dict[str, Any]]) -> list[str]:
164
+ """Tool call ids not answered by the ``tool`` messages that follow their call."""
165
+ unanswered: list[str] = []
166
+ for index, message in enumerate(messages):
167
+ ids = [c.get("id", "") for c in message.get("tool_calls") or []]
168
+ if not ids:
169
+ continue
170
+ answered: set[str] = set()
171
+ for reply in messages[index + 1 :]:
172
+ if reply.get("role") != "tool":
173
+ break
174
+ answered.add(str(reply.get("tool_call_id")))
175
+ unanswered.extend(i for i in ids if i not in answered)
176
+ return unanswered
177
+
178
+
163
179
  class Stub:
164
180
  """The server, and the script it plays."""
165
181
 
@@ -263,6 +279,23 @@ class Stub:
263
279
  stub.title_requests.append(request)
264
280
  self._stream(Reply(text=stub.title, prompt_tokens=300, completion_tokens=5))
265
281
  return
282
+ if unanswered := _unanswered_tool_calls(request.messages):
283
+ # Every upstream rejects a tool call left without its
284
+ # result - OpenAI, Anthropic and the Codex Responses
285
+ # backend alike - so the stub does too, in OpenAI's words.
286
+ stub.unexpected.append(f"tool calls without results: {unanswered}")
287
+ self._json(
288
+ 400,
289
+ {
290
+ "error": {
291
+ "message": "An assistant message with 'tool_calls' must be "
292
+ "followed by tool messages responding to each "
293
+ "'tool_call_id'. The following tool_call_ids did not have "
294
+ f"response messages: {', '.join(unanswered)}"
295
+ }
296
+ },
297
+ )
298
+ return
266
299
  with stub._arrived:
267
300
  stub.requests.append(request)
268
301
  reply = stub._script.popleft() if stub._script else None
@@ -368,6 +368,11 @@ class Terminal:
368
368
  assert self.exit_code is not None
369
369
  return self.exit_code
370
370
 
371
+ def kill(self) -> None:
372
+ """SIGKILL: the process dies with no chance to tidy up, as in a crash."""
373
+ os.kill(self.pid, signal.SIGKILL)
374
+ self.wait_exit(3)
375
+
371
376
  def close(self) -> None:
372
377
  if self.alive:
373
378
  with contextlib.suppress(ProcessLookupError):
@@ -378,8 +383,12 @@ class Terminal:
378
383
  with contextlib.suppress(ProcessLookupError):
379
384
  os.kill(self.pid, signal.SIGKILL)
380
385
  self.wait_exit(3)
381
- with contextlib.suppress(OSError):
382
- os.close(self.fd)
386
+ # Once only: a closed descriptor's number goes to the next terminal
387
+ # opened, and closing it again would close that one's pty under it.
388
+ if self.fd >= 0:
389
+ with contextlib.suppress(OSError):
390
+ os.close(self.fd)
391
+ self.fd = -1
383
392
 
384
393
  # -- the report ------------------------------------------------------
385
394