scootcli 0.8.1__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. {scootcli-0.8.1/src/scootcli.egg-info → scootcli-0.9.0}/PKG-INFO +5 -3
  2. {scootcli-0.8.1 → scootcli-0.9.0}/README.md +4 -2
  3. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/agent.py +82 -11
  5. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/cli.py +2 -0
  6. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/worktree.py +9 -4
  7. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/headless.py +2 -0
  8. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/hooks.py +16 -10
  9. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/anthropic.py +7 -3
  10. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/base.py +15 -1
  11. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/openai_chat.py +2 -0
  12. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/openai_responses.py +7 -4
  13. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/repl.py +30 -4
  14. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/sessions.py +29 -0
  15. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/base.py +119 -16
  16. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/edit_file.py +25 -12
  17. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/write_file.py +11 -5
  18. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/transport.py +27 -10
  19. scootcli-0.9.0/src/scootcli/worktree.py +163 -0
  20. {scootcli-0.8.1 → scootcli-0.9.0/src/scootcli.egg-info}/PKG-INFO +5 -3
  21. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_agent.py +68 -0
  22. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_anthropic.py +23 -0
  23. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_hooks.py +24 -0
  24. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_sessions.py +33 -0
  25. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_streaming.py +53 -0
  26. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_tools.py +112 -0
  27. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_transport_native.py +54 -0
  28. scootcli-0.9.0/tests/test_worktree.py +197 -0
  29. scootcli-0.8.1/src/scootcli/worktree.py +0 -114
  30. scootcli-0.8.1/tests/test_worktree.py +0 -92
  31. {scootcli-0.8.1 → scootcli-0.9.0}/LICENSE +0 -0
  32. {scootcli-0.8.1 → scootcli-0.9.0}/pyproject.toml +0 -0
  33. {scootcli-0.8.1 → scootcli-0.9.0}/setup.cfg +0 -0
  34. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/__main__.py +0 -0
  35. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/activity.py +0 -0
  36. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/approvals.py +0 -0
  37. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/auth.py +0 -0
  38. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/clipboard.py +0 -0
  39. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/__init__.py +0 -0
  40. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/approve.py +0 -0
  41. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/auth.py +0 -0
  42. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/base.py +0 -0
  43. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/compact.py +0 -0
  44. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/copy.py +0 -0
  45. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/exit.py +0 -0
  46. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/forget.py +0 -0
  47. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/help.py +0 -0
  48. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/hooks.py +0 -0
  49. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/init.py +0 -0
  50. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/logo.py +0 -0
  51. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/model.py +0 -0
  52. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/panel.py +0 -0
  53. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/reset.py +0 -0
  54. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/resume.py +0 -0
  55. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/route.py +0 -0
  56. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/save.py +0 -0
  57. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/scope.py +0 -0
  58. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/sessions.py +0 -0
  59. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/status.py +0 -0
  60. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/verbosity.py +0 -0
  61. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/yolo.py +0 -0
  62. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/config.py +0 -0
  63. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/context.py +0 -0
  64. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/credentials.py +0 -0
  65. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/errors.py +0 -0
  66. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/images.py +0 -0
  67. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/keys.py +0 -0
  68. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/lineeditor.py +0 -0
  69. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/logo.py +0 -0
  70. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/models.py +0 -0
  71. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/panel.py +0 -0
  72. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/preferences.py +0 -0
  73. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/presets.py +0 -0
  74. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/pricing.py +0 -0
  75. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/project.py +0 -0
  76. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/prompts.py +0 -0
  77. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/__init__.py +0 -0
  78. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/registry.py +0 -0
  79. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/router.py +0 -0
  80. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/rendering.py +0 -0
  81. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/resize.py +0 -0
  82. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/status.py +0 -0
  83. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/__init__.py +0 -0
  84. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/list_dir.py +0 -0
  85. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/open_editor.py +0 -0
  86. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/read_file.py +0 -0
  87. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/run_shell.py +0 -0
  88. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/search.py +0 -0
  89. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/update_plan.py +0 -0
  90. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/updates.py +0 -0
  91. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/vision.py +0 -0
  92. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/workspace.py +0 -0
  93. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli.egg-info/SOURCES.txt +0 -0
  94. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  95. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  96. {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli.egg-info/top_level.txt +0 -0
  97. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_approvals.py +0 -0
  98. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_auth.py +0 -0
  99. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_clipboard.py +0 -0
  100. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_config.py +0 -0
  101. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_headless.py +0 -0
  102. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_images.py +0 -0
  103. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_lineeditor.py +0 -0
  104. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_logo.py +0 -0
  105. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_models.py +0 -0
  106. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_panel.py +0 -0
  107. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_preferences.py +0 -0
  108. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_providers.py +0 -0
  109. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_readiness.py +0 -0
  110. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_repl.py +0 -0
  111. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_resilience.py +0 -0
  112. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_resize.py +0 -0
  113. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_router.py +0 -0
  114. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_scope.py +0 -0
  115. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_small_060.py +0 -0
  116. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_smoke.py +0 -0
  117. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_vision.py +0 -0
  118. {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.8.1
3
+ Version: 0.9.0
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -239,7 +239,8 @@ When a call needs approval you can approve once `[a]`, trust that tool for the s
239
239
  `/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
240
240
  Catastrophic shell commands (a denylist: `rm -rf /`, `git push --force`, piping downloads into a shell, and so on) are re-confirmed in every mode.
241
241
 
242
- For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge` or `/worktree discard` when done.
242
+ For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge`, `/worktree keep`, or `/worktree discard` when done.
243
+ A commit or merge that fails keeps the worktree and your files; nothing is removed by force except by `discard`.
243
244
 
244
245
  ### Streaming, images, sessions
245
246
 
@@ -314,7 +315,8 @@ python -m pytest -q # network-free suite
314
315
  ./scripts/build.sh # dist/scoot.pyz + wheel + sdist
315
316
  ```
316
317
 
317
- Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](./SPEC.md), what is next in [`ROADMAP.md`](./ROADMAP.md), and the release history in [`CHANGELOG.md`](./CHANGELOG.md).
318
+ Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](SPEC.md), what is next in [`ROADMAP.md`](./ROADMAP.md), and the release history in [`CHANGELOG.md`](./CHANGELOG.md).
319
+ Want to build a CLI agent like this yourself? [`docs/blueprint.md`](./docs/blueprint.md) is the minimal spec: the components, the turn, the tools, the permission rules, and the acceptance checks, with no dependency on scoot's code.
318
320
  New capability is a drop-in file: a tool in `tools/`, a slash command in `commands/`, a provider row or adapter in `providers/`.
