scootcli 0.4.0__tar.gz → 0.6.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.4.0/src/scootcli.egg-info → scootcli-0.6.0}/PKG-INFO +14 -4
  2. {scootcli-0.4.0 → scootcli-0.6.0}/README.md +13 -3
  3. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/agent.py +70 -1
  5. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/approvals.py +34 -0
  6. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/cli.py +21 -1
  7. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/__init__.py +1 -0
  8. scootcli-0.6.0/src/scootcli/commands/scope.py +33 -0
  9. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/status.py +12 -3
  10. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/config.py +2 -0
  11. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/headless.py +26 -0
  12. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/keys.py +6 -1
  13. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/panel.py +3 -0
  14. scootcli-0.6.0/src/scootcli/pricing.py +80 -0
  15. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/prompts.py +1 -1
  16. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/router.py +3 -0
  17. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/repl.py +68 -3
  18. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/status.py +1 -1
  19. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/base.py +76 -11
  20. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/edit_file.py +1 -1
  21. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/list_dir.py +1 -1
  22. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/open_editor.py +1 -1
  23. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/read_file.py +1 -1
  24. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/run_shell.py +2 -2
  25. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/write_file.py +1 -1
  26. scootcli-0.6.0/src/scootcli/updates.py +46 -0
  27. {scootcli-0.4.0 → scootcli-0.6.0/src/scootcli.egg-info}/PKG-INFO +14 -4
  28. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli.egg-info/SOURCES.txt +5 -0
  29. scootcli-0.6.0/tests/test_scope.py +225 -0
  30. scootcli-0.6.0/tests/test_small_060.py +182 -0
  31. {scootcli-0.4.0 → scootcli-0.6.0}/LICENSE +0 -0
  32. {scootcli-0.4.0 → scootcli-0.6.0}/pyproject.toml +0 -0
  33. {scootcli-0.4.0 → scootcli-0.6.0}/setup.cfg +0 -0
  34. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/__main__.py +0 -0
  35. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/activity.py +0 -0
  36. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/auth.py +0 -0
  37. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/clipboard.py +0 -0
  38. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/approve.py +0 -0
  39. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/auth.py +0 -0
  40. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/base.py +0 -0
  41. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/compact.py +0 -0
  42. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/copy.py +0 -0
  43. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/exit.py +0 -0
  44. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/forget.py +0 -0
  45. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/help.py +0 -0
  46. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/hooks.py +0 -0
  47. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/init.py +0 -0
  48. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/logo.py +0 -0
  49. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/model.py +0 -0
  50. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/panel.py +0 -0
  51. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/reset.py +0 -0
  52. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/resume.py +0 -0
  53. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/route.py +0 -0
  54. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/save.py +0 -0
  55. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/sessions.py +0 -0
  56. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/verbosity.py +0 -0
  57. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/worktree.py +0 -0
  58. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/yolo.py +0 -0
  59. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/context.py +0 -0
  60. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/credentials.py +0 -0
  61. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/errors.py +0 -0
  62. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/hooks.py +0 -0
  63. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/images.py +0 -0
  64. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/lineeditor.py +0 -0
  65. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/logo.py +0 -0
  66. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/models.py +0 -0
  67. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/preferences.py +0 -0
  68. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/presets.py +0 -0
  69. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/project.py +0 -0
  70. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/__init__.py +0 -0
  71. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/anthropic.py +0 -0
  72. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/base.py +0 -0
  73. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/openai_chat.py +0 -0
  74. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/openai_responses.py +0 -0
  75. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/registry.py +0 -0
  76. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/rendering.py +0 -0
  77. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/sessions.py +0 -0
  78. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/__init__.py +0 -0
  79. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/search.py +0 -0
  80. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/update_plan.py +0 -0
  81. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/transport.py +0 -0
  82. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/vision.py +0 -0
  83. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/workspace.py +0 -0
  84. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/worktree.py +0 -0
  85. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  86. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  87. {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli.egg-info/top_level.txt +0 -0
  88. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_agent.py +0 -0
  89. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_anthropic.py +0 -0
  90. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_approvals.py +0 -0
  91. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_auth.py +0 -0
  92. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_clipboard.py +0 -0
  93. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_config.py +0 -0
  94. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_headless.py +0 -0
  95. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_hooks.py +0 -0
  96. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_images.py +0 -0
  97. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_lineeditor.py +0 -0
  98. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_logo.py +0 -0
  99. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_models.py +0 -0
  100. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_panel.py +0 -0
  101. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_preferences.py +0 -0
  102. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_providers.py +0 -0
  103. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_readiness.py +0 -0
  104. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_repl.py +0 -0
  105. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_resilience.py +0 -0
  106. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_router.py +0 -0
  107. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_sessions.py +0 -0
  108. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_smoke.py +0 -0
  109. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_streaming.py +0 -0
  110. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_tools.py +0 -0
  111. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_transport_native.py +0 -0
  112. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_vision.py +0 -0
  113. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_workspace.py +0 -0
  114. {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.4.0
3
+ Version: 0.6.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
 
@@ -206,6 +206,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
206
206
  /model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
207
207
  /yolo auto-approve all /worktree isolate work in a git worktree
208
208
  /auth provider keys /logo show or toggle the mascot
209
+ /scope where file tools may go /hooks configured hooks and results
210
+ /route how auto picks a model
209
211
  /sessions list saved sessions /resume resume a saved session ([id])
210
212
  /forget delete session(s) /panel toggle the bottom status bar
211
213
  /verbosity feed detail (full|compact|quiet)
@@ -213,6 +215,7 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
213
215
  ```
214
216
 
215
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").
216
219
  Type `/` and press **Tab** to complete slash commands.
217
220
 
218
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.
@@ -223,7 +226,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
223
226
  ### Tools and approvals
224
227
 
225
228
  The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
226
- Every tool is sandboxed to the workspace root.
229
+ Paths are resolved against the workspace root, and `~` works.
230
+
231
+ The workspace is where the agent works, not a wall.
232
+ When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
233
+ `--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
227
234
 
228
235
  When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
229
236
  `/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
@@ -275,6 +282,9 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
275
282
  ```
276
283
 
277
284
  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.
285
+ **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, and `scoot --check-update` asks PyPI whether a newer one exists. 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.
286
+ `bash install.sh --uninstall` removes the command installed by the script (pipx users: `pipx uninstall scootcli`); config and sessions stay.
287
+
278
288
  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.
279
289
 
280
290
  Requirements: Python 3.9 or newer on macOS or Linux.
@@ -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
 
@@ -181,6 +181,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
181
181
  /model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
182
182
  /yolo auto-approve all /worktree isolate work in a git worktree
183
183
  /auth provider keys /logo show or toggle the mascot
184
+ /scope where file tools may go /hooks configured hooks and results
185
+ /route how auto picks a model
184
186
  /sessions list saved sessions /resume resume a saved session ([id])
185
187
  /forget delete session(s) /panel toggle the bottom status bar
186
188
  /verbosity feed detail (full|compact|quiet)
@@ -188,6 +190,7 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
188
190
  ```
189
191
 
190
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").
191
194
  Type `/` and press **Tab** to complete slash commands.
192
195
 
193
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.
@@ -198,7 +201,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
198
201
  ### Tools and approvals
199
202
 
200
203
  The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
201
- Every tool is sandboxed to the workspace root.
204
+ Paths are resolved against the workspace root, and `~` works.
205
+
206
+ The workspace is where the agent works, not a wall.
207
+ When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
208
+ `--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
202
209
 
203
210
  When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
204
211
  `/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
@@ -250,6 +257,9 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
250
257
  ```
251
258
 
252
259
  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.
260
+ **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, and `scoot --check-update` asks PyPI whether a newer one exists. 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.
261
+ `bash install.sh --uninstall` removes the command installed by the script (pipx users: `pipx uninstall scootcli`); config and sessions stay.
262
+
253
263
  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.
254
264
 
255
265
  Requirements: Python 3.9 or newer on macOS or Linux.
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.6.0"
4
4
 
@@ -70,6 +70,12 @@ class HeadlessUI:
70
70
  self.events.append(("approve", tool.name, args))
71
71
  return Approval(self.decision, args)
72
72
 
73
+ scope_decision = "dir" # what this headless UI answers to a scope question (tests override)
74
+
75
+ def approve_scope(self, tool, path, ctx) -> str:
76
+ self.events.append(("scope", tool.name, str(path)))
77
+ return self.scope_decision
78
+
73
79
  def auto_approved(self, tool, args) -> None:
74
80
  self.events.append(("auto", tool.name, args))
75
81
 
@@ -162,6 +168,9 @@ class Agent:
162
168
 
163
169
  Returns ``(result, streamed)`` where ``streamed`` is True if content was already printed.
164
170
  """
171
+ taker = getattr(ui, "take_note", None)
172
+ if callable(taker):
173
+ taker(session) # a Ctrl-N pressed during the previous activity
165
174
  messages = self._messages(session, cfg)
166
175
  hints = {"task_text": self._last_user_text(session), "needs_tools": True, "step": steps}
167
176
  if self._streaming and hasattr(ui, "stream") and hasattr(self.provider, "chat_stream"):
@@ -345,7 +354,16 @@ class Agent:
345
354
  # ── tool execution ───────────────────────────────────────────────────────────
346
355
  def _run_tools(self, session, tool_calls, ui, cancel_event, cfg=None) -> Optional[str]:
347
356
  cfg = cfg or getattr(session, "config", None) or self.config
348
- ctx = ToolContext(root=cfg.root, config=cfg, cancel_event=cancel_event)
357
+ scope = getattr(session, "scope", None)
358
+ if scope is None:
359
+ from .tools.base import Scope
360
+
361
+ scope = Scope(cfg.root, everything=(getattr(cfg, "scope", "workspace") == "anywhere"))
362
+ try:
363
+ session.scope = scope
364
+ except Exception:
365
+ pass
366
+ ctx = ToolContext(root=cfg.root, config=cfg, cancel_event=cancel_event, scope=scope)
349
367
  mode = getattr(session, "approval_mode", "always")
350
368
  # Session trust-list: tools the user chose to auto-approve for the rest of the session.
351
369
  trusted = getattr(session, "trusted_tools", None)
@@ -368,6 +386,17 @@ class Agent:
368
386
  self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
369
387
  continue
370
388
 
389
+ # Paths outside the workspace: ask the user once (this path, its directory, or anywhere).
390
+ outside = self._outside_paths(tool, args, ctx)
391
+ if outside:
392
+ verdict = self._ask_scope(session, tool, outside, ctx, ui)
393
+ if verdict == "abort":
394
+ self._append_tool(session, tc_id, "user aborted the operation")
395
+ return "abort"
396
+ if verdict == "deny":
397
+ self._append_tool(session, tc_id, f"user declined access outside the workspace: {outside[0]}")
398
+ ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
399
+ continue
371
400
  # A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
372
401
  hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
373
402
  if hook_action == "deny":
@@ -419,6 +448,46 @@ class Agent:
419
448
  self._append_tool(session, tc_id, payload or "(no output)")
420
449
  return None
421
450
 
451
+ @staticmethod
452
+ def _outside_paths(tool, args, ctx) -> "list":
453
+ from .tools.base import resolve_path
454
+
455
+ scope = ctx.scope
456
+ found = []
457
+ for raw in tool.paths(args):
458
+ try:
459
+ p = resolve_path(ctx.root, raw)
460
+ except Exception:
461
+ continue
462
+ if scope is None or not scope.allows(p):
463
+ found.append(p)
464
+ return found
465
+
466
+ def _ask_scope(self, session, tool, paths, ctx, ui) -> str:
467
+ """Ask for the first outside path; grant per the answer. Returns once|dir|all|deny|abort."""
468
+ scope = ctx.scope
469
+ if getattr(session, "config", None) is not None and getattr(session.config, "scope", "") == "anywhere":
470
+ scope.grant_all()
471
+ return "all"
472
+ asker = getattr(ui, "approve_scope", None)
473
+ if asker is None:
474
+ return "deny"
475
+ from .hooks import notify
476
+
477
+ notify(session, "scope", f"{tool.name} wants {paths[0]} outside the workspace")
478
+ verdict = asker(tool, paths[0], ctx)
479
+ if verdict == "once":
480
+ for p in paths:
481
+ scope.grant(p)
482
+ elif verdict == "dir":
483
+ for p in paths:
484
+ scope.grant_dir(p)
485
+ ui.assistant(f"allowing {scope.granted[-1]} for the rest of this session.")
486
+ elif verdict == "all":
487
+ scope.grant_all()
488
+ ui.assistant("allowing access anywhere on this machine for the rest of this session.")
489
+ return verdict
490
+
422
491
  @staticmethod
423
492
  def _handle_result(session, name: str, result: ToolResult, ui) -> None:
424
493
  """Surface a tool result: render a plan update specially, else the generic result line."""
@@ -126,6 +126,40 @@ def _edit_args(args: dict) -> dict:
126
126
  return args
127
127
 
128
128
 
129
+ SCOPE_CHOICES = ("once", "dir", "all", "deny", "abort")
130
+
131
+
132
+ def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
133
+ """Ask once about a path outside the workspace. Returns one of ``SCOPE_CHOICES``."""
134
+ from pathlib import Path as _P
135
+
136
+ p = _P(str(path))
137
+ where = p if p.is_dir() else p.parent
138
+ print(color(f" ⌂ {tool.name} wants {p}", "yellow") + color(" (outside the workspace)", "gray"))
139
+ while True:
140
+ print(
141
+ " "
142
+ + color("allow this path", "green") + " [a] "
143
+ + color(f"allow {where} this session", "green") + " [d] "
144
+ + color("allow anywhere this session", "green") + " [A] "
145
+ + color("skip", "yellow") + " [s] "
146
+ + color("quit", "red") + " [q] › ",
147
+ end="", flush=True,
148
+ )
149
+ key = read_key()
150
+ print(key)
151
+ if key in ("a", "y", "\r", "\n", ""):
152
+ return "once"
153
+ if key == "d":
154
+ return "dir"
155
+ if key == "A":
156
+ return "all"
157
+ if key == "s":
158
+ return "deny"
159
+ if key == "q":
160
+ return "abort"
161
+
162
+
129
163
  def request_approval(tool: Tool, args: dict, ctx: ToolContext) -> Approval:
130
164
  """Show the pending tool call and read the user's decision (with optional arg editing)."""
131
165
  while True:
@@ -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"],
@@ -59,6 +61,8 @@ def _build_parser() -> argparse.ArgumentParser:
59
61
  help="Don't inject the repo map (git + file tree) into the agent prompt.")
60
62
  parser.add_argument("--no-labels", action="store_true",
61
63
  help="Hide the role labels/gutters (❯ you / ⏺ scoot) in the REPL transcript.")
64
+ parser.add_argument("--scope", choices=["workspace", "anywhere"],
65
+ help="Where file tools may go: workspace (default; the first access outside it asks once) or anywhere.")
62
66
  parser.add_argument("--headless", action="store_true",
63
67
  help="Line-delimited JSON on stdin/stdout instead of the REPL (see docs/headless-protocol.md).")
64
68
  parser.add_argument("--no-logo", action="store_true",
@@ -92,6 +96,7 @@ def _config_from_args(args: argparse.Namespace) -> Config:
92
96
  proxy=args.proxy,
93
97
  root=args.root,
94
98
  approval="yolo" if (args.yolo or args.yes) else args.approval,
99
+ scope="anywhere" if args.yes else args.scope,
95
100
  verbose=True if args.verbose else None,
96
101
  stream=False if args.no_stream else None,
97
102
  panel=False if args.no_panel else None,
@@ -196,10 +201,13 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
196
201
  if outcome.content.strip() and not outcome.streamed:
197
202
  print(outcome.content.strip())
198
203
  if config.verbose:
204
+ from .pricing import fmt
205
+
199
206
  u = session.last_usage
200
207
  eprint(color(f"[{session.active_model}] steps={outcome.steps} "
201
208
  f"prompt={u.get('prompt_tokens', '?')} "
202
- f"completion={u.get('completion_tokens', '?')}", "gray"))
209
+ f"completion={u.get('completion_tokens', '?')} "
210
+ f"cost={fmt(session.session_cost())}", "gray"))
203
211
  return 0
