lcode-cli 0.28.0__tar.gz → 0.28.1__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 (162) hide show
  1. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/CHANGELOG.md +35 -1
  2. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/PKG-INFO +1 -1
  3. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/quickstart.md +1 -1
  4. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/usage.md +6 -3
  5. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/__init__.py +1 -1
  6. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/agent.py +31 -2
  7. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/api.py +1 -0
  8. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/context.py +68 -0
  9. lcode_cli-0.28.1/src/lcode/permissions.py +260 -0
  10. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/repl.py +1 -0
  11. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/tools.py +160 -33
  12. lcode_cli-0.28.1/tests/test_robustness.py +221 -0
  13. lcode_cli-0.28.0/src/lcode/permissions.py +0 -138
  14. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.editorconfig +0 -0
  15. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/CODEOWNERS +0 -0
  16. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  18. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  19. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  20. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/dependabot.yml +0 -0
  22. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/workflows/ci.yml +0 -0
  23. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/workflows/docs.yml +0 -0
  24. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/workflows/release.yml +0 -0
  25. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.gitignore +0 -0
  26. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.pre-commit-config.yaml +0 -0
  27. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/AGENTS.md +0 -0
  28. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/CODE_OF_CONDUCT.md +0 -0
  29. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/CONTRIBUTING.md +0 -0
  30. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/LICENSE +0 -0
  31. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/README.md +0 -0
  32. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/SECURITY.md +0 -0
  33. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/action.yml +0 -0
  34. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/agents.md +0 -0
  35. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/assets/demo.svg +0 -0
  36. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/assets/extra.css +0 -0
  37. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/assets/logo.svg +0 -0
  38. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/assets/social-preview.png +0 -0
  39. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/automation.md +0 -0
  40. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/bench.md +0 -0
  41. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/changelog.md +0 -0
  42. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/commands.md +0 -0
  43. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/configuration.md +0 -0
  44. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/contributing.md +0 -0
  45. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/editors.md +0 -0
  46. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/git.md +0 -0
  47. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/hooks.md +0 -0
  48. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/how-it-works.md +0 -0
  49. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/index.md +0 -0
  50. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/installation.md +0 -0
  51. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/lsp.md +0 -0
  52. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/mcp.md +0 -0
  53. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/memory.md +0 -0
  54. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/models.md +0 -0
  55. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/sandbox.md +0 -0
  56. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/search.md +0 -0
  57. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/servers.md +0 -0
  58. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/troubleshooting.md +0 -0
  59. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/examples/README.md +0 -0
  60. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/examples/api/fix_tests.py +0 -0
  61. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/examples/commands/review.md +0 -0
  62. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/examples/skills/commit-message/SKILL.md +0 -0
  63. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/examples/skills/commit-message/scripts/staged.sh +0 -0
  64. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/install.sh +0 -0
  65. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/mkdocs.yml +0 -0
  66. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/overrides/main.html +0 -0
  67. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/pyproject.toml +0 -0
  68. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/scripts/record_demo.py +0 -0
  69. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/scripts/social-preview.html +0 -0
  70. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/__main__.py +0 -0
  71. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/acp.py +0 -0
  72. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/action.py +0 -0
  73. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/backends.py +0 -0
  74. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/bench.py +0 -0
  75. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/bench_backend.py +0 -0
  76. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/bench_runtime.py +0 -0
  77. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/catalog.py +0 -0
  78. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/checkpoints.py +0 -0
  79. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/cli.py +0 -0
  80. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/clipboard.py +0 -0
  81. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/codesearch.py +0 -0
  82. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/config.py +0 -0
  83. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/extensions.py +0 -0
  84. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/frontmatter.py +0 -0
  85. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/gitflow.py +0 -0
  86. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/hardware.py +0 -0
  87. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/hooks.py +0 -0
  88. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/jobs.py +0 -0
  89. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/limits.py +0 -0
  90. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/lsp.py +0 -0
  91. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/__init__.py +0 -0
  92. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/auth.py +0 -0
  93. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/catalog.py +0 -0
  94. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/catalog.toml +0 -0
  95. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/client.py +0 -0
  96. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/commands.py +0 -0
  97. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/config.py +0 -0
  98. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/http.py +0 -0
  99. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/manager.py +0 -0
  100. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/protocol.py +0 -0
  101. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/server.py +0 -0
  102. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/stdio.py +0 -0
  103. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/memory.py +0 -0
  104. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/models.toml +0 -0
  105. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/notify.py +0 -0
  106. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/ollama.py +0 -0
  107. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/planning.py +0 -0
  108. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/render.py +0 -0
  109. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/repeats.py +0 -0
  110. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/repomap.py +0 -0
  111. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/residency.py +0 -0
  112. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/sandbox.py +0 -0
  113. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/secretscan.py +0 -0
  114. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/servers/__init__.py +0 -0
  115. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/servers/encord.py +0 -0
  116. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/servers/valohai.py +0 -0
  117. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/servers/xai.py +0 -0
  118. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/sessions.py +0 -0
  119. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/subagents.py +0 -0
  120. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/update.py +0 -0
  121. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/vision.py +0 -0
  122. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/web.py +0 -0
  123. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/conftest.py +0 -0
  124. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/fake_lsp_server.py +0 -0
  125. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/fake_mcp_server.py +0 -0
  126. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_acp.py +0 -0
  127. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_action.py +0 -0
  128. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_agent.py +0 -0
  129. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_api.py +0 -0
  130. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_backends.py +0 -0
  131. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_bench.py +0 -0
  132. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_catalog.py +0 -0
  133. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_checkpoints.py +0 -0
  134. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_cli.py +0 -0
  135. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_clipboard.py +0 -0
  136. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_codesearch.py +0 -0
  137. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_config.py +0 -0
  138. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_context.py +0 -0
  139. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_context_picker.py +0 -0
  140. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_extensions.py +0 -0
  141. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_gitflow.py +0 -0
  142. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_hooks.py +0 -0
  143. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_jobs.py +0 -0
  144. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_lsp.py +0 -0
  145. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_mcp.py +0 -0
  146. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_memory.py +0 -0
  147. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_permissions.py +0 -0
  148. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_plan_mode.py +0 -0
  149. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_repeats.py +0 -0
  150. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_repomap.py +0 -0
  151. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_residency.py +0 -0
  152. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_sandbox.py +0 -0
  153. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_secretscan.py +0 -0
  154. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_servers.py +0 -0
  155. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_sessions.py +0 -0
  156. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_subagents.py +0 -0
  157. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_tools.py +0 -0
  158. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_update.py +0 -0
  159. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_vision.py +0 -0
  160. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_web.py +0 -0
  161. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_xai.py +0 -0
  162. {lcode_cli-0.28.0 → lcode_cli-0.28.1}/uv.lock +0 -0
@@ -6,6 +6,39 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.28.1] - 2026-10-13
10
+
11
+ ### Fixed
12
+
13
+ Found by testing lcode's tools on real files and commands (#118):
14
+
15
+ - Some commands that change things ran without asking, because they were taken for read-only:
16
+ `sort -o`, `uniq in out`, `tree -o`, `git branch -D` and other branch changes, `git remote
17
+ add/remove`, `git log/diff --output`, `rg --pre` (which runs a program), `find -fls`, `date -s`,
18
+ and `nvidia-smi` settings. Arguments are now checked. `2>&1` and `>/dev/null` no longer make a
19
+ read-only command ask.
20
+ - "Always allow" for a command also covered chains that started with it: allowing `npm test` let
21
+ `npm test && rm -rf build` run without asking. Now a chain needs its own "always".
22
+ - A JSON tool call that the model only showed as an example in its answer was run. Text is now
23
+ taken as a call only when the whole message is one.
24
+ - A change you made to a file while lcode asked to edit it was overwritten. lcode now notices and
25
+ doesn't apply the edit, and the model redoes it on the current content.
26
+ - `replace_all: "false"`, and other values sent as text, were taken as true. They now get the
27
+ type the tool declares.
28
+ - The shell timeout wasn't enforced while a command kept printing, so a watcher started without
29
+ `background` hung the request. Long output is no longer held in memory either: 400 MB of output
30
+ used to cost 2.5 GB, and now it goes to a file, keeping its start, end and error lines.
31
+ - One tool result could overflow the context window: `read_file` returned up to ~40K tokens,
32
+ however small the window, and compaction only looked at the previous request. A result now takes
33
+ at most a quarter of the window (`read_file` says where to read on), and lcode makes room based on
34
+ the size of the next request.
35
+ - Sessions were saved only when a request ended, so a crash lost the whole request. They're now
36
+ saved after every step, and atomically, so a crash while writing can't corrupt them.
37
+ - Editing a file with Windows line endings turned the whole file to Unix line endings, and editing a
38
+ file that isn't UTF-8 (Windows-1252 or Latin-1) replaced every accented letter in it with `�`,
39
+ not only in the edited line. Files now keep their encoding, line endings and byte order mark. A
40
+ character the encoding can't hold is refused instead of written wrong.
41
+
9
42
  ## [0.28.0] - 2026-10-13
