scootcli 0.3.0__tar.gz → 0.4.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 (109) hide show
  1. {scootcli-0.3.0/src/scootcli.egg-info → scootcli-0.4.0}/PKG-INFO +37 -3
  2. {scootcli-0.3.0 → scootcli-0.4.0}/README.md +36 -2
  3. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/agent.py +73 -5
  5. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/cli.py +23 -8
  6. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/__init__.py +1 -0
  7. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/auth.py +16 -0
  8. scootcli-0.4.0/src/scootcli/commands/hooks.py +37 -0
  9. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/model.py +4 -0
  10. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/reset.py +3 -0
  11. scootcli-0.4.0/src/scootcli/headless.py +329 -0
  12. scootcli-0.4.0/src/scootcli/hooks.py +264 -0
  13. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/panel.py +3 -0
  14. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/base.py +7 -3
  15. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/registry.py +15 -4
  16. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/repl.py +52 -7
  17. {scootcli-0.3.0 → scootcli-0.4.0/src/scootcli.egg-info}/PKG-INFO +37 -3
  18. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli.egg-info/SOURCES.txt +5 -0
  19. scootcli-0.4.0/tests/test_headless.py +247 -0
  20. scootcli-0.4.0/tests/test_hooks.py +242 -0
  21. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_readiness.py +46 -0
  22. {scootcli-0.3.0 → scootcli-0.4.0}/LICENSE +0 -0
  23. {scootcli-0.3.0 → scootcli-0.4.0}/pyproject.toml +0 -0
  24. {scootcli-0.3.0 → scootcli-0.4.0}/setup.cfg +0 -0
  25. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/__main__.py +0 -0
  26. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/activity.py +0 -0
  27. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/approvals.py +0 -0
  28. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/auth.py +0 -0
  29. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/clipboard.py +0 -0
  30. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/approve.py +0 -0
  31. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/base.py +0 -0
  32. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/compact.py +0 -0
  33. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/copy.py +0 -0
  34. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/exit.py +0 -0
  35. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/forget.py +0 -0
  36. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/help.py +0 -0
  37. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/init.py +0 -0
  38. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/logo.py +0 -0
  39. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/panel.py +0 -0
  40. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/resume.py +0 -0
  41. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/route.py +0 -0
  42. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/save.py +0 -0
  43. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/sessions.py +0 -0
  44. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/status.py +0 -0
  45. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/verbosity.py +0 -0
  46. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/worktree.py +0 -0
  47. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/yolo.py +0 -0
  48. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/config.py +0 -0
  49. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/context.py +0 -0
  50. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/credentials.py +0 -0
  51. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/errors.py +0 -0
  52. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/images.py +0 -0
  53. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/keys.py +0 -0
  54. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/lineeditor.py +0 -0
  55. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/logo.py +0 -0
  56. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/models.py +0 -0
  57. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/preferences.py +0 -0
  58. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/presets.py +0 -0
  59. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/project.py +0 -0
  60. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/prompts.py +0 -0
  61. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/__init__.py +0 -0
  62. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/anthropic.py +0 -0
  63. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/openai_chat.py +0 -0
  64. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/openai_responses.py +0 -0
  65. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/router.py +0 -0
  66. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/rendering.py +0 -0
  67. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/sessions.py +0 -0
  68. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/status.py +0 -0
  69. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/__init__.py +0 -0
  70. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/base.py +0 -0
  71. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/edit_file.py +0 -0
  72. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/list_dir.py +0 -0
  73. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/open_editor.py +0 -0
  74. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/read_file.py +0 -0
  75. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/run_shell.py +0 -0
  76. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/search.py +0 -0
  77. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/update_plan.py +0 -0
  78. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/write_file.py +0 -0
  79. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/transport.py +0 -0
  80. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/vision.py +0 -0
  81. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/workspace.py +0 -0
  82. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/worktree.py +0 -0
  83. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  84. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  85. {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli.egg-info/top_level.txt +0 -0
  86. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_agent.py +0 -0
  87. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_anthropic.py +0 -0
  88. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_approvals.py +0 -0
  89. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_auth.py +0 -0
  90. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_clipboard.py +0 -0
  91. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_config.py +0 -0
  92. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_images.py +0 -0
  93. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_lineeditor.py +0 -0
  94. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_logo.py +0 -0
  95. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_models.py +0 -0
  96. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_panel.py +0 -0
  97. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_preferences.py +0 -0
  98. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_providers.py +0 -0
  99. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_repl.py +0 -0
  100. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_resilience.py +0 -0
  101. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_router.py +0 -0
  102. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_sessions.py +0 -0
  103. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_smoke.py +0 -0
  104. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_streaming.py +0 -0
  105. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_tools.py +0 -0
  106. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_transport_native.py +0 -0
  107. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_vision.py +0 -0
  108. {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_workspace.py +0 -0
  109. {scootcli-0.3.0 → scootcli-0.4.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.0
3
+ Version: 0.4.0
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -61,12 +61,19 @@ The naive version did real work in my repos for long enough that the next step w
61
61
  ## Quick start
62
62
 
63
63
  ```bash
64
- pipx install scootcli # or: pip install scootcli
64
+ pipx install scootcli && pipx ensurepath # then open a new terminal so `scoot` is on PATH
65
65
  scoot auth set openai # paste your OpenAI API key once (hidden input, validated, stored 0600)
66
66
  cd ~/code/your-project
67
67
  scoot # open the REPL
68
68
  ```
69
69
 
70
+ No pipx yet? On Debian, Ubuntu, and Pop!_OS it is `sudo apt install pipx`; on macOS `brew install pipx`.
71
+ Or skip pipx entirely with the one-line installer, which needs only `curl` and Python 3.9+:
72
+
73
+ ```bash
74
+ curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash
75
+ ```
76
+
70
77
  Or run entirely local with [Ollama](https://ollama.com), no key at all:
71
78
 
72
79
  ```bash
@@ -74,6 +81,9 @@ ollama pull llama3.2
74
81
  scoot --model ollama/llama3.2
75
82
  ```
76
83
 
84
+ **First run.** With no key saved and no Ollama running, scoot still opens: the banner says "no provider set up yet", a short block lists the three ways to set one up, and the bar shows `not set up` until a provider can answer.
85
+ Run `scoot auth set openai` (or `anthropic`) inside the REPL and the bar switches to the real provider and model at once.
86
+
77
87
  The first prompt:
78
88
 
79
89
  ```
@@ -227,6 +237,29 @@ Responses stream live and stay interruptible.
227
237
  Drag an image into the prompt and a vision-capable model describes it into the turn as text, so even a text-only coding model can act on it; `SCOOT_VISION_MODEL` pins the describer, `--no-images` turns the feature off.
228
238
  Every turn auto-saves under `~/.local/state/scoot/sessions/` (owner-only, secrets redacted, last 20 kept); `scoot --continue` or `/resume` picks up where you left off.
229
239
 
240
+ ### Automation: hooks and headless mode
241
+
242
+ **Hooks** run your own scripts at lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `SessionEnd`.
243
+ A hook gets a JSON payload on stdin and answers with an exit code or JSON on stdout; the shapes follow the convention Claude Code established, so a script written for one works with the other.
244
+ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
245
+
246
+ ```json
247
+ {
248
+ "PreToolUse": [
249
+ {"matcher": "run_shell|write_file|edit_file", "hooks": [{"type": "command", "command": "~/bin/guard.py", "timeout": 30}]}
250
+ ],
251
+ "Stop": [{"hooks": [{"type": "command", "command": "~/bin/notify.sh"}]}]
252
+ }
253
+ ```
254
+
255
+ 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.
256
+ 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.
257
+ `/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
258
+
259
+ **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.
260
+ Same sessions, tools, approvals, routing, and hooks as the REPL.
261
+ 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).
262
+
230
263
  ### The status bar
231
264
 
232
265
  The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `> >` thinking, `- -` stopped), the provider, the workspace and session id, the model, the approval mode, context size against the auto-compact threshold, cumulative tokens, message count, and the last error if any.
@@ -234,7 +267,7 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
234
267
  ## Install options
235
268
 
236
269
  ```bash
237
- pipx install scootcli # recommended: isolated, `scoot` on PATH
270
+ pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
238
271
  curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
239
272
  pip install scootcli # anywhere
240
273
  curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
@@ -242,6 +275,7 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
242
275
  ```
243
276
 
244
277
  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.
278
+ 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.
245
279
 
246
280
  Requirements: Python 3.9 or newer on macOS or Linux.
247
281
  Nothing else: no compiler, no packages, no `curl`.
@@ -36,12 +36,19 @@ The naive version did real work in my repos for long enough that the next step w
36
36
  ## Quick start
37
37
 
38
38
  ```bash
39
- pipx install scootcli # or: pip install scootcli
39
+ pipx install scootcli && pipx ensurepath # then open a new terminal so `scoot` is on PATH
40
40
  scoot auth set openai # paste your OpenAI API key once (hidden input, validated, stored 0600)
41
41
  cd ~/code/your-project
42
42
  scoot # open the REPL
43
43
  ```
44
44
 
45
+ No pipx yet? On Debian, Ubuntu, and Pop!_OS it is `sudo apt install pipx`; on macOS `brew install pipx`.
46
+ Or skip pipx entirely with the one-line installer, which needs only `curl` and Python 3.9+:
47
+
48
+ ```bash
49
+ curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash
50
+ ```
51
+
45
52
  Or run entirely local with [Ollama](https://ollama.com), no key at all:
46
53
 
47
54
  ```bash
@@ -49,6 +56,9 @@ ollama pull llama3.2
49
56
  scoot --model ollama/llama3.2
50
57
  ```
51
58
 
59
+ **First run.** With no key saved and no Ollama running, scoot still opens: the banner says "no provider set up yet", a short block lists the three ways to set one up, and the bar shows `not set up` until a provider can answer.
60
+ Run `scoot auth set openai` (or `anthropic`) inside the REPL and the bar switches to the real provider and model at once.
61
+
52
62
  The first prompt:
53
63
 
54
64
  ```
@@ -202,6 +212,29 @@ Responses stream live and stay interruptible.
202
212
  Drag an image into the prompt and a vision-capable model describes it into the turn as text, so even a text-only coding model can act on it; `SCOOT_VISION_MODEL` pins the describer, `--no-images` turns the feature off.
203
213
  Every turn auto-saves under `~/.local/state/scoot/sessions/` (owner-only, secrets redacted, last 20 kept); `scoot --continue` or `/resume` picks up where you left off.
204
214
 
215
+ ### Automation: hooks and headless mode
216
+
217
+ **Hooks** run your own scripts at lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `SessionEnd`.
218
+ A hook gets a JSON payload on stdin and answers with an exit code or JSON on stdout; the shapes follow the convention Claude Code established, so a script written for one works with the other.
219
+ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
220
+
221
+ ```json
222
+ {
223
+ "PreToolUse": [
224
+ {"matcher": "run_shell|write_file|edit_file", "hooks": [{"type": "command", "command": "~/bin/guard.py", "timeout": 30}]}
225
+ ],
226
+ "Stop": [{"hooks": [{"type": "command", "command": "~/bin/notify.sh"}]}]
227
+ }
228
+ ```
229
+
230
+ 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.
231
+ 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.
232
+ `/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
233
+
234
+ **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.
235
+ Same sessions, tools, approvals, routing, and hooks as the REPL.
236
+ 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).
237
+
205
238
  ### The status bar
206
239
 
207
240
  The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `> >` thinking, `- -` stopped), the provider, the workspace and session id, the model, the approval mode, context size against the auto-compact threshold, cumulative tokens, message count, and the last error if any.
@@ -209,7 +242,7 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
209
242
  ## Install options
210
243
 
211
244
  ```bash
212
- pipx install scootcli # recommended: isolated, `scoot` on PATH
245
+ pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
213
246
  curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
214
247
  pip install scootcli # anywhere
215
248
  curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
@@ -217,6 +250,7 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
217
250
  ```
218
251
 
219
252
  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.
253
+ 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.
220
254
 
221
255
  Requirements: Python 3.9 or newer on macOS or Linux.
222
256
  Nothing else: no compiler, no packages, no `curl`.
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
4
4
 
@@ -97,6 +97,7 @@ class Agent:
97
97
  self._refresh_workspace(session, cfg)
98
98
  steps = 0
99
99
  compacted = False
100
+ stop_blocks = 0
100
101
  while True:
101
102
  if cancel_event.is_set():
102
103
  return AgentOutcome("interrupted", steps=steps)
@@ -144,10 +145,16 @@ class Agent:
144
145
  return AgentOutcome(status, steps=steps)
145
146
  continue
146
147
 
147
- # No tool calls -> the model is done.
148
- return AgentOutcome(
149
- "done", content=_strip_done(result.content), steps=steps, streamed=streamed
150
- )
148
+ # No tool calls -> the model is done, unless a Stop hook asks for more (bounded).
149
+ content = _strip_done(result.content)
150
+ if stop_blocks < 3:
151
+ reason = self._stop_hook(session, content, steps)
152
+ if reason:
153
+ stop_blocks += 1
154
+ session.messages.append({"role": "user", "content": reason})
155
+ ui.assistant(f"continuing: {reason}")
156
+ continue
157
+ return AgentOutcome("done", content=content, steps=steps, streamed=streamed)
151
158
 
152
159
  # ── model call (streaming or buffered) ───────────────────────────────────────
153
160
  def _model_call(self, session, cfg, ui, cancel_event, steps):
@@ -181,6 +188,45 @@ class Agent:
181
188
  )
182
189
  return result, False
183
190
 
191
+ # ── hooks ────────────────────────────────────────────────────────────────────
192
+ @staticmethod
193
+ def _hooks(session):
194
+ from .hooks import for_session
195
+
196
+ return for_session(session)
197
+
198
+ def _stop_hook(self, session, content: str, steps: int) -> str:
199
+ hooks = self._hooks(session)
200
+ if hooks is None or not hooks.has("Stop"):
201
+ return ""
202
+ payload = hooks.payload(session, "Stop", last_assistant_message=content, steps=steps,
203
+ usage=getattr(session, "last_usage", {}) or {})
204
+ decision = hooks.run("Stop", payload)
205
+ return (decision.reason or "the Stop hook asked to continue") if decision.blocks else ""
206
+
207
+ def _pre_tool_hook(self, session, tool, args, cancel_event) -> "tuple[str, str]":
208
+ hooks = self._hooks(session)
209
+ if hooks is None or not hooks.has("PreToolUse"):
210
+ return "", ""
211
+ from .hooks import tool_kind
212
+
213
+ payload = hooks.payload(session, "PreToolUse", tool_name=tool.name, tool_input=args,
214
+ tool_kind=tool_kind(tool))
215
+ decision = hooks.run("PreToolUse", payload, cancel_event)
216
+ return decision.action, decision.reason
217
+
218
+ def _post_tool_hook(self, session, tool, args, result: ToolResult) -> None:
219
+ hooks = self._hooks(session)
220
+ if hooks is None or not hooks.has("PostToolUse"):
221
+ return
222
+ from .hooks import tool_kind
223
+
224
+ response = {"ok": result.ok, "summary": result.summary or "", "error": result.error or "",
225
+ "content": (result.content or "")[:4000]}
226
+ hooks.run("PostToolUse", hooks.payload(session, "PostToolUse", tool_name=tool.name,
227
+ tool_input=args, tool_kind=tool_kind(tool),
228
+ tool_response=response))
229
+
184
230
  @staticmethod
185
231
  def _last_user_text(session) -> str:
186
232
  for m in reversed(getattr(session, "messages", []) or []):
@@ -236,6 +282,13 @@ class Agent:
236
282
 
237
283
  def _messages(self, session, cfg=None) -> List[dict]:
238
284
  cfg = cfg or getattr(session, "config", None) or self.config
285
+ notes = getattr(session, "pending_notes", None)
286
+ if notes:
287
+ joined = "\n".join(n for n in notes if n)
288
+ notes.clear()
289
+ if joined:
290
+ session.messages.append({"role": "user",
291
+ "content": "Note from the user while you work:\n" + joined})
239
292
  system = build_agent_system_prompt(
240
293
  root=cfg.root,
241
294
  model=session.active_model,
@@ -315,11 +368,25 @@ class Agent:
315
368
  self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
316
369
  continue
317
370
 
371
+ # A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
372
+ hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
373
+ if hook_action == "deny":
374
+ self._append_tool(session, tc_id, f"user declined via hook: {hook_reason or 'no reason given'}")
375
+ ui.tool_result(name, ToolResult(ok=False, summary=f"denied by hook: {hook_reason}"[:80]))
376
+ continue
318
377
  # Approval policy: auto-approve when the mode/trust allows it, else prompt.
319
- if needs_prompt(mode, tool, args, trusted) is None:
378
+ must_prompt = needs_prompt(mode, tool, args, trusted)
379
+ if hook_action == "allow":
380
+ must_prompt = None
381
+ elif hook_action == "ask":
382
+ must_prompt = hook_reason or "hook asked for confirmation"
383
+ if must_prompt is None:
320
384
  if not getattr(tool, "auto_approve", False):
321
385
  ui.auto_approved(tool, args) # meta tools render their own output
322
386
  else:
387
+ from .hooks import notify
388
+
389
+ notify(session, "approval", f"{name} needs approval: {must_prompt}")
323
390
  approval = ui.approve(tool, args, ctx)
324
391
  if approval.decision == Decision.ABORT:
325
392
  self._append_tool(session, tc_id, "user aborted the operation")
@@ -347,6 +414,7 @@ class Agent:
347
414
  result = ToolResult.fail(f"tool crashed: {exc}")
348
415
 
349
416
  self._handle_result(session, name, result, ui)
417
+ self._post_tool_hook(session, tool, args, result)
350
418
  payload = result.content if result.ok else f"ERROR: {result.error}"
351
419
  self._append_tool(session, tc_id, payload or "(no output)")
352
420
  return None
@@ -59,6 +59,8 @@ def _build_parser() -> argparse.ArgumentParser:
59
59
  help="Don't inject the repo map (git + file tree) into the agent prompt.")
60
60
  parser.add_argument("--no-labels", action="store_true",
61
61
  help="Hide the role labels/gutters (❯ you / ⏺ scoot) in the REPL transcript.")
62
+ parser.add_argument("--headless", action="store_true",
63
+ help="Line-delimited JSON on stdin/stdout instead of the REPL (see docs/headless-protocol.md).")
62
64
  parser.add_argument("--no-logo", action="store_true",
63
65
  help="Hide the mascot (launch banner art + status-bar face). Persist with /logo off.")
64
66
  parser.add_argument("--no-emoji", action="store_true",
@@ -154,6 +156,17 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
154
156
  session = ReplSession(config, pool)
155
157
  if resume is not None:
156
158
  session.apply_record(resume)
159
+ from .hooks import Hooks, session_event, submit_prompt
160
+
161
+ session.hooks = Hooks(config.root)
162
+ session_event(session, "SessionStart", source="resume" if resume is not None else "startup")
163
+ submitted = submit_prompt(session, prompt)
164
+ if submitted is None:
165
+ reason = getattr(session, "hook_block_reason", "") or "a UserPromptSubmit hook blocked it"
166
+ eprint(color(f"⏹ prompt not sent: {reason}", "yellow"))
167
+ session_event(session, "SessionEnd", reason="blocked")
168
+ return 1
169
+ prompt = submitted
157
170
  # Fold any dropped image paths into the prompt (best-effort; no-op when none/disabled).
158
171
  try:
159
172
  from .vision import fold_images_into_text
@@ -166,6 +179,7 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
166
179
  agent_config = config.override(stream=False) if as_json else config
167
180
  outcome = Agent(agent_config, pool).run_turn(session, ReplUI(), threading.Event())
168
181
  session.autosave() # persist so `scoot -c` can continue this conversation
182
+ session_event(session, "SessionEnd", reason=outcome.status)
169
183
 
170
184
  if as_json:
171
185
  print(_json.dumps({
@@ -214,14 +228,8 @@ def _interactive(pool: ProviderPool, resume=None) -> int:
214
228
 
215
229
  resume = sessions.latest_for_root(str(config.root))
216
230
 
217
- try:
218
- from .providers.registry import readiness
219
-
220
- ready, message = readiness(config)
221
- if not ready: # first-run onboarding: guide, but still open the REPL so /auth is usable
222
- eprint(color(message, "yellow"))
223
- except ScootError as exc:
224
- eprint(color(f"⚠ {redact(str(exc))}", "yellow"))
231
+ # First-run onboarding happens inside the REPL (banner + a setup block), because anything printed
232
+ # here would be wiped by the screen clear the REPL does on start.
225
233
  return Repl(config, pool, resume=resume).run()
226
234
 
227
235
 
@@ -271,6 +279,13 @@ def main(argv: Optional[List[str]] = None) -> int:
271
279
 
272
280
  prompt = _resolve_prompt(args)
273
281
  resume = _resolve_resume(config, args)
282
+ if args.headless:
283
+ if prompt is not None:
284
+ eprint(color("--headless takes prompts on stdin, not as an argument.", "yellow"))
285
+ return 2
286
+ from .headless import run_headless
287
+
288
+ return run_headless(config.override(panel=False, dock=False, logo=False), pool, resume=resume)
274
289
  if prompt is not None:
275
290
  return _run_once(config, pool, prompt, args.json, resume=resume)
276
291
  return _interactive(pool, resume=resume)
@@ -52,6 +52,7 @@ def load_builtins() -> None:
52
52
  from . import copy as _copy # noqa: F401
53
53
  from . import logo as _logo # noqa: F401
54
54
  from . import route as _route # noqa: F401
55
+ from . import hooks as _hooks # noqa: F401
55
56
 
56
57
  _loaded = True
57
58
 
@@ -69,6 +69,7 @@ def _set(session, name: str) -> None:
69
69
  if pool is not None and hasattr(pool, "_providers"):
70
70
  pool._providers.pop(name, None) # rebuild with the new key on next use
71
71
  print(color(f"✔ {name} key saved to {path} (owner-only).", "green"))
72
+ _after_change(session)
72
73
 
73
74
 
74
75
  def _clear(session, name: str) -> None:
@@ -80,6 +81,7 @@ def _clear(session, name: str) -> None:
80
81
  print(color(f"forgot the saved {name} key.", "green"))
81
82
  else:
82
83
  print(color(f"no saved {name} key.", "gray"))
84
+ _after_change(session)
83
85
  pool = getattr(session, "provider", None)
84
86
  if pool is not None and hasattr(pool, "_providers"):
85
87
  pool._providers.pop(name, None)
@@ -90,6 +92,20 @@ def _clear(session, name: str) -> None:
90
92
  print(color(f" {missing_key_hint(spec)}", "gray"))
91
93
 
92
94
 
95
+ def _after_change(session) -> None:
96
+ """A key came or went: the default provider may have changed, so re-resolve and re-check."""
97
+ try:
98
+ if session.model.lower() in ("default", "auto"):
99
+ session.active_model = session.resolved_model()
100
+ session._available = None # the model list depends on which providers are configured
101
+ if session.refresh_readiness():
102
+ print(color(f"ready: {session.active_model}", "gray"))
103
+ else:
104
+ print(color(session.setup_message, "yellow"))
105
+ except Exception:
106
+ pass
107
+
108
+
93
109
  def _run(session, args: str):
94
110
  words = (args or "").split()
95
111
  if not words:
@@ -0,0 +1,37 @@
1
+ """/hooks — list the configured hooks and what they did last."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..hooks import EVENTS, enabled, global_path, project_path
6
+ from ..rendering import color
7
+ from . import register
8
+ from .base import SlashCommand
9
+
10
+
11
+ def _run(session, args: str):
12
+ hooks = getattr(session, "hooks", None)
13
+ if (args or "").strip() == "reload" and hooks is not None:
14
+ hooks.reload()
15
+ print(color("hooks reloaded.", "gray"))
16
+ print(color("hooks:", "bold"))
17
+ if not enabled():
18
+ print(color(" disabled by SCOOT_HOOKS=0", "yellow"))
19
+ root = getattr(getattr(session, "config", None), "root", ".")
20
+ print(color(f" files: {project_path(root)} (project), {global_path()} (global)", "gray"))
21
+ if hooks is None or not hooks.config:
22
+ print(color(" none configured", "gray"))
23
+ else:
24
+ for event in EVENTS:
25
+ for entry in hooks.config.get(event, []):
26
+ matcher = f" [{entry['matcher']}]" if entry.get("matcher") else ""
27
+ for hook in entry.get("hooks", []):
28
+ print(f" {color(event, 'cyan')}{matcher} {hook.get('command', '')}")
29
+ if hooks.history:
30
+ print(color(" recent:", "gray"))
31
+ for r in hooks.history[-8:]:
32
+ detail = f" {r.detail[:60]}" if r.detail else ""
33
+ print(f" {r.event:<16} {r.outcome:<8} {r.seconds:>5.2f}s {r.command[:40]}{detail}")
34
+ print(color(" usage: /hooks [reload]", "gray"))
35
+
36
+
37
+ register(SlashCommand("hooks", "list configured hooks and recent results", _run, usage="[reload]"))
@@ -40,6 +40,8 @@ def _run(session, args: str):
40
40
  set_model(arg)
41
41
  what = "picked per task from the live list" if arg == "auto" else f"the provider's preferred model, now {session.active_model}"
42
42
  print(color(f"model preference set to {arg} ({what}); saved for next launch.", "gray"))
43
+ if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
44
+ print(color(session.setup_message, "yellow"))
43
45
  return
44
46
 
45
47
  if arg not in available:
@@ -55,6 +57,8 @@ def _run(session, args: str):
55
57
  session.active_model = arg
56
58
  set_model(arg)
57
59
  print(color(f"model set to {arg}; saved for next launch.", "gray"))
60
+ if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
61
+ print(color(session.setup_message, "yellow"))
58
62
 
59
63
 
60
64
  register(SlashCommand("model", "list or switch model (default|auto|provider/model)", _run, usage="[provider/model|default|auto]"))
@@ -9,6 +9,9 @@ from .base import SlashCommand
9
9
 
10
10
  def _run(session, args: str):
11
11
  session.reset()
12
+ from ..hooks import session_event
13
+
14
+ session_event(session, "SessionStart", source="reset")
12
15
  redraw = getattr(session, "redraw_home", None)
13
16
  if callable(redraw):
14
17
  redraw("fresh session — conversation + context cleared.")