204
212
  eprint(color(f"⚠ {redact(outcome.error or outcome.status)}", "red"))
205
213
  return 1
@@ -267,6 +275,18 @@ def _resolve_prompt(args: argparse.Namespace) -> Optional[str]:
267
275
 
268
276
  def main(argv: Optional[List[str]] = None) -> int:
269
277
  args = _build_parser().parse_args(argv)
278
+ if args.check_update:
279
+ from .updates import check, is_newer, upgrade_hint
280
+
281
+ current, latest = check()
282
+ if latest is None:
283
+ print(f"scoot {current} (could not reach PyPI to check for updates)")
284
+ return 1
285
+ if is_newer(latest, current):
286
+ print(f"scoot {current}; newer release available: {latest}\n {upgrade_hint()}")
287
+ else:
288
+ print(f"scoot {current} is the latest release.")
289
+ return 0
270
290
  try:
271
291
  config = _config_from_args(args)
272
292
  pool = ProviderPool(config)
@@ -53,6 +53,7 @@ def load_builtins() -> None:
53
53
  from . import logo as _logo # noqa: F401
54
54
  from . import route as _route # noqa: F401
55
55
  from . import hooks as _hooks # noqa: F401
56
+ from . import scope as _scope # noqa: F401
56
57
 
57
58
  _loaded = True
