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.
- {scootcli-0.8.0/src/scootcli.egg-info → scootcli-0.9.0}/PKG-INFO +9 -6
- {scootcli-0.8.0 → scootcli-0.9.0}/README.md +8 -5
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/agent.py +82 -11
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/cli.py +6 -4
- scootcli-0.9.0/src/scootcli/commands/model.py +115 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/status.py +12 -1
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/worktree.py +9 -4
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/config.py +31 -9
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/headless.py +2 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/hooks.py +16 -10
- scootcli-0.9.0/src/scootcli/preferences.py +144 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/anthropic.py +7 -3
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/base.py +15 -1
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/openai_chat.py +2 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/openai_responses.py +7 -4
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/repl.py +30 -4
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/sessions.py +29 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/base.py +119 -16
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/edit_file.py +25 -12
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/write_file.py +11 -5
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/transport.py +27 -10
- scootcli-0.9.0/src/scootcli/worktree.py +163 -0
- {scootcli-0.8.0 → scootcli-0.9.0/src/scootcli.egg-info}/PKG-INFO +9 -6
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_agent.py +68 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_anthropic.py +23 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_hooks.py +24 -0
- scootcli-0.9.0/tests/test_preferences.py +266 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_sessions.py +33 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_streaming.py +53 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_tools.py +112 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_transport_native.py +54 -0
- scootcli-0.9.0/tests/test_worktree.py +197 -0
- scootcli-0.8.0/src/scootcli/commands/model.py +0 -65
- scootcli-0.8.0/src/scootcli/preferences.py +0 -87
- scootcli-0.8.0/src/scootcli/worktree.py +0 -114
- scootcli-0.8.0/tests/test_preferences.py +0 -132
- scootcli-0.8.0/tests/test_worktree.py +0 -92
- {scootcli-0.8.0 → scootcli-0.9.0}/LICENSE +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/pyproject.toml +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/setup.cfg +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/context.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/images.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/keys.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/models.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/panel.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/pricing.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/project.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/resize.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/status.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/updates.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli.egg-info/SOURCES.txt +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_approvals.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_auth.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_config.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_headless.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_images.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_logo.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_models.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_panel.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_providers.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_readiness.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_repl.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_resilience.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_resize.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_router.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_scope.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_small_060.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_smoke.py +0 -0
- {scootcli-0.8.0 → scootcli-0.9.0}/tests/test_vision.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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`](
|
|
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
|
|
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
|
|
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
|
|
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`](
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
146
|
+
saved = get_model(root)
|
|
147
|
+
if saved:
|
|
148
|
+
return saved, model_source(root) or "everywhere"
|
|
140
149
|
except Exception:
|
|
141
|
-
|
|
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=
|
|
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=
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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 = (
|
|
205
|
-
err = (
|
|
206
|
-
if
|
|
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
|
|
211
|
-
self._record(event, command, "error", f"exit {
|
|
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)
|