10
43
 
11
44
  ### Added
@@ -599,7 +632,8 @@ First public release.
599
632
  - `AGENTS.md` project instructions and `/init` to generate them.
600
633
  - One-line installer for Linux and macOS.
601
634
 
602
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.0...HEAD
635
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.1...HEAD
636
+ [0.28.1]: https://github.com/nasser1941/lcode/compare/v0.28.0...v0.28.1
603
637
  [0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
604
638
  [0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
605
639
  [0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.28.0
3
+ Version: 0.28.1
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.28.0 — local coding agent │
15
+ │ lcode v0.28.1 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -224,11 +224,14 @@ image support off with `lcode config set vision_model off`.
224
224
  `--yolo` start in one.
225
225
 
226
226
  Read-only commands run without asking: `ls`, `cat`, `head`, `tail`, `grep`, `rg`, `find`, `tree`,
227
- `wc`, `diff`, `git status/log/diff/show/branch/blame` and similar, as long as they don't redirect
228
- output, chain commands or run subshells. A `cd <folder> &&` in front is fine.
227
+ `wc`, `diff`, `git status/log/diff/show/branch/blame` and similar. Their arguments count too:
228
+ `sort -o file`, `git branch -D`, `git log --output=file`, `rg --pre` or `find -delete` change
229
+ things, so they ask. Pipes between read-only commands are fine, and so are `cd <folder> &&` in front
230
+ and `2>&1` or `>/dev/null`; anything else (`&&`, `;`, redirecting to a file, `$(…)`) asks.
229
231
 
230
232
  When asked, answer ++y++ (once), ++a++ (always, for this command or for all edits, until you quit)
231
- or ++n++. For lasting answers, write [permission rules](hooks.md#permission-rules): allow lists and
233
+ or ++n++. "Always" covers the same program, or for a chain the same programs in the same order:
234
+ allowing `npm test` doesn't allow `npm test && rm -rf build`. For lasting answers, write [permission rules](hooks.md#permission-rules): allow lists and
232
235
  deny lists, per user or per repository; deny always wins, even in `yolo`. Text after ++n++ goes to the model as instructions: `n run the tests with -x first`.
233
236
 
234
237
  !!! warning "yolo mode"
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.28.0"
6
+ __version__ = "0.28.1"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -6,6 +6,7 @@ import base64
6
6
  import contextlib
7
7
  import datetime as dt
8
8
  import json
9
+ import os
9
10
  import platform
10
11
  import re
11
12
  import shutil
@@ -81,6 +82,8 @@ REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's pe
81
82
  MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
82
83
  # How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
83
84
  MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
85
+ RESULT_SHARE = 0.25 # of the context window, at most, for one tool result
86
+ MIN_RESULT_CHARS = 8000
84
87
  READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
85
88
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
86
89
  PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
@@ -320,7 +323,9 @@ class Agent:
320
323
  self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
321
324
  self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
322
325
  self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
326
+ self.autosave = False # save the session after every step (sessions people resume; not the bench's)
323
327
  self.sent_chars = 0 # characters of the conversation the model had already read
328
+ self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
324
329
  self.turn_status = "" # how the last request ended: success, max_steps, loop
325
330
  self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
326
331
  self.response = None # the streaming response, so another thread can abort it
@@ -576,6 +581,7 @@ class Agent:
576
581
  self.tools.read_mtimes.clear()
577
582
  self.reflected = len(self.messages)
578
583
  self.ctx_used = len(self.messages[0]["content"]) // 3
584
+ self.counted_chars = None
579
585
 
580
586
  def session_file(self) -> Path:
581
587
  return sessions.sessions_dir() / f"{self.session_id}.json"
@@ -597,7 +603,10 @@ class Agent:
597
603
  "messages": self.messages,
598
604
  "checkpoints": self.checkpoints.to_json(),
599
605
  }
600
- f.write_text(json.dumps(data))
606
+ # Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
607
+ partial = f.with_name(f".{f.name}.partial")
608
+ partial.write_text(json.dumps(data))
609
+ os.replace(partial, f)
601
610
 
602
611
  def new_session(self) -> None:
603
612
  self.reset()
@@ -634,6 +643,7 @@ class Agent:
634
643
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
635
644
  self.reflected = len(self.messages) # checked for notes to remember when that session ended
636
645
  self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
646
+ self.counted_chars = None
637
647
  return note
638
648
 
639
649
  def load_latest(self) -> bool:
@@ -797,7 +807,7 @@ class Agent:
797
807
  self.usage["output_tokens"] += final.get("eval_count", 0)
798
808
  self.usage["output_ns"] += final.get("eval_duration", 0)
799
809
  self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
800
- self.sent_chars = conversation_chars(self.messages)
810
+ self.sent_chars = self.counted_chars = conversation_chars(self.messages)
801
811
  seconds = final.get("eval_duration", 0) / 1e9
802
812
  self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
803
813
  return message
@@ -943,6 +953,8 @@ class Agent:
943
953
  for step in range(self.max_steps):
944
954
  if self.cancel is not None and self.cancel.is_set():
945
955
  raise KeyboardInterrupt
956
+ if step and self.autosave:
957
+ self.save() # a crash or a closed terminal loses at most this step
946
958
  self.maybe_compact()
947
959
  try:
948
960
  reply = self.assistant_step()
@@ -1027,6 +1039,9 @@ class Agent:
1027
1039
  self.console.print(Text(f" {result[:300]}", style="red"))
1028
1040
  elif name in ("read_file", "grep", "glob", "list_dir"):
1029
1041
  self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
1042
+ budget = self.result_budget()
1043
+ if len(result) > budget and name not in ("agent", "skill"):
1044
+ result = context_tools.shorten(result, budget, f"{name} result")
1030
1045
  verdict = self.repeats.check(name, args, result)
1031
1046
  if verdict.note:
1032
1047
  result += verdict.note
@@ -1161,8 +1176,21 @@ class Agent:
1161
1176
  limit = min(limit, self.settings.compact_at)
1162
1177
  return limit
1163
1178
 
1179
+ def next_request_tokens(self) -> int:
1180
+ """The size of the next request: what the model last counted, plus what was added since (tool output,
1181
+ attachments). Deciding on the last count alone let one large tool result overflow the window."""
1182
+ if self.counted_chars is None:
1183
+ return self.ctx_used
1184
+ return self.ctx_used + max(0, conversation_chars(self.messages) - self.counted_chars) // 3
1185
+
1186
+ def result_budget(self) -> int:
1187
+ """Characters one tool result may take: a share of the window, so one large result can't fill it."""
1188
+ return max(MIN_RESULT_CHARS, int(self.settings.context * 3 * RESULT_SHARE))
1189
+
1164
1190
  def maybe_compact(self) -> None:
1165
1191
  limit = self.compact_limit()
1192
+ if self.next_request_tokens() > limit:
1193
+ self.ctx_used, self.counted_chars = self.next_request_tokens(), None
1166
1194
  if self.ctx_used <= limit:
1167
1195
  return
1168
1196
  enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
@@ -1231,6 +1259,7 @@ class Agent:
1231
1259
  )
1232
1260
  self.skills_loaded.clear()
1233
1261
  self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
1262
+ self.counted_chars = None
1234
1263
  self.summarized_size = self.ctx_used
1235
1264
  self.sent_chars = 0
1236
1265
  self.reflected = len(self.messages)
@@ -200,6 +200,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
200
200
  )
201
201
  agent = Agent(ollama, settings, cwd, console=console)
202
202
  agent.hardware = hw
203
+ agent.autosave = True
203
204
  agent.interactive = options.interactive
204
205
  if options.max_steps:
205
206
  agent.max_steps = options.max_steps
@@ -14,8 +14,10 @@ full output is saved to a file the model can read if it needs more.
14
14
  from __future__ import annotations
15
15
 
16
16
  import json
17
+ import os
17
18
  import re
18
19
  import time
20
+ from collections import deque
19
21
  from dataclasses import dataclass
20
22
  from pathlib import Path
21
23
  from typing import TYPE_CHECKING
@@ -253,6 +255,72 @@ def shorten(text: str, limit: int, label: str = "output") -> str:
253
255
  return f"{head}{note}{tail}{where}"
254
256
 
255
257
 
258
+ class OutputSpool:
259
+ """Output collected line by line with bounded memory: once it's long, everything goes to a file, and only the
260
+ start, the end and the error-looking lines stay in memory. What `shorten` returns comes out the same."""
261
+
262
+ def __init__(self, limit: int, label: str = "output") -> None:
263
+ self.limit, self.label = limit, label
264
+ self.head: list[str] = [] # everything, until the output gets long; then its start
265
+ self.tail: deque[str] = deque()
266
+ self.tail_chars = 0
267
+ self.errors: list[str] = []
268
+ self.chars = 0
269
+ self.file = None
270
+ self.path: Path | None = None
271
+
272
+ def add(self, line: str) -> None:
273
+ self.chars += len(line)
274
+ if self.file is None:
275
+ self.head.append(line)
276
+ if self.chars > self.limit * 2:
277
+ self._spool()
278
+ return
279
+ try:
280
+ self.file.write(line)
281
+ except OSError:
282
+ pass
283
+ if ERROR_LINE.search(line) and len(self.errors) < MAX_ERROR_LINES:
284
+ self.errors.append(line.strip()[:300])
285
+ self.tail.append(line)
286
+ self.tail_chars += len(line)
287
+ while self.tail_chars > self.limit // 4 and len(self.tail) > 1:
288
+ self.tail_chars -= len(self.tail.popleft())
289
+
290
+ def _spool(self) -> None:
291
+ text = "".join(self.head)
292
+ self.path = save_output(text, self.label)
293
+ if self.path is not None:
294
+ try:
295
+ self.file = self.path.open("a")
296
+ except OSError:
297
+ self.file = None
298
+ if self.file is None: # nowhere to keep it: keep only the start and carry on counting
299
+ self.file = open(os.devnull, "w") # noqa: SIM115
300
+ head = text[: self.limit * 3 // 5]
301
+ self.head = [head]
302
+
303
+ def text(self) -> str:
304
+ if self.file is None:
305
+ return shorten("".join(self.head), self.limit, self.label)
306
+ self.file.close()
307
+ head, tail = "".join(self.head), "".join(self.tail)
308
+ cut = self.chars - len(head) - len(tail)
309
+ note = f"\n\n... [{cut} characters cut"
310
+ if self.errors:
311
+ note += f"; {len(self.errors)} line(s) from them that look like errors:]\n" + "\n".join(self.errors)
312
+ note += "\n[...]\n\n"
313
+ else:
314
+ note += "] ...\n\n"
315
+ where = (
316
+ f"\n[The full {self.label} ({self.chars} characters) is in {self.path}: read parts of it with read_file "
317
+ "if you need them.]"
318
+ if self.path
319
+ else ""
320
+ )
321
+ return f"{head}{note}{tail}{where}"
322
+
323
+
256
324
  # ----------------------------------------------------------------------------- what uses the context
257
325
 
258
326
 
@@ -0,0 +1,260 @@
1
+ """Permission prompts for file edits and shell commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ from collections.abc import Callable
8
+
9
+ from rich.console import Console, RenderableType
10
+ from rich.panel import Panel
11
+
12
+ from lcode.config import PERMISSION_MODES
13
+
14
+ # Commands that only read state run without asking (when used without redirection or chaining).
15
+ SAFE_COMMANDS = {
16
+ "ls", "cat", "head", "tail", "wc", "pwd", "grep", "rg", "find", "tree", "file", "stat", "du", "df",
17
+ "echo", "which", "whoami", "uname", "date", "sort", "uniq", "cut", "nl", "diff", "basename", "dirname",
18
+ "realpath", "readlink", "nvidia-smi", "sw_vers",
19
+ } # fmt: skip
20
+ SAFE_GIT = {"status", "log", "diff", "show", "branch", "ls-files", "rev-parse", "remote", "blame", "shortlog"}
21
+ # For these, "always allow" is remembered per subcommand (e.g. `git commit`, not all of `git`).
22
+ MULTI_WORD = {"git", "npm", "pnpm", "yarn", "pip", "uv", "docker", "cargo", "go", "kubectl", "python", "python3", "brew"} # fmt: skip # noqa: E501
23
+
24
+
25
+ # `cd <plain path> &&` in front of a command: harmless, and models write it all the time.
26
+ NOT_ASKED = (
27
+ "This needs the user's permission, and nobody can be asked in this run, so it was not allowed. Do without it "
28
+ "if you can; otherwise finish and say what permission you need."
29
+ )
30
+ LEADING_CD = re.compile(r"""^\s*(?:cd\s+(?:[\w./~@+,=:-]+|'[^']*'|"[^"$`\\]*")\s*&&\s*)+""")
31
+
32
+
33
+ # Redirections that write nothing anyone keeps: stderr into stdout, or output thrown away.
34
+ HARMLESS_REDIRECT = re.compile(r"(?<![\w>&])(?:[12]?>&[12]|[12]?>\s*/dev/null)(?=\s|$)")
35
+ OPERATORS = {"|", "||", "&&", ";", "&", ">", ">>", "<", "<<", "<<<", ">&", "<&", ">|", "(", ")", ";;"}
36
+
37
+
38
+ def split_command(command: str) -> tuple[list[list[str]], list[str]] | None:
39
+ """The command's segments (as words) and the operators between them, quotes respected. None when it can't be
40
+ read: command substitution, process substitution or unbalanced quotes."""
41
+ if any(tok in command for tok in ("`", "$(", "<(", ">(")) or "\n" in command.strip():
42
+ return None
43
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
44
+ lexer.whitespace_split = True
45
+ try:
46
+ tokens = list(lexer)
47
+ except ValueError:
48
+ return None
49
+ segments: list[list[str]] = [[]]
50
+ operators: list[str] = []
51
+ for token in tokens:
52
+ if token in OPERATORS or (token and set(token) <= set("|&;<>()")):
53
+ operators.append(token)
54
+ segments.append([])
55
+ else:
56
+ segments[-1].append(token)
57
+ return [s for s in segments if s], operators
58
+
59
+
60
+ def segment_key(words: list[str]) -> str:
61
+ """The program, plus the subcommand for tools like git (`git commit`, `python -m pytest`)."""
62
+ while words and re.fullmatch(r"\w+=\S*", words[0]):
63
+ words = words[1:] # VAR=value in front
64
+ if not words:
65
+ return ""
66
+ if words[0] in MULTI_WORD and len(words) > 1:
67
+ if words[1] in ("-m", "-c") and len(words) > 2: # python -m <module>
68
+ return " ".join(words[:3])
69
+ return f"{words[0]} {words[1]}"
70
+ return words[0]
71
+
72
+
73
+ def bash_key(command: str) -> str:
74
+ """What "always allow" remembers. For one program, the program (plus subcommand for git and the like); for a
75
+ chain such as `npm test && rm -rf build`, every program in it, so allowing `npm test` doesn't allow the chain.
76
+ `cd dir &&` in front doesn't count."""
77
+ command = HARMLESS_REDIRECT.sub(" ", LEADING_CD.sub("", command)).strip()
78
+ parsed = split_command(command)
79
+ if parsed is None:
80
+ return "bash:" + " ".join(command.split()) # can't be read: only this exact command
81
+ segments, operators = parsed
82
+ keys, redirected = [], False
83
+ for i, words in enumerate(segments):
84
+ before = operators[i - 1] if i else ""
85
+ if before and before not in ("|", "&&", "||", ";", "&"):
86
+ redirected = True # a file name after > or <, not a program
87
+ continue
88
+ keys.append(segment_key(words))
89
+ key = " + ".join(k for k in keys if k) + (" (redirected)" if redirected else "")
90
+ return f"bash:{key}"
91
+
92
+
93
+ def option_values(words: list[str], *options: str) -> list[str]:
94
+ """The values of options such as `-o FILE`, `-oFILE` or `--output=FILE`."""
95
+ found = []
96
+ for i, word in enumerate(words):
97
+ for option in options:
98
+ if word == option and i + 1 < len(words):
99
+ found.append(words[i + 1])
100
+ elif option.startswith("--") and word.startswith(option + "="):
101
+ found.append(word.split("=", 1)[1])
102
+ elif not option.startswith("--") and word.startswith(option) and len(word) > len(option):
103
+ found.append(word[len(option) :])
104
+ return found
105
+
106
+
107
+ def flags(words: list[str]) -> list[str]:
108
+ return [w for w in words[1:] if w.startswith("-")]
109
+
110
+
111
+ def positional(words: list[str]) -> list[str]:
112
+ return [w for w in words[1:] if not w.startswith("-")]
113
+
114
+
115
+ # Branch and remote listing; anything else creates, deletes, renames or reconfigures.
116
+ BRANCH_LISTING = {
117
+ "-a", "--all", "-r", "--remotes", "-l", "--list", "-v", "-vv", "--verbose", "--show-current", "--color",
118
+ "--no-color", "--column", "--no-column", "--abbrev", "--no-abbrev", "-i", "--ignore-case", "--omit-empty",
119
+ } # fmt: skip
120
+ BRANCH_WITH_VALUE = {"--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format"}
121
+ NVIDIA_QUERY = ("-q", "--query", "-L", "--list-gpus", "--format", "-i", "--id", "-d", "--display", "-x", "--xml-format",
122
+ "-h", "--help", "--query-gpu", "--query-compute-apps") # fmt: skip
123
+
124
+
125
+ def safe_git(words: list[str]) -> bool:
126
+ if len(words) < 2 or words[1] not in SAFE_GIT:
127
+ return False # also `git -c …` and `git -C …`, which can run anything
128
+ sub, args = words[1], words[2:]
129
+ if sub in ("log", "show", "diff", "shortlog") and option_values(words, "--output"):
130
+ return False # writes the output to a file
131
+ if sub == "branch":
132
+ listing = any(a in ("-l", "--list") for a in args)
133
+ i = 0
134
+ while i < len(args):
135
+ arg = args[i]
136
+ name = arg.split("=", 1)[0]
137
+ if name in BRANCH_WITH_VALUE:
138
+ i += 1 if "=" in arg else 2
139
+ continue
140
+ if arg.startswith("-") and name not in BRANCH_LISTING:
141
+ return False # -d, -D, -m, -c, -f, -u, --set-upstream-to, --edit-description, …
142
+ if not arg.startswith("-") and not listing:
143
+ return False # `git branch name` creates a branch
144
+ i += 1
145
+ if sub == "remote":
146
+ return not args or all(a in ("-v", "--verbose") for a in args) or args[0] in ("show", "get-url")
147
+ return True
148
+
149
+
150
+ def safe_segment(words: list[str]) -> bool:
151
+ program = words[0]
152
+ if program == "git":
153
+ return safe_git(words)
154
+ if program not in SAFE_COMMANDS:
155
+ return False
156
+ if program == "sort" and (
157
+ option_values(words, "-o", "--output") or any(f.startswith("--compress-program") for f in flags(words))
158
+ ):
159
+ return False
160
+ if program == "uniq" and len(positional(words)) > 1:
161
+ return False # `uniq in out` writes out
162
+ if program == "tree" and option_values(words, "-o"):
163
+ return False
164
+ if program == "rg" and any(f.split("=")[0] in ("--pre", "--pre-glob") for f in flags(words)):
165
+ return False # runs a program on each file
166
+ if program == "find" and any(
167
+ w in ("-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", "-fprintf", "-fls") for w in words
168
+ ):
169
+ return False
170
+ if program == "date" and (option_values(words, "-s", "--set") or "-s" in words):
171
+ return False
172
+ if program == "file" and any(f in ("-C", "--compile") for f in flags(words)):
173
+ return False
174
+ if program == "nvidia-smi":
175
+ return all(f.split("=")[0] in NVIDIA_QUERY for f in flags(words))
176
+ return True
177
+
178
+
179
+ def is_read_only(command: str) -> bool:
180
+ """Whether a command only reads: known read-only programs with arguments that don't write, joined only by
181
+ pipes. `cd dir &&` in front, `2>&1` and `>/dev/null` are fine."""
182
+ command = HARMLESS_REDIRECT.sub(" ", LEADING_CD.sub("", command)).strip()
183
+ parsed = split_command(command)
184
+ if parsed is None:
185
+ return False
186
+ segments, operators = parsed
187
+ if not segments or any(op != "|" for op in operators):
188
+ return False
189
+ return all(safe_segment(words) for words in segments)
190
+
191
+
192
+ class Permissions:
193
+ def __init__(self, console: Console, mode: str = "ask"):
194
+ from lcode.hooks import Rules
195
+
196
+ self.console = console
197
+ self.mode = mode
198
+ self.always: set[str] = set()
199
+ self.rules = Rules() # allow and deny lists from the settings (lcode.hooks)
200
+ self.on_prompt = None # called with the title before lcode asks the user (notification hooks)
201
+ # Decides instead of asking (lcode.api, lcode acp): True, False, or a message saying why not.
202
+ self.approve: Callable[[dict], bool | str] | None = None
203
+
204
+ def rule(self, kind: str, target: str) -> tuple[bool, str] | None:
205
+ """A rule's verdict on an action: (allowed, message for the model), or None when no rule matches."""
206
+ verdict, which = self.rules.check(kind, target) if target else (None, "")
207
+ if verdict == "deny":
208
+ return False, f"A deny rule blocks this: {which}. Don't try it another way; tell the user if it's needed."
209
+ if verdict == "allow":
210
+ return True, ""
211
+ return None
212
+
213
+ def cycle(self) -> None:
214
+ self.mode = PERMISSION_MODES[(PERMISSION_MODES.index(self.mode) + 1) % len(PERMISSION_MODES)]
215
+
216
+ def needs_prompt(self, key: str, kind: str) -> bool:
217
+ if key in self.always or (self.mode == "yolo" and kind != "memory"):
218
+ return False
219
+ return not (kind == "edit" and self.mode == "auto-edit")
220
+
221
+ def request(self, key: str, kind: str, title: str, body: RenderableType, target: str = "") -> tuple[bool, str]:
222
+ """Ask the user. kind is 'edit', 'bash', 'web', 'mcp' or 'memory'. Returns (allowed, message for the model).
223
+
224
+ Memory notes follow the memory setting rather than the mode: with `memory = ask`, even yolo asks.
225
+ """
226
+ verdict = self.rule(kind, target)
227
+ if verdict is not None:
228
+ return verdict
229
+ if not self.needs_prompt(key, kind):
230
+ return True, ""
231
+ if self.approve is not None: # a program or a non-interactive run decides
232
+ target = target or key.split(":", 1)[-1]
233
+ if kind == "bash":
234
+ target = LEADING_CD.sub("", target) # the command itself, without `cd <folder> &&`
235
+ verdict = self.approve({"kind": kind, "title": title, "target": target, "key": key})
236
+ if verdict is True:
237
+ return True, ""
238
+ return False, verdict if isinstance(verdict, str) and verdict else NOT_ASKED
239
+ if self.on_prompt:
240
+ self.on_prompt(title)
241
+ self.console.print(Panel(body, title=title, title_align="left", border_style="yellow"))
242
+ scope = {"edit": "file edits", "memory": "memory notes"}.get(kind) or key.split(":", 1)[-1]
243
+ if kind == "mcp":
244
+ scope = scope.replace(":", " › ", 1)
245
+ try:
246
+ answer = input(f" Allow? [y]es / [a]lways for '{scope}' this session / [n]o (+ optional reason): ")
247
+ except EOFError:
248
+ return False, "The user denied this action (no input available)."
249
+ answer = answer.strip()
250
+ if answer.lower() in ("y", "yes", ""):
251
+ return True, ""
252
+ if answer.lower() in ("a", "always"):
253
+ self.always.add("edit" if kind == "edit" else key)
254
+ return True, ""
255
+ # "n", "no", "n <reason>" or any other text, which is taken as the reason.
256
+ reason = re.sub(r"^(no|n)\b[\s,:-]*", "", answer, flags=re.I).strip()
257
+ message = "The user denied this action."
258
+ if reason:
259
+ return False, f"{message} User says: {reason}"
260
+ return False, f"{message} Ask the user how to proceed or choose a different approach."
@@ -754,6 +754,7 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
754
754
  agent.messages = agent.messages[:index]
755
755
  agent.reflected = min(agent.reflected, index)
756
756
  agent.ctx_used = sum(len(json.dumps(m)) for m in agent.messages) // 3
757
+ agent.counted_chars = None
757
758
  c.print(f"The conversation is back to before request {target.n} as well.")
758
759
  elif plan.restore or plan.remove:
759
760
  what = "undid the file changes from" if not rewind else "rolled the files back to before"