58
59
 
@@ -0,0 +1,33 @@
1
+ """/scope — where file tools may go: the workspace, plus what you allowed, or anywhere."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..rendering import color, eprint
6
+ from . import register
7
+ from .base import SlashCommand
8
+
9
+
10
+ def _run(session, args: str):
11
+ scope = getattr(session, "scope", None)
12
+ if scope is None:
13
+ eprint(color("scope is not available in this context.", "yellow"))
14
+ return
15
+ want = (args or "").strip().lower()
16
+ if want in ("anywhere", "all", "everywhere"):
17
+ scope.grant_all()
18
+ print(color("file tools may now go anywhere on this machine for this session.", "gray"))
19
+ elif want == "workspace":
20
+ scope.everything = False
21
+ scope.granted.clear()
22
+ print(color("back to the workspace only; the next access outside it will ask.", "gray"))
23
+ elif want:
24
+ scope.grant_dir(want)
25
+ print(color(f"allowed {scope.granted[-1]} for this session.", "gray"))
26
+ print(color("scope: ", "bold") + scope.describe())
27
+ for g in scope.granted:
28
+ print(f" {color('+', 'green')} {g}")
29
+ print(color(" usage: /scope anywhere · /scope workspace · /scope <dir> (also --scope, SCOOT_SCOPE)", "gray"))
30
+
31
+
32
+ register(SlashCommand("scope", "where file tools may go (workspace | anywhere | <dir>)", _run,
33
+ usage="[anywhere|workspace|<dir>]"))
@@ -23,8 +23,11 @@ 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),
30
+ ("scope", getattr(session, "scope", None).describe() if getattr(session, "scope", None) else "workspace"),
28
31
  ("streaming", "on" if getattr(cfg, "stream", True) else "off"),
29
32
  ("panel", "on" if getattr(session, "status_bar", None) and session.status_bar.enabled
30
33
  else "off"),
@@ -43,10 +46,16 @@ def _run(session, args: str):
43
46
  for label, value in rows:
44
47
  print(f" {color(label + ':', 'gray'):<28} {value}")
45
48
  by_model = getattr(session, "usage_by_model", None) or {}
46
- if len(by_model) > 1 or (by_model and session.model.lower() == "auto"):
47
- 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')}")
48
53
  for name, u in sorted(by_model.items(), key=lambda kv: -(kv[1]['prompt'] + kv[1]['completion'])):
49
- 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')}")
50
59
  if session.model.lower() == "auto" and getattr(session, "route_reason", ""):
51
60
  print(f" {color('routed by:', 'gray'):<28} {session.route_reason}")
52
61
  trusted = getattr(session, "trusted_tools", None)
@@ -179,6 +179,7 @@ class Config:
179
179
  vision_model: str = "auto" # provider/model for image descriptions, or "auto" → pick a capable one
180
180
  image_max_bytes: int = 4 * 1024 * 1024 # per-image cap (no downscale without a 3rd-party lib)
181
181
  editor: str = "idea" # external editor for the open_editor tool: idea | vscode
182
+ scope: str = "workspace" # where file tools may go: workspace (ask once outside it) | anywhere
182
183
  env_files: Tuple[str, ...] = () # the .env files that were read, in load order (shown by /status)
183
184
  logo: bool = True # mascot in the launch banner + status-bar face (--no-logo / SCOOT_LOGO / /logo)
184
185
  emoji: bool = True # 🛴 transcript label; off → ⏺ for terminals without an emoji font (--no-emoji)
@@ -216,6 +217,7 @@ class Config:
216
217
  vision_model=get("SCOOT_VISION_MODEL", "auto"),
217
218
  image_max_bytes=int(get("SCOOT_IMAGE_MAX_BYTES", str(4 * 1024 * 1024))),
218
219
  editor=get("SCOOT_EDITOR", "idea").strip().lower(),
220
+ scope=get("SCOOT_SCOPE", "workspace").strip().lower(),
219
221
  logo=_resolve_logo_setting(get("SCOOT_LOGO")),
220
222
  emoji=_as_bool(get("SCOOT_EMOJI", "true")),
221
223
  )
@@ -136,6 +136,32 @@ class HeadlessUI:
136
136
  auto_approved=False, decision=str(msg.get("decision")).lower())
137
137
  return Approval(decision, new_args)
138
138
 
139
+ def approve_scope(self, tool, path, ctx) -> str:
140
+ req = self._call_id()
141
+ self.writer.emit("scope_request", id=req, name=tool.name, path=str(path),
142
+ options=["allow_once", "allow_dir", "allow_all", "deny", "abort"], timeout_s=self.timeout)
143
+ mapping = {"allow_once": "once", "allow_dir": "dir", "allow_all": "all", "deny": "deny", "abort": "abort"}
144
+ deadline = time.time() + self.timeout
145
+ while True:
146
+ remaining = deadline - time.time()
147
+ if remaining <= 0:
148
+ self.writer.emit("notice", message=f"scope request {req} timed out after {self.timeout:g}s; denied")
149
+ return "deny"
150
+ if self.cancel_event is not None and self.cancel_event.is_set():
151
+ return "abort"
152
+ try:
153
+ msg = self.answers.get(timeout=min(0.2, remaining))
154
+ except queue.Empty:
155
+ continue
156
+ if msg.get("id") not in (None, "", req):
157
+ self.writer.emit("error", message=f"answer for unknown request {msg.get('id')}", kind="protocol")
158
+ continue
159
+ verdict = mapping.get(str(msg.get("decision", "")).lower())
160
+ if verdict is None:
161
+ self.writer.emit("error", message=f"unknown scope decision {msg.get('decision')!r}; expected one of {list(mapping)}", kind="protocol")
162
+ continue
163
+ return verdict
164
+
139
165
  def plan(self, plan) -> None:
140
166
  self.writer.emit("plan", steps=plan)
141
167
 
@@ -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}"
@@ -23,7 +23,7 @@ You run inside the user's workspace and act through tools. Be concise, correct,
23
23
  ENVIRONMENT
24
24
  - Working directory (root): {{workspace_root}}
25
25
  - OS: {{os}} Model: {{model}}
26
- - All file paths you use must stay inside the root. Never touch paths outside it.
26
+ - The root is your workspace, not a wall: when the task needs files elsewhere on this machine (~/.config, another repo, /etc), use them with absolute paths (~ is fine). The first access outside the workspace asks the user once; never tell the user you are not allowed to leave the workspace, and never hand them a script to run in your place because of it.
27
27
 
28
28
  WORKSPACE (auto-generated map of the current repo — use it to orient before listing/reading files)
29
29
  {{workspace}}
@@ -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: