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.
- {hx_cli-0.2.10 → hx_cli-0.2.11}/CHANGELOG.md +20 -1
- {hx_cli-0.2.10 → hx_cli-0.2.11}/PKG-INFO +1 -1
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/events.py +3 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/loop.py +118 -19
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/messages.py +58 -1
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/bash.py +23 -5
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/renderers.py +5 -3
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/runtime.py +10 -5
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/blocks.py +17 -1
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/stub.py +33 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/terminal.py +11 -2
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_tools.py +91 -1
- {hx_cli-0.2.10 → hx_cli-0.2.11}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/.gitignore +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/README.md +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/install.sh +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/pyproject.toml +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/cli.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/config.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/docs.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/git.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/keys.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/net.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/paths.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/trace.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/commands.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/pickers.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/conftest.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_commands.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/test_live.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.10 → hx_cli-0.2.11}/tests/test_live_tavily.py +0 -0
- {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.
|
|
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
|
|
@@ -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 =
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
623
|
+
await run(pending)
|
|
587
624
|
concurrent.clear()
|
|
588
|
-
|
|
625
|
+
await run(call)
|
|
589
626
|
else:
|
|
590
627
|
concurrent.append(call)
|
|
591
628
|
|
|
592
629
|
if concurrent:
|
|
593
|
-
|
|
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
|
-
|
|
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(
|
|
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=
|
|
752
|
+
emit_progress=progress,
|
|
666
753
|
)
|
|
667
754
|
result = await self.tools.call(call.name, call.input, ctx)
|
|
668
755
|
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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
|
|
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
|
-
|
|
382
|
-
|
|
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
|
|