319
321
 
320
322
  ## Security
@@ -214,7 +214,8 @@ When a call needs approval you can approve once `[a]`, trust that tool for the s
214
214
  `/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
215
215
  Catastrophic shell commands (a denylist: `rm -rf /`, `git push --force`, piping downloads into a shell, and so on) are re-confirmed in every mode.
216
216
 
217
- For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge` or `/worktree discard` when done.
217
+ For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge`, `/worktree keep`, or `/worktree discard` when done.
218
+ A commit or merge that fails keeps the worktree and your files; nothing is removed by force except by `discard`.
218
219
 
219
220
  ### Streaming, images, sessions
220
221
 
@@ -289,7 +290,8 @@ python -m pytest -q # network-free suite
289
290
  ./scripts/build.sh # dist/scoot.pyz + wheel + sdist
290
291
  ```
291
292
 
292
- Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](./SPEC.md), what is next in [`ROADMAP.md`](./ROADMAP.md), and the release history in [`CHANGELOG.md`](./CHANGELOG.md).
293
+ Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](SPEC.md), what is next in [`ROADMAP.md`](./ROADMAP.md), and the release history in [`CHANGELOG.md`](./CHANGELOG.md).
294
+ Want to build a CLI agent like this yourself? [`docs/blueprint.md`](./docs/blueprint.md) is the minimal spec: the components, the turn, the tools, the permission rules, and the acceptance checks, with no dependency on scoot's code.
293
295
  New capability is a drop-in file: a tool in `tools/`, a slash command in `commands/`, a provider row or adapter in `providers/`.
294
296
 
295
297
  ## Security
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.8.1"
3
+ __version__ = "0.9.0"
4
4
 
@@ -27,13 +27,20 @@ from .tools.base import ToolContext, ToolResult
27
27
 
28
28
  @dataclass
29
29
  class AgentOutcome:
30
- status: str # "done" | "interrupted" | "aborted" | "max_steps" | "error"
30
+ status: str # "done" | "incomplete" | "interrupted" | "aborted" | "max_steps" | "error"
31
31
  content: str = ""
32
32
  error: str = ""
33
33
  steps: int = 0
34
34
  streamed: bool = False # True if the final content was already printed live (streaming)
35
35
 
36
36
 
37
+ # finish reasons that mean the reply is not the model's whole answer, and what to tell the user.
38
+ _CUT_OFF = {
39
+ "length": "the model hit its output limit",
40
+ "incomplete": "the stream ended before the reply was complete",
41
+ }
42
+
43
+
37
44
  def _strip_done(text: str) -> str:
38
45
  """Remove a trailing ``DONE`` sentinel line from the model's final message."""
39
46
  lines = (text or "").rstrip().splitlines()
@@ -104,6 +111,7 @@ class Agent:
104
111
  steps = 0
105
112
  compacted = False
106
113
  stop_blocks = 0
114
+ cut_off = 0 # tool batches the model could not finish (output limit); bounded retries
107
115
  while True:
108
116
  if cancel_event.is_set():
109
117
  return AgentOutcome("interrupted", steps=steps)
@@ -145,14 +153,28 @@ class Agent:
145
153
  if result.tool_calls:
146
154
  if (result.content or "").strip() and not streamed:
147
155
  ui.assistant(result.content.strip())
156
+ if result.finish_reason in _CUT_OFF:
157
+ # The reply stopped before the calls were complete: their arguments cannot be
158
+ # trusted, so nothing runs. Tell the model once or twice, then give up.
159
+ cut_off += 1
160
+ self._close_batch(session, result.tool_calls,
161
+ "not executed: the reply was cut off before this tool call was "
162
+ "complete; call it again with less in one call")
163
+ if cut_off >= 2:
164
+ return AgentOutcome("incomplete", steps=steps, error=_CUT_OFF[result.finish_reason])
165
+ ui.assistant(f"reply cut off ({_CUT_OFF[result.finish_reason]}); asking the model to retry.")
166
+ continue
148
167
  signal = self._run_tools(session, result.tool_calls, ui, cancel_event, cfg)
149
168
  if signal in ("abort", "interrupted"):
150
169
  status = "aborted" if signal == "abort" else "interrupted"
151
170
  return AgentOutcome(status, steps=steps)
152
171
  continue
153
172
 
154
- # No tool calls -> the model is done, unless a Stop hook asks for more (bounded).
173
+ # No tool calls -> the model is done, unless it was cut off or a Stop hook asks for more (bounded).
155
174
  content = _strip_done(result.content)
175
+ if result.finish_reason in _CUT_OFF:
176
+ return AgentOutcome("incomplete", content=content, steps=steps, streamed=streamed,
177
+ error=_CUT_OFF[result.finish_reason])
156
178
  if stop_blocks < 3:
157
179
  reason = self._stop_hook(session, content, steps)
158
180
  if reason:
@@ -344,16 +366,48 @@ class Agent:
344
366
  )
345
367
 
346
368
  @staticmethod
347
- def _parse_args(raw) -> dict:
369
+ def _tool_payload(result: ToolResult) -> str:
370
+ """What the model reads back. On failure the error line comes first, followed by whatever
371
+ the tool captured: a failing test's or compiler's output is exactly what the model needs
372
+ next, and ``ERROR: exit 1`` alone only invites a blind rerun."""
373
+ if result.ok:
374
+ return result.content or "(no output)"
375
+ head = f"ERROR: {result.error or 'failed'}"
376
+ body = (result.content or "").strip()
377
+ if body.startswith(result.error or "\0"):
378
+ body = body[len(result.error):].lstrip("\n")
379
+ return f"{head}\n{body}" if body else head
380
+
381
+ @staticmethod
382
+ def _parse_args(raw) -> Optional[dict]:
383
+ """The call's arguments as a dict; ``None`` when they are not a JSON object (a truncated or
384
+ malformed call must not run as if it had no arguments)."""
348
385
  if isinstance(raw, dict):
349
386
  return raw
350
387
  if not raw:
351
388
  return {}
352
389
  try:
353
390
  parsed = json.loads(raw)
