scootcli 0.5.0__tar.gz → 0.7.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.5.0/src/scootcli.egg-info → scootcli-0.7.0}/PKG-INFO +22 -7
- {scootcli-0.5.0 → scootcli-0.7.0}/README.md +21 -6
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/agent.py +9 -2
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/cli.py +19 -1
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/status.py +11 -3
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/headless.py +15 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/hooks.py +36 -6
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/keys.py +6 -1
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/panel.py +3 -0
- scootcli-0.7.0/src/scootcli/pricing.py +80 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/router.py +3 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/repl.py +59 -3
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/status.py +1 -1
- scootcli-0.7.0/src/scootcli/updates.py +46 -0
- {scootcli-0.5.0 → scootcli-0.7.0/src/scootcli.egg-info}/PKG-INFO +22 -7
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli.egg-info/SOURCES.txt +3 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_headless.py +1 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_hooks.py +33 -0
- scootcli-0.7.0/tests/test_small_060.py +202 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/LICENSE +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/pyproject.toml +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/setup.cfg +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/config.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/context.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/images.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/models.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/project.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/base.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_agent.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_approvals.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_auth.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_config.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_images.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_logo.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_models.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_panel.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_preferences.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_providers.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_readiness.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_repl.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_resilience.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_router.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_scope.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_sessions.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_smoke.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_streaming.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_tools.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_vision.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_workspace.py +0 -0
- {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scootcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -135,7 +135,7 @@ Write your own rules in `~/.config/scoot/router.json` (or the file named by `SCO
|
|
|
135
135
|
],
|
|
136
136
|
"default": "ollama/llama3.2",
|
|
137
137
|
"classifier": {
|
|
138
|
-
"model": "
|
|
138
|
+
"model": "openai/gpt-5-mini",
|
|
139
139
|
"tiers": {"simple": "ollama/llama3.2", "coding": "openai/gpt-5.3-codex", "hard": "anthropic/claude-opus-5"}
|
|
140
140
|
}
|
|
141
141
|
}
|
|
@@ -145,7 +145,7 @@ Conditions: `complex`, `has_images`, `needs_tools`, `est_tokens_over`, `prompt_m
|
|
|
145
145
|
The optional `classifier` asks a small model one question per turn ("simple, coding, or hard?") and maps the answer to a tier; it adds a short call, and any failure falls through to the rules.
|
|
146
146
|
Expect it to be rough with a 3B local model: on a hand-labelled set of seven prompts, `llama3.2` and `qwen2.5` each got four right, mostly confusing "coding" with "hard".
|
|
147
147
|
The rules are deterministic, so put the decisions you care about there, and if you want a better judge, name a cheap hosted model as the classifier (`openai/gpt-5-mini`), which costs a few hundred tokens per turn.
|
|
148
|
-
`/route` shows the rules in force and why the current model was picked; `/status` shows tokens per model.
|
|
148
|
+
`/route` shows the rules in force and why the current model was picked; `/status` shows tokens, cached tokens, and cost per model, from the list prices in `pricing.py` (stamped with the date they were last checked; unknown models show `?`).
|
|
149
149
|
`SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
|
|
150
150
|
On Claude Opus 5 the server-side refusal fallback is requested by default, so a declined request is retried on another Claude model inside the same call; `SCOOT_ANTHROPIC_FALLBACKS=0` turns that off.
|
|
151
151
|
|
|
@@ -215,6 +215,7 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
215
215
|
```
|
|
216
216
|
|
|
217
217
|
Press **ESC** while a turn is running to interrupt it; the conversation is kept.
|
|
218
|
+
Press **Ctrl-N** while a turn is running to add a note: scoot asks for one line at the next model call and the model sees it before continuing ("use pytest, not unittest").
|
|
218
219
|
Type `/` and press **Tab** to complete slash commands.
|
|
219
220
|
|
|
220
221
|
**Terminals.** scoot works in macOS Terminal, iTerm2, and inside tmux; the status bar uses a scroll region, the input dock uses raw mode, and clipboard copy uses the system tool or an OSC-52 escape.
|
|
@@ -258,12 +259,13 @@ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
|
|
|
258
259
|
}
|
|
259
260
|
```
|
|
260
261
|
|
|
261
|
-
A `PreToolUse` hook can answer `{"permissionDecision": "deny", "reason": "..."}` to skip a tool (the model is told why), `allow` to skip the approval prompt, or `ask` to force one even in `yolo`; exit code 2 denies with stderr as the reason.
|
|
262
|
+
A `PreToolUse` hook can answer `{"permissionDecision": "deny", "reason": "..."}` to skip a tool (the model is told why), `allow` to skip the approval prompt, or `ask` to force one even in `yolo`; exit code 2 denies with stderr as the reason. Claude Code's nested `hookSpecificOutput` answer is accepted as is, so a hook written for Claude Code works without edits.
|
|
262
263
|
A `Stop` hook that answers `{"decision": "block", "reason": "run the tests first"}` sends the agent back to work with that instruction, at most three times per turn.
|
|
264
|
+
Matchers understand Claude Code's tool names as well as scoot's, so `"matcher": "Bash|Write|Edit"` fires for `run_shell`, `write_file`, and `edit_file`, and the payload carries the alias as `tool_alias`: one `hooks.json` and one script can serve both tools.
|
|
263
265
|
`/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
|
|
264
266
|
|
|
265
267
|
**Headless mode** is for editors, automation, and remote-control tools: `scoot --headless` reads JSON lines on stdin (`prompt`, `approve`, `note`, `interrupt`, `command`, `shutdown`) and writes JSON lines on stdout (streamed text, tool calls, approval requests, results, plan updates, usage, errors, a heartbeat), with nothing else ever printed there.
|
|
266
|
-
Same sessions, tools, approvals, routing, and hooks as the REPL.
|
|
268
|
+
Same sessions, tools, approvals, routing, and hooks as the REPL; every `turn_end` carries the turn's tokens and cost, and `--json` one-shot output carries `cost` too.
|
|
267
269
|
The message tables and a full transcript are in [`docs/headless-protocol.md`](./docs/headless-protocol.md); an unanswered approval is denied after `SCOOT_APPROVAL_TIMEOUT` seconds (default 120).
|
|
268
270
|
|
|
269
271
|
### The status bar
|
|
@@ -275,13 +277,26 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
275
277
|
```bash
|
|
276
278
|
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
277
279
|
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
|
|
278
|
-
pip install scootcli #
|
|
280
|
+
pip install scootcli # inside a venv, container, or CI job (system Pythons on recent Linux and Homebrew block pip outside a venv)
|
|
279
281
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
280
282
|
git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
|
|
281
283
|
```
|
|
282
284
|
|
|
283
285
|
The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
|
|
284
|
-
|
|
286
|
+
## Upgrade options
|
|
287
|
+
|
|
288
|
+
Use the same tool you installed with:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
pipx upgrade scootcli # pipx install
|
|
292
|
+
pip install --upgrade scootcli # pip install
|
|
293
|
+
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # the curl installer: rerun it, it always fetches the latest release
|
|
294
|
+
scoot --version # what you have
|
|
295
|
+
scoot --check-update # ask PyPI whether a newer release exists
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
With `SCOOT_UPDATE_CHECK=1` the REPL checks once in the background at start and shows `⬆ x.y.z` in the status bar; nothing contacts PyPI otherwise.
|
|
299
|
+
To remove scoot: `pipx uninstall scootcli`, `pip uninstall scootcli`, or `bash install.sh --uninstall` for the curl installer; config in `~/.config/scoot` and sessions in `~/.local/state/scoot` stay either way.
|
|
285
300
|
|
|
286
301
|
Both pipx and the script install into `~/.local/bin`. On a fresh Linux account that directory is added to PATH at login only if it already exists, so after the very first install either open a new login shell or run `pipx ensurepath`; the installer prints the exact line for your shell.
|
|
287
302
|
|
|
@@ -110,7 +110,7 @@ Write your own rules in `~/.config/scoot/router.json` (or the file named by `SCO
|
|
|
110
110
|
],
|
|
111
111
|
"default": "ollama/llama3.2",
|
|
112
112
|
"classifier": {
|
|
113
|
-
"model": "
|
|
113
|
+
"model": "openai/gpt-5-mini",
|
|
114
114
|
"tiers": {"simple": "ollama/llama3.2", "coding": "openai/gpt-5.3-codex", "hard": "anthropic/claude-opus-5"}
|
|
115
115
|
}
|
|
116
116
|
}
|
|
@@ -120,7 +120,7 @@ Conditions: `complex`, `has_images`, `needs_tools`, `est_tokens_over`, `prompt_m
|
|
|
120
120
|
The optional `classifier` asks a small model one question per turn ("simple, coding, or hard?") and maps the answer to a tier; it adds a short call, and any failure falls through to the rules.
|
|
121
121
|
Expect it to be rough with a 3B local model: on a hand-labelled set of seven prompts, `llama3.2` and `qwen2.5` each got four right, mostly confusing "coding" with "hard".
|
|
122
122
|
The rules are deterministic, so put the decisions you care about there, and if you want a better judge, name a cheap hosted model as the classifier (`openai/gpt-5-mini`), which costs a few hundred tokens per turn.
|
|
123
|
-
`/route` shows the rules in force and why the current model was picked; `/status` shows tokens per model.
|
|
123
|
+
`/route` shows the rules in force and why the current model was picked; `/status` shows tokens, cached tokens, and cost per model, from the list prices in `pricing.py` (stamped with the date they were last checked; unknown models show `?`).
|
|
124
124
|
`SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
|
|
125
125
|
On Claude Opus 5 the server-side refusal fallback is requested by default, so a declined request is retried on another Claude model inside the same call; `SCOOT_ANTHROPIC_FALLBACKS=0` turns that off.
|
|
126
126
|
|
|
@@ -190,6 +190,7 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
190
190
|
```
|
|
191
191
|
|
|
192
192
|
Press **ESC** while a turn is running to interrupt it; the conversation is kept.
|
|
193
|
+
Press **Ctrl-N** while a turn is running to add a note: scoot asks for one line at the next model call and the model sees it before continuing ("use pytest, not unittest").
|
|
193
194
|
Type `/` and press **Tab** to complete slash commands.
|
|
194
195
|
|
|
195
196
|
**Terminals.** scoot works in macOS Terminal, iTerm2, and inside tmux; the status bar uses a scroll region, the input dock uses raw mode, and clipboard copy uses the system tool or an OSC-52 escape.
|
|
@@ -233,12 +234,13 @@ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
|
|
|
233
234
|
}
|
|
234
235
|
```
|
|
235
236
|
|
|
236
|
-
A `PreToolUse` hook can answer `{"permissionDecision": "deny", "reason": "..."}` to skip a tool (the model is told why), `allow` to skip the approval prompt, or `ask` to force one even in `yolo`; exit code 2 denies with stderr as the reason.
|
|
237
|
+
A `PreToolUse` hook can answer `{"permissionDecision": "deny", "reason": "..."}` to skip a tool (the model is told why), `allow` to skip the approval prompt, or `ask` to force one even in `yolo`; exit code 2 denies with stderr as the reason. Claude Code's nested `hookSpecificOutput` answer is accepted as is, so a hook written for Claude Code works without edits.
|
|
237
238
|
A `Stop` hook that answers `{"decision": "block", "reason": "run the tests first"}` sends the agent back to work with that instruction, at most three times per turn.
|
|
239
|
+
Matchers understand Claude Code's tool names as well as scoot's, so `"matcher": "Bash|Write|Edit"` fires for `run_shell`, `write_file`, and `edit_file`, and the payload carries the alias as `tool_alias`: one `hooks.json` and one script can serve both tools.
|
|
238
240
|
`/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
|
|
239
241
|
|
|
240
242
|
**Headless mode** is for editors, automation, and remote-control tools: `scoot --headless` reads JSON lines on stdin (`prompt`, `approve`, `note`, `interrupt`, `command`, `shutdown`) and writes JSON lines on stdout (streamed text, tool calls, approval requests, results, plan updates, usage, errors, a heartbeat), with nothing else ever printed there.
|
|
241
|
-
Same sessions, tools, approvals, routing, and hooks as the REPL.
|
|
243
|
+
Same sessions, tools, approvals, routing, and hooks as the REPL; every `turn_end` carries the turn's tokens and cost, and `--json` one-shot output carries `cost` too.
|
|
242
244
|
The message tables and a full transcript are in [`docs/headless-protocol.md`](./docs/headless-protocol.md); an unanswered approval is denied after `SCOOT_APPROVAL_TIMEOUT` seconds (default 120).
|
|
243
245
|
|
|
244
246
|
### The status bar
|
|
@@ -250,13 +252,26 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
250
252
|
```bash
|
|
251
253
|
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
252
254
|
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
|
|
253
|
-
pip install scootcli #
|
|
255
|
+
pip install scootcli # inside a venv, container, or CI job (system Pythons on recent Linux and Homebrew block pip outside a venv)
|
|
254
256
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
255
257
|
git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
|
|
256
258
|
```
|
|
257
259
|
|
|
258
260
|
The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
|
|
259
|
-
|
|
261
|
+
## Upgrade options
|
|
262
|
+
|
|
263
|
+
Use the same tool you installed with:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
pipx upgrade scootcli # pipx install
|
|
267
|
+
pip install --upgrade scootcli # pip install
|
|
268
|
+
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # the curl installer: rerun it, it always fetches the latest release
|
|
269
|
+
scoot --version # what you have
|
|
270
|
+
scoot --check-update # ask PyPI whether a newer release exists
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
With `SCOOT_UPDATE_CHECK=1` the REPL checks once in the background at start and shows `⬆ x.y.z` in the status bar; nothing contacts PyPI otherwise.
|
|
274
|
+
To remove scoot: `pipx uninstall scootcli`, `pip uninstall scootcli`, or `bash install.sh --uninstall` for the curl installer; config in `~/.config/scoot` and sessions in `~/.local/state/scoot` stay either way.
|
|
260
275
|
|
|
261
276
|
Both pipx and the script install into `~/.local/bin`. On a fresh Linux account that directory is added to PATH at login only if it already exists, so after the very first install either open a new login shell or run `pipx ensurepath`; the installer prints the exact line for your shell.
|
|
262
277
|
|
|
@@ -168,6 +168,9 @@ class Agent:
|
|
|
168
168
|
|
|
169
169
|
Returns ``(result, streamed)`` where ``streamed`` is True if content was already printed.
|
|
170
170
|
"""
|
|
171
|
+
taker = getattr(ui, "take_note", None)
|
|
172
|
+
if callable(taker):
|
|
173
|
+
taker(session) # a Ctrl-N pressed during the previous activity
|
|
171
174
|
messages = self._messages(session, cfg)
|
|
172
175
|
hints = {"task_text": self._last_user_text(session), "needs_tools": True, "step": steps}
|
|
173
176
|
if self._streaming and hasattr(ui, "stream") and hasattr(self.provider, "chat_stream"):
|
|
@@ -216,8 +219,10 @@ class Agent:
|
|
|
216
219
|
return "", ""
|
|
217
220
|
from .hooks import tool_kind
|
|
218
221
|
|
|
222
|
+
from .hooks import tool_alias
|
|
223
|
+
|
|
219
224
|
payload = hooks.payload(session, "PreToolUse", tool_name=tool.name, tool_input=args,
|
|
220
|
-
tool_kind=tool_kind(tool))
|
|
225
|
+
tool_kind=tool_kind(tool), tool_alias=tool_alias(tool.name))
|
|
221
226
|
decision = hooks.run("PreToolUse", payload, cancel_event)
|
|
222
227
|
return decision.action, decision.reason
|
|
223
228
|
|
|
@@ -229,9 +234,11 @@ class Agent:
|
|
|
229
234
|
|
|
230
235
|
response = {"ok": result.ok, "summary": result.summary or "", "error": result.error or "",
|
|
231
236
|
"content": (result.content or "")[:4000]}
|
|
237
|
+
from .hooks import tool_alias
|
|
238
|
+
|
|
232
239
|
hooks.run("PostToolUse", hooks.payload(session, "PostToolUse", tool_name=tool.name,
|
|
233
240
|
tool_input=args, tool_kind=tool_kind(tool),
|
|
234
|
-
tool_response=response))
|
|
241
|
+
tool_alias=tool_alias(tool.name), tool_response=response))
|
|
235
242
|
|
|
236
243
|
@staticmethod
|
|
237
244
|
def _last_user_text(session) -> str:
|
|
@@ -33,6 +33,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
33
33
|
)
|
|
34
34
|
# Global flags (CLI-flag layer of config precedence).
|
|
35
35
|
parser.add_argument("--version", action="version", version=f"scoot {__version__}")
|
|
36
|
+
parser.add_argument("--check-update", action="store_true",
|
|
37
|
+
help="Ask PyPI whether a newer scoot exists, print both versions, and exit.")
|
|
36
38
|
parser.add_argument("--model", help="Model as provider/model (e.g. openai/gpt-5.3-codex), 'default', or 'auto'.")
|
|
37
39
|
parser.add_argument("--provider", help="Default provider (openai, ollama, ...) for bare model names.")
|
|
38
40
|
parser.add_argument("--effort", choices=["low", "medium", "high", "xhigh"],
|
|
@@ -192,6 +194,7 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
192
194
|
"content": outcome.content,
|
|
193
195
|
"error": outcome.error,
|
|
194
196
|
"usage": session.last_usage,
|
|
197
|
+
"cost": session.session_cost(),
|
|
195
198
|
}, indent=2))
|
|
196
199
|
return 0 if outcome.status == "done" else 1
|
|
197
200
|
|
|
@@ -199,10 +202,13 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
199
202
|
if outcome.content.strip() and not outcome.streamed:
|
|
200
203
|
print(outcome.content.strip())
|
|
201
204
|
if config.verbose:
|
|
205
|
+
from .pricing import fmt
|
|
206
|
+
|
|
202
207
|
u = session.last_usage
|
|
203
208
|
eprint(color(f"[{session.active_model}] steps={outcome.steps} "
|
|
204
209
|
f"prompt={u.get('prompt_tokens', '?')} "
|
|
205
|
-
f"completion={u.get('completion_tokens', '?')}
|
|
210
|
+
f"completion={u.get('completion_tokens', '?')} "
|
|
211
|
+
f"cost={fmt(session.session_cost())}", "gray"))
|
|
206
212
|
return 0
|
|
207
213
|
eprint(color(f"⚠ {redact(outcome.error or outcome.status)}", "red"))
|
|
208
214
|
return 1
|
|
@@ -270,6 +276,18 @@ def _resolve_prompt(args: argparse.Namespace) -> Optional[str]:
|
|
|
270
276
|
|
|
271
277
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
272
278
|
args = _build_parser().parse_args(argv)
|
|
279
|
+
if args.check_update:
|
|
280
|
+
from .updates import check, is_newer, upgrade_hint
|
|
281
|
+
|
|
282
|
+
current, latest = check()
|
|
283
|
+
if latest is None:
|
|
284
|
+
print(f"scoot {current} (could not reach PyPI to check for updates)")
|
|
285
|
+
return 1
|
|
286
|
+
if is_newer(latest, current):
|
|
287
|
+
print(f"scoot {current}; newer release available: {latest}\n {upgrade_hint()}")
|
|
288
|
+
else:
|
|
289
|
+
print(f"scoot {current} is the latest release.")
|
|
290
|
+
return 0
|
|
273
291
|
try:
|
|
274
292
|
config = _config_from_args(args)
|
|
275
293
|
pool = ProviderPool(config)
|
|
@@ -23,6 +23,8 @@ def _run(session, args: str):
|
|
|
23
23
|
("model", session.active_model + (" (auto)" if session.model.lower() == "auto" else "")),
|
|
24
24
|
("effort", getattr(cfg, "effort", "medium")),
|
|
25
25
|
("env files", ", ".join(getattr(cfg, "env_files", ()) or ()) or "none"),
|
|
26
|
+
("update", (f"{session.update_available} available: pipx upgrade scootcli"
|
|
27
|
+
if getattr(session, "update_available", "") else "none known (scoot --check-update, or SCOOT_UPDATE_CHECK=1)")),
|
|
26
28
|
("root", str(cfg.root)),
|
|
27
29
|
("approval", session.approval_mode),
|
|
28
30
|
("scope", getattr(session, "scope", None).describe() if getattr(session, "scope", None) else "workspace"),
|
|
@@ -44,10 +46,16 @@ def _run(session, args: str):
|
|
|
44
46
|
for label, value in rows:
|
|
45
47
|
print(f" {color(label + ':', 'gray'):<28} {value}")
|
|
46
48
|
by_model = getattr(session, "usage_by_model", None) or {}
|
|
47
|
-
if
|
|
48
|
-
|
|
49
|
+
if by_model:
|
|
50
|
+
from ..pricing import UPDATED, cost, fmt
|
|
51
|
+
|
|
52
|
+
print(f" {color('tokens and cost by model:', 'gray')}")
|
|
49
53
|
for name, u in sorted(by_model.items(), key=lambda kv: -(kv[1]['prompt'] + kv[1]['completion'])):
|
|
50
|
-
|
|
54
|
+
c = cost(name, u["prompt"], u["completion"], u.get("cached", 0))
|
|
55
|
+
cached = f" cached={u['cached']}" if u.get("cached") else ""
|
|
56
|
+
print(f" {color(name, 'cyan'):<40} prompt={u['prompt']}{cached} completion={u['completion']} calls={u['calls']} {fmt(c)}")
|
|
57
|
+
total = session.session_cost() if hasattr(session, "session_cost") else None
|
|
58
|
+
print(f" {color('cost (session):', 'gray'):<28} {fmt(total)} {color(f'list prices as of {UPDATED}; unknown models show ?', 'gray')}")
|
|
51
59
|
if session.model.lower() == "auto" and getattr(session, "route_reason", ""):
|
|
52
60
|
print(f" {color('routed by:', 'gray'):<28} {session.route_reason}")
|
|
53
61
|
trusted = getattr(session, "trusted_tools", None)
|
|
@@ -285,6 +285,7 @@ class Headless:
|
|
|
285
285
|
self.cancel = threading.Event()
|
|
286
286
|
self.ui.cancel_event = self.cancel
|
|
287
287
|
self.session.last_error = ""
|
|
288
|
+
before = self._session_cost()
|
|
288
289
|
try:
|
|
289
290
|
with redirect_stdout(sys.stderr): # any stray print from a tool or command stays off the protocol
|
|
290
291
|
try:
|
|
@@ -303,6 +304,7 @@ class Headless:
|
|
|
303
304
|
self.writer.emit("error", message=outcome.error, kind="turn")
|
|
304
305
|
self.writer.emit("turn_end", turn=self._turn, status=outcome.status, steps=outcome.steps,
|
|
305
306
|
model=self.session.active_model, usage=self.session.last_usage,
|
|
307
|
+
cost=self._turn_cost(before), cost_session=self._session_cost(),
|
|
306
308
|
content=outcome.content, error=outcome.error)
|
|
307
309
|
except ScootError as exc:
|
|
308
310
|
self.writer.emit("error", message=str(exc), hint=getattr(exc, "hint", ""), kind="turn")
|
|
@@ -313,6 +315,19 @@ class Headless:
|
|
|
313
315
|
self.cancel = None
|
|
314
316
|
self.ui.cancel_event = None
|
|
315
317
|
|
|
318
|
+
def _session_cost(self):
|
|
319
|
+
fn = getattr(self.session, "session_cost", None)
|
|
320
|
+
try:
|
|
321
|
+
return fn() if callable(fn) else None
|
|
322
|
+
except Exception:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
def _turn_cost(self, before):
|
|
326
|
+
after = self._session_cost()
|
|
327
|
+
if after is None or before is None:
|
|
328
|
+
return None
|
|
329
|
+
return round(after - before, 6)
|
|
330
|
+
|
|
316
331
|
def _command(self, msg: dict) -> None:
|
|
317
332
|
from . import commands
|
|
318
333
|
import io
|
|
@@ -36,6 +36,12 @@ EVENTS = ("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop
|
|
|
36
36
|
DEFAULT_TIMEOUT = 60
|
|
37
37
|
_MAX_CAPTURE = 16_000
|
|
38
38
|
_TOOL_KINDS = {"read": "read", "write": "write", "shell": "shell"}
|
|
39
|
+
# Claude Code's names for the same jobs, so a hooks.json written for it ("matcher": "Bash|Write|Edit")
|
|
40
|
+
# fires for scoot's tools too, and a shared script can switch on ``tool_alias``.
|
|
41
|
+
TOOL_ALIASES = {
|
|
42
|
+
"read_file": "Read", "list_dir": "LS", "search": "Grep", "write_file": "Write", "edit_file": "Edit",
|
|
43
|
+
"run_shell": "Bash", "open_editor": "Open", "update_plan": "TodoWrite",
|
|
44
|
+
}
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
@dataclass
|
|
@@ -96,6 +102,19 @@ def tool_kind(tool) -> str:
|
|
|
96
102
|
return _TOOL_KINDS.get(getattr(tool, "risk", ""), "read")
|
|
97
103
|
|
|
98
104
|
|
|
105
|
+
def tool_alias(name: str) -> str:
|
|
106
|
+
return TOOL_ALIASES.get(name, name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def matcher_hits(matcher: str, tool_name: str) -> bool:
|
|
110
|
+
"""A matcher regex matches scoot's tool name or its Claude Code alias."""
|
|
111
|
+
try:
|
|
112
|
+
rx = re.compile(str(matcher))
|
|
113
|
+
except re.error:
|
|
114
|
+
return False
|
|
115
|
+
return bool(rx.search(tool_name) or rx.search(tool_alias(tool_name)))
|
|
116
|
+
|
|
117
|
+
|
|
99
118
|
class Hooks:
|
|
100
119
|
"""The merged hook configuration for one workspace, plus a record of what ran."""
|
|
101
120
|
|
|
@@ -149,10 +168,7 @@ class Hooks:
|
|
|
149
168
|
for entry in self.config.get(event, []):
|
|
150
169
|
matcher = entry.get("matcher")
|
|
151
170
|
if matcher and event in ("PreToolUse", "PostToolUse"):
|
|
152
|
-
|
|
153
|
-
if not re.search(str(matcher), tool_name):
|
|
154
|
-
continue
|
|
155
|
-
except re.error:
|
|
171
|
+
if not matcher_hits(matcher, tool_name):
|
|
156
172
|
continue
|
|
157
173
|
for hook in entry.get("hooks", []):
|
|
158
174
|
if not isinstance(hook, dict) or hook.get("type", "command") != "command":
|
|
@@ -212,7 +228,21 @@ class Hooks:
|
|
|
212
228
|
@staticmethod
|
|
213
229
|
def _interpret(event: str, data: dict) -> Decision:
|
|
214
230
|
reason = str(data.get("reason") or "")
|
|
215
|
-
|
|
231
|
+
# Claude Code nests a hook's decision under "hookSpecificOutput" (with the reason as
|
|
232
|
+
# "permissionDecisionReason" and extra context as "additionalContext"); scoot's own flat
|
|
233
|
+
# shape puts the same keys at the top level. Read both, nested first.
|
|
234
|
+
hso = data.get("hookSpecificOutput")
|
|
235
|
+
if isinstance(hso, dict):
|
|
236
|
+
if not reason:
|
|
237
|
+
reason = str(hso.get("permissionDecisionReason") or hso.get("reason") or "")
|
|
238
|
+
perm_src = hso.get("permissionDecision")
|
|
239
|
+
context_src = hso.get("additionalContext")
|
|
240
|
+
else:
|
|
241
|
+
perm_src = data.get("permissionDecision")
|
|
242
|
+
context_src = None
|
|
243
|
+
perm = str(perm_src or "").lower()
|
|
244
|
+
if perm == "approve":
|
|
245
|
+
perm = "allow" # Claude Code's older synonym
|
|
216
246
|
if event == "PreToolUse" and perm in ("allow", "deny", "ask"):
|
|
217
247
|
return Decision(action=perm, reason=reason)
|
|
218
248
|
raw = str(data.get("decision") or "").lower()
|
|
@@ -220,7 +250,7 @@ class Hooks:
|
|
|
220
250
|
return Decision(action="deny" if event == "PreToolUse" else "block", reason=reason)
|
|
221
251
|
if raw == "approve" and event == "PreToolUse":
|
|
222
252
|
return Decision(action="allow", reason=reason)
|
|
223
|
-
context = data.get("additionalContext") or data.get("context") or ""
|
|
253
|
+
context = context_src or data.get("additionalContext") or data.get("context") or ""
|
|
224
254
|
return Decision(context=str(context) if context else "")
|
|
225
255
|
|
|
226
256
|
def _record(self, event: str, command: str, outcome: str, detail: str, started: float) -> None:
|
|
@@ -24,6 +24,7 @@ except ImportError: # pragma: no cover - non-POSIX
|
|
|
24
24
|
_HAVE_TERMIOS = False
|
|
25
25
|
|
|
26
26
|
ESC = "\x1b"
|
|
27
|
+
NOTE_KEY = b"\x0e" # Ctrl-N: ask for a note at the next model call
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def read_key() -> str:
|
|
@@ -54,8 +55,9 @@ class InterruptibleSection:
|
|
|
54
55
|
is simply unavailable — the work still runs normally.
|
|
55
56
|
"""
|
|
56
57
|
|
|
57
|
-
def __init__(self, cancel_event: threading.Event):
|
|
58
|
+
def __init__(self, cancel_event: threading.Event, note_event: "threading.Event | None" = None):
|
|
58
59
|
self.cancel_event = cancel_event
|
|
60
|
+
self.note_event = note_event
|
|
59
61
|
self.enabled = _HAVE_TERMIOS and sys.stdin.isatty()
|
|
60
62
|
self._fd = sys.stdin.fileno() if self.enabled else -1
|
|
61
63
|
self._old_attrs = None
|
|
@@ -88,6 +90,9 @@ class InterruptibleSection:
|
|
|
88
90
|
ch = os.read(self._fd, 1)
|
|
89
91
|
except (OSError, ValueError):
|
|
90
92
|
break
|
|
93
|
+
if ch == NOTE_KEY and self.note_event is not None:
|
|
94
|
+
self.note_event.set()
|
|
95
|
+
continue
|
|
91
96
|
if ch == b"\x1b":
|
|
92
97
|
# Distinguish a bare ESC (a real interrupt) from an escape sequence — arrow keys,
|
|
93
98
|
# mouse reports, or bracketed-paste markers (\x1b[201~) left in the buffer after a
|
|
@@ -108,6 +108,9 @@ def build_status_text(session, user: Optional[str] = None) -> str:
|
|
|
108
108
|
err = getattr(session, "last_error", None)
|
|
109
109
|
if err:
|
|
110
110
|
segments.append(f"⚠ {err}")
|
|
111
|
+
newer = getattr(session, "update_available", "")
|
|
112
|
+
if newer:
|
|
113
|
+
segments.append(f"⬆ {newer}")
|
|
111
114
|
# The mascot's face leads the bar; its eyes follow the turn (idle / thinking / stopped).
|
|
112
115
|
if getattr(getattr(session, "config", None), "logo", True):
|
|
113
116
|
from .logo import face
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Published list prices per million tokens, for the cost column in ``/status``.
|
|
2
|
+
|
|
3
|
+
This table goes stale; ``UPDATED`` says when it was last checked against the vendors' pricing pages.
|
|
4
|
+
Unknown models get no cost, never a guess. Local models cost nothing. Prices are USD.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .providers.base import split_model_id
|
|
12
|
+
|
|
13
|
+
UPDATED = "2026-09-06"
|
|
14
|
+
|
|
15
|
+
# (model id prefix, input, cached input, output). Longest matching prefix wins.
|
|
16
|
+
# Anthropic: cache reads are 10% of input; cache writes (25% premium) are counted as plain input here.
|
|
17
|
+
PRICES = [
|
|
18
|
+
# OpenAI (developers.openai.com/api/docs/pricing)
|
|
19
|
+
("gpt-5.6-astra", 10.00, 1.00, 50.00), ("gpt-5.6-sol", 4.00, 0.40, 20.00),
|
|
20
|
+
("gpt-5.6-terra", 2.00, 0.20, 12.00), ("gpt-5.6-luna", 0.20, 0.02, 1.20),
|
|
21
|
+
("gpt-5.5-pro", 30.00, 30.00, 180.00), ("gpt-5.5", 5.00, 0.50, 30.00),
|
|
22
|
+
("gpt-5.4-pro", 30.00, 30.00, 180.00), ("gpt-5.4-mini", 0.75, 0.075, 4.50),
|
|
23
|
+
("gpt-5.4-nano", 0.20, 0.02, 1.25), ("gpt-5.4", 2.50, 0.25, 15.00),
|
|
24
|
+
("gpt-5.3-codex", 1.75, 0.175, 14.00), ("gpt-5.2-pro", 21.00, 21.00, 168.00),
|
|
25
|
+
("gpt-5.2", 1.75, 0.175, 14.00), ("gpt-5.1", 1.25, 0.125, 10.00),
|
|
26
|
+
("gpt-5-pro", 15.00, 15.00, 120.00), ("gpt-5-mini", 0.25, 0.025, 2.00),
|
|
27
|
+
("gpt-5-nano", 0.05, 0.005, 0.40), ("gpt-5", 1.25, 0.125, 10.00),
|
|
28
|
+
("gpt-4.1-mini", 0.40, 0.10, 1.60), ("gpt-4.1", 2.00, 0.50, 8.00),
|
|
29
|
+
("gpt-4o-mini", 0.15, 0.075, 0.60), ("gpt-4o", 2.50, 1.25, 10.00),
|
|
30
|
+
# Anthropic (first-party API rates)
|
|
31
|
+
("claude-fable-5", 10.00, 1.00, 50.00), ("claude-mythos-5", 10.00, 1.00, 50.00),
|
|
32
|
+
("claude-opus-5", 5.00, 0.50, 25.00), ("claude-opus-4-8", 5.00, 0.50, 25.00),
|
|
33
|
+
("claude-opus-4-7", 5.00, 0.50, 25.00), ("claude-opus-4-6", 5.00, 0.50, 25.00),
|
|
34
|
+
("claude-sonnet-5", 2.00, 0.20, 10.00), ("claude-sonnet-4-6", 3.00, 0.30, 15.00),
|
|
35
|
+
("claude-haiku-4-5", 1.00, 0.10, 5.00),
|
|
36
|
+
]
|
|
37
|
+
_FREE_PROVIDERS = ("ollama",)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def price_for(model: str) -> Optional[Tuple[float, float, float]]:
|
|
41
|
+
"""``(input, cached_input, output)`` per million tokens, or ``None`` when unknown."""
|
|
42
|
+
provider, name = split_model_id(model)
|
|
43
|
+
if provider in _FREE_PROVIDERS:
|
|
44
|
+
return (0.0, 0.0, 0.0)
|
|
45
|
+
name = (name or model or "").lower()
|
|
46
|
+
best = None
|
|
47
|
+
for prefix, inp, cached, out in PRICES:
|
|
48
|
+
if name.startswith(prefix) and (best is None or len(prefix) > len(best[0])):
|
|
49
|
+
best = (prefix, inp, cached, out)
|
|
50
|
+
return (best[1], best[2], best[3]) if best else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cost(model: str, prompt: int, completion: int, cached: int = 0) -> Optional[float]:
|
|
54
|
+
"""USD for one or more calls; ``cached`` is the part of ``prompt`` served from cache."""
|
|
55
|
+
p = price_for(model)
|
|
56
|
+
if p is None:
|
|
57
|
+
return None
|
|
58
|
+
inp, cached_price, out = p
|
|
59
|
+
cached = max(0, min(int(cached or 0), int(prompt or 0)))
|
|
60
|
+
fresh = int(prompt or 0) - cached
|
|
61
|
+
return (fresh * inp + cached * cached_price + int(completion or 0) * out) / 1_000_000
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cached_tokens(usage: dict) -> int:
|
|
65
|
+
"""Cached-input tokens from a normalized usage dict, whichever vendor shape it came in."""
|
|
66
|
+
if not isinstance(usage, dict):
|
|
67
|
+
return 0
|
|
68
|
+
n = usage.get("cache_read_input_tokens")
|
|
69
|
+
if n is None:
|
|
70
|
+
details = usage.get("input_tokens_details") or usage.get("prompt_tokens_details") or {}
|
|
71
|
+
n = details.get("cached_tokens") if isinstance(details, dict) else 0
|
|
72
|
+
return int(n or 0)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fmt(usd: Optional[float]) -> str:
|
|
76
|
+
if usd is None:
|
|
77
|
+
return "?"
|
|
78
|
+
if usd == 0:
|
|
79
|
+
return "$0"
|
|
80
|
+
return f"${usd:.4f}" if usd < 0.01 else f"${usd:.2f}"
|
|
@@ -220,6 +220,9 @@ def describe(router: Router) -> List[str]:
|
|
|
220
220
|
if cfg.classifier:
|
|
221
221
|
lines.append(f"classifier: {cfg.classifier['model']} → " + ", ".join(
|
|
222
222
|
f"{k}={v}" for k, v in cfg.classifier["tiers"].items()))
|
|
223
|
+
if str(cfg.classifier["model"]).startswith("ollama/"):
|
|
224
|
+
lines.append(" note: small local models are rough judges here (about half right in our test); "
|
|
225
|
+
"a hosted mini model such as openai/gpt-5-mini classifies better for a few hundred tokens")
|
|
223
226
|
for i, rule in enumerate(cfg.rules, 1):
|
|
224
227
|
lines.append(f" {i}. when {json.dumps(rule.get('when') or {})} → {rule['use']}")
|
|
225
228
|
if cfg.default:
|
|
@@ -79,6 +79,7 @@ class ReplSession:
|
|
|
79
79
|
self.mascot_state = "idle" # drives the mascot's eyes in the status bar: idle | thinking | stopped
|
|
80
80
|
self.usage_by_model: dict = {} # qualified model -> {"prompt": n, "completion": n, "calls": n}
|
|
81
81
|
self.provider_ready = True # False until a provider can take a request (see refresh_readiness)
|
|
82
|
+
self.update_available = "" # newer release on PyPI, when the opt-in check found one
|
|
82
83
|
from .tools.base import Scope
|
|
83
84
|
|
|
84
85
|
self.scope = Scope(config.root, everything=(getattr(config, "scope", "workspace") == "anywhere"))
|
|
@@ -212,12 +213,27 @@ class ReplSession:
|
|
|
212
213
|
completion = int((usage or {}).get("completion_tokens", 0) or 0)
|
|
213
214
|
self.total_prompt += prompt
|
|
214
215
|
self.total_completion += completion
|
|
216
|
+
from .pricing import cached_tokens
|
|
217
|
+
|
|
215
218
|
key = model or getattr(self, "active_model", "") or "?"
|
|
216
|
-
slot = self.usage_by_model.setdefault(key, {"prompt": 0, "completion": 0, "calls": 0})
|
|
219
|
+
slot = self.usage_by_model.setdefault(key, {"prompt": 0, "completion": 0, "cached": 0, "calls": 0})
|
|
217
220
|
slot["prompt"] += prompt
|
|
218
221
|
slot["completion"] += completion
|
|
222
|
+
slot["cached"] += cached_tokens(usage)
|
|
219
223
|
slot["calls"] += 1
|
|
220
224
|
|
|
225
|
+
def session_cost(self):
|
|
226
|
+
"""USD spent this session across models, or ``None`` if any model's price is unknown."""
|
|
227
|
+
from .pricing import cost
|
|
228
|
+
|
|
229
|
+
total = 0.0
|
|
230
|
+
for name, u in self.usage_by_model.items():
|
|
231
|
+
c = cost(name, u["prompt"], u["completion"], u.get("cached", 0))
|
|
232
|
+
if c is None:
|
|
233
|
+
return None
|
|
234
|
+
total += c
|
|
235
|
+
return total
|
|
236
|
+
|
|
221
237
|
def full_messages(self) -> List[dict]:
|
|
222
238
|
return [{"role": "system", "content": CHAT_SYSTEM_PROMPT}, *self.messages]
|
|
223
239
|
|
|
@@ -294,6 +310,7 @@ class ReplUI:
|
|
|
294
310
|
def __init__(self, labels: bool = True, verbosity: str = "full", emoji: bool = True):
|
|
295
311
|
self.labels = labels
|
|
296
312
|
self.emoji = emoji # 🛴 label, or ⏺ when the terminal has no emoji font
|
|
313
|
+
self.note_requested = threading.Event() # Ctrl-N during a turn; taken at the next model call
|
|
297
314
|
self.verbosity = verbosity if verbosity in self.LEVELS else "full"
|
|
298
315
|
self._tty = sys.stdout.isatty()
|
|
299
316
|
self._transient = False # a transient line is currently on screen (no trailing newline)
|
|
@@ -375,7 +392,7 @@ class ReplUI:
|
|
|
375
392
|
status = Status()
|
|
376
393
|
status.start(message)
|
|
377
394
|
try:
|
|
378
|
-
with InterruptibleSection(cancel_event):
|
|
395
|
+
with InterruptibleSection(cancel_event, self.note_requested):
|
|
379
396
|
yield
|
|
380
397
|
finally:
|
|
381
398
|
status.stop() # clears the spinner row; the tool row above stays on screen
|
|
@@ -393,7 +410,7 @@ class ReplUI:
|
|
|
393
410
|
status.start("thinking…")
|
|
394
411
|
printer = _StreamPrinter(status, labels=self.labels, on_label=self.label_once)
|
|
395
412
|
try:
|
|
396
|
-
with InterruptibleSection(cancel_event):
|
|
413
|
+
with InterruptibleSection(cancel_event, self.note_requested):
|
|
397
414
|
yield printer
|
|
398
415
|
finally:
|
|
399
416
|
if not printer.started:
|
|
@@ -421,6 +438,30 @@ class ReplUI:
|
|
|
421
438
|
|
|
422
439
|
return request_scope(tool, path, ctx)
|
|
423
440
|
|
|
441
|
+
def take_note(self, session) -> None:
|
|
442
|
+
"""If Ctrl-N was pressed during the last activity, read one line now (cooked mode) and queue it
|
|
443
|
+
for the next model call. Called by the agent between activities."""
|
|
444
|
+
if not self.note_requested.is_set():
|
|
445
|
+
return
|
|
446
|
+
self.note_requested.clear()
|
|
447
|
+
self._commit_line()
|
|
448
|
+
try:
|
|
449
|
+
text = input(color(" note › ", "cyan")).strip()
|
|
450
|
+
except (EOFError, KeyboardInterrupt):
|
|
451
|
+
print()
|
|
452
|
+
return
|
|
453
|
+
if not text:
|
|
454
|
+
return
|
|
455
|
+
notes = getattr(session, "pending_notes", None)
|
|
456
|
+
if notes is None:
|
|
457
|
+
notes = []
|
|
458
|
+
try:
|
|
459
|
+
session.pending_notes = notes
|
|
460
|
+
except Exception:
|
|
461
|
+
return
|
|
462
|
+
notes.append(text)
|
|
463
|
+
print(color(" noted; the model sees it at its next call.", "gray"))
|
|
464
|
+
|
|
424
465
|
def auto_approved(self, tool, args) -> None:
|
|
425
466
|
import json as _json
|
|
426
467
|
|
|
@@ -551,6 +592,7 @@ class Repl:
|
|
|
551
592
|
from .hooks import session_event
|
|
552
593
|
|
|
553
594
|
session_event(self.session, "SessionStart", source="resume" if self.session.resumed else "startup")
|
|
595
|
+
self._start_update_check()
|
|
554
596
|
code = 1
|
|
555
597
|
try:
|
|
556
598
|
code = self._loop()
|
|
@@ -615,6 +657,20 @@ class Repl:
|
|
|
615
657
|
else:
|
|
616
658
|
print(color(" (run with --verbose or set SCOOT_VERBOSE=1 for a traceback)", "gray"))
|
|
617
659
|
|
|
660
|
+
def _start_update_check(self) -> None:
|
|
661
|
+
"""Opt-in (SCOOT_UPDATE_CHECK=1): one PyPI request in the background; the bar shows ⬆ x.y.z."""
|
|
662
|
+
from .updates import enabled, is_newer, latest_version
|
|
663
|
+
|
|
664
|
+
if not enabled():
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
def _run() -> None:
|
|
668
|
+
latest = latest_version()
|
|
669
|
+
if is_newer(latest):
|
|
670
|
+
self.session.update_available = latest
|
|
671
|
+
|
|
672
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
673
|
+
|
|
618
674
|
def _auth_user(self):
|
|
619
675
|
"""Status-bar identity: the provider serving the active model, or a plain 'not set up'."""
|
|
620
676
|
if not getattr(self.session, "provider_ready", True):
|