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.
Files changed (114) hide show
  1. {scootcli-0.5.0/src/scootcli.egg-info → scootcli-0.7.0}/PKG-INFO +22 -7
  2. {scootcli-0.5.0 → scootcli-0.7.0}/README.md +21 -6
  3. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/agent.py +9 -2
  5. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/cli.py +19 -1
  6. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/status.py +11 -3
  7. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/headless.py +15 -0
  8. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/hooks.py +36 -6
  9. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/keys.py +6 -1
  10. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/panel.py +3 -0
  11. scootcli-0.7.0/src/scootcli/pricing.py +80 -0
  12. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/router.py +3 -0
  13. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/repl.py +59 -3
  14. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/status.py +1 -1
  15. scootcli-0.7.0/src/scootcli/updates.py +46 -0
  16. {scootcli-0.5.0 → scootcli-0.7.0/src/scootcli.egg-info}/PKG-INFO +22 -7
  17. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli.egg-info/SOURCES.txt +3 -0
  18. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_headless.py +1 -0
  19. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_hooks.py +33 -0
  20. scootcli-0.7.0/tests/test_small_060.py +202 -0
  21. {scootcli-0.5.0 → scootcli-0.7.0}/LICENSE +0 -0
  22. {scootcli-0.5.0 → scootcli-0.7.0}/pyproject.toml +0 -0
  23. {scootcli-0.5.0 → scootcli-0.7.0}/setup.cfg +0 -0
  24. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/__main__.py +0 -0
  25. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/activity.py +0 -0
  26. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/approvals.py +0 -0
  27. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/auth.py +0 -0
  28. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/clipboard.py +0 -0
  29. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/__init__.py +0 -0
  30. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/approve.py +0 -0
  31. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/auth.py +0 -0
  32. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/base.py +0 -0
  33. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/compact.py +0 -0
  34. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/copy.py +0 -0
  35. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/exit.py +0 -0
  36. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/forget.py +0 -0
  37. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/help.py +0 -0
  38. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/hooks.py +0 -0
  39. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/init.py +0 -0
  40. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/logo.py +0 -0
  41. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/model.py +0 -0
  42. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/panel.py +0 -0
  43. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/reset.py +0 -0
  44. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/resume.py +0 -0
  45. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/route.py +0 -0
  46. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/save.py +0 -0
  47. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/scope.py +0 -0
  48. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/sessions.py +0 -0
  49. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/verbosity.py +0 -0
  50. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/worktree.py +0 -0
  51. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/commands/yolo.py +0 -0
  52. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/config.py +0 -0
  53. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/context.py +0 -0
  54. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/credentials.py +0 -0
  55. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/errors.py +0 -0
  56. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/images.py +0 -0
  57. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/lineeditor.py +0 -0
  58. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/logo.py +0 -0
  59. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/models.py +0 -0
  60. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/preferences.py +0 -0
  61. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/presets.py +0 -0
  62. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/project.py +0 -0
  63. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/prompts.py +0 -0
  64. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/__init__.py +0 -0
  65. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/anthropic.py +0 -0
  66. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/base.py +0 -0
  67. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/openai_chat.py +0 -0
  68. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/openai_responses.py +0 -0
  69. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/providers/registry.py +0 -0
  70. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/rendering.py +0 -0
  71. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/sessions.py +0 -0
  72. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/__init__.py +0 -0
  73. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/base.py +0 -0
  74. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/edit_file.py +0 -0
  75. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/list_dir.py +0 -0
  76. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/open_editor.py +0 -0
  77. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/read_file.py +0 -0
  78. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/run_shell.py +0 -0
  79. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/search.py +0 -0
  80. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/update_plan.py +0 -0
  81. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/tools/write_file.py +0 -0
  82. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/transport.py +0 -0
  83. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/vision.py +0 -0
  84. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/workspace.py +0 -0
  85. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli/worktree.py +0 -0
  86. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  87. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  88. {scootcli-0.5.0 → scootcli-0.7.0}/src/scootcli.egg-info/top_level.txt +0 -0
  89. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_agent.py +0 -0
  90. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_anthropic.py +0 -0
  91. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_approvals.py +0 -0
  92. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_auth.py +0 -0
  93. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_clipboard.py +0 -0
  94. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_config.py +0 -0
  95. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_images.py +0 -0
  96. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_lineeditor.py +0 -0
  97. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_logo.py +0 -0
  98. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_models.py +0 -0
  99. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_panel.py +0 -0
  100. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_preferences.py +0 -0
  101. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_providers.py +0 -0
  102. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_readiness.py +0 -0
  103. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_repl.py +0 -0
  104. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_resilience.py +0 -0
  105. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_router.py +0 -0
  106. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_scope.py +0 -0
  107. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_sessions.py +0 -0
  108. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_smoke.py +0 -0
  109. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_streaming.py +0 -0
  110. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_tools.py +0 -0
  111. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_transport_native.py +0 -0
  112. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_vision.py +0 -0
  113. {scootcli-0.5.0 → scootcli-0.7.0}/tests/test_workspace.py +0 -0
  114. {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.5.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": "ollama/llama3.2",
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 # anywhere
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
- **Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have.
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": "ollama/llama3.2",
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 # anywhere
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
- **Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have.
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
 
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
4
4
 
@@ -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', '?')}", "gray"))
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 len(by_model) > 1 or (by_model and session.model.lower() == "auto"):
48
- print(f" {color('tokens by model:', 'gray')}")
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
- print(f" {color(name, 'cyan'):<40} prompt={u['prompt']} completion={u['completion']} calls={u['calls']}")
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
- try:
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
- perm = str(data.get("permissionDecision") or "").lower()
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):