354
- return parsed if isinstance(parsed, dict) else {}
355
391
  except (json.JSONDecodeError, TypeError):
356
- return {}
392
+ return None
393
+ return parsed if isinstance(parsed, dict) else None
394
+
395
+ @staticmethod
396
+ def _close_batch(session, tool_calls, note: str) -> None:
397
+ """Give every call in the batch that has no result yet a result saying why.
398
+
399
+ Called on abort, interrupt, and cut-off replies: a history with an unanswered tool call is
400
+ rejected by providers on the next request, so the turn must never leave one behind.
401
+ """
402
+ answered = set()
403
+ for m in reversed(session.messages):
404
+ if m.get("role") != "tool":
405
+ break
406
+ answered.add(m.get("tool_call_id"))
407
+ for tc in tool_calls:
408
+ tc_id = tc.get("id", "")
409
+ if tc_id not in answered:
410
+ session.messages.append({"role": "tool", "tool_call_id": tc_id, "content": note})
357
411
 
358
412
  # ── tool execution ───────────────────────────────────────────────────────────
359
413
  def _run_tools(self, session, tool_calls, ui, cancel_event, cfg=None) -> Optional[str]:
@@ -379,7 +433,7 @@ class Agent:
379
433
  pass
380
434
  for tc in tool_calls:
381
435
  if cancel_event.is_set():
382
- return "interrupted"
436
+ return self._interrupted(session, tool_calls)
383
437
  tc_id = tc.get("id", "")
384
438
  fn = tc.get("function", {}) or {}
385
439
  name = fn.get("name", "")
@@ -389,6 +443,11 @@ class Agent:
389
443
  if tool is None:
390
444
  self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
391
445
  continue
446
+ if args is None:
447
+ self._append_tool(session, tc_id, "error: the arguments are not a JSON object (the call may "
448
+ "have been cut off); call the tool again")
449
+ ui.tool_result(name, ToolResult(ok=False, summary="malformed arguments"))
450
+ continue
392
451
 
393
452
  # Paths outside the workspace: ask the user once (this path, its directory, or anywhere).
394
453
  outside = self._outside_paths(tool, args, ctx)
@@ -396,13 +455,16 @@ class Agent:
396
455
  verdict = self._ask_scope(session, tool, outside, ctx, ui)
397
456
  if verdict == "abort":
398
457
  self._append_tool(session, tc_id, "user aborted the operation")
399
- return "abort"
458
+ return self._aborted(session, tool_calls)
400
459
  if verdict == "deny":
401
460
  self._append_tool(session, tc_id, f"user declined access outside the workspace: {outside[0]}")
402
461
  ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
403
462
  continue
404
463
  # A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
405
464
  hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
465
+ if cancel_event.is_set(): # ESC while the hook ran
466
+ self._append_tool(session, tc_id, "not executed: the user interrupted the turn")
467
+ return self._interrupted(session, tool_calls)
406
468
  if hook_action == "deny":
407
469
  self._append_tool(session, tc_id, f"user declined via hook: {hook_reason or 'no reason given'}")
408
470
  ui.tool_result(name, ToolResult(ok=False, summary=f"denied by hook: {hook_reason}"[:80]))
@@ -423,7 +485,7 @@ class Agent:
423
485
  approval = ui.approve(tool, args, ctx)
424
486
  if approval.decision == Decision.ABORT:
425
487
  self._append_tool(session, tc_id, "user aborted the operation")
426
- return "abort"
488
+ return self._aborted(session, tool_calls)
427
489
  if approval.decision == Decision.SKIP:
428
490
  self._append_tool(
429
491
  session, tc_id, "user declined this action; consider another approach"
@@ -442,16 +504,25 @@ class Agent:
442
504
  with ui.activity(f"running {name}…", cancel_event):
443
505
  result = tool.run(args, ctx)
444
506
  except Interrupted:
445
- return "interrupted"
507
+ self._append_tool(session, tc_id, "interrupted by the user while running; it may have "
508
+ "partially completed, check before repeating it")
509
+ return self._interrupted(session, tool_calls)
446
510
  except Exception as exc: # a tool must never take down the loop
447
511
  result = ToolResult.fail(f"tool crashed: {exc}")
448
512
 
449
513
  self._handle_result(session, name, result, ui)
450
514
  self._post_tool_hook(session, tool, args, result)
451
- payload = result.content if result.ok else f"ERROR: {result.error}"
452
- self._append_tool(session, tc_id, payload or "(no output)")
515
+ self._append_tool(session, tc_id, self._tool_payload(result))
453
516
  return None
454
517
 
518
+ def _aborted(self, session, tool_calls) -> str:
519
+ self._close_batch(session, tool_calls, "not executed: the user aborted the turn")
520
+ return "abort"
521
+
522
+ def _interrupted(self, session, tool_calls) -> str:
523
+ self._close_batch(session, tool_calls, "not executed: the user interrupted the turn")
524
+ return "interrupted"
525
+
455
526
  @staticmethod
456
527
  def _outside_paths(tool, args, ctx) -> "list":
457
528
  from .tools.base import resolve_path
@@ -210,6 +210,8 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
210
210
  f"completion={u.get('completion_tokens', '?')} "
211
211
  f"cost={fmt(session.session_cost())}", "gray"))
212
212
  return 0
213
+ if outcome.status == "incomplete" and outcome.content.strip() and not outcome.streamed:
214
+ print(outcome.content.strip())
213
215
  eprint(color(f"⚠ {redact(outcome.error or outcome.status)}", "red"))
214
216
  return 1
215
217
 
@@ -25,7 +25,7 @@ def _start(session):
25
25
  eprint(color(f"could not create worktree: {exc}", "red"))
26
26
  return
27
27
  session.worktree = w
28
- session.config = session.config.override(root=str(w.path))
28
+ session.switch_root(w.path)
29
29
  print(color(f"⚙ isolated in worktree {w.branch}", "cyan"))
30
30
  print(color(f" root → {w.path}", "gray"))
31
31
  print(color(" work freely (try /yolo); then /worktree merge|keep|discard", "gray"))
@@ -36,10 +36,15 @@ def _finish(session, action):
36
36
  if w is None:
37
37
  print(color("not in a worktree.", "yellow"))
38
38
  return
39
- msg = wt.finish(w, action)
40
- session.config = session.config.override(root=str(w.original_root))
39
+ result = wt.finish(w, action)
40
+ print(color(result.message, "gray" if result.ok else "yellow"))
41
+ if result.retained:
42
+ # The work is still in the worktree; the session stays there so nothing is lost.
43
+ print(color(f" still in worktree {w.branch} at {w.path}", "gray"))
44
+ print(color(f" fix it and run /worktree {action} again, or /worktree discard to drop it", "gray"))
45
+ return
46
+ session.switch_root(w.original_root)
41
47
  session.worktree = None
42
- print(color(f"{msg}", "gray"))
43
48
  print(color(f" root → {w.original_root}", "gray"))
44
49
 
45
50
 
@@ -302,6 +302,8 @@ class Headless:
302
302
  self.writer.emit("notice", message=f"reached the step limit ({self.session.config.max_steps}); send another prompt to continue")
303
303
  if outcome.status == "error":
304
304
  self.writer.emit("error", message=outcome.error, kind="turn")
305
+ if outcome.status == "incomplete":
306
+ self.writer.emit("notice", message=f"reply cut off: {outcome.error}; the content is partial")
305
307
  self.writer.emit("turn_end", turn=self._turn, status=outcome.status, steps=outcome.steps,
306
308
  model=self.session.active_model, usage=self.session.last_usage,
307
309
  cost=self._turn_cost(before), cost_session=self._session_cost(),
@@ -24,7 +24,6 @@ from __future__ import annotations
24
24
  import json
25
25
  import os
26
26
  import re
27
- import subprocess
28
27
  import threading
29
28
  import time
30
29
  from dataclasses import dataclass, field
@@ -186,29 +185,36 @@ class Hooks:
186
185
  return Decision(context="\n".join(context))
187
186
 
188
187
  def _run_command(self, event: str, command: str, payload: dict, timeout: int, cancel_event) -> Decision:
188
+ from .errors import Interrupted
189
+ from .tools.base import ToolError, run_subprocess
190
+
189
191
  env = dict(os.environ)
190
192
  env["SCOOT_SESSION_ID"] = str(payload.get("session_id", ""))
191
193
  env["SCOOT_HOOK_EVENT"] = event
192
194
  started = time.time()
193
195
  try:
194
- proc = subprocess.run(
195
- command, shell=True, cwd=str(self.root), env=env, input=json.dumps(payload),
196
- capture_output=True, text=True, timeout=timeout,
196
+ # The same runner as the tools: its own process group, bounded waits, ESC honoured.
197
+ returncode, stdout, stderr = run_subprocess(
198
+ ["/bin/sh", "-c", command], self.root, cancel_event, timeout=timeout, env=env,
199
+ input_text=json.dumps(payload),
197
200
  )
198
- except subprocess.TimeoutExpired:
201
+ except ToolError:
199
202
  self._record(event, command, "timeout", f"after {timeout}s", started)
200
203
  return Decision()
204
+ except Interrupted:
205
+ self._record(event, command, "cancelled", "by the user", started)
206
+ return Decision()
201
207
  except OSError as exc:
202
208
  self._record(event, command, "error", str(exc), started)
203
209
  return Decision()
204
- out = (proc.stdout or "")[:_MAX_CAPTURE].strip()
205
- err = (proc.stderr or "")[:_MAX_CAPTURE].strip()
206
- if proc.returncode == 2:
210
+ out = (stdout or "")[:_MAX_CAPTURE].strip()
211
+ err = (stderr or "")[:_MAX_CAPTURE].strip()
212
+ if returncode == 2:
207
213
  action = "deny" if event == "PreToolUse" else "block"
208
214
  self._record(event, command, action, err, started)
209
215
  return Decision(action=action, reason=err or "blocked by hook")
210
- if proc.returncode != 0:
211
- self._record(event, command, "error", f"exit {proc.returncode}: {err[:200]}", started)
216
+ if returncode != 0:
217
+ self._record(event, command, "error", f"exit {returncode}: {err[:200]}", started)
212
218
  return Decision()
213
219
  if not out:
214
220
  self._record(event, command, "ok", "", started)
@@ -36,6 +36,7 @@ from .base import (
36
36
  ChatRequest,
37
37
  ChatResult,
38
38
  decode_json,
39
+ STREAM_ENDED,
39
40
  finish_stream,
40
41
  iter_sse_json,
41
42
  normalize_usage,
@@ -184,7 +185,7 @@ def translate_tool(tool: dict) -> dict:
184
185
 
185
186
  # ── response parsing ────────────────────────────────────────────────────────────
186
187
  _STOP = {"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop",
187
- "pause_turn": "stop", "refusal": "refusal"}
188
+ "pause_turn": "stop", "refusal": "refusal", STREAM_ENDED: "incomplete"}
188
189
 
189
190
 
190
191
  def total_input_usage(usage: Optional[dict]) -> dict:
@@ -213,7 +214,9 @@ def build_result(blocks: List[dict], model: str, usage: Optional[dict], stop_rea
213
214
  "function": {"name": b.get("name", ""), "arguments": json.dumps(b.get("input") or {})}}
214
215
  for b in blocks if b.get("type") == "tool_use"]
215
216
  content = streamed_text or "".join(texts)
216
- finish = "tool_calls" if tool_calls else _STOP.get(stop_reason, "stop")
217
+ finish = _STOP.get(stop_reason, "stop")
218
+ if finish not in ("length", "incomplete") and tool_calls:
219
+ finish = "tool_calls"
217
220
  message: dict = {"role": "assistant", "content": content}
218
221
  if tool_calls:
219
222
  message["tool_calls"] = tool_calls
@@ -351,4 +354,5 @@ class AnthropicProvider(BaseProvider):
351
354
  raise_for_status(400, json.dumps({"error": state.error}), self.name)
352
355
  raise ApiError(str(state.error))
353
356
  return build_result(state.ordered_blocks(), self.qualified(state.model or req.model), state.usage,
354
- state.stop_reason, state.stop_details, streamed_text="".join(state.text))
357
+ state.stop_reason or STREAM_ENDED, state.stop_details,
358
+ streamed_text="".join(state.text))
@@ -380,20 +380,34 @@ def iter_sse_json(lines: Iterator[str], status_holder: dict, leftovers: List[str
380
380
  status_holder["saw_data"] = True
381
381
  data = stripped[len("data:"):].strip()
382
382
  if data == "[DONE]":
383
+ status_holder["done"] = True
383
384
  continue
384
385
  try:
385
386
  yield json.loads(data)
386
387
  except json.JSONDecodeError:
388
+ # Counted, not skipped: a dropped event means lost text or a broken tool call.
389
+ status_holder["malformed"] = status_holder.get("malformed", 0) + 1
387
390
  continue
388
391
  else:
389
392
  leftovers.append(line)
390
393
 
391
394
 
392
395
  def finish_stream(status_holder: dict, leftovers: List[str], provider: str) -> None:
393
- """After a stream ends: raise the typed error for a non-200 status or a plain error body."""
396
+ """After a stream ends: raise the typed error for a non-200 status or a plain error body, for
397
+ a 200 that carried no events at all, and for events that could not be parsed."""
394
398
  status = status_holder.get("status", 200)
395
399
  if status != 200 or (not status_holder.get("saw_data") and leftovers):
396
400
  body = "".join(leftovers).strip()
397
401
  raise_for_status(status if status != 200 else 400, body, provider)
398
402
  if body:
399
403
  raise ApiError(body[:200], status=status)
404
+ if not status_holder.get("saw_data"):
405
+ raise ApiError(f"{provider} returned an empty stream (no events before the connection closed)",
406
+ status=status, hint="a proxy or the endpoint closed the connection early; try again")
407
+ malformed = status_holder.get("malformed", 0)
408
+ if malformed:
409
+ raise ApiError(f"{provider} sent {malformed} unreadable stream event(s); the reply is not complete",
410
+ status=status, hint="try again; if it repeats, check the proxy or the endpoint")
411
+
412
+
413
+ STREAM_ENDED = "stream_ended" # an adapter's stop marker when the stream closed before its terminal event
@@ -137,6 +137,8 @@ class OpenAIChatProvider(BaseProvider):
137
137
  if text:
138
138
  on_delta(text)
139
139
  finish_stream(holder, leftovers, self.name)
140
+ if not acc.finish_reason and not holder.get("done"):
141
+ acc.finish_reason = "incomplete" # neither a finish_reason nor [DONE]: the stream broke off
140
142
  result = acc.result(req.model)
141
143
  result.model = self.qualified(result.model)
142
144
  return result
@@ -26,6 +26,7 @@ from .base import (
26
26
  ChatRequest,
27
27
  ChatResult,
28
28
  decode_json,
29
+ STREAM_ENDED,
29
30
  finish_stream,
30
31
  iter_sse_json,
31
32
  normalize_usage,
@@ -149,10 +150,12 @@ def build_result(items: List[dict], model: str, usage: Optional[dict], status: s
149
150
  texts = [_text_of(it) for it in items if it.get("type") == "message"]
150
151
  tool_calls = [_tool_call_of(it) for it in items if it.get("type") == "function_call"]
151
152
  content = streamed_text or "".join(texts)
152
- if tool_calls:
153
+ if status == "incomplete":
154
+ finish = "length" # even with tool calls: their arguments may be cut short
155
+ elif status == STREAM_ENDED:
156
+ finish = "incomplete"
157
+ elif tool_calls:
153
158
  finish = "tool_calls"
154
- elif status == "incomplete":
155
- finish = "length"
156
159
  else:
157
160
  finish = "stop"
158
161
  message: dict = {"role": "assistant", "content": content}
@@ -245,4 +248,4 @@ class OpenAIResponsesProvider(BaseProvider):
245
248
  raise_for_status(400, json.dumps({"error": state.error}), self.name)
246
249
  raise ApiError(str(state.error))
247
250
  return build_result(state.items, self.qualified(state.model or req.model), state.usage,
248
- state.status, streamed_text="".join(state.text))
251
+ state.status or STREAM_ENDED, streamed_text="".join(state.text))
@@ -120,23 +120,41 @@ class ReplSession:
120
120
  messages=self.messages,
121
121
  )
122
122
 
123
+ def switch_root(self, root) -> None:
124
+ """Move the session to another workspace (entering or leaving a worktree).
125
+
126
+ The config root, the file scope, and the hooks move together, so a worktree nested under the
127
+ original checkout does not keep the original in scope. Paths granted for the old root are
128
+ dropped rather than carried into the new one.
129
+ """
130
+ from .tools.base import Scope
131
+
132
+ self.config = self.config.override(root=str(root))
133
+ self.scope = Scope(self.config.root, everything=(getattr(self.config, "scope", "workspace") == "anywhere"))
134
+ if getattr(self, "hooks", None) is not None:
135
+ from .hooks import Hooks
136
+
137
+ self.hooks = Hooks(self.config.root)
138
+
123
139
  def apply_record(self, record) -> None:
124
- """Load a saved conversation + settings into this live session.
140
+ """Load a saved conversation into this live session.
125
141
 
126
142
  The saved model is adopted only when this run set nothing explicit (the preference is still
127
143
  the ``default`` alias) and the saved model's provider is configured; an explicit ``--model`` /
128
144
  ``SCOOT_MODEL`` / saved preference for this run always wins over what the session recorded.
145
+ The approval mode is never restored: it is this run's permission policy, and resuming a
146
+ session that once ran in ``yolo`` must not quietly turn ``--approval always`` into ``yolo``.
129
147
  """
148
+ from .sessions import complete_tool_results
149
+
130
150
  self.id = record.id
131
151
  self.created = record.created or self.created
132
152
  if self._may_adopt_saved_model(record.model, record.active_model):
133
153
  self.model = record.model or self.model
134
154
  self.active_model = record.active_model or self.active_model
135
- if record.approval_mode in MODES:
136
- self.approval_mode = record.approval_mode
137
155
  self.total_prompt = record.total_prompt
138
156
  self.total_completion = record.total_completion
139
- self.messages = list(record.messages)
157
+ self.messages = complete_tool_results(list(record.messages))
140
158
  self.resumed = True
141
159
 
142
160
  def refresh_readiness(self) -> bool:
@@ -967,6 +985,14 @@ class Repl:
967
985
  f"[{session.resolved_model()}] steps={outcome.steps} "
968
986
  f"prompt={u.get('prompt_tokens', '?')} "
969
987
  f"completion={u.get('completion_tokens', '?')}", "gray"))
988
+ elif outcome.status == "incomplete":
989
+ partial = outcome.content.strip()
990
+ session.last_output = partial
991
+ if partial and not outcome.streamed:
992
+ if self.labels and sys.stdout.isatty():
993
+ print(_assistant_label(self.ui.emoji))
994
+ print(partial)
995
+ print(color(f"⚠ reply cut off: {outcome.error}. ask it to continue, or split the task.", "yellow"))
970
996
  elif outcome.status == "interrupted":
971
997
  print(color("⏹ interrupted", "yellow"))
972
998
  elif outcome.status == "aborted":
@@ -35,6 +35,35 @@ def new_session_id() -> str:
35
35
  return time.strftime("%Y%m%d-%H%M%S") + "-" + os.urandom(2).hex()
36
36
 
37
37
 
38
+ def complete_tool_results(messages: List[dict]) -> List[dict]:
39
+ """Return ``messages`` with a result present for every assistant tool call.
40
+
41
+ A turn interrupted mid-batch used to leave calls without results; providers reject such a
42
+ history on the next request. Missing results are filled with a note, in place, right after the
43
+ results that do exist, so the conversation replays.
44
+ """
45
+ out: List[dict] = []
46
+ i = 0
47
+ while i < len(messages):
48
+ m = messages[i]
49
+ out.append(m)
50
+ i += 1
51
+ calls = m.get("tool_calls") if isinstance(m, dict) and m.get("role") == "assistant" else None
52
+ if not calls:
53
+ continue
54
+ answered = set()
55
+ while i < len(messages) and isinstance(messages[i], dict) and messages[i].get("role") == "tool":
56
+ answered.add(messages[i].get("tool_call_id"))
57
+ out.append(messages[i])
58
+ i += 1
59
+ for tc in calls:
60
+ tc_id = tc.get("id", "") if isinstance(tc, dict) else ""
61
+ if tc_id not in answered:
62
+ out.append({"role": "tool", "tool_call_id": tc_id,
63
+ "content": "no result recorded: the turn was interrupted before this call finished"})
64
+ return out
65
+
66
+
38
67
  def _redact_messages(messages: List[dict]) -> List[dict]:
39
68
  """Copy messages, masking token-like substrings in string content fields."""
40
69
  cleaned: List[dict] = []