scootcli 0.8.0__tar.gz → 0.9.0__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 (121) hide show
  1. {scootcli-0.8.0/src/scootcli.egg-info → scootcli-0.9.0}/PKG-INFO +9 -6
  2. {scootcli-0.8.0 → scootcli-0.9.0}/README.md +8 -5
  3. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/agent.py +82 -11
  5. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/cli.py +6 -4
  6. scootcli-0.9.0/src/scootcli/commands/model.py +115 -0
  7. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/status.py +12 -1
  8. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/worktree.py +9 -4
  9. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/config.py +31 -9
  10. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/headless.py +2 -0
  11. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/hooks.py +16 -10
  12. scootcli-0.9.0/src/scootcli/preferences.py +144 -0
  13. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/anthropic.py +7 -3
  14. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/base.py +15 -1
  15. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/openai_chat.py +2 -0
  16. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/openai_responses.py +7 -4
  17. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/repl.py +30 -4
  18. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/sessions.py +29 -0
  19. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/base.py +119 -16
  20. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/edit_file.py +25 -12
  21. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/write_file.py +11 -5
  22. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/transport.py +27 -10
  23. scootcli-0.9.0/src/scootcli/worktree.py +163 -0
  24. {scootcli-0.8.0 → scootcli-0.9.0/src/scootcli.egg-info}/PKG-INFO +9 -6
  25. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_agent.py +68 -0
  26. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_anthropic.py +23 -0
  27. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_hooks.py +24 -0
  28. scootcli-0.9.0/tests/test_preferences.py +266 -0
  29. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_sessions.py +33 -0
  30. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_streaming.py +53 -0
  31. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_tools.py +112 -0
  32. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_transport_native.py +54 -0
  33. scootcli-0.9.0/tests/test_worktree.py +197 -0
  34. scootcli-0.8.0/src/scootcli/commands/model.py +0 -65
  35. scootcli-0.8.0/src/scootcli/preferences.py +0 -87
  36. scootcli-0.8.0/src/scootcli/worktree.py +0 -114
  37. scootcli-0.8.0/tests/test_preferences.py +0 -132
  38. scootcli-0.8.0/tests/test_worktree.py +0 -92
  39. {scootcli-0.8.0 → scootcli-0.9.0}/LICENSE +0 -0
  40. {scootcli-0.8.0 → scootcli-0.9.0}/pyproject.toml +0 -0
  41. {scootcli-0.8.0 → scootcli-0.9.0}/setup.cfg +0 -0
  42. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/__main__.py +0 -0
  43. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/activity.py +0 -0
  44. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/approvals.py +0 -0
  45. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/auth.py +0 -0
  46. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/clipboard.py +0 -0
  47. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/__init__.py +0 -0
  48. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/approve.py +0 -0
  49. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/auth.py +0 -0
  50. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/base.py +0 -0
  51. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/compact.py +0 -0
  52. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/copy.py +0 -0
  53. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/exit.py +0 -0
  54. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/forget.py +0 -0
  55. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/help.py +0 -0
  56. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/hooks.py +0 -0
  57. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/init.py +0 -0
  58. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/logo.py +0 -0
  59. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/panel.py +0 -0
  60. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/reset.py +0 -0
  61. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/resume.py +0 -0
  62. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/route.py +0 -0
  63. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/save.py +0 -0
  64. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/scope.py +0 -0
  65. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/sessions.py +0 -0
  66. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/verbosity.py +0 -0
  67. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/yolo.py +0 -0
  68. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/context.py +0 -0
  69. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/credentials.py +0 -0
  70. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/errors.py +0 -0
  71. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/images.py +0 -0
  72. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/keys.py +0 -0
  73. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/lineeditor.py +0 -0
  74. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/logo.py +0 -0
  75. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/models.py +0 -0
  76. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/panel.py +0 -0
  77. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/presets.py +0 -0
  78. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/pricing.py +0 -0
  79. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/project.py +0 -0
  80. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/prompts.py +0 -0
  81. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/__init__.py +0 -0
  82. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/registry.py +0 -0
  83. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/router.py +0 -0
  84. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/rendering.py +0 -0
  85. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/resize.py +0 -0
  86. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/status.py +0 -0
  87. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/__init__.py +0 -0
  88. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/list_dir.py +0 -0
  89. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/open_editor.py +0 -0
  90. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/read_file.py +0 -0
  91. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/run_shell.py +0 -0
  92. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/search.py +0 -0
  93. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/update_plan.py +0 -0
  94. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/updates.py +0 -0
  95. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/vision.py +0 -0
  96. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/workspace.py +0 -0
  97. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli.egg-info/SOURCES.txt +0 -0
  98. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  99. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  100. {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli.egg-info/top_level.txt +0 -0
  101. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_approvals.py +0 -0
  102. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_auth.py +0 -0
  103. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_clipboard.py +0 -0
  104. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_config.py +0 -0
  105. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_headless.py +0 -0
  106. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_images.py +0 -0
  107. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_lineeditor.py +0 -0
  108. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_logo.py +0 -0
  109. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_models.py +0 -0
  110. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_panel.py +0 -0
  111. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_providers.py +0 -0
  112. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_readiness.py +0 -0
  113. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_repl.py +0 -0
  114. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_resilience.py +0 -0
  115. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_resize.py +0 -0
  116. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_router.py +0 -0
  117. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_scope.py +0 -0
  118. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_small_060.py +0 -0
  119. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_smoke.py +0 -0
  120. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_vision.py +0 -0
  121. {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -115,8 +115,9 @@ scoot --model openai/gpt-5.3-codex "..."
115
115
  scoot --model auto "..." # pick a model per prompt from the live list (cheap for trivial, strong for edits)
116
116
  ```
117
117
 
118
- Inside the REPL, `/model <provider/model>` switches and is remembered for the next launch; `/model default` goes back to the provider's preferred model.
119
- Starting the REPL with `--model` is remembered the same way, so `scoot --model ollama/llama3.2` once and plain `scoot` afterwards keep using it; a one-shot `scoot --model X "prompt"` does not change the saved choice.
118
+ Inside the REPL, `/model <provider/model>` switches and is remembered for the folder you are in, so each project can have its own model; `/model default` goes back to the provider's preferred model.
119
+ Starting the REPL with `--model` is remembered the same way, so `scoot --model ollama/llama3.2` once and plain `scoot` afterwards keep using it there; a one-shot `scoot --model X "prompt"` does not change the saved choice.
120
+ `/model X everywhere` makes `X` the model for every folder that has no choice of its own, `/model forget` drops the current folder's choice so it follows that, and `/status` says where the active model came from.
120
121
 
121
122
  ### Routing
122
123
 
@@ -171,7 +172,7 @@ The most useful settings (see [`.env.example`](./.env.example) for all of them):
171
172
  |---|---|---|
172
173
  | `OPENAI_API_KEY` | OpenAI key (or `scoot auth set openai`) | |
173
174
  | `SCOOT_PROVIDER` | default provider for bare model names | first with a key, else `ollama` |
174
- | `SCOOT_MODEL` | `default`, `auto`, or `provider/model` | `default` |
175
+ | `SCOOT_MODEL` | `default`, `auto`, or `provider/model`; overrides the saved per-folder and global choices | `default` |
175
176
  | `SCOOT_EFFORT` | reasoning effort | `medium` |
176
177
  | `SCOOT_APPROVAL` | `always` · `auto-read` · `auto-edits` · `yolo` | `yolo` |
177
178
  | `SCOOT_MAX_STEPS` | tool-call steps per turn before asking to continue | `50` |
@@ -238,7 +239,8 @@ When a call needs approval you can approve once `[a]`, trust that tool for the s
238
239
  `/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
239
240
  Catastrophic shell commands (a denylist: `rm -rf /`, `git push --force`, piping downloads into a shell, and so on) are re-confirmed in every mode.
240
241
 
241
- For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge` or `/worktree discard` when done.
242
+ For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge`, `/worktree keep`, or `/worktree discard` when done.
243
+ A commit or merge that fails keeps the worktree and your files; nothing is removed by force except by `discard`.
242
244
 
243
245
  ### Streaming, images, sessions
244
246
 
@@ -313,7 +315,8 @@ python -m pytest -q # network-free suite
313
315
  ./scripts/build.sh # dist/scoot.pyz + wheel + sdist
314
316
  ```
315
317
 
316
- Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](./SPEC.md), what is next in [`ROADMAP.md`](./ROADMAP.md), and the release history in [`CHANGELOG.md`](./CHANGELOG.md).
318
+ Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](SPEC.md), what is next in [`ROADMAP.md`](./ROADMAP.md), and the release history in [`CHANGELOG.md`](./CHANGELOG.md).
319
+ Want to build a CLI agent like this yourself? [`docs/blueprint.md`](./docs/blueprint.md) is the minimal spec: the components, the turn, the tools, the permission rules, and the acceptance checks, with no dependency on scoot's code.
317
320
  New capability is a drop-in file: a tool in `tools/`, a slash command in `commands/`, a provider row or adapter in `providers/`.
318
321
 
319
322
  ## Security
@@ -90,8 +90,9 @@ scoot --model openai/gpt-5.3-codex "..."
90
90
  scoot --model auto "..." # pick a model per prompt from the live list (cheap for trivial, strong for edits)
91
91
  ```
92
92
 
93
- Inside the REPL, `/model <provider/model>` switches and is remembered for the next launch; `/model default` goes back to the provider's preferred model.
94
- Starting the REPL with `--model` is remembered the same way, so `scoot --model ollama/llama3.2` once and plain `scoot` afterwards keep using it; a one-shot `scoot --model X "prompt"` does not change the saved choice.
93
+ Inside the REPL, `/model <provider/model>` switches and is remembered for the folder you are in, so each project can have its own model; `/model default` goes back to the provider's preferred model.
94
+ Starting the REPL with `--model` is remembered the same way, so `scoot --model ollama/llama3.2` once and plain `scoot` afterwards keep using it there; a one-shot `scoot --model X "prompt"` does not change the saved choice.
95
+ `/model X everywhere` makes `X` the model for every folder that has no choice of its own, `/model forget` drops the current folder's choice so it follows that, and `/status` says where the active model came from.
95
96
 
96
97
  ### Routing
97
98
 
@@ -146,7 +147,7 @@ The most useful settings (see [`.env.example`](./.env.example) for all of them):
146
147
  |---|---|---|
147
148
  | `OPENAI_API_KEY` | OpenAI key (or `scoot auth set openai`) | |
148
149
  | `SCOOT_PROVIDER` | default provider for bare model names | first with a key, else `ollama` |
149
- | `SCOOT_MODEL` | `default`, `auto`, or `provider/model` | `default` |
150
+ | `SCOOT_MODEL` | `default`, `auto`, or `provider/model`; overrides the saved per-folder and global choices | `default` |
150
151
  | `SCOOT_EFFORT` | reasoning effort | `medium` |
151
152
  | `SCOOT_APPROVAL` | `always` · `auto-read` · `auto-edits` · `yolo` | `yolo` |
152
153
  | `SCOOT_MAX_STEPS` | tool-call steps per turn before asking to continue | `50` |
@@ -213,7 +214,8 @@ When a call needs approval you can approve once `[a]`, trust that tool for the s
213
214
  `/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
214
215
  Catastrophic shell commands (a denylist: `rm -rf /`, `git push --force`, piping downloads into a shell, and so on) are re-confirmed in every mode.
215
216
 
216
- For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge` or `/worktree discard` when done.
217
+ For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge`, `/worktree keep`, or `/worktree discard` when done.
218
+ A commit or merge that fails keeps the worktree and your files; nothing is removed by force except by `discard`.
217
219
 
218
220
  ### Streaming, images, sessions
219
221
 
@@ -288,7 +290,8 @@ python -m pytest -q # network-free suite
288
290
  ./scripts/build.sh # dist/scoot.pyz + wheel + sdist
289
291
  ```
290
292
 
291
- Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](./SPEC.md), what is next in [`ROADMAP.md`](./ROADMAP.md), and the release history in [`CHANGELOG.md`](./CHANGELOG.md).
293
+ Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](SPEC.md), what is next in [`ROADMAP.md`](./ROADMAP.md), and the release history in [`CHANGELOG.md`](./CHANGELOG.md).
294
+ Want to build a CLI agent like this yourself? [`docs/blueprint.md`](./docs/blueprint.md) is the minimal spec: the components, the turn, the tools, the permission rules, and the acceptance checks, with no dependency on scoot's code.
292
295
  New capability is a drop-in file: a tool in `tools/`, a slash command in `commands/`, a provider row or adapter in `providers/`.
293
296
 
294
297
  ## Security
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.0"
4
4
 
@@ -27,13 +27,20 @@ from .tools.base import ToolContext, ToolResult
27
27
 
28
28
  @dataclass
29
29
  class AgentOutcome:
30
- status: str # "done" | "interrupted" | "aborted" | "max_steps" | "error"
30
+ status: str # "done" | "incomplete" | "interrupted" | "aborted" | "max_steps" | "error"
31
31
  content: str = ""
32
32
  error: str = ""
33
33
  steps: int = 0
34
34
  streamed: bool = False # True if the final content was already printed live (streaming)
35
35
 
36
36
 
37
+ # finish reasons that mean the reply is not the model's whole answer, and what to tell the user.
38
+ _CUT_OFF = {
39
+ "length": "the model hit its output limit",
40
+ "incomplete": "the stream ended before the reply was complete",
41
+ }
42
+
43
+
37
44
  def _strip_done(text: str) -> str:
38
45
  """Remove a trailing ``DONE`` sentinel line from the model's final message."""
39
46
  lines = (text or "").rstrip().splitlines()
@@ -104,6 +111,7 @@ class Agent:
104
111
  steps = 0
105
112
  compacted = False
106
113
  stop_blocks = 0
114
+ cut_off = 0 # tool batches the model could not finish (output limit); bounded retries
107
115
  while True:
108
116
  if cancel_event.is_set():
109
117
  return AgentOutcome("interrupted", steps=steps)
@@ -145,14 +153,28 @@ class Agent:
145
153
  if result.tool_calls:
146
154
  if (result.content or "").strip() and not streamed:
147
155
  ui.assistant(result.content.strip())
156
+ if result.finish_reason in _CUT_OFF:
157
+ # The reply stopped before the calls were complete: their arguments cannot be
158
+ # trusted, so nothing runs. Tell the model once or twice, then give up.
159
+ cut_off += 1
160
+ self._close_batch(session, result.tool_calls,
161
+ "not executed: the reply was cut off before this tool call was "
162
+ "complete; call it again with less in one call")
163
+ if cut_off >= 2:
164
+ return AgentOutcome("incomplete", steps=steps, error=_CUT_OFF[result.finish_reason])
165
+ ui.assistant(f"reply cut off ({_CUT_OFF[result.finish_reason]}); asking the model to retry.")
166
+ continue
148
167
  signal = self._run_tools(session, result.tool_calls, ui, cancel_event, cfg)
149
168
  if signal in ("abort", "interrupted"):
150
169
  status = "aborted" if signal == "abort" else "interrupted"
151
170
  return AgentOutcome(status, steps=steps)
152
171
  continue
153
172
 
154
- # No tool calls -> the model is done, unless a Stop hook asks for more (bounded).
173
+ # No tool calls -> the model is done, unless it was cut off or a Stop hook asks for more (bounded).
155
174
  content = _strip_done(result.content)
175
+ if result.finish_reason in _CUT_OFF:
176
+ return AgentOutcome("incomplete", content=content, steps=steps, streamed=streamed,
177
+ error=_CUT_OFF[result.finish_reason])
156
178
  if stop_blocks < 3:
157
179
  reason = self._stop_hook(session, content, steps)
158
180
  if reason:
@@ -344,16 +366,48 @@ class Agent:
344
366
  )
345
367
 
346
368
  @staticmethod
347
- def _parse_args(raw) -> dict:
369
+ def _tool_payload(result: ToolResult) -> str:
370
+ """What the model reads back. On failure the error line comes first, followed by whatever
371
+ the tool captured: a failing test's or compiler's output is exactly what the model needs
372
+ next, and ``ERROR: exit 1`` alone only invites a blind rerun."""
373
+ if result.ok:
374
+ return result.content or "(no output)"
375
+ head = f"ERROR: {result.error or 'failed'}"
376
+ body = (result.content or "").strip()
377
+ if body.startswith(result.error or "\0"):
378
+ body = body[len(result.error):].lstrip("\n")
379
+ return f"{head}\n{body}" if body else head
380
+
381
+ @staticmethod
382
+ def _parse_args(raw) -> Optional[dict]:
383
+ """The call's arguments as a dict; ``None`` when they are not a JSON object (a truncated or
384
+ malformed call must not run as if it had no arguments)."""
348
385
  if isinstance(raw, dict):
349
386
  return raw
350
387
  if not raw:
351
388
  return {}
352
389
  try:
353
390
  parsed = json.loads(raw)
354
- return parsed if isinstance(parsed, dict) else {}
355
391
  except (json.JSONDecodeError, TypeError):
356
- return {}
392
+ return None
393
+ return parsed if isinstance(parsed, dict) else None
394
+
395
+ @staticmethod
396
+ def _close_batch(session, tool_calls, note: str) -> None:
397
+ """Give every call in the batch that has no result yet a result saying why.
398
+
399
+ Called on abort, interrupt, and cut-off replies: a history with an unanswered tool call is
400
+ rejected by providers on the next request, so the turn must never leave one behind.
401
+ """
402
+ answered = set()
403
+ for m in reversed(session.messages):
404
+ if m.get("role") != "tool":
405
+ break
406
+ answered.add(m.get("tool_call_id"))
407
+ for tc in tool_calls:
408
+ tc_id = tc.get("id", "")
409
+ if tc_id not in answered:
410
+ session.messages.append({"role": "tool", "tool_call_id": tc_id, "content": note})
357
411
 
358
412
  # ── tool execution ───────────────────────────────────────────────────────────
359
413
  def _run_tools(self, session, tool_calls, ui, cancel_event, cfg=None) -> Optional[str]:
@@ -379,7 +433,7 @@ class Agent:
379
433
  pass
380
434
  for tc in tool_calls:
381
435
  if cancel_event.is_set():
382
- return "interrupted"
436
+ return self._interrupted(session, tool_calls)
383
437
  tc_id = tc.get("id", "")
384
438
  fn = tc.get("function", {}) or {}
385
439
  name = fn.get("name", "")
@@ -389,6 +443,11 @@ class Agent:
389
443
  if tool is None:
390
444
  self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
391
445
  continue
446
+ if args is None:
447
+ self._append_tool(session, tc_id, "error: the arguments are not a JSON object (the call may "
448
+ "have been cut off); call the tool again")
449
+ ui.tool_result(name, ToolResult(ok=False, summary="malformed arguments"))
450
+ continue
392
451
 
393
452
  # Paths outside the workspace: ask the user once (this path, its directory, or anywhere).
394
453
  outside = self._outside_paths(tool, args, ctx)
@@ -396,13 +455,16 @@ class Agent:
396
455
  verdict = self._ask_scope(session, tool, outside, ctx, ui)
397
456
  if verdict == "abort":
398
457
  self._append_tool(session, tc_id, "user aborted the operation")
399
- return "abort"
458
+ return self._aborted(session, tool_calls)
400
459
  if verdict == "deny":
401
460
  self._append_tool(session, tc_id, f"user declined access outside the workspace: {outside[0]}")
402
461
  ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
403
462
  continue
404
463
  # A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
405
464
  hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
465
+ if cancel_event.is_set(): # ESC while the hook ran
466
+ self._append_tool(session, tc_id, "not executed: the user interrupted the turn")
467
+ return self._interrupted(session, tool_calls)
406
468
  if hook_action == "deny":
407
469
  self._append_tool(session, tc_id, f"user declined via hook: {hook_reason or 'no reason given'}")
408
470
  ui.tool_result(name, ToolResult(ok=False, summary=f"denied by hook: {hook_reason}"[:80]))
@@ -423,7 +485,7 @@ class Agent:
423
485
  approval = ui.approve(tool, args, ctx)
424
486
  if approval.decision == Decision.ABORT:
425
487
  self._append_tool(session, tc_id, "user aborted the operation")
426
- return "abort"
488
+ return self._aborted(session, tool_calls)
427
489
  if approval.decision == Decision.SKIP:
428
490
  self._append_tool(
429
491
  session, tc_id, "user declined this action; consider another approach"
@@ -442,16 +504,25 @@ class Agent:
442
504
  with ui.activity(f"running {name}…", cancel_event):
443
505
  result = tool.run(args, ctx)
444
506
  except Interrupted:
445
- return "interrupted"
507
+ self._append_tool(session, tc_id, "interrupted by the user while running; it may have "
508
+ "partially completed, check before repeating it")
509
+ return self._interrupted(session, tool_calls)
446
510
  except Exception as exc: # a tool must never take down the loop
447
511
  result = ToolResult.fail(f"tool crashed: {exc}")
448
512
 
449
513
  self._handle_result(session, name, result, ui)
450
514
  self._post_tool_hook(session, tool, args, result)
451
- payload = result.content if result.ok else f"ERROR: {result.error}"
452
- self._append_tool(session, tc_id, payload or "(no output)")
515
+ self._append_tool(session, tc_id, self._tool_payload(result))
453
516
  return None
454
517
 
518
+ def _aborted(self, session, tool_calls) -> str:
519
+ self._close_batch(session, tool_calls, "not executed: the user aborted the turn")
520
+ return "abort"
521
+
522
+ def _interrupted(self, session, tool_calls) -> str:
523
+ self._close_batch(session, tool_calls, "not executed: the user interrupted the turn")
524
+ return "interrupted"
525
+
455
526
  @staticmethod
456
527
  def _outside_paths(tool, args, ctx) -> "list":
457
528
  from .tools.base import resolve_path
@@ -210,6 +210,8 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
210
210
  f"completion={u.get('completion_tokens', '?')} "
211
211
  f"cost={fmt(session.session_cost())}", "gray"))
212
212
  return 0
213
+ if outcome.status == "incomplete" and outcome.content.strip() and not outcome.streamed:
214
+ print(outcome.content.strip())
213
215
  eprint(color(f"⚠ {redact(outcome.error or outcome.status)}", "red"))
214
216
  return 1
215
217
 
@@ -229,9 +231,9 @@ def _interactive(pool: ProviderPool, resume=None, remember_model: Optional[str]
229
231
  """Launch the persistent REPL (banner, live status, ESC-interrupt, slash-commands).
230
232
 
231
233
  ``remember_model`` is the model named on the command line (``--model``), if any. Launching the REPL
232
- with it counts as choosing it, exactly like ``/model`` inside the REPL, so it is saved as the
233
- preference and a plain ``scoot`` next time starts on the same model. One-shot prompts and headless
234
- runs do not save it: a flag on a single command is not a choice for the next session.
234
+ with it counts as choosing it for this folder, exactly like ``/model`` inside the REPL, so a plain
235
+ ``scoot`` here next time starts on the same model. One-shot prompts and headless runs do not save
236
+ it: a flag on a single command is not a choice for the next session.
235
237
  """
236
238
  from .repl import Repl
237
239
 
@@ -239,7 +241,7 @@ def _interactive(pool: ProviderPool, resume=None, remember_model: Optional[str]
239
241
  if remember_model and remember_model.strip():
240
242
  from .preferences import set_model
241
243
 
242
- set_model(remember_model.strip())
244
+ set_model(remember_model.strip(), root=config.root)
243
245
  # resume=auto (SCOOT_RESUME / --resume-last): reload the latest session for this directory
244
246
  # unless the user already picked one explicitly via --resume/--continue.
245
247
  if resume is None and getattr(config, "resume", "hint") == "auto":
@@ -0,0 +1,115 @@
1
+ """/model: list available models or switch the active one.
2
+
3
+ Models are ``provider/model`` ids (``openai/gpt-5.3-codex``, ``ollama/llama3.2``); a bare name means
4
+ the default provider. A switch made here is **persisted** (``~/.config/scoot/preferences.json``) for
5
+ this folder, so the next launch here reuses it; ``/model X everywhere`` saves it for every folder
6
+ without a choice of its own, and ``/model forget`` drops this folder's choice so it follows that. An
7
+ explicit ``--model`` flag or ``SCOOT_MODEL`` still overrides it for that run.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .. import preferences
13
+ from ..providers.base import qualify, split_model_id
14
+ from ..rendering import color
15
+ from . import register
16
+ from .base import SlashCommand
17
+
18
+ _USAGE = "/model <provider/model> · /model default · /model auto (pick per task) · add 'everywhere' for all folders · /model forget"
19
+
20
+
21
+ def _root(session):
22
+ return getattr(getattr(session, "config", None), "root", None)
23
+
24
+
25
+ def _saved_line(session) -> str:
26
+ folder, everywhere = preferences.saved_models(_root(session))
27
+ return f"saved: this folder {folder or '(none)'} · everywhere {everywhere or '(none)'}"
28
+
29
+
30
+ def _remember(session, name: str, everywhere: bool) -> str:
31
+ """Persist ``name`` for this folder (or every folder); return how to say so."""
32
+ root = _root(session)
33
+ if everywhere:
34
+ preferences.set_model_everywhere(name, root=root)
35
+ _note_origin(session, name, preferences.EVERYWHERE)
36
+ return "saved for every folder"
37
+ preferences.set_model(name, root=root)
38
+ _note_origin(session, name, preferences.FOLDER)
39
+ return "saved for this folder"
40
+
41
+
42
+ def _note_origin(session, name: str, source: str) -> None:
43
+ """Keep ``config.model_source`` truthful after a switch, so ``/status`` names the right layer."""
44
+ cfg = getattr(session, "config", None)
45
+ if cfg is not None and hasattr(cfg, "override"):
46
+ session.config = cfg.override(model=name, model_source=source)
47
+
48
+
49
+ def _run(session, args: str):
50
+ words = args.split()
51
+ everywhere = False
52
+ if words and words[-1].lower() == "everywhere":
53
+ everywhere = True
54
+ words = words[:-1]
55
+ arg = " ".join(words)
56
+ available = session.available_models()
57
+
58
+ if not arg:
59
+ print(color(f"models (preference: {session.model} → active: {session.active_model})", "bold"))
60
+ print(color(f" {_saved_line(session)}", "gray"))
61
+ current = ""
62
+ for mid in available:
63
+ head, _ = split_model_id(mid)
64
+ if head != current:
65
+ current = head
66
+ print(color(f" {head}", "bold"))
67
+ mark = color(" ← active", "green") if mid == session.active_model else ""
68
+ print(" " + color(mid, "cyan") + mark)
69
+ errors = getattr(getattr(session, "provider", None), "list_errors", None) or {}
70
+ for name, err in sorted(errors.items()):
71
+ print(color(f" {name}: unavailable ({err})", "yellow"))
72
+ print(color(f" use: {_USAGE}", "gray"))
73
+ return
74
+
75
+ if arg == "forget":
76
+ preferences.clear_model(root=_root(session))
77
+ session.model = preferences.get_model(_root(session)) or "default"
78
+ session.active_model = session.resolved_model()
79
+ _note_origin(session, session.model, preferences.model_source(_root(session)) or "default")
80
+ print(color(f"this folder's model choice forgotten; now {session.model} → {session.active_model}. "
81
+ f"{_saved_line(session)}", "gray"))
82
+ if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
83
+ print(color(session.setup_message, "yellow"))
84
+ return
85
+
86
+ if arg in ("auto", "default"):
87
+ session.model = arg
88
+ session.active_model = session.resolved_model()
89
+ how = _remember(session, arg, everywhere)
90
+ what = "picked per task from the live list" if arg == "auto" else f"the provider's preferred model, now {session.active_model}"
91
+ print(color(f"model preference set to {arg} ({what}); {how}.", "gray"))
92
+ if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
93
+ print(color(session.setup_message, "yellow"))
94
+ return
95
+
96
+ if arg not in available:
97
+ head, _ = split_model_id(arg)
98
+ if head is None:
99
+ try:
100
+ arg = qualify(session.provider.default_name, arg)
101
+ except Exception:
102
+ pass
103
+ if arg not in available:
104
+ print(color(f"warning: '{arg}' is not in the models list; setting it anyway.", "yellow"))
105
+ session.model = arg
106
+ session.active_model = arg
107
+ how = _remember(session, arg, everywhere)
108
+ print(color(f"model set to {arg}; {how}.", "gray"))
109
+ if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
110
+ print(color(session.setup_message, "yellow"))
111
+
112
+
113
+ register(SlashCommand("model", "list or switch model (default|auto|provider/model), per folder or everywhere",
114
+ _run, usage="[provider/model|default|auto] [everywhere] | forget"))
115
+
@@ -7,6 +7,16 @@ from . import register
7
7
  from .base import SlashCommand
8
8
 
9
9
 
10
+ _ORIGINS = {"flag": "--model", "env": "SCOOT_MODEL", "folder": "saved for this folder",
11
+ "everywhere": "saved for every folder", "default": "the default"}
12
+
13
+
14
+ def _model_origin(session) -> str:
15
+ """``" (from --model)"`` and the like: which layer chose the model this run started with."""
16
+ src = getattr(getattr(session, "config", None), "model_source", "")
17
+ return f" (from {_ORIGINS[src]})" if src in _ORIGINS else ""
18
+
19
+
10
20
  def _run(session, args: str):
11
21
  cfg = session.config
12
22
  try:
@@ -20,7 +30,8 @@ def _run(session, args: str):
20
30
  provider_row = getattr(cfg, "provider", "") or "?"
21
31
  rows = [
22
32
  ("provider", provider_row),
23
- ("model", session.active_model + (" (auto)" if session.model.lower() == "auto" else "")),
33
+ ("model", session.active_model + (" (auto)" if session.model.lower() == "auto" else "")
34
+ + _model_origin(session)),
24
35
  ("effort", getattr(cfg, "effort", "medium")),
25
36
  ("env files", ", ".join(getattr(cfg, "env_files", ()) or ()) or "none"),
26
37
  ("update", (f"{session.update_available} available: pipx upgrade scootcli"
@@ -25,7 +25,7 @@ def _start(session):
25
25
  eprint(color(f"could not create worktree: {exc}", "red"))
26
26
  return
27
27
  session.worktree = w
28
- session.config = session.config.override(root=str(w.path))
28
+ session.switch_root(w.path)
29
29
  print(color(f"⚙ isolated in worktree {w.branch}", "cyan"))
30
30
  print(color(f" root → {w.path}", "gray"))
31
31
  print(color(" work freely (try /yolo); then /worktree merge|keep|discard", "gray"))
@@ -36,10 +36,15 @@ def _finish(session, action):
36
36
  if w is None:
37
37
  print(color("not in a worktree.", "yellow"))
38
38
  return
39
- msg = wt.finish(w, action)
40
- session.config = session.config.override(root=str(w.original_root))
39
+ result = wt.finish(w, action)
40
+ print(color(result.message, "gray" if result.ok else "yellow"))
41
+ if result.retained:
42
+ # The work is still in the worktree; the session stays there so nothing is lost.
43
+ print(color(f" still in worktree {w.branch} at {w.path}", "gray"))
44
+ print(color(f" fix it and run /worktree {action} again, or /worktree discard to drop it", "gray"))
45
+ return
46
+ session.switch_root(w.original_root)
41
47
  session.worktree = None
42
- print(color(f"{msg}", "gray"))
43
48
  print(color(f" root → {w.original_root}", "gray"))
44
49
 
45
50
 
@@ -2,6 +2,9 @@
2
2
 
3
3
  Precedence (highest wins):
4
4
  CLI flag → environment variable → project ``.env`` → global ``~/.config/scoot/.env`` → default.
5
+ The model has two more layers between the ``.env`` files and the default: the model saved for this
6
+ workspace root, then the one saved for every folder (see ``preferences.py``); ``model_source`` says
7
+ which layer answered.
5
8
 
6
9
  Two optional ``.env`` files are read: the global one in scoot's config home, and the nearest ``.env``
7
10
  found walking up from the current directory (so a repo-root file applies from any subdirectory).
@@ -129,16 +132,23 @@ def is_model_alias(model: Optional[str]) -> bool:
129
132
  return (model or "").strip().lower() in (DEFAULT_MODEL_ALIAS, AUTO_MODEL_ALIAS, "")
130
133
 
131
134
 
132
- def _resolve_model_setting(model_env: Optional[str]) -> str:
133
- """Model precedence (below the CLI flag, applied later): SCOOT_MODEL → saved pref → default."""
135
+ MODEL_SOURCES = ("flag", "env", "folder", "everywhere", "default")
136
+
137
+
138
+ def _resolve_model_setting(model_env: Optional[str], root=None) -> Tuple[str, str]:
139
+ """``(model, source)`` below the CLI flag (applied later): SCOOT_MODEL → the model saved for
140
+ ``root`` → the model saved for every folder → default."""
134
141
  if model_env:
135
- return model_env
142
+ return model_env, "env"
136
143
  try:
137
- from .preferences import get_model
144
+ from .preferences import get_model, model_source
138
145
 
139
- return get_model() or DEFAULT_MODEL_ALIAS
146
+ saved = get_model(root)
147
+ if saved:
148
+ return saved, model_source(root) or "everywhere"
140
149
  except Exception:
141
- return DEFAULT_MODEL_ALIAS
150
+ pass
151
+ return DEFAULT_MODEL_ALIAS, "default"
142
152
 
143
153
 
144
154
  def _resolve_logo_setting(logo_env: Optional[str]) -> bool:
@@ -161,6 +171,7 @@ class Config:
161
171
  proxy: str = DEFAULT_PROXY
162
172
  provider: str = "" # default provider name; empty → first provider with a key, else ollama
163
173
  model: str = DEFAULT_MODEL_ALIAS # "default" | "auto" | a model id, ideally provider/model
174
+ model_source: str = "default" # where ``model`` came from: flag | env | folder | everywhere | default
164
175
  effort: str = "medium" # reasoning effort for models that take it: low | medium | high | xhigh
165
176
  timeout: int = 120
166
177
  max_steps: int = 50
@@ -194,17 +205,20 @@ class Config:
194
205
  load_dotenv(env_file)
195
206
 
196
207
  get = os.environ.get
208
+ root = Path(get("SCOOT_ROOT", str(cwd))).resolve()
209
+ model, model_source = _resolve_model_setting(get("SCOOT_MODEL"), root)
197
210
  return cls(
198
211
  proxy=get("HTTPS_PROXY") or get("https_proxy") or DEFAULT_PROXY,
199
212
  env_files=tuple(str(f) for f in files),
200
213
  provider=get("SCOOT_PROVIDER", "").strip().lower(),
201
- model=_resolve_model_setting(get("SCOOT_MODEL")),
214
+ model=model,
215
+ model_source=model_source,
202
216
  effort=get("SCOOT_EFFORT", "medium").strip().lower(),
203
217
  timeout=int(get("SCOOT_TIMEOUT", "120")),
204
218
  max_steps=int(get("SCOOT_MAX_STEPS", "50")),
205
219
  compact_at=int(get("SCOOT_COMPACT_AT", "100000")),
206
220
  approval=get("SCOOT_APPROVAL", "yolo"),
207
- root=Path(get("SCOOT_ROOT", str(cwd))).resolve(),
221
+ root=root,
208
222
  verbose=_as_bool(get("SCOOT_VERBOSE", "false")),
209
223
  stream=_as_bool(get("SCOOT_STREAM", "true")),
210
224
  panel=_as_bool(get("SCOOT_PANEL", "true")),
@@ -223,10 +237,18 @@ class Config:
223
237
  )
224
238
 
225
239
  def override(self, **kwargs) -> "Config":
226
- """Return a copy with the given (non-None) fields overridden — the CLI-flag layer."""
240
+ """Return a copy with the given (non-None) fields overridden: the CLI-flag layer.
241
+
242
+ A ``model`` here is the flag. A new ``root`` (``--root``) without one re-resolves the saved
243
+ model for that folder, unless the flag or ``SCOOT_MODEL`` already decided it.
244
+ """
227
245
  clean = {k: v for k, v in kwargs.items() if v is not None}
228
246
  if "root" in clean:
229
247
  clean["root"] = Path(clean["root"]).resolve()
248
+ if "model" in clean:
249
+ clean.setdefault("model_source", "flag")
250
+ elif "root" in clean and self.model_source not in ("flag", "env") and clean["root"] != self.root:
251
+ clean["model"], clean["model_source"] = _resolve_model_setting(None, clean["root"])
230
252
  return replace(self, **clean)
231
253
 
232
254
  def resolve_model(self, task_hint: str = "") -> str:
@@ -302,6 +302,8 @@ class Headless:
302
302
  self.writer.emit("notice", message=f"reached the step limit ({self.session.config.max_steps}); send another prompt to continue")
303
303
  if outcome.status == "error":
304
304
  self.writer.emit("error", message=outcome.error, kind="turn")
305
+ if outcome.status == "incomplete":
306
+ self.writer.emit("notice", message=f"reply cut off: {outcome.error}; the content is partial")
305
307
  self.writer.emit("turn_end", turn=self._turn, status=outcome.status, steps=outcome.steps,
306
308
  model=self.session.active_model, usage=self.session.last_usage,
307
309
  cost=self._turn_cost(before), cost_session=self._session_cost(),
@@ -24,7 +24,6 @@ from __future__ import annotations
24
24
  import json
25
25
  import os
26
26
  import re
27
- import subprocess
28
27
  import threading
29
28
  import time
30
29
  from dataclasses import dataclass, field
@@ -186,29 +185,36 @@ class Hooks:
186
185
  return Decision(context="\n".join(context))
187
186
 
188
187
  def _run_command(self, event: str, command: str, payload: dict, timeout: int, cancel_event) -> Decision:
188
+ from .errors import Interrupted
189
+ from .tools.base import ToolError, run_subprocess
190
+
189
191
  env = dict(os.environ)
190
192
  env["SCOOT_SESSION_ID"] = str(payload.get("session_id", ""))
191
193
  env["SCOOT_HOOK_EVENT"] = event
192
194
  started = time.time()
193
195
  try:
194
- proc = subprocess.run(
195
- command, shell=True, cwd=str(self.root), env=env, input=json.dumps(payload),
196
- capture_output=True, text=True, timeout=timeout,
196
+ # The same runner as the tools: its own process group, bounded waits, ESC honoured.
197
+ returncode, stdout, stderr = run_subprocess(
198
+ ["/bin/sh", "-c", command], self.root, cancel_event, timeout=timeout, env=env,
199
+ input_text=json.dumps(payload),
197
200
  )
198
- except subprocess.TimeoutExpired:
201
+ except ToolError:
199
202
  self._record(event, command, "timeout", f"after {timeout}s", started)
200
203
  return Decision()
204
+ except Interrupted:
205
+ self._record(event, command, "cancelled", "by the user", started)
206
+ return Decision()
201
207
  except OSError as exc:
202
208
  self._record(event, command, "error", str(exc), started)
203
209
  return Decision()
204
- out = (proc.stdout or "")[:_MAX_CAPTURE].strip()
205
- err = (proc.stderr or "")[:_MAX_CAPTURE].strip()
206
- if proc.returncode == 2:
210
+ out = (stdout or "")[:_MAX_CAPTURE].strip()
211
+ err = (stderr or "")[:_MAX_CAPTURE].strip()
212
+ if returncode == 2:
207
213
  action = "deny" if event == "PreToolUse" else "block"
208
214
  self._record(event, command, action, err, started)
209
215
  return Decision(action=action, reason=err or "blocked by hook")
210
- if proc.returncode != 0:
211
- self._record(event, command, "error", f"exit {proc.returncode}: {err[:200]}", started)
216
+ if returncode != 0:
217
+ self._record(event, command, "error", f"exit {returncode}: {err[:200]}", started)
212
218
  return Decision()
213
219
  if not out:
214
220
  self._record(event, command, "ok", "", started)